Compare commits
10 commits
843a88659f
...
a9e6b4a824
Author | SHA1 | Date | |
---|---|---|---|
dece | a9e6b4a824 | ||
dece | 09f30d3d00 | ||
dece | 3d73d14983 | ||
dece | a2051e082d | ||
dece | f7b4607ed6 | ||
dece | 016e4a49f9 | ||
dece | 7ed83f9389 | ||
dece | 8d82c1bd53 | ||
dece | ee9b637bae | ||
dece | dc6fb068bb |
51
ARCHITECTURE.md
Normal file
51
ARCHITECTURE.md
Normal 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.
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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():
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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]:
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Reference in a new issue