Compare commits

..

10 commits

Author SHA1 Message Date
dece a9e6b4a824 setup: bump to 0.3.1
Also bump Gopher plugin to 0.1.1.
2021-07-08 15:32:49 +02:00
dece 09f30d3d00 gemtext: fix missing empty lines in pre. blocks 2021-07-08 15:29:55 +02:00
dece 3d73d14983 browser: use m instead of § to change render mode 2021-07-08 15:16:19 +02:00
dece a2051e082d welcome: add geminiquickst.art link and :q 2021-07-08 15:16:19 +02:00
dece f7b4607ed6 metalines: use a 3-uple instead of dict 2021-07-08 15:16:19 +02:00
dece 016e4a49f9 clean 2021-07-08 15:16:19 +02:00
dece 7ed83f9389 page: rework rendering params into RenderOptions
Also make the list item bullet configurable, if you don't like the •.
2021-07-08 15:16:19 +02:00
dece 8d82c1bd53 browser: show unknown command error 2021-07-08 15:16:19 +02:00
dece ee9b637bae architecture: add high-level view 2021-07-08 15:16:19 +02:00
dece dc6fb068bb board: update 2021-07-08 15:16:19 +02:00
16 changed files with 217 additions and 145 deletions

51
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,51 @@
Architecture
============
This document is for people who want to get an overview of the Bebop code and
the way things work. These are high-level views, more details are given in the
respective modules' docstrings.
Events
------
There are no event loop dispatching actions asynchronously, everything runs in a
single thread. The UI waits for user input and reacts on them.
In the future we may decouple the UI from the core browser to allow background
downloads and streaming content.
Rendering
---------
A core element of Bebop is what I call "metalines", which are lines of text as
they are rendered on screen, along with specific line metadata. Metalines are
rendered directly on screen, so they are not wrapped, cut or whatever: they
already represent formatted text as it will be shown. They carry information
such as line type (i.e. is this line part of a link, a title, etc), and more
specific data such as target URLs for link lines.
Rendering from the server response to showing content in the curses UI takes
several steps:
1. Parse the response from the server. If it's successful and a text MIME type
is provided, use it to parse the response content.
2. Response parsing can directly produce metalines (e.g. from `text/plain`) or
have intermediate parsing steps: a `text/gemini` document is first parsed
into a list of gemtext "elements" (paragraphs, links, etc), and converting
those elements into metalines happen in a following step. This lets Bebop
separate gemtext semantics from the way it is rendered, thus using the
desired wrapping and margins.
3. Metalines are rendered on the screen, with the colors and attributes
matching the line type.
Plugins
-------
The plugin interface is improved only when there is demand for something; for
now it only supports plugins that can handle new schemes.

View file

