page: rework rendering params into RenderOptions

Also make the list item bullet configurable, if you don't like the •.
This commit is contained in:
dece 2021-06-28 00:49:44 +02:00
parent 8d82c1bd53
commit 7ed83f9389
9 changed files with 88 additions and 76 deletions

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
@ -671,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
@ -813,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}'.")

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

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

View file

@ -9,6 +9,7 @@ elements classes as they are quite coupled to Gemtext parsing/rendering.
""" """
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 +19,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,7 +39,18 @@ 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")
@dataclass
class RenderOptions:
"""Rendering options."""
width: int
mode: str
bullet: str
def generate_metalines(elements: list, options: RenderOptions) -> list:
"""Format elements into a list of lines with metadata. """Format elements into a list of lines with metadata.
The returned list ("metalines") are tuples (meta, line), meta being a The returned list ("metalines") are tuples (meta, line), meta being a
@ -54,11 +65,9 @@ def generate_metalines(elements, width, dumb=False):
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 = ({"type": LineType.NONE}, "") separator = ({"type": LineType.NONE}, "")
has_margins = False has_margins = False
thin_type = None thin_type = None
@ -68,28 +77,28 @@ 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 = [({"type": LineType.PARAGRAPH}, "")]
# If current element requires margins and is not the first elements, # If current element requires margins and is not the first elements,
@ -113,9 +122,9 @@ def generate_dumb_metalines(lines):
return [({"type": LineType.PARAGRAPH}, line) for line in lines] return [({"type": LineType.PARAGRAPH}, line) 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}}}"
@ -129,19 +138,19 @@ def format_title(title: Title, context: dict):
return [({"type": LineType(title.level)}, line) for line in lines] return [({"type": LineType(title.level)}, line) 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 [({"type": LineType.PARAGRAPH}, line) 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_meta = {
"type": LineType.LINK, "type": LineType.LINK,
"url": link.url, "url": link.url,
@ -154,7 +163,7 @@ def format_link(link: Link, context: dict):
return first_line + other_lines return first_line + other_lines
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 [
({"type": LineType.PREFORMATTED}, line) ({"type": LineType.PREFORMATTED}, line)
@ -162,17 +171,17 @@ def format_preformatted(preformatted: Preformatted, context: dict):
] ]
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 [({"type": LineType.BLOCKQUOTE}, line) 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 [({"type": LineType.LIST_ITEM}, line) for line in lines]

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

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

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