Compare commits

...

10 Commits

@ -0,0 +1,51 @@
Architecture
============
This document is for people who want to get an overview of the Bebop code and
the way things work. These are high-level views, more details are given in the
respective modules' docstrings.
Events
------
There are no event loop dispatching actions asynchronously, everything runs in a
single thread. The UI waits for user input and reacts on them.
In the future we may decouple the UI from the core browser to allow background
downloads and streaming content.
Rendering
---------
A core element of Bebop is what I call "metalines", which are lines of text as
they are rendered on screen, along with specific line metadata. Metalines are
rendered directly on screen, so they are not wrapped, cut or whatever: they
already represent formatted text as it will be shown. They carry information
such as line type (i.e. is this line part of a link, a title, etc), and more
specific data such as target URLs for link lines.
Rendering from the server response to showing content in the curses UI takes
several steps:
1. Parse the response from the server. If it's successful and a text MIME type
is provided, use it to parse the response content.
2. Response parsing can directly produce metalines (e.g. from `text/plain`) or
have intermediate parsing steps: a `text/gemini` document is first parsed
into a list of gemtext "elements" (paragraphs, links, etc), and converting
those elements into metalines happen in a following step. This lets Bebop
separate gemtext semantics from the way it is rendered, thus using the
desired wrapping and margins.
3. Metalines are rendered on the screen, with the colors and attributes
matching the line type.
Plugins
-------
The plugin interface is improved only when there is demand for something; for
now it only supports plugins that can handle new schemes.

@ -29,6 +29,11 @@ handle big files (e.g. gemini://tilde.team/~tomasino/irc/log.txt)
allow encoding overrides (useful for gopher i guess)
config for web browser, default to webbrowser module
use pubkeys instead of the whole DER hash for TOFU
specify external commands interface (exec, run, pipes)
table of contents
better default editor than vim
search engine
auto-open some media types

@ -18,7 +18,6 @@ from bebop.bookmarks import (
save_bookmark,
)
from bebop.colors import A_ITALIC, 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
@ -26,7 +25,7 @@ from bebop.help import get_help
from bebop.history import History
from bebop.identity import load_identities
from bebop.links import Links
from bebop.metalines import LineType
from bebop.metalines import LineType, RENDER_MODES
from bebop.mime import MimeType
from bebop.mouse import ButtonState
from bebop.navigation import (
@ -37,7 +36,7 @@ from bebop.navigation import (
parse_url,
unparse_url,
)
from bebop.page import Page
from bebop.page import Page, get_render_options
from bebop.page_pad import PagePad
from bebop.preferences import load_capsule_prefs, save_capsule_prefs
from bebop.welcome import WELCOME_PAGE
@ -259,7 +258,7 @@ class Browser:
self.edit_page()
elif char == ord("y"):
self.open_history()
elif char == ord("§"):
elif char == ord("m"):
self.toggle_render_mode()
elif char == ord("/"):
self.search_in_page()
@ -377,16 +376,19 @@ class Browser:
self.open_home()
elif command in ("i", "info"):
self.show_page_info()
return
else:
self.set_status_error(f"Unknown command '{command}'.")
# And commands with one or more args.
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])
else:
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])
else:
self.set_status_error(f"Unknown command '{command}'.")
def get_user_text_input(self, status_text, char, prefix="", strip=False,
escape_to_none=False):
@ -550,19 +552,19 @@ class Browser:
line_pos = y + py
if line_pos >= len(self.current_page.metalines):
return
meta, line = self.current_page.metalines[line_pos]
if meta["type"] != LineType.LINK:
ltype, ltext, lextra = self.current_page.metalines[line_pos]
if ltype != LineType.LINK:
return
# "url" key is contained only in the first line of the link if its text
# is wrapped, so if the user did not click on the first line, rewind to
# get the URL.
while "url" not in meta:
while not lextra or "url" not in lextra:
line_pos -= 1
meta, line = self.current_page.metalines[line_pos]
url = meta["url"]
_, ltext, lextra = self.current_page.metalines[line_pos]
url = lextra["url"]
# The click is valid if it is on the link itself or the dimmed preview.
col_pos = x + px
if col_pos > len(line):
if col_pos > len(ltext):
ch = self.page_pad.pad.instr(line_pos, col_pos, 1)
if ch == b' ':
return
@ -668,7 +670,7 @@ class Browser:
def open_internal_page(self, name, gemtext):
"""Open some content corresponding to a "bebop:" internal URL."""
page = Page.from_gemtext(gemtext, self.config["text_width"])
page = Page.from_gemtext(gemtext, get_render_options(self.config))
self.load_page(page)
self.current_url = "bebop:" + name
@ -780,8 +782,8 @@ class Browser:
page = self.current_page
if not page:
return
mime = page.mime.short if page.mime else "(unknown MIME type)"
encoding = page.encoding or "(unknown encoding)"
mime = page.mime.short if page.mime else "unk. MIME"
encoding = page.encoding or "unk. encoding"
size = f"{len(page.source)} chars"
lines = f"{len(page.metalines)} lines"
info = f"{mime} {encoding} {size} {lines}"
@ -810,18 +812,17 @@ class Browser:
def toggle_render_mode(self):
"""Switch to the next render mode for the current page."""
page = self.current_page
if not page or page.render is None:
if not page or page.render_opts is None:
return
if page.render not in RENDER_MODES:
render_opts = page.render_opts
current_mode = render_opts.mode
if current_mode not in RENDER_MODES:
next_mode = RENDER_MODES[0]
else:
cur_mod_index = RENDER_MODES.index(page.render)
cur_mod_index = RENDER_MODES.index(current_mode)
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
)
render_opts.mode = next_mode
new_page = Page.from_gemtext(page.source, render_opts)
self.load_page(new_page)
self.set_status(f"Using render mode '{next_mode}'.")
@ -833,8 +834,8 @@ class Browser:
if not search:
return
self.search_res_lines = []
for index, (_, line) in enumerate(self.current_page.metalines):
if search in line:
for index, (_, ltext, _) in enumerate(self.current_page.metalines):
if search in ltext:
self.search_res_lines.append(index)
if self.search_res_lines:
self.move_to_search_result(Browser.SEARCH_NEXT)

