diff --git a/bebop/browser/browser.py b/bebop/browser/browser.py index d575b5e..08cad97 100644 --- a/bebop/browser/browser.py +++ b/bebop/browser/browser.py @@ -17,9 +17,10 @@ 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_identities_list_path +from bebop.fs import get_capsule_prefs_path, get_identities_list_path from bebop.help import get_help from bebop.history import History from bebop.identity import load_identities @@ -36,6 +37,7 @@ 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 @@ -153,6 +155,11 @@ class Browser: 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 = ( @@ -324,6 +331,8 @@ class Browser: elif command == "forget-certificate": from bebop.browser.gemini import forget_certificate forget_certificate(self, words[1]) + elif command == "render": + 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.""" @@ -423,7 +432,7 @@ class Browser: self.set_status_error(f"Protocol '{scheme}' not supported.") def load_page(self, page: Page): - """Load Gemtext data as the current page.""" + """Set this page as the current page and refresh appropriate windows.""" old_pad_height = self.page_pad.dim[0] self.page_pad.show_page(page) if self.page_pad.dim[0] < old_pad_height: @@ -676,3 +685,23 @@ class Browser: 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() diff --git a/bebop/browser/gemini.py b/bebop/browser/gemini.py index e05be8b..c153c0e 100644 --- a/bebop/browser/gemini.py +++ b/bebop/browser/gemini.py @@ -13,6 +13,7 @@ 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 @@ -213,7 +214,13 @@ def _handle_successful_response(browser: Browser, response: Response, url: str): except LookupError: error = f"Unknown encoding {encoding}." else: - page = Page.from_gemtext(text, browser.config["text_width"]) + 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) else: encoding = "utf-8" text = response.content.decode(encoding, errors="replace") diff --git a/bebop/config.py b/bebop/config.py index 5bf6b23..d27b6ee 100644 --- a/bebop/config.py +++ b/bebop/config.py @@ -15,8 +15,11 @@ 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): diff --git a/bebop/fs.py b/bebop/fs.py index af2146e..98762b6 100644 --- a/bebop/fs.py +++ b/bebop/fs.py @@ -59,6 +59,12 @@ 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. @@ -78,5 +84,10 @@ 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) diff --git a/bebop/help.py b/bebop/help.py index 6f709a2..ab16e74 100644 --- a/bebop/help.py +++ b/bebop/help.py @@ -65,6 +65,7 @@ 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: diff --git a/bebop/page.py b/bebop/page.py index 569d029..bf806a1 100644 --- a/bebop/page.py +++ b/bebop/page.py @@ -28,10 +28,11 @@ class Page: encoding: str = "" @staticmethod - def from_gemtext(gemtext: str, wrap_at: int, dumb: bool =False): + def from_gemtext(gemtext: str, wrap_at: int, render: str ="fancy"): """Produce a Page from a Gemtext file or string.""" - elements, links, title = parse_gemtext(gemtext, dumb=dumb) - metalines = generate_metalines(elements, wrap_at, dumb=dumb) + 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) @staticmethod diff --git a/bebop/preferences.py b/bebop/preferences.py new file mode 100644 index 0000000..46de625 --- /dev/null +++ b/bebop/preferences.py @@ -0,0 +1,59 @@ +"""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) diff --git a/bebop/tests/test_preferences.py b/bebop/tests/test_preferences.py new file mode 100644 index 0000000..5a38810 --- /dev/null +++ b/bebop/tests/test_preferences.py @@ -0,0 +1,54 @@ +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")