screen: link disambiguation, indent wraps, etc

This commit is contained in:
dece 2021-02-13 23:34:45 +01:00
parent 20bcdf9df4
commit 34780bdf5e
5 changed files with 83 additions and 48 deletions

View file

@ -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

View file

@ -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://"):

View file

@ -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:

View file

@ -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

View file

@ -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."""