screen: link disambiguation, indent wraps, etc
This commit is contained in:
parent
20bcdf9df4
commit
34780bdf5e
|
@ -54,8 +54,12 @@ class EscapeCommandInterrupt(Exception):
|
||||||
|
|
||||||
|
|
||||||
class TerminateCommandInterrupt(Exception):
|
class TerminateCommandInterrupt(Exception):
|
||||||
"""Signal that validation ended command line input early. Use `command`."""
|
"""Signal that validation ended command line input early.
|
||||||
|
|
||||||
def __init__(self, command: str, *args, **kwargs):
|
The value to use is stored in the command attribute. This value can be of
|
||||||
|
any type: str for common commands but also int for ID input, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, command, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.command = command
|
self.command = command
|
||||||
|
|
|
@ -25,6 +25,11 @@ def parse_url(url, absolute=False):
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_url(url):
|
||||||
|
"""Parse and unparse an URL to ensure it has been properly formatted."""
|
||||||
|
return urllib.parse.urlunparse(parse_url(url))
|
||||||
|
|
||||||
|
|
||||||
def join_url(base_url, url):
|
def join_url(base_url, url):
|
||||||
"""Join a base URL with a relative url."""
|
"""Join a base URL with a relative url."""
|
||||||
if base_url.startswith("gemini://"):
|
if base_url.startswith("gemini://"):
|
||||||
|
|
|
@ -147,7 +147,7 @@ class Response:
|
||||||
meta: str = ""
|
meta: str = ""
|
||||||
content: bytes = b""
|
content: bytes = b""
|
||||||
|
|
||||||
HEADER_RE = re.compile(r"(\d{2}) (\S*)")
|
HEADER_RE = re.compile(r"(\d{2}) (.*)")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def generic_code(self):
|
def generic_code(self):
|
||||||
|
@ -166,7 +166,7 @@ class Response:
|
||||||
return None
|
return None
|
||||||
code, meta = match.groups()
|
code, meta = match.groups()
|
||||||
response = Response(StatusCode(int(code)), meta=meta)
|
response = Response(StatusCode(int(code)), meta=meta)
|
||||||
if Response.get_generic_code(response.code) == StatusCode.SUCCESS:
|
if response.generic_code == StatusCode.SUCCESS:
|
||||||
content_offset = response_header_len + len(LINE_TERM)
|
content_offset = response_header_len + len(LINE_TERM)
|
||||||
response.content = data[content_offset:]
|
response.content = data[content_offset:]
|
||||||
elif response.code == StatusCode.UNKNOWN:
|
elif response.code == StatusCode.UNKNOWN:
|
||||||
|
|
|
@ -83,10 +83,9 @@ def format_title(title: Title, context: dict):
|
||||||
lines = (line_template.format(line) for line in wrapped)
|
lines = (line_template.format(line) for line in wrapped)
|
||||||
else:
|
else:
|
||||||
if title.level == 2:
|
if title.level == 2:
|
||||||
text = " " + title.text
|
lines = wrap_words(title.text, context["width"], indent=2)
|
||||||
else:
|
else:
|
||||||
text = title.text
|
lines = wrap_words(title.text, context["width"])
|
||||||
lines = wrap_words(text, context["width"])
|
|
||||||
# Title levels match the type constants of titles.
|
# Title levels match the type constants of titles.
|
||||||
return [({"type": LineType(title.level)}, line) for line in lines]
|
return [({"type": LineType(title.level)}, line) for line in lines]
|
||||||
|
|
||||||
|
@ -99,17 +98,21 @@ def format_paragraph(paragraph: Paragraph, context: dict):
|
||||||
|
|
||||||
def format_link(link: Link, context: dict):
|
def format_link(link: Link, context: dict):
|
||||||
"""Return metalines for this link."""
|
"""Return metalines for this link."""
|
||||||
|
# Get a new link and build the "[id]" anchor.
|
||||||
link_id = context["last_link_id"] + 1
|
link_id = context["last_link_id"] + 1
|
||||||
context["last_link_id"] = link_id
|
context["last_link_id"] = link_id
|
||||||
link_text = link.text or link.url
|
link_text = link.text or link.url
|
||||||
text = f"[{link_id}] " + link_text
|
link_anchor = f"[{link_id}] "
|
||||||
lines = wrap_words(text, context["width"])
|
# Wrap lines, indented by the link anchor length.
|
||||||
|
lines = wrap_words(link_text, context["width"], indent=len(link_anchor))
|
||||||
first_line_meta = {
|
first_line_meta = {
|
||||||
"type": LineType.LINK,
|
"type": LineType.LINK,
|
||||||
"url": link.url,
|
"url": link.url,
|
||||||
"link_id": link_id
|
"link_id": link_id
|
||||||
}
|
}
|
||||||
first_line = [(first_line_meta, lines[0])]
|
# Replace first line indentation with the anchor.
|
||||||
|
first_line_text = link_anchor + lines[0][len(link_anchor):]
|
||||||
|
first_line = [(first_line_meta, first_line_text)]
|
||||||
other_lines = [({"type": LineType.LINK}, line) for line in lines[1:]]
|
other_lines = [({"type": LineType.LINK}, line) for line in lines[1:]]
|
||||||
return first_line + other_lines
|
return first_line + other_lines
|
||||||
|
|
||||||
|
@ -128,10 +131,10 @@ def format_blockquote(blockquote: Blockquote, context: dict):
|
||||||
return [({"type": LineType.BLOCKQUOTE}, line) for line in lines]
|
return [({"type": LineType.BLOCKQUOTE}, line) for line in lines]
|
||||||
|
|
||||||
|
|
||||||
def wrap_words(text, width):
|
def wrap_words(text, width, indent=0):
|
||||||
"""Wrap a text in several lines according to the renderer's width."""
|
"""Wrap a text in several lines according to the renderer's width."""
|
||||||
lines = []
|
lines = []
|
||||||
line = ""
|
line = " " * indent
|
||||||
words = _explode_words(text)
|
words = _explode_words(text)
|
||||||
for word in words:
|
for word in words:
|
||||||
line_len, word_len = len(line), len(word)
|
line_len, word_len = len(line), len(word)
|
||||||
|
@ -140,11 +143,13 @@ def wrap_words(text, width):
|
||||||
# Push only non-empty lines.
|
# Push only non-empty lines.
|
||||||
if line_len > 0:
|
if line_len > 0:
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
line = ""
|
line = " " * indent
|
||||||
# Force split words that are longer than the width.
|
# Force split words that are longer than the width.
|
||||||
while word_len > width:
|
while word_len > width:
|
||||||
lines.append(word[:width - 1] + JOIN_CHAR)
|
split_offset = width - 1 - indent
|
||||||
word = word[width - 1:]
|
word_line = " " * indent + word[:split_offset] + JOIN_CHAR
|
||||||
|
lines.append(word_line)
|
||||||
|
word = word[split_offset:]
|
||||||
word_len = len(word)
|
word_len = len(word)
|
||||||
word = word.lstrip()
|
word = word.lstrip()
|
||||||
line += word
|
line += word
|
||||||
|
|
|
@ -7,7 +7,7 @@ from bebop.colors import ColorPair, init_colors
|
||||||
from bebop.command_line import (CommandLine, EscapeCommandInterrupt,
|
from bebop.command_line import (CommandLine, EscapeCommandInterrupt,
|
||||||
TerminateCommandInterrupt)
|
TerminateCommandInterrupt)
|
||||||
from bebop.mouse import ButtonState
|
from bebop.mouse import ButtonState
|
||||||
from bebop.navigation import join_url, parse_url
|
from bebop.navigation import join_url, parse_url, sanitize_url
|
||||||
from bebop.page import Page
|
from bebop.page import Page
|
||||||
from bebop.protocol import Request, Response
|
from bebop.protocol import Request, Response
|
||||||
|
|
||||||
|
@ -135,36 +135,42 @@ class Screen:
|
||||||
self.status_data = f"Error: {text}", ColorPair.ERROR
|
self.status_data = f"Error: {text}", ColorPair.ERROR
|
||||||
self.refresh_status_line()
|
self.refresh_status_line()
|
||||||
|
|
||||||
def open_url(self, url):
|
def open_url(self, url, redirections=0):
|
||||||
"""Try to open an URL.
|
"""Try to open an URL.
|
||||||
|
|
||||||
If the URL is not strictly absolute, it will be opened relatively to the
|
If the URL is not strictly absolute, it will be opened relatively to the
|
||||||
current URL, unless there is no current URL yet.
|
current URL, unless there is no current URL yet.
|
||||||
"""
|
"""
|
||||||
|
if redirections > 5:
|
||||||
|
self.set_status_error(f"too many redirections ({url})")
|
||||||
|
return
|
||||||
if self.current_url:
|
if self.current_url:
|
||||||
parts = parse_url(url)
|
parts = parse_url(url)
|
||||||
else:
|
else:
|
||||||
parts = parse_url(url, absolute=True)
|
parts = parse_url(url, absolute=True)
|
||||||
if parts.scheme == "gemini":
|
if parts.scheme == "gemini":
|
||||||
|
# If there is no netloc, this is a relative URL.
|
||||||
if not parts.netloc:
|
if not parts.netloc:
|
||||||
url = join_url(self.current_url, url)
|
url = join_url(self.current_url, url)
|
||||||
self.open_gemini_url(url)
|
self.open_gemini_url(sanitize_url(url), redirections)
|
||||||
else:
|
else:
|
||||||
self.set_status_error(f"protocol {parts.scheme} not supported.")
|
self.set_status_error(f"protocol {parts.scheme} not supported")
|
||||||
|
|
||||||
def open_gemini_url(self, url):
|
def open_gemini_url(self, url, redirections=0):
|
||||||
"""Open a Gemini URL and set the formatted response as content."""
|
"""Open a Gemini URL and set the formatted response as content."""
|
||||||
|
with open("/tmp/a", "at") as f: f.write(url + "\n")
|
||||||
self.set_status(f"Loading {url}")
|
self.set_status(f"Loading {url}")
|
||||||
req = Request(url, self.stash)
|
req = Request(url, self.stash)
|
||||||
connected = req.connect()
|
connected = req.connect()
|
||||||
if not connected:
|
if not connected:
|
||||||
if req.state == Request.STATE_ERROR_CERT:
|
if req.state == Request.STATE_ERROR_CERT:
|
||||||
self.set_status_error("certificate was missing or corrupt.")
|
error = f"certificate was missing or corrupt ({url})"
|
||||||
|
self.set_status_error(error)
|
||||||
elif req.state == Request.STATE_UNTRUSTED_CERT:
|
elif req.state == Request.STATE_UNTRUSTED_CERT:
|
||||||
self.set_status_error("certificate has been changed.")
|
self.set_status_error(f"certificate has been changed ({url})")
|
||||||
# TODO propose the user ways to handle this.
|
# TODO propose the user ways to handle this.
|
||||||
else:
|
else:
|
||||||
self.set_status_error("connection failed.")
|
self.set_status_error(f"connection failed ({url})")
|
||||||
return
|
return
|
||||||
|
|
||||||
if req.state == Request.STATE_INVALID_CERT:
|
if req.state == Request.STATE_INVALID_CERT:
|
||||||
|
@ -178,7 +184,7 @@ class Screen:
|
||||||
|
|
||||||
response = Response.parse(req.proceed())
|
response = Response.parse(req.proceed())
|
||||||
if not response:
|
if not response:
|
||||||
self.set_status_error("server response parsing failed.")
|
self.set_status_error(f"server response parsing failed ({url})")
|
||||||
return
|
return
|
||||||
|
|
||||||
if response.code == 20:
|
if response.code == 20:
|
||||||
|
@ -186,7 +192,9 @@ class Screen:
|
||||||
self.current_url = url
|
self.current_url = url
|
||||||
self.set_status(url)
|
self.set_status(url)
|
||||||
elif response.generic_code == 30 and response.meta:
|
elif response.generic_code == 30 and response.meta:
|
||||||
self.open_gemini_url(response.meta)
|
self.open_url(response.meta, redirections=redirections + 1)
|
||||||
|
elif response.generic_code in (40, 50):
|
||||||
|
self.set_status_error(response.meta or Response.code.name)
|
||||||
|
|
||||||
def load_page(self, gemtext: bytes):
|
def load_page(self, gemtext: bytes):
|
||||||
"""Load Gemtext data as the current page."""
|
"""Load Gemtext data as the current page."""
|
||||||
|
@ -233,49 +241,54 @@ class Screen:
|
||||||
When a digit key is pressed, the user intents to visit a link (or
|
When a digit key is pressed, the user intents to visit a link (or
|
||||||
dropped something on the numpad). To reduce the number of key types
|
dropped something on the numpad). To reduce the number of key types
|
||||||
needed, Bebop uses the following algorithm:
|
needed, Bebop uses the following algorithm:
|
||||||
- If the highest link ID on the page is less than 10, pressing the key
|
- If the current user input identifies a link without ambiguity, it is
|
||||||
takes you to the link.
|
used directly.
|
||||||
- If it's higher than 10, the user either inputs as many digits required
|
- If it is ambiguous, the user either inputs as many digits required
|
||||||
to disambiguate the link ID, or press enter to validate her input.
|
to disambiguate the link ID, or press enter to validate her input.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- I have 3 links. Pressing "2" takes me to link 2.
|
- I have 3 links. Pressing "2" takes me to link 2.
|
||||||
- I have 15 links. Pressing "3" and Enter takes me to link 2.
|
- I have 15 links. Pressing "3" takes me to link 3 (no ambiguity).
|
||||||
- I have 15 links. Pressing "1" and "2" takes me to link 12 (no
|
- I have 15 links. Pressing "1" and "2" takes me to link 12.
|
||||||
ambiguity, so Enter is not required).
|
|
||||||
- I have 456 links. Pressing "1", "2" and Enter takes me to link 12.
|
- I have 456 links. Pressing "1", "2" and Enter takes me to link 12.
|
||||||
- I have 456 links. Pressing "1", "2" and "6" takes me to link 126 (no
|
- I have 456 links. Pressing "1", "2" and "6" takes me to link 126.
|
||||||
ambiguity as well).
|
|
||||||
"""
|
"""
|
||||||
digit = init_char & 0xf
|
digit = init_char & 0xf
|
||||||
links = self.page.links
|
links = self.page.links
|
||||||
num_links = len(links)
|
num_links = len(links)
|
||||||
|
# If there are less than 10 links, just open it now.
|
||||||
if num_links < 10:
|
if num_links < 10:
|
||||||
self.open_link(links, digit)
|
self.open_link(links, digit)
|
||||||
return
|
return
|
||||||
required_digits = 0
|
# Else check if the digit alone is sufficient.
|
||||||
|
digit = chr(init_char)
|
||||||
|
max_digits = 0
|
||||||
while num_links:
|
while num_links:
|
||||||
required_digits += 1
|
max_digits += 1
|
||||||
num_links //= 10
|
num_links //= 10
|
||||||
validator = lambda ch: self._validate_link_digit(ch, required_digits)
|
disambiguous = self.disambiguate_link_id(digit, links, max_digits)
|
||||||
link_input = self.command_line.focus("&", validator, chr(init_char))
|
if disambiguous is not None:
|
||||||
|
self.open_link(links, disambiguous)
|
||||||
|
return
|
||||||
|
# Else, focus the command line to let the user input more digits.
|
||||||
|
validator = lambda ch: self._validate_link_digit(ch, links, max_digits)
|
||||||
|
link_input = self.command_line.focus("&", validator, digit)
|
||||||
try:
|
try:
|
||||||
link_id = int(link_input)
|
self.open_link(links, int(link_input))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.set_status_error("invalid link ID")
|
self.set_status_error("invalid link ID")
|
||||||
return
|
|
||||||
self.open_link(links, link_id)
|
|
||||||
|
|
||||||
def _validate_link_digit(self, ch: int, required_digits: int):
|
def _validate_link_digit(self, ch: int, links, max_digits: int):
|
||||||
"""Handle input chars to be used as link ID."""
|
"""Handle input chars to be used as link ID."""
|
||||||
# Handle common chars.
|
# Handle common chars.
|
||||||
ch = self.validate_common_char(ch)
|
ch = self.validate_common_char(ch)
|
||||||
# Only accept digits. If we reach the amount of required digits, open
|
# Only accept digits. If we reach the amount of required digits, open
|
||||||
# link now and leave command line. Else just process it.
|
# link now and leave command line. Else just process it.
|
||||||
if curses.ascii.isdigit(ch):
|
if curses.ascii.isdigit(ch):
|
||||||
digits = self.command_line.gather()
|
digits = self.command_line.gather() + chr(ch)
|
||||||
if len(digits) + 1 == required_digits:
|
disambiguous = self.disambiguate_link_id(digits, links, max_digits)
|
||||||
raise TerminateCommandInterrupt(digits + chr(ch))
|
if disambiguous is not None:
|
||||||
|
raise TerminateCommandInterrupt(disambiguous)
|
||||||
return ch
|
return ch
|
||||||
# If not a digit but a printable character, ignore it.
|
# If not a digit but a printable character, ignore it.
|
||||||
if curses.ascii.isprint(ch):
|
if curses.ascii.isprint(ch):
|
||||||
|
@ -283,9 +296,15 @@ class Screen:
|
||||||
# Everything else could be a control character and should be processed.
|
# Everything else could be a control character and should be processed.
|
||||||
return ch
|
return ch
|
||||||
|
|
||||||
def disambiguate_link_id(self, digits: str, max_digits: int):
|
def disambiguate_link_id(self, digits: str, links, max_digits: int):
|
||||||
|
"""Return the only possible link ID as str, or None on ambiguities."""
|
||||||
if len(digits) == max_digits:
|
if len(digits) == max_digits:
|
||||||
return digits
|
return int(digits)
|
||||||
|
candidates = [
|
||||||
|
link_id for link_id, url in links.items()
|
||||||
|
if str(link_id).startswith(digits)
|
||||||
|
]
|
||||||
|
return candidates[0] if len(candidates) == 1 else None
|
||||||
|
|
||||||
def open_link(self, links, link_id: int):
|
def open_link(self, links, link_id: int):
|
||||||
"""Open the link with this link ID."""
|
"""Open the link with this link ID."""
|
||||||
|
@ -300,9 +319,11 @@ class Screen:
|
||||||
Right now, only vertical scrolling is handled.
|
Right now, only vertical scrolling is handled.
|
||||||
"""
|
"""
|
||||||
if bstate & ButtonState.SCROLL_UP:
|
if bstate & ButtonState.SCROLL_UP:
|
||||||
self.page.scroll_v(-3)
|
if self.page.scroll_v(-3):
|
||||||
|
self.refresh_page()
|
||||||
elif bstate & ButtonState.SCROLL_DOWN:
|
elif bstate & ButtonState.SCROLL_DOWN:
|
||||||
self.page.scroll_v(3, self.h - 2)
|
if self.page.scroll_v(3, self.h - 2):
|
||||||
|
self.refresh_page()
|
||||||
|
|
||||||
def handle_resize(self):
|
def handle_resize(self):
|
||||||
"""Try to not make everything collapse on resizes."""
|
"""Try to not make everything collapse on resizes."""
|
||||||
|
|
Reference in a new issue