@ -5,7 +5,7 @@ from pathlib import Path
from urllib.parse import quote, unquote
from bebop.browser.browser import Browser
from bebop.page import Page
from bebop.page import Page, get_render_options
def open_file(browser: Browser, filepath: str, encoding="utf-8"):
@ -36,7 +36,7 @@ def open_file(browser: Browser, filepath: str, encoding="utf-8"):
browser.set_status_error(f"Failed to open file: {exc}")
return None
if path.suffix == ".gmi":
page = Page.from_gemtext(text, browser.config["text_width"])
page = Page.from_gemtext(text, get_render_options(browser.config))
else:
page = Page.from_text(text)
browser.load_page(page)
@ -48,8 +48,8 @@ def open_file(browser: Browser, filepath: str, encoding="utf-8"):
if entry.is_dir():
name += "/"
gemtext += f"=> {entry_path} {name}\n"
wrap_at = browser.config["text_width"]
browser.load_page(Page.from_gemtext(gemtext, wrap_at))
render_opts = get_render_options(browser.config)
browser.load_page(Page.from_gemtext(gemtext, render_opts))
file_url = f"file://{path}"
browser.current_url = file_url
return file_url

@ -12,7 +12,7 @@ from bebop.identity import (
get_identities_for_url, load_identities, save_identities
)
from bebop.navigation import set_parameter
from bebop.page import Page
from bebop.page import Page, get_render_options
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
@ -138,7 +138,7 @@ def _handle_untrusted_cert(browser: Browser, request: Request):
)
alert_page = Page.from_gemtext(
alert_page_source,
browser.config["text_width"]
get_render_options(browser.config)
)
browser.load_page(alert_page)
@ -214,13 +214,11 @@ 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)
render_opts = get_render_options(browser.config)
pref_mode = get_url_render_mode_pref(browser.capsule_prefs, url)
if pref_mode:
render_opts.mode = pref_mode
page = Page.from_gemtext(text, render_opts)
else:
encoding = "utf-8"
text = response.content.decode(encoding, errors="replace")

@ -32,10 +32,9 @@ DEFAULT_CONFIG = {
"scroll_step": 3,
"persistent_history": False,
"enabled_plugins": [],
"list_item_bullet": "",
}
RENDER_MODES = ("fancy", "dumb")
def load_config(config_path: Path):
if not config_path.is_file():

