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
----------------------------------------
dumb rendering mode per site
well, preferences per site maybe?
does encoding really work? cf. egsam
add metadata to status bar
more UT
setup.py
make client cert gen configurable
@ -19,17 +22,12 @@ buffers (tabs)
a11y? tts?
handle soft-hyphens on wrapping
bug: combining chars reduce lengths
use a pad for command-line
use a pad for status bar
non shit command-line
response code 11 (if still there)
gopher?
opt. maintain history between sessions
history (forward) (useful?)
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
logging
home page
different rendering mode
preferences per site

View file

@ -17,10 +17,9 @@ from bebop.bookmarks import (
save_bookmark,
)
from bebop.colors import ColorPair, init_colors
from bebop.config import RENDER_MODES
from bebop.command_line import CommandLine
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.history import History
from bebop.identity import load_identities
@ -37,7 +36,6 @@ from bebop.navigation import (
)
from bebop.page import Page
from bebop.page_pad import PagePad
from bebop.preferences import load_capsule_prefs, save_capsule_prefs
from bebop.welcome import WELCOME_PAGE
@ -79,7 +77,7 @@ class Browser:
self.cache = {}
self.special_pages = self.setup_special_pages()
self.last_download: Optional[Tuple[MimeType, Path]] = None
self.identities = {}
self.identities = load_identities(get_identities_list_path()) or {}
self._current_url = ""
@property
@ -149,25 +147,7 @@ class Browser:
self.config["command_editor"]
)
failed_to_load = []
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:
if start_url:
self.open_url(start_url)
else:
self.open_home()
@ -228,8 +208,6 @@ class Browser:
self.edit_page()
elif char == ord("y"):
self.open_history()
elif char == ord("§"):
self.toggle_render_mode()
elif curses.ascii.isdigit(char):
self.handle_digit_input(char)
elif char == curses.KEY_MOUSE:
@ -251,6 +229,15 @@ class Browser:
self.scroll_page_vertically(-1)
elif char == ord("l"):
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
def page_pad_size(self):
@ -319,22 +306,16 @@ class Browser:
command = words[0]
if num_words == 1:
if command == "help":
self.open_help()
elif command in ("q", "quit"):
if command in ("q", "quit"):
self.running = False
elif command in ("h", "home"):
self.open_home()
elif command in ("i", "info"):
self.show_page_info()
return
if command in ("o", "open"):
self.open_url(words[1])
elif command == "forget-certificate":
from bebop.browser.gemini import forget_certificate
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):
"""Get user input from the command-line."""
@ -434,7 +415,7 @@ class Browser:
self.set_status_error(f"Protocol '{scheme}' not supported.")
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]
self.page_pad.show_page(page)
if self.page_pad.dim[0] < old_pad_height:
@ -676,52 +657,3 @@ class Browser:
def open_welcome_page(self):
"""Open the default 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.page import Page
from bebop.preferences import get_url_render_mode_pref
from bebop.protocol import Request, Response
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:
browser.load_page(browser.cache[url])
browser.current_url = url
browser.set_status(url)
return url
logging.info(
@ -214,20 +214,10 @@ def _handle_successful_response(browser: Browser, response: Response, url: str):
except LookupError:
error = f"Unknown encoding {encoding}."
else:
text_width = 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)
page = Page.from_gemtext(text, browser.config["text_width"])
else:
encoding = "utf-8"
text = response.content.decode(encoding, errors="replace")
text = response.content.decode("utf-8", errors="replace")
page = Page.from_text(text)
if page:
page.mime = mime_type
page.encoding = encoding
else:
download_dir = browser.config["download_path"]
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.current_url = url
browser.cache[url] = page
browser.set_status(url)
return url
elif filepath:
try:

View file

@ -15,11 +15,8 @@ DEFAULT_CONFIG = {
"external_commands": {},
"external_command_default": ["xdg-open"],
"home": "bebop:welcome",
"render_mode": "fancy",
}
RENDER_MODES = ("fancy", "dumb")
def load_config(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="):
download_path = line.rstrip().split("=", maxsplit=1)[1]
download_path = download_path.strip('"')
home = expanduser("~")
download_path = download_path.replace("$HOME", home)
return Path(download_path)
download_path = download_path.replace("$HOME", expanduser("~"))
break
except OSError:
pass
if download_path:
return Path(download_path)
return Path.home()
@ -59,12 +60,6 @@ def get_identities_path():
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]:
"""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()
if not identities_path.exists():
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:
return str(exc)

