Compare commits

...

8 commits

11 changed files with 181 additions and 44 deletions

View file

@ -1,8 +1,5 @@
TODO
----------------------------------------
opt. maintain history between sessions
directory view for file scheme
search in page (ugh)
@ -31,6 +28,7 @@ remember scroll pos in history
identity management
"previous/next" pages
configurable keybinds
handle big files (e.g. gemini://tilde.team/~tomasino/irc/log.txt)
@ -59,3 +57,5 @@ home page
different rendering mode
preferences per site
basic mouse support
basic local browsing
search in page

View file

@ -64,8 +64,12 @@ class Browser:
returning the page source path.
- last_download: tuple of MimeType and path, or None.
- identities: identities map.
- search_res_lines: list of lines containing results of the last search.
"""
SEARCH_NEXT = 0
SEARCH_PREVIOUS = 1
def __init__(self, config, cert_stash):
self.config = config
self.stash = cert_stash
@ -81,6 +85,7 @@ class Browser:
self.special_pages = self.setup_special_pages()
self.last_download: Optional[Tuple[MimeType, Path]] = None
self.identities = {}
self.search_res_lines = []
self._current_url = ""
@property
@ -127,16 +132,17 @@ class Browser:
def _run(self, stdscr, start_url=None):
"""Start displaying content and handling events."""
# Setup Curses.
self.screen = stdscr
self.screen.clear()
self.screen.refresh()
mousemask = curses.mousemask(curses.ALL_MOUSE_EVENTS)
if mousemask == 0:
logging.error("Could not enable mouse support.")
curses.curs_set(0)
init_colors()
# Setup windows and pads.
self.dim = self.screen.getmaxyx()
self.page_pad = PagePad(self.h - 2)
self.status_line = self.screen.subwin(
@ -152,6 +158,7 @@ class Browser:
self.config["command_editor"]
)
# Load user data files, record which failed to load to warn the user.
failed_to_load = []
identities = load_identities(get_identities_list_path())
if identities is None:
@ -164,10 +171,15 @@ class Browser:
else:
self.capsule_prefs = capsule_prefs
# Load user data files that may not exist (no warning).
if self.config["persistent_history"]:
if not self.history.load():
logging.warning("Could not load history file.")
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."
"These may be replaced if you continue."
)
self.set_status_error(error_msg)
elif start_url:
@ -175,12 +187,16 @@ class Browser:
else:
self.open_home()
# Start listening for inputs.
while self.running:
try:
self.handle_inputs()
except KeyboardInterrupt:
self.set_status("Cancelled.")
if self.config["persistent_history"]:
self.history.save()
def handle_inputs(self):
char = self.screen.getch()
if char == ord("?"):
@ -233,6 +249,12 @@ class Browser:
self.open_history()
elif char == ord("§"):
self.toggle_render_mode()
elif char == ord("/"):
self.search_in_page()
elif char == ord("n"):
self.move_to_search_result(Browser.SEARCH_NEXT)
elif char == ord("N"):
self.move_to_search_result(Browser.SEARCH_PREVIOUS)
elif curses.ascii.isdigit(char):
self.handle_digit_input(char)
elif char == curses.KEY_MOUSE:
@ -483,9 +505,9 @@ class Browser:
If the click is on a link (appropriate line and columns), open it.
"""
if not self.page_pad or not self.page_pad.current_page:
return
page = self.page_pad.current_page
if not page:
return
px, py = self.page_pad.current_column, self.page_pad.current_line
line_pos = y + py
if line_pos >= len(page.metalines):
@ -664,7 +686,10 @@ class Browser:
return
command = self.config["source_editor"] + [source_filename]
open_external_program(command)
success = open_external_program(command)
if not success:
self.set_status_error("Could not open editor.")
if delete_source_after:
os.unlink(source_filename)
self.refresh_windows()
@ -713,13 +738,14 @@ class Browser:
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
if not page:
return
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}"
lines = f"{len(page.metalines)} lines"
info = f"{mime} {encoding} {size} {lines}"
self.set_status(info)
def set_render_mode(self, mode):
@ -744,9 +770,9 @@ class Browser:
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 not page:
return
if page.render is None or page.render not in RENDER_MODES:
next_mode = RENDER_MODES[0]
else:
@ -759,3 +785,46 @@ class Browser:
)
self.load_page(new_page)
self.set_status(f"Using render mode '{next_mode}'.")
def search_in_page(self):
"""Search for words in the page."""
page = self.page_pad.current_page
if not page:
return
search = self.get_user_text_input("Search", CommandLine.CHAR_TEXT)
if not search:
return
self.search_res_lines = []
for index, (_, line) in enumerate(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)
else:
self.set_status(f"'{search}' not found.")
def move_to_search_result(self, prev_or_next: int):
"""Move to the next or previous search result."""
current_line = self.page_pad.current_line
next_line = None
index = 1
max_index = len(self.search_res_lines)
if prev_or_next == Browser.SEARCH_NEXT:
for line in self.search_res_lines:
if line > current_line:
next_line = line
break
index += 1
elif prev_or_next == Browser.SEARCH_PREVIOUS:
index = max_index
for line in reversed(self.search_res_lines):
if line < current_line:
next_line = line
break
index -= 1
if next_line is None:
return
self.set_status(f"Result {index}/{max_index}")
self.page_pad.current_line = next_line
self.refresh_windows()

