Compare commits

...

13 commits

Author SHA1 Message Date
dece 0b1a98fb73 board: update 2021-05-29 01:29:19 +02:00
dece a468c94386 browser: use "set-render-mode" instead of "render" 2021-05-29 01:26:48 +02:00
dece d42a294516 browser: add a keybind to toggle render modes 2021-05-29 01:26:15 +02:00
dece 4192c2a84d welcome: tell user how to change the home page 2021-05-29 01:15:12 +02:00
dece 440f6357d8 page: docs 2021-05-29 01:15:04 +02:00
dece 690879f558 identity: indent the file 2021-05-29 01:14:31 +02:00
dece 2d493af64b preferences: basic per-capsule preferences
For now only a per-path render mode is available.
2021-05-29 01:13:43 +02:00
dece f827ce3ee1 browser: show an error early on loading failure 2021-05-29 01:11:38 +02:00
dece 1468e6ef10 page: add a dumb rendering mode
Dumb mode affects Gemtext parsing (gotta keep those empty lines) and
rendering (render empty lines, do not attempt to put smart margins).
2021-05-28 13:34:08 +02:00
dece 46ec9879e6 help: add "help" command in case of 2021-05-28 13:26:41 +02:00
dece b22981cef6 browser: fix minor status bar issues 2021-05-24 21:11:58 +02:00
dece 8e4f8c4c70 mime: fix issue with charset param 2021-05-24 20:10:04 +02:00
dece 4738e495b2 browser: add command to show page info 2021-05-24 20:09:34 +02:00
14 changed files with 285 additions and 49 deletions

View file

@ -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

View file

@ -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}'.")

View file

@ -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:

View file

@ -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):

View file

@ -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)

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) -> 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):

View file

@ -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:

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) 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

View file

@ -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)

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, *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("=")

View file

@ -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
View 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)

View 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")

View file

@ -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.
""" """