@ -29,6 +29,11 @@ handle big files (e.g. gemini://tilde.team/~tomasino/irc/log.txt)
allow encoding overrides (useful for gopher i guess) allow encoding overrides (useful for gopher i guess)
config for web browser, default to webbrowser module config for web browser, default to webbrowser module
use pubkeys instead of the whole DER hash for TOFU use pubkeys instead of the whole DER hash for TOFU
specify external commands interface (exec, run, pipes)
table of contents
better default editor than vim
search engine
auto-open some media types

View file

@ -18,7 +18,6 @@ from bebop.bookmarks import (
save_bookmark, save_bookmark,
) )
from bebop.colors import A_ITALIC, ColorPair, init_colors from bebop.colors import A_ITALIC, ColorPair, init_colors
from bebop.config import RENDER_MODES
from bebop.command_line import CommandLine from bebop.command_line import CommandLine
from bebop.external import open_external_program from bebop.external import open_external_program
from bebop.fs import get_capsule_prefs_path, get_identities_list_path from bebop.fs import get_capsule_prefs_path, get_identities_list_path
@ -26,7 +25,7 @@ from bebop.help import get_help
from bebop.history import History from bebop.history import History
from bebop.identity import load_identities from bebop.identity import load_identities
from bebop.links import Links from bebop.links import Links
from bebop.metalines import LineType from bebop.metalines import LineType, RENDER_MODES
from bebop.mime import MimeType from bebop.mime import MimeType
from bebop.mouse import ButtonState from bebop.mouse import ButtonState
from bebop.navigation import ( from bebop.navigation import (
@ -37,7 +36,7 @@ from bebop.navigation import (
parse_url, parse_url,
unparse_url, unparse_url,
) )
from bebop.page import Page from bebop.page import Page, get_render_options
from bebop.page_pad import PagePad from bebop.page_pad import PagePad
from bebop.preferences import load_capsule_prefs, save_capsule_prefs from bebop.preferences import load_capsule_prefs, save_capsule_prefs
from bebop.welcome import WELCOME_PAGE from bebop.welcome import WELCOME_PAGE
@ -259,7 +258,7 @@ class Browser:
self.edit_page() self.edit_page()
elif char == ord("y"): elif char == ord("y"):
self.open_history() self.open_history()
elif char == ord("§"): elif char == ord("m"):
self.toggle_render_mode() self.toggle_render_mode()
elif char == ord("/"): elif char == ord("/"):
self.search_in_page() self.search_in_page()
@ -377,16 +376,19 @@ class Browser:
self.open_home() self.open_home()
elif command in ("i", "info"): elif command in ("i", "info"):
self.show_page_info() self.show_page_info()
return else:
self.set_status_error(f"Unknown command '{command}'.")
# And commands with one or more args. # And commands with one or more args.
if command in ("o", "open"): else:
self.open_url(words[1]) if command in ("o", "open"):
elif command == "forget-certificate": self.open_url(words[1])
from bebop.browser.gemini import forget_certificate elif command == "forget-certificate":
forget_certificate(self, words[1]) from bebop.browser.gemini import forget_certificate
elif command == "set-render-mode": forget_certificate(self, words[1])
self.set_render_mode(words[1]) elif command == "set-render-mode":
self.set_render_mode(words[1])
else:
self.set_status_error(f"Unknown command '{command}'.")
def get_user_text_input(self, status_text, char, prefix="", strip=False, def get_user_text_input(self, status_text, char, prefix="", strip=False,
escape_to_none=False): escape_to_none=False):
@ -550,19 +552,19 @@ class Browser:
line_pos = y + py line_pos = y + py
if line_pos >= len(self.current_page.metalines): if line_pos >= len(self.current_page.metalines):
return return
meta, line = self.current_page.metalines[line_pos] ltype, ltext, lextra = self.current_page.metalines[line_pos]
if meta["type"] != LineType.LINK: if ltype != LineType.LINK:
return return
# "url" key is contained only in the first line of the link if its text # "url" key is contained only in the first line of the link if its text
# is wrapped, so if the user did not click on the first line, rewind to # is wrapped, so if the user did not click on the first line, rewind to
# get the URL. # get the URL.
while "url" not in meta: while not lextra or "url" not in lextra:
line_pos -= 1 line_pos -= 1
meta, line = self.current_page.metalines[line_pos] _, ltext, lextra = self.current_page.metalines[line_pos]
url = meta["url"] url = lextra["url"]
# The click is valid if it is on the link itself or the dimmed preview. # The click is valid if it is on the link itself or the dimmed preview.
col_pos = x + px col_pos = x + px
if col_pos > len(line): if col_pos > len(ltext):
ch = self.page_pad.pad.instr(line_pos, col_pos, 1) ch = self.page_pad.pad.instr(line_pos, col_pos, 1)
if ch == b' ': if ch == b' ':
return return
@ -668,7 +670,7 @@ class Browser:
def open_internal_page(self, name, gemtext): def open_internal_page(self, name, gemtext):
"""Open some content corresponding to a "bebop:" internal URL.""" """Open some content corresponding to a "bebop:" internal URL."""
page = Page.from_gemtext(gemtext, self.config["text_width"]) page = Page.from_gemtext(gemtext, get_render_options(self.config))
self.load_page(page) self.load_page(page)
self.current_url = "bebop:" + name self.current_url = "bebop:" + name
@ -780,8 +782,8 @@ class Browser:
page = self.current_page page = self.current_page
if not page: if not page:
return return
mime = page.mime.short if page.mime else "(unknown MIME type)" mime = page.mime.short if page.mime else "unk. MIME"
encoding = page.encoding or "(unknown encoding)" encoding = page.encoding or "unk. encoding"
size = f"{len(page.source)} chars" size = f"{len(page.source)} chars"
lines = f"{len(page.metalines)} lines" lines = f"{len(page.metalines)} lines"
info = f"{mime} {encoding} {size} {lines}" info = f"{mime} {encoding} {size} {lines}"
@ -810,18 +812,17 @@ class Browser:
def toggle_render_mode(self): def toggle_render_mode(self):
"""Switch to the next render mode for the current page.""" """Switch to the next render mode for the current page."""
page = self.current_page page = self.current_page
if not page or page.render is None: if not page or page.render_opts is None:
return return
if page.render not in RENDER_MODES: render_opts = page.render_opts
current_mode = render_opts.mode
if current_mode not in RENDER_MODES:
next_mode = RENDER_MODES[0] next_mode = RENDER_MODES[0]
else: else:
cur_mod_index = RENDER_MODES.index(page.render) cur_mod_index = RENDER_MODES.index(current_mode)
next_mode = RENDER_MODES[(cur_mod_index + 1) % len(RENDER_MODES)] next_mode = RENDER_MODES[(cur_mod_index + 1) % len(RENDER_MODES)]
new_page = Page.from_gemtext( render_opts.mode = next_mode
page.source, new_page = Page.from_gemtext(page.source, render_opts)
wrap_at=self.config["text_width"],
render=next_mode
)
self.load_page(new_page) self.load_page(new_page)
self.set_status(f"Using render mode '{next_mode}'.") self.set_status(f"Using render mode '{next_mode}'.")
@ -833,8 +834,8 @@ class Browser:
if not search: if not search:
return return
self.search_res_lines = [] self.search_res_lines = []
for index, (_, line) in enumerate(self.current_page.metalines): for index, (_, ltext, _) in enumerate(self.current_page.metalines):
if search in line: if search in ltext:
self.search_res_lines.append(index) self.search_res_lines.append(index)
if self.search_res_lines: if self.search_res_lines:
self.move_to_search_result(Browser.SEARCH_NEXT) self.move_to_search_result(Browser.SEARCH_NEXT)

View file

@ -5,7 +5,7 @@ from pathlib import Path
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
from bebop.browser.browser import Browser from bebop.browser.browser import Browser
from bebop.page import Page from bebop.page import Page, get_render_options
def open_file(browser: Browser, filepath: str, encoding="utf-8"): def open_file(browser: Browser, filepath: str, encoding="utf-8"):
@ -36,7 +36,7 @@ def open_file(browser: Browser, filepath: str, encoding="utf-8"):
browser.set_status_error(f"Failed to open file: {exc}") browser.set_status_error(f"Failed to open file: {exc}")
return None return None
if path.suffix == ".gmi": if path.suffix == ".gmi":
page = Page.from_gemtext(text, browser.config["text_width"]) page = Page.from_gemtext(text, get_render_options(browser.config))
else: else:
page = Page.from_text(text) page = Page.from_text(text)
browser.load_page(page) browser.load_page(page)
@ -48,8 +48,8 @@ def open_file(browser: Browser, filepath: str, encoding="utf-8"):
if entry.is_dir(): if entry.is_dir():
name += "/" name += "/"
gemtext += f"=> {entry_path} {name}\n" gemtext += f"=> {entry_path} {name}\n"
wrap_at = browser.config["text_width"] render_opts = get_render_options(browser.config)
browser.load_page(Page.from_gemtext(gemtext, wrap_at)) browser.load_page(Page.from_gemtext(gemtext, render_opts))
file_url = f"file://{path}" file_url = f"file://{path}"
browser.current_url = file_url browser.current_url = file_url
return file_url return file_url

View file

@ -12,7 +12,7 @@ from bebop.identity import (
get_identities_for_url, load_identities, save_identities get_identities_for_url, load_identities, save_identities
) )
from bebop.navigation import set_parameter from bebop.navigation import set_parameter
from bebop.page import Page from bebop.page import Page, get_render_options
from bebop.preferences import get_url_render_mode_pref from bebop.preferences import get_url_render_mode_pref
from bebop.protocol import Request, Response from bebop.protocol import Request, Response
from bebop.tofu import trust_fingerprint, untrust_fingerprint, WRONG_FP_ALERT from bebop.tofu import trust_fingerprint, untrust_fingerprint, WRONG_FP_ALERT
@ -138,7 +138,7 @@ def _handle_untrusted_cert(browser: Browser, request: Request):
) )
alert_page = Page.from_gemtext( alert_page = Page.from_gemtext(
alert_page_source, alert_page_source,
browser.config["text_width"] get_render_options(browser.config)
) )
browser.load_page(alert_page) browser.load_page(alert_page)
@ -214,13 +214,11 @@ def _handle_successful_response(browser: Browser, response: Response, url: str):
except LookupError: except LookupError:
error = f"Unknown encoding {encoding}." error = f"Unknown encoding {encoding}."
else: else:
text_width = browser.config["text_width"] render_opts = get_render_options(browser.config)
render_mode = get_url_render_mode_pref( pref_mode = get_url_render_mode_pref(browser.capsule_prefs, url)
browser.capsule_prefs, if pref_mode:
url, render_opts.mode = pref_mode
browser.config["render_mode"] page = Page.from_gemtext(text, render_opts)
)
page = Page.from_gemtext(text, text_width, render=render_mode)
else: else:
encoding = "utf-8" encoding = "utf-8"
text = response.content.decode(encoding, errors="replace") text = response.content.decode(encoding, errors="replace")