@ -63,9 +63,10 @@ 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:
# Empty lines:
# - in standard mode, discard them, except for preformatted blocks.
# - in dumb mode, keep them.
if not line and not (dumb or preformatted):
continue
if line.startswith(Preformatted.FENCE):

@ -38,7 +38,7 @@ 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
* m: use another render mode for the current page
* slash (/): search for some text
* n: go to next search result
* N: go to previous search result
@ -72,6 +72,7 @@ Here are the available options:
* generate_client_cert_command (see note 3): command to generate a client cert.
* history_limit (int): maximum entries in history.
* home (string): home page.
* list_item_bullet (string): text shown before every list item.
* persistent_history (bool): save and reload history.
* render_mode (string): default render mode to use ("fancy" or "dumb").
* scroll_step (int): number of lines/columns to scroll in one step.
@ -102,11 +103,11 @@ def get_help(config, plugins):
)
config_list = "\n".join(
(
f"* {key} = {value} (default {repr(DEFAULT_CONFIG[key])})"
if value != DEFAULT_CONFIG[key]
else f"* {key} = {value}"
f"* {key} = {config[key]} (default {repr(DEFAULT_CONFIG[key])})"
if config[key] != DEFAULT_CONFIG[key]
else f"* {key} = {config[key]}"
)
for key, value in config.items()
for key in sorted(config)
)
return HELP_PAGE.format(
plugin_commands=plugin_commands,

@ -6,9 +6,22 @@ displayed, along with associated meta-data such as its type or a link's URL.
Note that metalines can be generated by custom functions without relying on the
elements classes as they are quite coupled to Gemtext parsing/rendering.
The metalines are tuples (ltype, line, lextra):
- ltype is the LineType.
- line is the text content itself.
- lextra is either a dict of additional data, or None.
The lextra part is currently only used for links, and can contain the following
keys:
- url: 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.
"""
import string
from dataclasses import dataclass
from enum import IntEnum
from typing import List
@ -18,7 +31,6 @@ from bebop.gemtext import (
SPLIT_CHARS = " \t-"
JOIN_CHAR = "-"
LIST_ITEM_MARK = ""
class LineType(IntEnum):
@ -39,27 +51,26 @@ class LineType(IntEnum):
ERROR = 9 # Not part of Gemtext but useful internally.
def generate_metalines(elements, width, dumb=False):
"""Format elements into a list of lines with metadata.
RENDER_MODES = ("fancy", "dumb")
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:
- 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.
@dataclass
class RenderOptions:
"""Rendering options."""
width: int
mode: str
bullet: str
def generate_metalines(elements: list, options: RenderOptions) -> list:
"""Format elements into a list of lines with metadata.
Arguments:
- elements: list of elements to use.
- width: max text width to use.
- dumb: if True, standard presentation margins are ignored.
- options: RenderOptions to respect when generating metalines.
"""
metalines = []
context = {"width": width}
separator = ({"type": LineType.NONE}, "")
separator = (LineType.NONE, "", None)
has_margins = False
thin_type = None
for index, element in enumerate(elements):
@ -68,30 +79,30 @@ def generate_metalines(elements, width, dumb=False):
has_margins = False
thin_type = None
if isinstance(element, Title):
element_metalines = format_title(element, context)
element_metalines = format_title(element, options)
has_margins = True
elif isinstance(element, Paragraph):
element_metalines = format_paragraph(element, context)
element_metalines = format_paragraph(element, options)
has_margins = True
elif isinstance(element, Link):
element_metalines = format_link(element, context)
element_metalines = format_link(element, options)
thin_type = LineType.LINK
elif isinstance(element, Preformatted):
element_metalines = format_preformatted(element, context)
element_metalines = format_preformatted(element, options)
has_margins = True
elif isinstance(element, Blockquote):
element_metalines = format_blockquote(element, context)
element_metalines = format_blockquote(element, options)
has_margins = True
elif isinstance(element, ListItem):
element_metalines = format_list_item(element, context)
element_metalines = format_list_item(element, options)
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 options.mode == "dumb":
if not element_metalines:
element_metalines = [({"type": LineType.PARAGRAPH}, "")]
element_metalines = [(LineType.PARAGRAPH, "", None)]
# 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
@ -110,12 +121,12 @@ def generate_metalines(elements, width, dumb=False):
def generate_dumb_metalines(lines):
"""Generate dumb metalines: all lines are given the PARAGRAPH line type."""
return [({"type": LineType.PARAGRAPH}, line) for line in lines]
return [(LineType.PARAGRAPH, line, None) for line in lines]
def format_title(title: Title, context: dict):
def format_title(title: Title, options: RenderOptions):
"""Return metalines for this title."""
width = context["width"]
width = options.width
if title.level == 1:
wrapped = wrap_words(title.text, width)
line_template = f"{{:^{width}}}"
@ -126,55 +137,51 @@ def format_title(title: Title, context: dict):
else:
lines = wrap_words(title.text, width)
# Title levels match the type constants of titles.
return [({"type": LineType(title.level)}, line) for line in lines]
return [(LineType(title.level), line, None) for line in lines]
def format_paragraph(paragraph: Paragraph, context: dict):
def format_paragraph(paragraph: Paragraph, options: RenderOptions):
"""Return metalines for this paragraph."""
lines = wrap_words(paragraph.text, context["width"])
return [({"type": LineType.PARAGRAPH}, line) for line in lines]
lines = wrap_words(paragraph.text, options.width)
return [(LineType.PARAGRAPH, line, None) for line in lines]
def format_link(link: Link, context: dict):
def format_link(link: Link, options: RenderOptions):
"""Return metalines for this link."""
# Get a new link and build the "[id]" anchor.
link_anchor = f"[{link.ident}] "
link_text = link.text or link.url
# Wrap lines, indented by the link anchor length.
lines = wrap_words(link_text, context["width"], indent=len(link_anchor))
first_line_meta = {
"type": LineType.LINK,
lines = wrap_words(link_text, options.width, indent=len(link_anchor))
first_line_extra = {
"url": link.url,
"link_id": link.ident
}
# Replace first line indentation with the anchor.
first_line_text = link_anchor + lines[0][len(link_anchor):]
first_line = [(first_line_meta, first_line_text)]
other_lines = [({"type": LineType.LINK}, line) for line in lines[1:]]
return first_line + other_lines
first_line = [(LineType.LINK, first_line_text, first_line_extra)]
other_lines = [(LineType.LINK, line, None) for line in lines[1:]]
return first_line + other_lines # type: ignore
def format_preformatted(preformatted: Preformatted, context: dict):
def format_preformatted(preformatted: Preformatted, options: RenderOptions):
"""Return metalines for this preformatted block."""
return [
({"type": LineType.PREFORMATTED}, line)
for line in preformatted.lines
]
return [(LineType.PREFORMATTED, line, None) for line in preformatted.lines]
def format_blockquote(blockquote: Blockquote, context: dict):
def format_blockquote(blockquote: Blockquote, options: RenderOptions):
"""Return metalines for this blockquote."""
lines = wrap_words(blockquote.text, context["width"], indent=2)
return [({"type": LineType.BLOCKQUOTE}, line) for line in lines]
lines = wrap_words(blockquote.text, options.width, indent=2)
return [(LineType.BLOCKQUOTE, line, None) for line in lines]
def format_list_item(item: ListItem, context: dict):
def format_list_item(item: ListItem, options: RenderOptions):
"""Return metalines for this list item."""
indent = len(LIST_ITEM_MARK)
lines = wrap_words(item.text, context["width"], indent=indent)
first_line = LIST_ITEM_MARK + lines[0][indent:]
indent = len(options.bullet)
lines = wrap_words(item.text, options.width, indent=indent)
first_line = options.bullet + lines[0][indent:]
lines[0] = first_line
return [({"type": LineType.LIST_ITEM}, line) for line in lines]
return [(LineType.LIST_ITEM, line, None) for line in lines]
def wrap_words(text: str, width: int, indent: int =0) -> List[str]:

@ -2,11 +2,21 @@ 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.metalines import (
RenderOptions, generate_dumb_metalines, generate_metalines)
from bebop.mime import MimeType
from bebop.links import Links
def get_render_options(config: dict):
"""Prepare RenderOptions from the user config."""
return RenderOptions(
width=config["text_width"],
mode=config["render_mode"],
bullet=config["list_item_bullet"],
)
@dataclass
class Page:
"""Page-related data.
@ -21,7 +31,7 @@ class Page:
- 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.
- render_opts: optional render options used to create the page from Gemtext.
"""
source: str
metalines: list = field(default_factory=list)
@ -29,15 +39,21 @@ class Page:
title: str = ""
mime: Optional[MimeType] = None
encoding: str = ""
render: Optional[str] = None
render_opts: Optional[RenderOptions] = None
@staticmethod
def from_gemtext(gemtext: str, wrap_at: int, render: str ="fancy"):
def from_gemtext(gemtext: str, options: RenderOptions):
"""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)
dumb = options.mode == "dumb"
elements, links, title = parse_gemtext(gemtext, dumb=dumb)
metalines = generate_metalines(elements, options)
return Page(
gemtext,
metalines,
links,
title,
render_opts=options
)
@staticmethod
def from_text(text: str):

@ -4,7 +4,7 @@ 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.
This is a map from URLs to dicts. The only used key is render_mode.
"""
import json
@ -25,7 +25,7 @@ def load_capsule_prefs(prefs_path: Path) -> Optional[dict]:
return prefs
def save_capsule_prefs(prefs: dict, prefs_path: Path):
def save_capsule_prefs(prefs: dict, prefs_path: Path) -> bool:
"""Save the capsule preferences. Return True on success."""
try:
with open(prefs_path, "wt") as prefs_file:
@ -36,7 +36,7 @@ def save_capsule_prefs(prefs: dict, prefs_path: Path):
return True
def get_url_render_mode_pref(prefs: dict, url: str, default: str):
def get_url_render_mode_pref(prefs: dict, url: str) -> Optional[str]:
"""Return the desired render mode for this URL.
If the preferences contain the URL or a parent URL, the corresponding render
@ -46,14 +46,13 @@ def get_url_render_mode_pref(prefs: dict, url: str, default: str):
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
return None
key = max(prefix_urls, key=len)
preference = prefs[key]
return preference.get("render_mode", default)
return preference.get("render_mode")

@ -1,4 +1,4 @@
"""Rendering Gemtext in curses."""
"""Rendering metalines in curses."""
import curses
@ -37,13 +37,12 @@ def render_lines(metalines, window, max_width):
def render_line(metaline, window, max_width):
"""Write a single line to the window."""
meta, line = metaline
line_type = meta["type"]
attributes = get_base_line_attributes(line_type)
line = line[:max_width - 1]
ltype, ltext, lextra = metaline
attributes = get_base_line_attributes(ltype)
line = ltext[:max_width - 1]
window.addstr(line, attributes)
if meta["type"] == LineType.LINK and "url" in meta:
url_text = f' {meta["url"]}'
if ltype == LineType.LINK and lextra and "url" in lextra:
url_text = f' {lextra["url"]}'
attributes = (
curses.color_pair(ColorPair.LINK_PREVIEW)
| curses.A_DIM

@ -6,49 +6,41 @@ class TestPreferences(unittest.TestCase):
def test_get_url_render_mode_pref(self):
prefs = {}
self.assertEqual(get_url_render_mode_pref(
self.assertIsNone(get_url_render_mode_pref(
prefs,
"gemini://example.com",
"default"
), "default")
))
prefs["gemini://example.com"] = {}
self.assertEqual(get_url_render_mode_pref(
self.assertIsNone(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")

@ -13,17 +13,20 @@ WELCOME_PAGE = """\
# 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 \
Press "?" or type ":help" and press 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.
=> bebop:help Offline help page
=> gemini://dece.space/dev/bebop.gmi Online Bebop home
To quit Bebop, type `:q` and press enter.
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.
New to Gemini? Check out this catalog of capsules to get you started:
New to Gemini? Check out these capsules to get you started:
=> gemini://medusae.space/ medusae.space
=> gemini://geminiquickst.art Gemini Quickstart
=> gemini://medusae.space/ The medusae.space catalog
"""

@ -1,6 +1,6 @@
[metadata]
name = bebop-browser-gopher
version = 0.1.0
version = 0.1.1
description = Gopher plugin for the Bebop terminal browser
long_description = file: README.md
license = GPLv3

@ -1,6 +1,6 @@
[metadata]
name = bebop-browser
version = 0.3.0
version = 0.3.1
description = Terminal browser for Gemini
long_description = file: README.md
license = GPLv3

Loading…
Cancel
Save