Compare commits
13 commits
0dd29c63ae
...
0b1a98fb73
Author | SHA1 | Date | |
---|---|---|---|
dece | 0b1a98fb73 | ||
dece | a468c94386 | ||
dece | d42a294516 | ||
dece | 4192c2a84d | ||
dece | 440f6357d8 | ||
dece | 690879f558 | ||
dece | 2d493af64b | ||
dece | f827ce3ee1 | ||
dece | 1468e6ef10 | ||
dece | 46ec9879e6 | ||
dece | b22981cef6 | ||
dece | 8e4f8c4c70 | ||
dece | 4738e495b2 |
14
BOARD.txt
14
BOARD.txt
|
@ -1,11 +1,8 @@
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -22,12 +19,17 @@ 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
|
||||||
non shit command-line
|
use a pad for 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -53,3 +55,5 @@ media files
|
||||||
identity management
|
identity management
|
||||||
logging
|
logging
|
||||||
home page
|
home page
|
||||||
|
different rendering mode
|
||||||
|
preferences per site
|
||||||
|
|
|
@ -17,9 +17,10 @@ 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_identities_list_path
|
from bebop.fs import get_capsule_prefs_path, 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
|
||||||
|
@ -36,6 +37,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
@ -77,7 +79,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 = load_identities(get_identities_list_path()) or {}
|
self.identities = {}
|
||||||
self._current_url = ""
|
self._current_url = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -147,7 +149,25 @@ class Browser:
|
||||||
self.config["command_editor"]
|
self.config["command_editor"]
|
||||||
)
|
)
|
||||||
|
|
||||||
if start_url:
|
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:
|
||||||
self.open_url(start_url)
|
self.open_url(start_url)
|
||||||
else:
|
else:
|
||||||
self.open_home()
|
self.open_home()
|
||||||
|
@ -208,6 +228,8 @@ 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:
|
||||||
|
@ -229,15 +251,6 @@ 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):
|
||||||
|
@ -306,16 +319,22 @@ class Browser:
|
||||||
|
|
||||||
command = words[0]
|
command = words[0]
|
||||||
if num_words == 1:
|
if num_words == 1:
|
||||||
if command in ("q", "quit"):
|
if command == "help":
|
||||||
|
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."""
|
||||||
|
@ -415,7 +434,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):
|
||||||
"""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]
|
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:
|
||||||
|
@ -657,3 +676,52 @@ 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}'.")
|
||||||
|
|
|
@ -13,6 +13,7 @@ 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
|
||||||
|
|
||||||
|
@ -73,7 +74,6 @@ 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,10 +214,20 @@ 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:
|
||||||
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:
|
else:
|
||||||
text = response.content.decode("utf-8", errors="replace")
|
encoding = "utf-8"
|
||||||
|
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)
|
||||||
|
@ -228,7 +238,6 @@ 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:
|
||||||
|
|
|
@ -15,8 +15,11 @@ 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):
|
||||||
|
|
18
bebop/fs.py
18
bebop/fs.py
|
@ -39,12 +39,11 @@ 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('"')
|
||||||
download_path = download_path.replace("$HOME", expanduser("~"))
|
home = expanduser("~")
|
||||||
break
|
download_path = download_path.replace("$HOME", home)
|
||||||
|
return Path(download_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
if download_path:
|
|
||||||
return Path(download_path)
|
|
||||||
return Path.home()
|
return Path.home()
|
||||||
|
|
||||||
|
|
||||||
|
@ -60,6 +59,12 @@ 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.
|
||||||
|
|
||||||
|
@ -79,5 +84,10 @@ 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)
|
||||||
|
|
|
@ -54,7 +54,7 @@ class ListItem:
|
||||||
ParsedGemtext = namedtuple("ParsedGemtext", ("elements", "links", "title"))
|
ParsedGemtext = namedtuple("ParsedGemtext", ("elements", "links", "title"))
|
||||||
|
|
||||||
|
|
||||||
def parse_gemtext(text: str) -> ParsedGemtext:
|
def parse_gemtext(text: str, dumb=False) -> 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,7 +63,9 @@ def parse_gemtext(text: str) -> ParsedGemtext:
|
||||||
preformatted = None
|
preformatted = None
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
line = line.rstrip()
|
line = line.rstrip()
|
||||||
if not line:
|
# In standard mode, discard empty lines. In dumb mode, empty lines are
|
||||||
|
# kept as basic text.
|
||||||
|
if not line and not dumb:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if line.startswith(Preformatted.FENCE):
|
if line.startswith(Preformatted.FENCE):
|
||||||
|
|
|
@ -37,16 +37,20 @@ 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.
|
||||||
|
|
||||||
* o/open <url>: open this URL
|
* help: show this help page
|
||||||
* q/quit: well, quit
|
* o(pen) <url>: open this URL
|
||||||
* h/home: open your home page
|
* q(uit): well, quit
|
||||||
* forget_certificate <hostname>: remove saved fingerprint for this hostname
|
* 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
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
@ -63,6 +67,7 @@ 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:
|
||||||
|
|
||||||
|
|
|
@ -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)
|
json.dump(identities, identities_file, indent=2)
|
||||||
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
|
||||||
|
|
|
@ -35,18 +35,23 @@ class LineType(IntEnum):
|
||||||
LIST_ITEM = 8
|
LIST_ITEM = 8
|
||||||
|
|
||||||
|
|
||||||
def generate_metalines(elements, width):
|
def generate_metalines(elements, width, dumb=False):
|
||||||
"""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 line a text line to display. Currently the only
|
dict of metadata and a text line to display. Currently the only metadata
|
||||||
metadata keys used are:
|
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}
|
||||||
|
@ -78,12 +83,17 @@ def generate_metalines(elements, width):
|
||||||
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.
|
||||||
if (
|
elif (
|
||||||
(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)
|
||||||
|
|
|
@ -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, *parameters = mime_string.split(";")
|
type_str, *param_strs = mime_string.split(";")
|
||||||
parameters = {}
|
parameters = {}
|
||||||
for param in map(lambda s: s.strip().lower(), parameters):
|
for param in map(lambda s: s.strip().lower(), param_strs):
|
||||||
if param.count("=") != 1:
|
if param.count("=") != 1:
|
||||||
return None
|
return None
|
||||||
param_name, param_value = param.split("=")
|
param_name, param_value = param.split("=")
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
@ -17,18 +19,25 @@ 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):
|
def from_gemtext(gemtext: str, wrap_at: int, render: str ="fancy"):
|
||||||
"""Produce a Page from a Gemtext file or string."""
|
"""Produce a Page from a Gemtext file or string."""
|
||||||
elements, links, title = parse_gemtext(gemtext)
|
dumb_mode = render == "dumb"
|
||||||
metalines = generate_metalines(elements, wrap_at)
|
elements, links, title = parse_gemtext(gemtext, dumb=dumb_mode)
|
||||||
return Page(gemtext, metalines, links, title)
|
metalines = generate_metalines(elements, wrap_at, dumb=dumb_mode)
|
||||||
|
return Page(gemtext, metalines, links, title, render=render)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_text(text: str):
|
def from_text(text: str):
|
||||||
|
|
59
bebop/preferences.py
Normal file
59
bebop/preferences.py
Normal file
|
@ -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)
|
54
bebop/tests/test_preferences.py
Normal file
54
bebop/tests/test_preferences.py
Normal file
|
@ -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")
|
|
@ -5,10 +5,13 @@ WELCOME_PAGE = """\
|
||||||
|
|
||||||
Hi! Welcome to the Bebop browser! 🚀🎶
|
Hi! Welcome to the Bebop browser! 🚀🎶
|
||||||
|
|
||||||
Press "?" to see the keybinds, commands and more, or visit the link below by \
|
Press "?" or type ":help" and enter to see the keybinds, commands and more, \
|
||||||
pressing its link ID (1). To start browsing right away, press "o", type an URL \
|
or visit the link below by pressing its link ID (1). To start browsing \
|
||||||
and press Enter.
|
right away, press "o", type an URL and press Enter.
|
||||||
|
|
||||||
=> bebop:help Help
|
=> bebop:help Offline help page
|
||||||
=> gemini://dece.space/dev/bebop.gmi Online documentation
|
=> 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.
|
||||||
"""
|
"""
|
||||||
|
|
Reference in a new issue