View file

@ -32,10 +32,9 @@ DEFAULT_CONFIG = {
"scroll_step": 3, "scroll_step": 3,
"persistent_history": False, "persistent_history": False,
"enabled_plugins": [], "enabled_plugins": [],
"list_item_bullet": "",
} }
RENDER_MODES = ("fancy", "dumb")
def load_config(config_path: Path): def load_config(config_path: Path):
if not config_path.is_file(): if not config_path.is_file():

View file

@ -63,9 +63,10 @@ def parse_gemtext(text: str, dumb=False) -> ParsedGemtext:
preformatted = None preformatted = None
for line in text.splitlines(): for line in text.splitlines():
line = line.rstrip() line = line.rstrip()
# In standard mode, discard empty lines. In dumb mode, empty lines are # Empty lines:
# kept as basic text. # - in standard mode, discard them, except for preformatted blocks.
if not line and not dumb: # - in dumb mode, keep them.
if not line and not (dumb or preformatted):
continue continue
if line.startswith(Preformatted.FENCE): if line.startswith(Preformatted.FENCE):

View file

@ -38,7 +38,7 @@ Keybinds using the SHIFT key are written uppercase. Keybinds using the ALT (or M
* y: open history * y: open history
* digits: go to the corresponding link ID * digits: go to the corresponding link ID
* escape: reset status line text * escape: reset status line text
* section sign (§): toggle between render modes for the current page * m: use another render mode for the current page
* slash (/): search for some text * slash (/): search for some text
* n: go to next search result * n: go to next search result
* N: go to previous search result * N: go to previous search result
@ -72,6 +72,7 @@ Here are the available options:
* generate_client_cert_command (see note 3): command to generate a client cert. * generate_client_cert_command (see note 3): command to generate a client cert.
* history_limit (int): maximum entries in history. * history_limit (int): maximum entries in history.
* home (string): home page. * home (string): home page.
* list_item_bullet (string): text shown before every list item.
* persistent_history (bool): save and reload history. * persistent_history (bool): save and reload history.
* render_mode (string): default render mode to use ("fancy" or "dumb"). * render_mode (string): default render mode to use ("fancy" or "dumb").
* scroll_step (int): number of lines/columns to scroll in one step. * scroll_step (int): number of lines/columns to scroll in one step.
@ -102,11 +103,11 @@ def get_help(config, plugins):
) )
config_list = "\n".join( config_list = "\n".join(
( (
f"* {key} = {value} (default {repr(DEFAULT_CONFIG[key])})" f"* {key} = {config[key]} (default {repr(DEFAULT_CONFIG[key])})"
if value != DEFAULT_CONFIG[key] if config[key] != DEFAULT_CONFIG[key]
else f"* {key} = {value}" else f"* {key} = {config[key]}"
) )
for key, value in config.items() for key in sorted(config)
) )
return HELP_PAGE.format( return HELP_PAGE.format(
plugin_commands=plugin_commands, plugin_commands=plugin_commands,

View file

@ -6,9 +6,22 @@ displayed, along with associated meta-data such as its type or a link's URL.
Note that metalines can be generated by custom functions without relying on the Note that metalines can be generated by custom functions without relying on the
elements classes as they are quite coupled to Gemtext parsing/rendering. elements classes as they are quite coupled to Gemtext parsing/rendering.
The metalines are tuples (ltype, line, lextra):
- ltype is the LineType.
- line is the text content itself.
- lextra is either a dict of additional data, or None.
The lextra part is currently only used for links, and can contain the following
keys:
- url: the URL the link on this line refers to. Note that this key is present
only for the first line of the link, i.e. long link descriptions wrapped on
multiple lines will not have a this key except for the first line.
- link_id: only alongside "url" key, ID generated for this link.
""" """
import string import string
from dataclasses import dataclass
from enum import IntEnum from enum import IntEnum
from typing import List from typing import List
@ -18,7 +31,6 @@ from bebop.gemtext import (
SPLIT_CHARS = " \t-" SPLIT_CHARS = " \t-"
JOIN_CHAR = "-" JOIN_CHAR = "-"
LIST_ITEM_MARK = ""
class LineType(IntEnum): class LineType(IntEnum):
@ -39,27 +51,26 @@ class LineType(IntEnum):
ERROR = 9 # Not part of Gemtext but useful internally. ERROR = 9 # Not part of Gemtext but useful internally.
def generate_metalines(elements, width, dumb=False): RENDER_MODES = ("fancy", "dumb")
"""Format elements into a list of lines with metadata.
The returned list ("metalines") are tuples (meta, line), meta being a
dict of metadata and a text line to display. Currently the only metadata @dataclass
keys used are: class RenderOptions:
- type: one of the Renderer.TYPE constants. """Rendering options."""
- url: only for links, the URL the link on this line refers to. Note width: int
that this key is present only for the first line of the link, i.e. mode: str
long link descriptions wrapped on multiple lines will not have a this bullet: str
key except for the first line.
- link_id: only alongside "url" key, ID generated for this link.
def generate_metalines(elements: list, options: RenderOptions) -> list:
"""Format elements into a list of lines with metadata.
Arguments: Arguments:
- elements: list of elements to use. - elements: list of elements to use.
- width: max text width to use. - options: RenderOptions to respect when generating metalines.
- dumb: if True, standard presentation margins are ignored.
""" """
metalines = [] metalines = []
context = {"width": width} separator = (LineType.NONE, "", None)
separator = ({"type": LineType.NONE}, "")
has_margins = False has_margins = False
thin_type = None thin_type = None
for index, element in enumerate(elements): for index, element in enumerate(elements):
@ -68,30 +79,30 @@ def generate_metalines(elements, width, dumb=False):
has_margins = False has_margins = False
thin_type = None thin_type = None
if isinstance(element, Title): if isinstance(element, Title):
element_metalines = format_title(element, context) element_metalines = format_title(element, options)
has_margins = True has_margins = True
elif isinstance(element, Paragraph): elif isinstance(element, Paragraph):
element_metalines = format_paragraph(element, context) element_metalines = format_paragraph(element, options)
has_margins = True has_margins = True
elif isinstance(element, Link): elif isinstance(element, Link):
element_metalines = format_link(element, context) element_metalines = format_link(element, options)
thin_type = LineType.LINK thin_type = LineType.LINK
elif isinstance(element, Preformatted): elif isinstance(element, Preformatted):
element_metalines = format_preformatted(element, context) element_metalines = format_preformatted(element, options)
has_margins = True has_margins = True
elif isinstance(element, Blockquote): elif isinstance(element, Blockquote):
element_metalines = format_blockquote(element, context) element_metalines = format_blockquote(element, options)
has_margins = True has_margins = True
elif isinstance(element, ListItem): elif isinstance(element, ListItem):
element_metalines = format_list_item(element, context) element_metalines = format_list_item(element, options)
thin_type = LineType.LIST_ITEM thin_type = LineType.LIST_ITEM
else: else:
continue continue
# In dumb mode, elements producing no metalines still need to be # In dumb mode, elements producing no metalines still need to be
# rendered as empty lines. # rendered as empty lines.
if dumb: if options.mode == "dumb":
if not element_metalines: if not element_metalines:
element_metalines = [({"type": LineType.PARAGRAPH}, "")] element_metalines = [(LineType.PARAGRAPH, "", None)]
# If current element requires margins and is not the first elements, # If current element requires margins and is not the first elements,
# separate from previous element. Also do it if the current element does # separate from previous element. Also do it if the current element does
# not require margins but follows an element that required it (e.g. link # not require margins but follows an element that required it (e.g. link
@ -110,12 +121,12 @@ def generate_metalines(elements, width, dumb=False):
def generate_dumb_metalines(lines): def generate_dumb_metalines(lines):
"""Generate dumb metalines: all lines are given the PARAGRAPH line type.""" """Generate dumb metalines: all lines are given the PARAGRAPH line type."""
return [({"type": LineType.PARAGRAPH}, line) for line in lines] return [(LineType.PARAGRAPH, line, None) for line in lines]
def format_title(title: Title, context: dict): def format_title(title: Title, options: RenderOptions):
"""Return metalines for this title.""" """Return metalines for this title."""
width = context["width"] width = options.width
if title.level == 1: if title.level == 1:
wrapped = wrap_words(title.text, width) wrapped = wrap_words(title.text, width)
line_template = f"{{:^{width}}}" line_template = f"{{:^{width}}}"
@ -126,55 +137,51 @@ def format_title(title: Title, context: dict):
else: else:
lines = wrap_words(title.text, width) lines = wrap_words(title.text, 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 [(LineType(title.level), line, None) for line in lines]
def format_paragraph(paragraph: Paragraph, context: dict): def format_paragraph(paragraph: Paragraph, options: RenderOptions):
"""Return metalines for this paragraph.""" """Return metalines for this paragraph."""
lines = wrap_words(paragraph.text, context["width"]) lines = wrap_words(paragraph.text, options.width)
return [({"type": LineType.PARAGRAPH}, line) for line in lines] return [(LineType.PARAGRAPH, line, None) for line in lines]
def format_link(link: Link, context: dict): def format_link(link: Link, options: RenderOptions):
"""Return metalines for this link.""" """Return metalines for this link."""
# Get a new link and build the "[id]" anchor. # Get a new link and build the "[id]" anchor.
link_anchor = f"[{link.ident}] " link_anchor = f"[{link.ident}] "
link_text = link.text or link.url link_text = link.text or link.url
# Wrap lines, indented by the link anchor length. # Wrap lines, indented by the link anchor length.
lines = wrap_words(link_text, context["width"], indent=len(link_anchor)) lines = wrap_words(link_text, options.width, indent=len(link_anchor))
first_line_meta = { first_line_extra = {
"type": LineType.LINK,
"url": link.url, "url": link.url,
"link_id": link.ident "link_id": link.ident
} }
# Replace first line indentation with the anchor. # Replace first line indentation with the anchor.
first_line_text = link_anchor + lines[0][len(link_anchor):] first_line_text = link_anchor + lines[0][len(link_anchor):]
first_line = [(first_line_meta, first_line_text)] first_line = [(LineType.LINK, first_line_text, first_line_extra)]
other_lines = [({"type": LineType.LINK}, line) for line in lines[1:]] other_lines = [(LineType.LINK, line, None) for line in lines[1:]]
return first_line + other_lines return first_line + other_lines # type: ignore
def format_preformatted(preformatted: Preformatted, context: dict): def format_preformatted(preformatted: Preformatted, options: RenderOptions):
"""Return metalines for this preformatted block.""" """Return metalines for this preformatted block."""
return [ return [(LineType.PREFORMATTED, line, None) for line in preformatted.lines]
({"type": LineType.PREFORMATTED}, line)
for line in preformatted.lines
]
def format_blockquote(blockquote: Blockquote, context: dict): def format_blockquote(blockquote: Blockquote, options: RenderOptions):
"""Return metalines for this blockquote.""" """Return metalines for this blockquote."""
lines = wrap_words(blockquote.text, context["width"], indent=2) lines = wrap_words(blockquote.text, options.width, indent=2)
return [({"type": LineType.BLOCKQUOTE}, line) for line in lines] return [(LineType.BLOCKQUOTE, line, None) for line in lines]
def format_list_item(item: ListItem, context: dict): def format_list_item(item: ListItem, options: RenderOptions):
"""Return metalines for this list item.""" """Return metalines for this list item."""
indent = len(LIST_ITEM_MARK) indent = len(options.bullet)
lines = wrap_words(item.text, context["width"], indent=indent) lines = wrap_words(item.text, options.width, indent=indent)
first_line = LIST_ITEM_MARK + lines[0][indent:] first_line = options.bullet + lines[0][indent:]
lines[0] = first_line lines[0] = first_line
return [({"type": LineType.LIST_ITEM}, line) for line in lines] return [(LineType.LIST_ITEM, line, None) for line in lines]
def wrap_words(text: str, width: int, indent: int =0) -> List[str]: def wrap_words(text: str, width: int, indent: int =0) -> List[str]:

View file

@ -2,11 +2,21 @@ from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from bebop.gemtext import parse_gemtext from bebop.gemtext import parse_gemtext
from bebop.metalines import generate_dumb_metalines, generate_metalines from bebop.metalines import (
RenderOptions, generate_dumb_metalines, generate_metalines)
from bebop.mime import MimeType from bebop.mime import MimeType
from bebop.links import Links from bebop.links import Links
def get_render_options(config: dict):
"""Prepare RenderOptions from the user config."""
return RenderOptions(
width=config["text_width"],
mode=config["render_mode"],
bullet=config["list_item_bullet"],
)
@dataclass @dataclass
class Page: class Page:
"""Page-related data. """Page-related data.
@ -21,7 +31,7 @@ class Page:
- title: optional page title. - title: optional page title.
- mime: optional MIME type received from the server. - mime: optional MIME type received from the server.
- encoding: optional encoding received from the server. - encoding: optional encoding received from the server.
- render: optional render mode used to create the page from Gemtext. - render_opts: optional render options used to create the page from Gemtext.
""" """
source: str source: str
metalines: list = field(default_factory=list) metalines: list = field(default_factory=list)
@ -29,15 +39,21 @@ class Page:
title: str = "" title: str = ""
mime: Optional[MimeType] = None mime: Optional[MimeType] = None
encoding: str = "" encoding: str = ""
render: Optional[str] = None render_opts: Optional[RenderOptions] = None
@staticmethod @staticmethod
def from_gemtext(gemtext: str, wrap_at: int, render: str ="fancy"): def from_gemtext(gemtext: str, options: RenderOptions):
"""Produce a Page from a Gemtext file or string.""" """Produce a Page from a Gemtext file or string."""
dumb_mode = render == "dumb" dumb = options.mode == "dumb"
elements, links, title = parse_gemtext(gemtext, dumb=dumb_mode) elements, links, title = parse_gemtext(gemtext, dumb=dumb)
metalines = generate_metalines(elements, wrap_at, dumb=dumb_mode) metalines = generate_metalines(elements, options)
return Page(gemtext, metalines, links, title, render=render) return Page(
gemtext,
metalines,
links,
title,
render_opts=options
)
@staticmethod @staticmethod
def from_text(text: str): def from_text(text: str):

View file

@ -4,7 +4,7 @@ Currently contains only overrides for render modes, per URL path. In the future
it may be interesting to move a few things from the config to here, such as the it may be interesting to move a few things from the config to here, such as the
text width. text width.
This is a map from an URL to dicts. The only used key is render_mode. This is a map from URLs to dicts. The only used key is render_mode.
""" """
import json import json
@ -25,7 +25,7 @@ def load_capsule_prefs(prefs_path: Path) -> Optional[dict]:
return prefs return prefs
def save_capsule_prefs(prefs: dict, prefs_path: Path): def save_capsule_prefs(prefs: dict, prefs_path: Path) -> bool:
"""Save the capsule preferences. Return True on success.""" """Save the capsule preferences. Return True on success."""
try: try:
with open(prefs_path, "wt") as prefs_file: with open(prefs_path, "wt") as prefs_file:
@ -36,7 +36,7 @@ def save_capsule_prefs(prefs: dict, prefs_path: Path):
return True return True
def get_url_render_mode_pref(prefs: dict, url: str, default: str): def get_url_render_mode_pref(prefs: dict, url: str) -> Optional[str]:
"""Return the desired render mode for this URL. """Return the desired render mode for this URL.
If the preferences contain the URL or a parent URL, the corresponding render If the preferences contain the URL or a parent URL, the corresponding render
@ -46,14 +46,13 @@ def get_url_render_mode_pref(prefs: dict, url: str, default: str):
Arguments: Arguments:
- prefs: current capsule preferences. - prefs: current capsule preferences.
- url: URL about to be rendered. - url: URL about to be rendered.
- default: default render mode if no user preferences match.
""" """
prefix_urls = [] prefix_urls = []
for key in prefs: for key in prefs:
if url.startswith(key): if url.startswith(key):
prefix_urls.append(key) prefix_urls.append(key)
if not prefix_urls: if not prefix_urls:
return default return None
key = max(prefix_urls, key=len) key = max(prefix_urls, key=len)
preference = prefs[key] preference = prefs[key]
return preference.get("render_mode", default) return preference.get("render_mode")

View file

@ -1,4 +1,4 @@
"""Rendering Gemtext in curses.""" """Rendering metalines in curses."""
import curses import curses
@ -37,13 +37,12 @@ def render_lines(metalines, window, max_width):
def render_line(metaline, window, max_width): def render_line(metaline, window, max_width):
"""Write a single line to the window.""" """Write a single line to the window."""
meta, line = metaline ltype, ltext, lextra = metaline
line_type = meta["type"] attributes = get_base_line_attributes(ltype)
attributes = get_base_line_attributes(line_type) line = ltext[:max_width - 1]
line = line[:max_width - 1]
window.addstr(line, attributes) window.addstr(line, attributes)
if meta["type"] == LineType.LINK and "url" in meta: if ltype == LineType.LINK and lextra and "url" in lextra:
url_text = f' {meta["url"]}' url_text = f' {lextra["url"]}'
attributes = ( attributes = (
curses.color_pair(ColorPair.LINK_PREVIEW) curses.color_pair(ColorPair.LINK_PREVIEW)
| curses.A_DIM | curses.A_DIM

View file

@ -6,49 +6,41 @@ class TestPreferences(unittest.TestCase):
def test_get_url_render_mode_pref(self): def test_get_url_render_mode_pref(self):
prefs = {} prefs = {}
self.assertEqual(get_url_render_mode_pref( self.assertIsNone(get_url_render_mode_pref(
prefs, prefs,
"gemini://example.com", "gemini://example.com",
"default" ))
), "default")
prefs["gemini://example.com"] = {} prefs["gemini://example.com"] = {}
self.assertEqual(get_url_render_mode_pref( self.assertIsNone(get_url_render_mode_pref(
prefs, prefs,
"gemini://example.com", "gemini://example.com",
"default" ))
), "default")
prefs["gemini://example.com"] = {"render_mode": "test"} prefs["gemini://example.com"] = {"render_mode": "test"}
self.assertEqual(get_url_render_mode_pref( self.assertEqual(get_url_render_mode_pref(
prefs, prefs,
"gemini://example.com", "gemini://example.com",
"default"
), "test") ), "test")
self.assertEqual(get_url_render_mode_pref( self.assertEqual(get_url_render_mode_pref(
prefs, prefs,
"gemini://example.com/path", "gemini://example.com/path",
"default"
), "test") ), "test")
prefs["gemini://example.com/specific/subdir"] = {"render_mode": "test2"} prefs["gemini://example.com/specific/subdir"] = {"render_mode": "test2"}
self.assertEqual(get_url_render_mode_pref( self.assertEqual(get_url_render_mode_pref(
prefs, prefs,
"gemini://example.com/path", "gemini://example.com/path",
"default"
), "test") ), "test")
self.assertEqual(get_url_render_mode_pref( self.assertEqual(get_url_render_mode_pref(
prefs, prefs,
"gemini://example.com/specific", "gemini://example.com/specific",
"default"
), "test") ), "test")
self.assertEqual(get_url_render_mode_pref( self.assertEqual(get_url_render_mode_pref(
prefs, prefs,
"gemini://example.com/specific/subdir", "gemini://example.com/specific/subdir",
"default"
), "test2") ), "test2")
self.assertEqual(get_url_render_mode_pref( self.assertEqual(get_url_render_mode_pref(
prefs, prefs,
"gemini://example.com/specific/subdir/subsubdir", "gemini://example.com/specific/subdir/subsubdir",
"default"
), "test2") ), "test2")

View file

@ -13,17 +13,20 @@ WELCOME_PAGE = """\
# Welcome to the Bebop browser! 🚀 # Welcome to the Bebop browser! 🚀
Press "?" or type ":help" and enter to see the keybinds, commands and more, \ Press "?" or type ":help" and press enter to see the keybinds, commands and \
or visit the link below by pressing its link ID (1). To start browsing \ more, or visit the link below by pressing its link ID (1). To start browsing \
right away, press "o", type an URL and press Enter. right away, press "o", type an URL and press Enter.
=> bebop:help Offline help page => bebop:help Offline help page
=> gemini://dece.space/dev/bebop.gmi Online Bebop home => gemini://dece.space/dev/bebop.gmi Online Bebop home
To quit Bebop, type `:q` and press enter.
You can configure which page to show up when starting Bebop instead of this \ You can configure which page to show up when starting Bebop instead of this \
one: set your home URL in the "home" key of your configuration file. one: set your home URL in the "home" key of your configuration file.
New to Gemini? Check out this catalog of capsules to get you started: New to Gemini? Check out these capsules to get you started:
=> gemini://medusae.space/ medusae.space => gemini://geminiquickst.art Gemini Quickstart
=> gemini://medusae.space/ The medusae.space catalog
""" """

View file

@ -1,6 +1,6 @@
[metadata] [metadata]
name = bebop-browser-gopher name = bebop-browser-gopher
version = 0.1.0 version = 0.1.1
description = Gopher plugin for the Bebop terminal browser description = Gopher plugin for the Bebop terminal browser
long_description = file: README.md long_description = file: README.md
license = GPLv3 license = GPLv3

View file

@ -1,6 +1,6 @@
[metadata] [metadata]
name = bebop-browser name = bebop-browser
version = 0.3.0 version = 0.3.1
description = Terminal browser for Gemini description = Terminal browser for Gemini
long_description = file: README.md long_description = file: README.md
license = GPLv3 license = GPLv3