View file

@ -54,7 +54,7 @@ class ListItem:
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."""
elements = []
links = Links()
@ -63,9 +63,7 @@ def parse_gemtext(text: str, dumb=False) -> ParsedGemtext:
preformatted = None
for line in text.splitlines():
line = line.rstrip()
# In standard mode, discard empty lines. In dumb mode, empty lines are
# kept as basic text.
if not line and not dumb:
if not line:
continue
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
* digits: go to the corresponding link ID
* escape: reset status line text
* section sign (§): toggle between render modes for the current page
* C-c: cancel current operation
## Commands
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(pen) <url>: open this URL
* q(uit): well, quit
* h(ome): open your home page
* 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
* o/open <url>: open this URL
* q/quit: well, quit
* h/home: open your home page
* forget_certificate <hostname>: remove saved fingerprint for this hostname
## Configuration
@ -67,7 +63,6 @@ Here are the available options:
* external_commands (see note 2): commands to open various files.
* external_command_default (see note 1): default command to open files.
* home (string): home page.
* render_mode (string): default render mode to use ("fancy" or "dumb").
Notes:

View file

@ -49,7 +49,7 @@ def save_identities(identities: dict, identities_path: Path):
"""Save the certificate stash. Return True on success."""
try:
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:
logging.error(f"Failed to save identities '{identities_path}': {exc}")
return False

View file

@ -35,23 +35,18 @@ class LineType(IntEnum):
LIST_ITEM = 8
def generate_metalines(elements, width, dumb=False):
def generate_metalines(elements, width):
"""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
keys used are:
dict of metadata and line a text line to display. Currently the only
metadata keys used are:
- type: one of the Renderer.TYPE constants.
- 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.
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.
Arguments:
- elements: list of elements to use.
- width: max text width to use.
- dumb: if True, standard presentation margins are ignored.
"""
metalines = []
context = {"width": width}
@ -83,17 +78,12 @@ def generate_metalines(elements, width, dumb=False):
thin_type = LineType.LIST_ITEM
else:
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,
# 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
# after a paragraph). Also do it if both the current and previous
# elements do not require margins but differ in type.
elif (
if (
(has_margins and index > 0)
or (not has_margins and previous_had_margins)
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"]:
"""Parse a MIME string into a MimeType instance, or None on error."""
if ";" in mime_string:
type_str, *param_strs = mime_string.split(";")
type_str, *parameters = mime_string.split(";")
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:
return None
param_name, param_value = param.split("=")

View file

@ -1,9 +1,7 @@
from dataclasses import dataclass, field
from typing import Optional
from bebop.gemtext import parse_gemtext
from bebop.metalines import generate_dumb_metalines, generate_metalines
from bebop.mime import MimeType
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
lookup and disambiguation.
- 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
metalines: list = field(default_factory=list)
links: Links = field(default_factory=Links)
title: str = ""
mime: Optional[MimeType] = None
encoding: str = ""
render: Optional[str] = None
@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."""
dumb_mode = render == "dumb"
elements, links, title = parse_gemtext(gemtext, dumb=dumb_mode)
metalines = generate_metalines(elements, wrap_at, dumb=dumb_mode)
return Page(gemtext, metalines, links, title, render=render)
elements, links, title = parse_gemtext(gemtext)
metalines = generate_metalines(elements, wrap_at)
return Page(gemtext, metalines, links, title)
@staticmethod
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! 🚀🎶
Press "?" or type ":help" and enter to see the keybinds, commands and 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.
Press "?" to see the keybinds, commands and 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.
=> bebop:help Offline help page
=> gemini://dece.space/dev/bebop.gmi Online Bebop home
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.
=> bebop:help Help
=> gemini://dece.space/dev/bebop.gmi Online documentation
"""