Compare commits

..

No commits in common. "0b1a98fb73035c1600cccdfb714bebc05873ecc8" and "0dd29c63aeddd62dd11673e09aece42443b58929" have entirely different histories.

14 changed files with 49 additions and 285 deletions

View file

@ -1,8 +1,11 @@
TODO TODO
---------------------------------------- ----------------------------------------
dumb rendering mode per site
well, preferences per site maybe?
does encoding really work? cf. egsam
add metadata to status bar
more UT more UT
setup.py setup.py
make client cert gen configurable
@ -19,17 +22,12 @@ buffers (tabs)
a11y? tts? a11y? tts?
handle soft-hyphens on wrapping handle soft-hyphens on wrapping
bug: combining chars reduce lengths bug: combining chars reduce lengths
use a pad for command-line non shit command-line
use a pad for status bar
response code 11 (if still there) response code 11 (if still there)
gopher? gopher?
opt. maintain history between sessions opt. maintain history between sessions
history (forward) (useful?) history (forward) (useful?)
search in page (ugh) search in page (ugh)
remember scroll pos in history
identity management
"previous/next" pages
directory view for file scheme
@ -55,5 +53,3 @@ media files
identity management identity management
logging logging
home page home page
different rendering mode
preferences per site

View file

@ -17,10 +17,9 @@ from bebop.bookmarks import (
save_bookmark, save_bookmark,
) )
from bebop.colors import ColorPair, init_colors from bebop.colors import 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_identities_list_path
from bebop.help import get_help 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
@ -37,7 +36,6 @@ from bebop.navigation import (
) )
from bebop.page import Page from bebop.page import Page
from bebop.page_pad import PagePad from bebop.page_pad import PagePad
from bebop.preferences import load_capsule_prefs, save_capsule_prefs
from bebop.welcome import WELCOME_PAGE from bebop.welcome import WELCOME_PAGE
@ -79,7 +77,7 @@ class Browser:
self.cache = {} self.cache = {}
self.special_pages = self.setup_special_pages() self.special_pages = self.setup_special_pages()
self.last_download: Optional[Tuple[MimeType, Path]] = None self.last_download: Optional[Tuple[MimeType, Path]] = None
self.identities = {} self.identities = load_identities(get_identities_list_path()) or {}
self._current_url = "" self._current_url = ""
@property @property
@ -149,25 +147,7 @@ class Browser:
self.config["command_editor"] self.config["command_editor"]
) )
failed_to_load = [] if start_url:
identities = load_identities(get_identities_list_path())
if identities is None:
failed_to_load.append("identities")
else:
self.identities = identities
capsule_prefs = load_capsule_prefs(get_capsule_prefs_path())
if capsule_prefs is None:
failed_to_load.append("capsule preferences")
else:
self.capsule_prefs = capsule_prefs
if failed_to_load:
error_msg = (
f"Failed to open some local data: {', '.join(failed_to_load)}. "
"Some data may be lost if you continue."
)
self.set_status_error(error_msg)
elif start_url:
self.open_url(start_url) self.open_url(start_url)
else: else:
self.open_home() self.open_home()
@ -228,8 +208,6 @@ 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("§"):
self.toggle_render_mode()
elif curses.ascii.isdigit(char): elif curses.ascii.isdigit(char):
self.handle_digit_input(char) self.handle_digit_input(char)
elif char == curses.KEY_MOUSE: elif char == curses.KEY_MOUSE:
@ -251,6 +229,15 @@ class Browser:
self.scroll_page_vertically(-1) self.scroll_page_vertically(-1)
elif char == ord("l"): elif char == ord("l"):
self.scroll_page_horizontally(1) self.scroll_page_horizontally(1)
# elif char == ord("@"):
# self.current_url = "bebop:debugzone"
# t = "\n".join("* " + u for u in self.history.urls)
# t += "\n\n" + "\n".join("* " + u for u in self.history.backlist)
# self.load_page(Page.from_text(t))
# # unctrled = curses.unctrl(char)
# # if unctrled == b"^T":
# # self.set_status("test!")
# pass
@property @property
def page_pad_size(self): def page_pad_size(self):
@ -319,22 +306,16 @@ class Browser:
command = words[0] command = words[0]
if num_words == 1: if num_words == 1:
if command == "help": if command in ("q", "quit"):
self.open_help()
elif command in ("q", "quit"):
self.running = False self.running = False
elif command in ("h", "home"): elif command in ("h", "home"):
self.open_home() self.open_home()
elif command in ("i", "info"):
self.show_page_info()
return return
if command in ("o", "open"): if command in ("o", "open"):
self.open_url(words[1]) self.open_url(words[1])
elif command == "forget-certificate": elif command == "forget-certificate":
from bebop.browser.gemini import forget_certificate from bebop.browser.gemini import forget_certificate
forget_certificate(self, words[1]) forget_certificate(self, words[1])
elif command == "set-render-mode":
self.set_render_mode(words[1])
def get_user_text_input(self, status_text, char, prefix="", strip=False): def get_user_text_input(self, status_text, char, prefix="", strip=False):
"""Get user input from the command-line.""" """Get user input from the command-line."""
@ -434,7 +415,7 @@ class Browser:
self.set_status_error(f"Protocol '{scheme}' not supported.") self.set_status_error(f"Protocol '{scheme}' not supported.")
def load_page(self, page: Page): def load_page(self, page: Page):
"""Set this page as the current page and refresh appropriate windows.""" """Load Gemtext data as the current page."""
old_pad_height = self.page_pad.dim[0] old_pad_height = self.page_pad.dim[0]
self.page_pad.show_page(page) self.page_pad.show_page(page)
if self.page_pad.dim[0] < old_pad_height: if self.page_pad.dim[0] < old_pad_height:
@ -676,52 +657,3 @@ class Browser:
def open_welcome_page(self): def open_welcome_page(self):
"""Open the default welcome page.""" """Open the default welcome page."""
self.open_internal_page("welcome", WELCOME_PAGE) self.open_internal_page("welcome", WELCOME_PAGE)
def show_page_info(self):
"""Show some page informations in the status bar."""
if not self.page_pad or not self.page_pad.current_page:
return
page = self.page_pad.current_page
mime = page.mime.short if page.mime else "(unknown MIME type)"
encoding = page.encoding or "(unknown encoding)"
size = f"{len(page.source)} chars"
info = f"{mime} {encoding} {size}"
self.set_status(info)
def set_render_mode(self, mode):
"""Set the render mode for the current path or capsule."""
if mode not in RENDER_MODES:
valid_modes = ", ".join(RENDER_MODES)
self.set_status_error("Valid render modes are: " + valid_modes)
return
url = self.get_user_text_input(
f"Set '{mode}' render mode for which URL (includes children)?",
CommandLine.CHAR_TEXT,
prefix=self.current_url,
strip=True
)
if not url:
return
prefs = self.capsule_prefs.get(url, {})
prefs["render_mode"] = mode
self.capsule_prefs[url] = prefs
save_capsule_prefs(self.capsule_prefs, get_capsule_prefs_path())
self.reload_page()
def toggle_render_mode(self):
"""Switch to the next render mode for the current page."""
if not self.page_pad or not self.page_pad.current_page:
return
page = self.page_pad.current_page
if page.render is None or page.render not in RENDER_MODES:
next_mode = RENDER_MODES[0]
else:
cur_mod_index = RENDER_MODES.index(page.render)
next_mode = RENDER_MODES[(cur_mod_index + 1) % len(RENDER_MODES)]
new_page = Page.from_gemtext(
page.source,
wrap_at=self.config["text_width"],
render=next_mode
)
self.load_page(new_page)
self.set_status(f"Using render mode '{next_mode}'.")