View file

@ -1,5 +1,9 @@
"""Local files browser."""
import logging
from pathlib import Path
from urllib.parse import quote, unquote
from bebop.browser.browser import Browser
from bebop.page import Page
@ -7,10 +11,9 @@ from bebop.page import Page
def open_file(browser: Browser, filepath: str, encoding="utf-8"):
"""Open a file and render it.
This should be used only on Gemtext files or at least text files.
Anything else will produce garbage and may crash the program. In the
future this should be able to use a different parser according to a MIME
type or something.
This should be used only text files or directories. Anything else will
produce garbage and may crash the program. In the future this should be able
to use a different parser according to a MIME type or something.
Arguments:
- browser: Browser object making the request.
@ -20,13 +23,29 @@ def open_file(browser: Browser, filepath: str, encoding="utf-8"):
Returns:
The loaded file URI on success, None otherwise (e.g. file not found).
"""
try:
with open(filepath, "rt", encoding=encoding) as f:
text = f.read()
except (OSError, ValueError) as exc:
browser.set_status_error(f"Failed to open file: {exc}")
path = Path(unquote(filepath))
if not path.exists():
logging.error(f"File {path} does not exist.")
return None
browser.load_page(Page.from_text(text))
file_url = "file://" + filepath
if path.is_file():
try:
with open(path, "rt", encoding=encoding) as f:
text = f.read()
except (OSError, ValueError) as exc:
browser.set_status_error(f"Failed to open file: {exc}")
return None
browser.load_page(Page.from_text(text))
elif path.is_dir():
gemtext = str(path) + "\n\n"
for entry in sorted(path.iterdir()):
entry_path = quote(str(entry.absolute()))
name = entry.name
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))
file_url = f"file://{path}"
browser.current_url = file_url
return file_url

View file

@ -200,7 +200,9 @@ class CommandLine:
return
command = self.editor_command + [temp_filepath]
open_external_program(command)
success = open_external_program(command)
if not success:
return
try:
with open(temp_filepath, "rt") as temp_file:

View file

@ -29,6 +29,7 @@ DEFAULT_CONFIG = {
"-subj", "/CN={common_name}",
],
"scroll_step": 3,
"persistent_history": False,
}
RENDER_MODES = ("fancy", "dumb")

View file

