Compare commits

..

No commits in common. "a9e6b4a824a96bd4db1a2648af811db75f8f5e73" and "843a88659f2497928a84451293cf73767a8e5146" have entirely different histories.

16 changed files with 145 additions and 217 deletions

View file

@ -1,51 +0,0 @@
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.

View file

@ -29,11 +29,6 @@ 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

View file

@ -18,6 +18,7 @@ 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
@ -25,7 +26,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, RENDER_MODES
from bebop.metalines import LineType
from bebop.mime import MimeType
from bebop.mouse import ButtonState
from bebop.navigation import (
@ -36,7 +37,7 @@ from bebop.navigation import (
parse_url,
unparse_url,
)
from bebop.page import Page, get_render_options
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
@ -258,7 +259,7 @@ class Browser:
self.edit_page()
elif char == ord("y"):
self.open_history()
elif char == ord("m"):
elif char == ord("§"):
self.toggle_render_mode()
elif char == ord("/"):
self.search_in_page()
@ -376,10 +377,9 @@ class Browser:
self.open_home()
elif command in ("i", "info"):
self.show_page_info()
else:
self.set_status_error(f"Unknown command '{command}'.")
return
# And commands with one or more args.
else:
if command in ("o", "open"):
self.open_url(words[1])
elif command == "forget-certificate":
@ -387,8 +387,6 @@ class Browser:
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):
@ -552,19 +550,19 @@ class Browser:
line_pos = y + py
if line_pos >= len(self.current_page.metalines):
return
ltype, ltext, lextra = self.current_page.metalines[line_pos]
if ltype != LineType.LINK:
meta, line = self.current_page.metalines[line_pos]
if meta["type"] != 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 not lextra or "url" not in lextra:
while "url" not in meta:
line_pos -= 1
_, ltext, lextra = self.current_page.metalines[line_pos]
url = lextra["url"]
meta, line = self.current_page.metalines[line_pos]
url = meta["url"]
# The click is valid if it is on the link itself or the dimmed preview.
col_pos = x + px
if col_pos > len(ltext):
if col_pos > len(line):
ch = self.page_pad.pad.instr(line_pos, col_pos, 1)
if ch == b' ':
return
@ -670,7 +668,7 @@ class Browser:
def open_internal_page(self, name, gemtext):
"""Open some content corresponding to a "bebop:" internal URL."""
page = Page.from_gemtext(gemtext, get_render_options(self.config))
page = Page.from_gemtext(gemtext, self.config["text_width"])
self.load_page(page)
self.current_url = "bebop:" + name
@ -782,8 +780,8 @@ class Browser:
page = self.current_page
if not page:
return
mime = page.mime.short if page.mime else "unk. MIME"
encoding = page.encoding or "unk. encoding"
mime = page.mime.short if page.mime else "(unknown MIME type)"
encoding = page.encoding or "(unknown encoding)"
size = f"{len(page.source)} chars"
lines = f"{len(page.metalines)} lines"
info = f"{mime} {encoding} {size} {lines}"
@ -812,17 +810,18 @@ 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_opts is None:
if not page or page.render is None:
return
render_opts = page.render_opts
current_mode = render_opts.mode
if current_mode not in RENDER_MODES:
if page.render not in RENDER_MODES:
next_mode = RENDER_MODES[0]
else:
cur_mod_index = RENDER_MODES.index(current_mode)
cur_mod_index = RENDER_MODES.index(page.render)
next_mode = RENDER_MODES[(cur_mod_index + 1) % len(RENDER_MODES)]
render_opts.mode = next_mode
new_page = Page.from_gemtext(page.source, render_opts)
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}'.")
@ -834,8 +833,8 @@ class Browser:
if not search:
return
self.search_res_lines = []
for index, (_, ltext, _) in enumerate(self.current_page.metalines):
if search in ltext:
for index, (_, line) in enumerate(self.current_page.metalines):
if search in line:
self.search_res_lines.append(index)
if self.search_res_lines:
self.move_to_search_result(Browser.SEARCH_NEXT)

View file

@ -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, get_render_options
from bebop.page import Page
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, get_render_options(browser.config))
page = Page.from_gemtext(text, browser.config["text_width"])
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"
render_opts = get_render_options(browser.config)
browser.load_page(Page.from_gemtext(gemtext, render_opts))
wrap_at = browser.config["text_width"]
browser.load_page(Page.from_gemtext(gemtext, wrap_at))
file_url = f"file://{path}"
browser.current_url = file_url
return file_url

View file

@ -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, get_render_options
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
@ -138,7 +138,7 @@ def _handle_untrusted_cert(browser: Browser, request: Request):
)
alert_page = Page.from_gemtext(
alert_page_source,
get_render_options(browser.config)
browser.config["text_width"]
)
browser.load_page(alert_page)
@ -214,11 +214,13 @@ def _handle_successful_response(browser: Browser, response: Response, url: str):
except LookupError:
error = f"Unknown encoding {encoding}."
else:
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)
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")

View file