View file

@ -13,7 +13,6 @@ from bebop.identity import (
) )
from bebop.navigation import set_parameter from bebop.navigation import set_parameter
from bebop.page import Page from bebop.page import Page
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
@ -74,6 +73,7 @@ def open_gemini_url(
if use_cache and url in browser.cache: if use_cache and url in browser.cache:
browser.load_page(browser.cache[url]) browser.load_page(browser.cache[url])
browser.current_url = url browser.current_url = url
browser.set_status(url)
return url return url
logging.info( logging.info(
@ -214,20 +214,10 @@ 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"] page = Page.from_gemtext(text, browser.config["text_width"])
render_mode = get_url_render_mode_pref(
browser.capsule_prefs,
url,
browser.config["render_mode"]
)
page = Page.from_gemtext(text, text_width, render=render_mode)
else: else:
encoding = "utf-8" text = response.content.decode("utf-8", errors="replace")
text = response.content.decode(encoding, errors="replace")
page = Page.from_text(text) page = Page.from_text(text)
if page:
page.mime = mime_type
page.encoding = encoding
else: else:
download_dir = browser.config["download_path"] download_dir = browser.config["download_path"]
filepath = _get_download_path(url, download_dir=download_dir) filepath = _get_download_path(url, download_dir=download_dir)
@ -238,6 +228,7 @@ def _handle_successful_response(browser: Browser, response: Response, url: str):
browser.load_page(page) browser.load_page(page)
browser.current_url = url browser.current_url = url
browser.cache[url] = page browser.cache[url] = page
browser.set_status(url)
return url return url
elif filepath: elif filepath:
try: try:

View file

@ -15,11 +15,8 @@ DEFAULT_CONFIG = {
"external_commands": {}, "external_commands": {},
"external_command_default": ["xdg-open"], "external_command_default": ["xdg-open"],
"home": "bebop:welcome", "home": "bebop:welcome",
"render_mode": "fancy",
} }
RENDER_MODES = ("fancy", "dumb")
def load_config(config_path): def load_config(config_path):
if not os.path.isfile(config_path): if not os.path.isfile(config_path):

View file

@ -39,11 +39,12 @@ def get_downloads_path() -> Path:
if line.startswith("XDG_DOWNLOAD_DIR="): if line.startswith("XDG_DOWNLOAD_DIR="):
download_path = line.rstrip().split("=", maxsplit=1)[1] download_path = line.rstrip().split("=", maxsplit=1)[1]
download_path = download_path.strip('"') download_path = download_path.strip('"')
home = expanduser("~") download_path = download_path.replace("$HOME", expanduser("~"))
download_path = download_path.replace("$HOME", home) break
return Path(download_path)
except OSError: except OSError:
pass pass
if download_path:
return Path(download_path)
return Path.home() return Path.home()
@ -59,12 +60,6 @@ def get_identities_path():
return get_user_data_path() / "identities" return get_user_data_path() / "identities"
@lru_cache(None)
def get_capsule_prefs_path():
"""Return the directory where identities are stored."""
return get_user_data_path() / "capsule_prefs.json"
def ensure_bebop_files_exist() -> Optional[str]: def ensure_bebop_files_exist() -> Optional[str]:
"""Ensure various Bebop's files or directories are present. """Ensure various Bebop's files or directories are present.
@ -84,10 +79,5 @@ def ensure_bebop_files_exist() -> Optional[str]:
identities_path = get_identities_path() identities_path = get_identities_path()
if not identities_path.exists(): if not identities_path.exists():
identities_path.mkdir(parents=True) identities_path.mkdir(parents=True)
# Ensure the capsule preferences file exists.
capsule_prefs_path = get_capsule_prefs_path()
if not capsule_prefs_path.exists():
with open(capsule_prefs_path, "wt") as prefs_file:
prefs_file.write("{}")
except OSError as exc: except OSError as exc:
return str(exc) return str(exc)

View file

@ -54,7 +54,7 @@ class ListItem:
ParsedGemtext = namedtuple("ParsedGemtext", ("elements", "links", "title")) ParsedGemtext = namedtuple("ParsedGemtext", ("elements", "links", "title"))
def parse_gemtext(text: str, dumb=False) -> ParsedGemtext: def parse_gemtext(text: str) -> ParsedGemtext:
"""Parse a string of Gemtext into a list of elements.""" """Parse a string of Gemtext into a list of elements."""
elements = [] elements = []
links = Links() links = Links()
@ -63,9 +63,7 @@ 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 if not line:
# kept as basic text.
if not line and not dumb:
continue continue
if line.startswith(Preformatted.FENCE): if line.startswith(Preformatted.FENCE):

View file

@ -37,20 +37,16 @@ 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
* C-c: cancel current operation * C-c: cancel current operation
## Commands ## Commands
Commands are mostly for actions requiring user input. You can type a command with arguments by pressing the corresponding keybind above. Commands are mostly for actions requiring user input. You can type a command with arguments by pressing the corresponding keybind above.
* help: show this help page * o/open <url>: open this URL
* o(pen) <url>: open this URL * q/quit: well, quit
* q(uit): well, quit * h/home: open your home page
* h(ome): open your home page * forget_certificate <hostname>: remove saved fingerprint for this hostname
* i(nfo): show page informations
* forget-certificate <hostname>: remove saved fingerprint for this hostname
* set-render-mode: set render mode preference for the current URL
## Configuration ## Configuration
@ -67,7 +63,6 @@ Here are the available options:
* external_commands (see note 2): commands to open various files. * external_commands (see note 2): commands to open various files.
* external_command_default (see note 1): default command to open files. * external_command_default (see note 1): default command to open files.
* home (string): home page. * home (string): home page.
* render_mode (string): default render mode to use ("fancy" or "dumb").
Notes: Notes:

View file

@ -49,7 +49,7 @@ def save_identities(identities: dict, identities_path: Path):
"""Save the certificate stash. Return True on success.""" """Save the certificate stash. Return True on success."""
try: try:
with open(identities_path, "wt") as identities_file: with open(identities_path, "wt") as identities_file:
json.dump(identities, identities_file, indent=2) json.dump(identities, identities_file)
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
logging.error(f"Failed to save identities '{identities_path}': {exc}") logging.error(f"Failed to save identities '{identities_path}': {exc}")
return False return False

View file

@ -35,23 +35,18 @@ class LineType(IntEnum):
LIST_ITEM = 8 LIST_ITEM = 8
def generate_metalines(elements, width, dumb=False): def generate_metalines(elements, width):
"""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
dict of metadata and a text line to display. Currently the only metadata dict of metadata and line a text line to display. Currently the only
keys used are: metadata keys used are:
- type: one of the Renderer.TYPE constants. - type: one of the Renderer.TYPE constants.
- url: only for links, the URL the link on this line refers to. Note - url: only for links, 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. 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 long link descriptions wrapped on multiple lines will not have a this
key except for the first line. key except for the first line.
- link_id: only alongside "url" key, ID generated for this link. - link_id: only alongside "url" key, ID generated for this link.
Arguments:
- elements: list of elements to use.
- width: max text width to use.
- dumb: if True, standard presentation margins are ignored.
""" """
metalines = [] metalines = []
context = {"width": width} context = {"width": width}
@ -83,17 +78,12 @@ def generate_metalines(elements, width, dumb=False):
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
# rendered as empty lines.
if dumb:
if not element_metalines:
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,
# 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
# after a paragraph). Also do it if both the current and previous # after a paragraph). Also do it if both the current and previous
# elements do not require margins but differ in type. # elements do not require margins but differ in type.
elif ( if (
(has_margins and index > 0) (has_margins and index > 0)
or (not has_margins and previous_had_margins) or (not has_margins and previous_had_margins)
or (not has_margins and thin_type != last_thin_type) or (not has_margins and thin_type != last_thin_type)

View file

@ -25,9 +25,9 @@ class MimeType:
def from_str(mime_string) -> Optional["MimeType"]: def from_str(mime_string) -> Optional["MimeType"]:
"""Parse a MIME string into a MimeType instance, or None on error.""" """Parse a MIME string into a MimeType instance, or None on error."""
if ";" in mime_string: if ";" in mime_string:
type_str, *param_strs = mime_string.split(";") type_str, *parameters = mime_string.split(";")
parameters = {} parameters = {}
for param in map(lambda s: s.strip().lower(), param_strs): for param in map(lambda s: s.strip().lower(), parameters):
if param.count("=") != 1: if param.count("=") != 1:
return None return None
param_name, param_value = param.split("=") param_name, param_value = param.split("=")

View file

@ -1,9 +1,7 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
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 generate_dumb_metalines, generate_metalines
from bebop.mime import MimeType
from bebop.links import Links from bebop.links import Links
@ -19,25 +17,18 @@ class Page:
corresponding metalines, it is meant to be used as a quick map for link ID corresponding metalines, it is meant to be used as a quick map for link ID
lookup and disambiguation. lookup and disambiguation.
- title: optional page title. - title: optional page title.
- mime: optional MIME type received from the server.
- encoding: optional encoding received from the server.
- render: optional render mode used to create the page from Gemtext.
""" """
source: str source: str
metalines: list = field(default_factory=list) metalines: list = field(default_factory=list)
links: Links = field(default_factory=Links) links: Links = field(default_factory=Links)
title: str = "" title: str = ""
mime: Optional[MimeType] = None
encoding: str = ""
render: Optional[str] = None
@staticmethod @staticmethod
def from_gemtext(gemtext: str, wrap_at: int, render: str ="fancy"): def from_gemtext(gemtext: str, wrap_at: int):
"""Produce a Page from a Gemtext file or string.""" """Produce a Page from a Gemtext file or string."""
dumb_mode = render == "dumb" elements, links, title = parse_gemtext(gemtext)
elements, links, title = parse_gemtext(gemtext, dumb=dumb_mode) metalines = generate_metalines(elements, wrap_at)
metalines = generate_metalines(elements, wrap_at, dumb=dumb_mode) return Page(gemtext, metalines, links, title)
return Page(gemtext, metalines, links, title, render=render)
@staticmethod @staticmethod
def from_text(text: str): def from_text(text: str):

View file

@ -1,59 +0,0 @@
"""Per-capsule preferences.
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
text width.
This is a map from an URL to dicts. The only used key is render_mode.
"""
import json
import logging
from pathlib import Path
from typing import Optional
def load_capsule_prefs(prefs_path: Path) -> Optional[dict]:
"""Return saved capsule preferences or None on error."""
prefs = {}
try:
with open(prefs_path, "rt") as prefs_file:
prefs = json.load(prefs_file)
except (OSError, ValueError) as exc:
logging.error(f"Failed to load capsule prefs '{prefs_path}': {exc}")
return None
return prefs
def save_capsule_prefs(prefs: dict, prefs_path: Path):
"""Save the capsule preferences. Return True on success."""
try:
with open(prefs_path, "wt") as prefs_file:
json.dump(prefs, prefs_file, indent=2)
except (OSError, ValueError) as exc:
logging.error(f"Failed to save capsule prefs '{prefs_path}': {exc}")
return False
return True
def get_url_render_mode_pref(prefs: dict, url: str, default: str):
"""Return the desired render mode for this URL.
If the preferences contain the URL or a parent URL, the corresponding render
mode is used. If several URLs are prefixes of the `url` argument, the
longest one is used to get the matching preference.
Arguments:
- prefs: current capsule preferences.
- url: URL about to be rendered.
- default: default render mode if no user preferences match.
"""
prefix_urls = []
for key in prefs:
if url.startswith(key):
prefix_urls.append(key)
if not prefix_urls:
return default
key = max(prefix_urls, key=len)
preference = prefs[key]
return preference.get("render_mode", default)

View file

@ -1,54 +0,0 @@
import unittest
from ..preferences import get_url_render_mode_pref
class TestPreferences(unittest.TestCase):
def test_get_url_render_mode_pref(self):
prefs = {}
self.assertEqual(get_url_render_mode_pref(
prefs,
"gemini://example.com",
"default"
), "default")
prefs["gemini://example.com"] = {}
self.assertEqual(get_url_render_mode_pref(
prefs,
"gemini://example.com",
"default"
), "default")
prefs["gemini://example.com"] = {"render_mode": "test"}
self.assertEqual(get_url_render_mode_pref(
prefs,
"gemini://example.com",
"default"
), "test")
self.assertEqual(get_url_render_mode_pref(
prefs,
"gemini://example.com/path",
"default"
), "test")
prefs["gemini://example.com/specific/subdir"] = {"render_mode": "test2"}
self.assertEqual(get_url_render_mode_pref(
prefs,
"gemini://example.com/path",
"default"
), "test")
self.assertEqual(get_url_render_mode_pref(
prefs,
"gemini://example.com/specific",
"default"
), "test")
self.assertEqual(get_url_render_mode_pref(
prefs,
"gemini://example.com/specific/subdir",
"default"
), "test2")
self.assertEqual(get_url_render_mode_pref(
prefs,
"gemini://example.com/specific/subdir/subsubdir",
"default"
), "test2")

View file

@ -5,13 +5,10 @@ WELCOME_PAGE = """\
Hi! Welcome to the Bebop browser! 🚀🎶 Hi! Welcome to the Bebop browser! 🚀🎶
Press "?" or type ":help" and enter to see the keybinds, commands and more, \ Press "?" to see the keybinds, commands and more, or visit the link below by \
or visit the link below by pressing its link ID (1). To start browsing \ pressing its link ID (1). To start browsing right away, press "o", type an URL \
right away, press "o", type an URL and press Enter. and press Enter.
=> bebop:help Offline help page => bebop:help Help
=> gemini://dece.space/dev/bebop.gmi Online Bebop home => gemini://dece.space/dev/bebop.gmi Online documentation
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.
""" """