@ -1,6 +1,7 @@
"""Call external commands."""
import curses
import logging
import subprocess
@ -9,12 +10,21 @@ def open_external_program(command):
The caller has to refresh whatever windows it manages after calling this
method or garbage may be left on the screen.
Returns:
True if no exception occured.
"""
curses.nocbreak()
curses.echo()
curses.curs_set(1)
subprocess.run(command)
result = True
try:
subprocess.run(command)
except OSError as exc:
logging.error(f"Failed to run '{command}': {exc}")
result = False
curses.mousemask(curses.ALL_MOUSE_EVENTS)
curses.curs_set(0)
curses.noecho()
curses.cbreak()
return result

View file

@ -48,23 +48,29 @@ def get_downloads_path() -> Path:
@lru_cache(None)
def get_identities_list_path():
def get_identities_list_path() -> Path:
"""Return the identities JSON file path."""
return get_user_data_path() / "identities.json"
@lru_cache(None)
def get_identities_path():
def get_identities_path() -> Path:
"""Return the directory where identities are stored."""
return get_user_data_path() / "identities"
@lru_cache(None)
def get_capsule_prefs_path():
def get_capsule_prefs_path() -> Path:
"""Return the directory where identities are stored."""
return get_user_data_path() / "capsule_prefs.json"
@lru_cache(None)
def get_history_path() -> Path:
"""Return the saved history path."""
return get_user_data_path() / "history.txt"
def ensure_bebop_files_exist() -> Optional[str]:
"""Ensure various Bebop's files or directories are present.

View file

@ -38,6 +38,9 @@ Keybinds using the SHIFT key are written uppercase. Keybinds using the ALT (or M
* digits: go to the corresponding link ID
* escape: reset status line text
* 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
* C-c: cancel current operation
## Commands
@ -58,18 +61,19 @@ Bebop uses a JSON file (usually in ~/.config). It is created with default values
Here are the available options:
* connect_timeout (int): seconds before connection times out.
* text_width (int): rendered line length.
* download_path (string): download path.
* source_editor (see note 1): command to use for editing sources.
* command_editor (see note 1): command to use for editing cli input.
* history_limit (int): maximum entries in history.
* external_commands (see note 2): commands to open various files.
* connect_timeout (int): seconds before connection times out.
* download_path (string): download path.
* external_command_default (see note 1): default command to open files.
* home (string): home page.
* render_mode (string): default render mode to use ("fancy" or "dumb").
* external_commands (see note 2): commands to open various files.
* generate_client_cert_command (see note 3): command to generate a client cert.
* history_limit (int): maximum entries in history.
* home (string): home page.
* 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.
* source_editor (see note 1): command to use for editing sources.
* text_width (int): rendered line length.
Notes:

View file

@ -1,10 +1,12 @@
"""History management."""
import logging
from bebop.fs import get_history_path
class History:
"""Basic browsing history manager.
"""
"""Basic browsing history manager."""
def __init__(self, limit):
self.urls = []
@ -55,3 +57,28 @@ class History:
urls.append(url)
seen.add(url)
return "# History\n\n" + "\n".join("=> " + url for url in urls)
def save(self):
"""Save current history to user data."""
history_path = get_history_path()
try:
with open(history_path, "wt") as history_file:
for url in self.urls:
history_file.write(url + "\n")
except OSError as exc:
logging.error(f"Failed to save history {history_path}: {exc}")
return False
return True
def load(self):
"""Load saved history from user data."""
history_path = get_history_path()
self.urls = []
try:
with open(history_path, "rt") as history_file:
for url in history_file:
self.urls.append(url.rstrip())
except OSError as exc:
logging.error(f"Failed to load history {history_path}: {exc}")
return False
return True

View file

@ -19,9 +19,8 @@ def render_lines(metalines, window, max_width):
- max_width: line length limit for the pad.
Returns:
The tuple of integers (error, height, width), error being a non-zero value
if an error occured during rendering, and height and width being the new
dimensions of the resized window.
The tuple of integers (height, width), the new dimensions of the resized
window.
"""
num_lines = len(metalines)
new_dimensions = max(num_lines, 1), max_width

View file

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