@ -32,9 +32,10 @@ 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():

View file

@ -63,10 +63,9 @@ def parse_gemtext(text: str, dumb=False) -> ParsedGemtext:
preformatted = None
for line in text.splitlines():
line = line.rstrip()
# Empty lines:
# - in standard mode, discard them, except for preformatted blocks.
# - in dumb mode, keep them.
if not line and not (dumb or preformatted):
# In standard mode, discard empty lines. In dumb mode, empty lines are
# kept as basic text.
if not line and not dumb:
continue
if line.startswith(Preformatted.FENCE):

View file

@ -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
* m: use another render mode for the current page
* section sign (§): toggle between render modes for the current page
* slash (/): search for some text
* n: go to next search result
* N: go to previous search result
@ -72,7 +72,6 @@ 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.
@ -103,11 +102,11 @@ def get_help(config, plugins):
)
config_list = "\n".join(
(
f"* {key} = {config[key]} (default {repr(DEFAULT_CONFIG[key])})"
if config[key] != DEFAULT_CONFIG[key]
else f"* {key} = {config[key]}"
f"* {key} = {value} (default {repr(DEFAULT_CONFIG[key])})"
if value != DEFAULT_CONFIG[key]
else f"* {key} = {value}"
)
for key in sorted(config)
for key, value in config.items()
)
return HELP_PAGE.format(
plugin_commands=plugin_commands,

View file

@ -6,22 +6,9 @@ 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
@ -31,6 +18,7 @@ from bebop.gemtext import (
SPLIT_CHARS = " \t-"
JOIN_CHAR = "-"
LIST_ITEM_MARK = ""
class LineType(IntEnum):
@ -51,26 +39,27 @@ class LineType(IntEnum):
ERROR = 9 # Not part of Gemtext but useful internally.
RENDER_MODES = ("fancy", "dumb")
@dataclass
class RenderOptions:
"""Rendering options."""
width: int
mode: str
bullet: str
def generate_metalines(elements: list, options: RenderOptions) -> list:
def generate_metalines(elements, width, dumb=False):
"""Format elements into a list of lines with metadata.
The returned list ("metalines") are tuples (meta, line), meta being a
dict of metadata and a text line to display. Currently the only metadata
keys used are:
- type: one of the Renderer.TYPE constants.
- url: only for links, the URL the link on this line refers to. Note
that this key is present only for the first line of the link, i.e.
long link descriptions wrapped on multiple lines will not have a this
key except for the first line.
- link_id: only alongside "url" key, ID generated for this link.
Arguments:
- elements: list of elements to use.
- options: RenderOptions to respect when generating metalines.
- width: max text width to use.
- dumb: if True, standard presentation margins are ignored.
"""
metalines = []
separator = (LineType.NONE, "", None)
context = {"width": width}
separator = ({"type": LineType.NONE}, "")
has_margins = False
thin_type = None
for index, element in enumerate(elements):
@ -79,30 +68,30 @@ def generate_metalines(elements: list, options: RenderOptions) -> list:
has_margins = False
thin_type = None
if isinstance(element, Title):
element_metalines = format_title(element, options)
element_metalines = format_title(element, context)
has_margins = True
elif isinstance(element, Paragraph):
element_metalines = format_paragraph(element, options)
element_metalines = format_paragraph(element, context)
has_margins = True
elif isinstance(element, Link):
element_metalines = format_link(element, options)
element_metalines = format_link(element, context)
thin_type = LineType.LINK
elif isinstance(element, Preformatted):
element_metalines = format_preformatted(element, options)
element_metalines = format_preformatted(element, context)
has_margins = True
elif isinstance(element, Blockquote):
element_metalines = format_blockquote(element, options)
element_metalines = format_blockquote(element, context)
has_margins = True
elif isinstance(element, ListItem):
element_metalines = format_list_item(element, options)
element_metalines = format_list_item(element, context)
thin_type = LineType.LIST_ITEM
else:
continue
# In dumb mode, elements producing no metalines still need to be
# rendered as empty lines.
if options.mode == "dumb":
if dumb:
if not element_metalines:
element_metalines = [(LineType.PARAGRAPH, "", None)]
element_metalines = [({"type": LineType.PARAGRAPH}, "")]
# If current element requires margins and is not the first elements,
# separate from previous element. Also do it if the current element does
# not require margins but follows an element that required it (e.g. link
@ -121,12 +110,12 @@ def generate_metalines(elements: list, options: RenderOptions) -> list:
def generate_dumb_metalines(lines):
"""Generate dumb metalines: all lines are given the PARAGRAPH line type."""
return [(LineType.PARAGRAPH, line, None) for line in lines]
return [({"type": LineType.PARAGRAPH}, line) for line in lines]
def format_title(title: Title, options: RenderOptions):
def format_title(title: Title, context: dict):
"""Return metalines for this title."""
width = options.width
width = context["width"]
if title.level == 1:
wrapped = wrap_words(title.text, width)
line_template = f"{{:^{width}}}"
@ -137,51 +126,55 @@ def format_title(title: Title, options: RenderOptions):
else:
lines = wrap_words(title.text, width)
# Title levels match the type constants of titles.
return [(LineType(title.level), line, None) for line in lines]
return [({"type": LineType(title.level)}, line) for line in lines]
def format_paragraph(paragraph: Paragraph, options: RenderOptions):
def format_paragraph(paragraph: Paragraph, context: dict):
"""Return metalines for this paragraph."""
lines = wrap_words(paragraph.text, options.width)
return [(LineType.PARAGRAPH, line, None) for line in lines]
lines = wrap_words(paragraph.text, context["width"])
return [({"type": LineType.PARAGRAPH}, line) for line in lines]
def format_link(link: Link, options: RenderOptions):
def format_link(link: Link, context: dict):
"""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, options.width, indent=len(link_anchor))
first_line_extra = {
lines = wrap_words(link_text, context["width"], indent=len(link_anchor))
first_line_meta = {
"type": LineType.LINK,
"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 = [(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
first_line = [(first_line_meta, first_line_text)]
other_lines = [({"type": LineType.LINK}, line) for line in lines[1:]]
return first_line + other_lines
def format_preformatted(preformatted: Preformatted, options: RenderOptions):
def format_preformatted(preformatted: Preformatted, context: dict):
"""Return metalines for this preformatted block."""
return [(LineType.PREFORMATTED, line, None) for line in preformatted.lines]
return [
({"type": LineType.PREFORMATTED}, line)
for line in preformatted.lines
]
def format_blockquote(blockquote: Blockquote, options: RenderOptions):
def format_blockquote(blockquote: Blockquote, context: dict):
"""Return metalines for this blockquote."""
lines = wrap_words(blockquote.text, options.width, indent=2)
return [(LineType.BLOCKQUOTE, line, None) for line in lines]
lines = wrap_words(blockquote.text, context["width"], indent=2)
return [({"type": LineType.BLOCKQUOTE}, line) for line in lines]
def format_list_item(item: ListItem, options: RenderOptions):
def format_list_item(item: ListItem, context: dict):
"""Return metalines for this list item."""
indent = len(options.bullet)
lines = wrap_words(item.text, options.width, indent=indent)
first_line = options.bullet + lines[0][indent:]
indent = len(LIST_ITEM_MARK)
lines = wrap_words(item.text, context["width"], indent=indent)
first_line = LIST_ITEM_MARK + lines[0][indent:]
lines[0] = first_line
return [(LineType.LIST_ITEM, line, None) for line in lines]
return [({"type": LineType.LIST_ITEM}, line) for line in lines]
def wrap_words(text: str, width: int, indent: int =0) -> List[str]:

View file

@ -2,21 +2,11 @@ from dataclasses import dataclass, field
from typing import Optional
from bebop.gemtext import parse_gemtext
from bebop.metalines import (
RenderOptions, generate_dumb_metalines, generate_metalines)
from bebop.metalines import 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.
@ -31,7 +21,7 @@ class Page:
- title: optional page title.
- mime: optional MIME type received from the server.
- encoding: optional encoding received from the server.
- render_opts: optional render options used to create the page from Gemtext.
- render: optional render mode used to create the page from Gemtext.
"""
source: str
metalines: list = field(default_factory=list)
@ -39,21 +29,15 @@ class Page:
title: str = ""
mime: Optional[MimeType] = None
encoding: str = ""
render_opts: Optional[RenderOptions] = None
render: Optional[str] = None
@staticmethod
def from_gemtext(gemtext: str, options: RenderOptions):
def from_gemtext(gemtext: str, wrap_at: int, render: str ="fancy"):
"""Produce a Page from a Gemtext file or string."""
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
)
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)
@staticmethod
def from_text(text: str):

View file

@ -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 URLs to dicts. The only used key is render_mode.
This is a map from an URL 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) -> bool:
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:
@ -36,7 +36,7 @@ def save_capsule_prefs(prefs: dict, prefs_path: Path) -> bool:
return True
def get_url_render_mode_pref(prefs: dict, url: str) -> Optional[str]:
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
@ -46,13 +46,14 @@ def get_url_render_mode_pref(prefs: dict, url: str) -> Optional[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 None
return default
key = max(prefix_urls, key=len)
preference = prefs[key]
return preference.get("render_mode")
return preference.get("render_mode", default)

View file

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

View file

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

@ -13,20 +13,17 @@ WELCOME_PAGE = """\
# Welcome to the Bebop browser! 🚀
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 \
Press "?" or type ":help" and enter to see the keybinds, commands and more, \
or visit the link below by pressing its link ID (1). To start browsing \
right away, press "o", type an URL and press Enter.
=> 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 these capsules to get you started:
New to Gemini? Check out this catalog of capsules to get you started:
=> gemini://geminiquickst.art Gemini Quickstart
=> gemini://medusae.space/ The medusae.space catalog
=> gemini://medusae.space/ medusae.space
"""

View file

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

View file

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