Compare commits

...

8 commits

11 changed files with 181 additions and 44 deletions

View file

@ -1,8 +1,5 @@
TODO 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 identity management
"previous/next" pages "previous/next" pages
configurable keybinds configurable keybinds
handle big files (e.g. gemini://tilde.team/~tomasino/irc/log.txt)
@ -59,3 +57,5 @@ home page
different rendering mode different rendering mode
preferences per site preferences per site
basic mouse support basic mouse support
basic local browsing
search in page

View file

@ -64,8 +64,12 @@ class Browser:
returning the page source path. returning the page source path.
- last_download: tuple of MimeType and path, or None. - last_download: tuple of MimeType and path, or None.
- identities: identities map. - 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): def __init__(self, config, cert_stash):
self.config = config self.config = config
self.stash = cert_stash self.stash = cert_stash
@ -81,6 +85,7 @@ class Browser:
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 = {} self.identities = {}
self.search_res_lines = []
self._current_url = "" self._current_url = ""
@property @property
@ -127,16 +132,17 @@ class Browser:
def _run(self, stdscr, start_url=None): def _run(self, stdscr, start_url=None):
"""Start displaying content and handling events.""" """Start displaying content and handling events."""
# Setup Curses.
self.screen = stdscr self.screen = stdscr
self.screen.clear() self.screen.clear()
self.screen.refresh() self.screen.refresh()
mousemask = curses.mousemask(curses.ALL_MOUSE_EVENTS) mousemask = curses.mousemask(curses.ALL_MOUSE_EVENTS)
if mousemask == 0: if mousemask == 0:
logging.error("Could not enable mouse support.") logging.error("Could not enable mouse support.")
curses.curs_set(0) curses.curs_set(0)
init_colors() init_colors()
# Setup windows and pads.
self.dim = self.screen.getmaxyx() self.dim = self.screen.getmaxyx()
self.page_pad = PagePad(self.h - 2) self.page_pad = PagePad(self.h - 2)
self.status_line = self.screen.subwin( self.status_line = self.screen.subwin(
@ -152,6 +158,7 @@ class Browser:
self.config["command_editor"] self.config["command_editor"]
) )
# Load user data files, record which failed to load to warn the user.
failed_to_load = [] failed_to_load = []
identities = load_identities(get_identities_list_path()) identities = load_identities(get_identities_list_path())
if identities is None: if identities is None:
@ -164,10 +171,15 @@ class Browser:
else: else:
self.capsule_prefs = capsule_prefs 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: if failed_to_load:
error_msg = ( error_msg = (
f"Failed to open some local data: {', '.join(failed_to_load)}. " 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) self.set_status_error(error_msg)
elif start_url: elif start_url:
@ -175,12 +187,16 @@ class Browser:
else: else:
self.open_home() self.open_home()
# Start listening for inputs.
while self.running: while self.running:
try: try:
self.handle_inputs() self.handle_inputs()
except KeyboardInterrupt: except KeyboardInterrupt:
self.set_status("Cancelled.") self.set_status("Cancelled.")
if self.config["persistent_history"]:
self.history.save()
def handle_inputs(self): def handle_inputs(self):
char = self.screen.getch() char = self.screen.getch()
if char == ord("?"): if char == ord("?"):
@ -233,6 +249,12 @@ class Browser:
self.open_history() self.open_history()
elif char == ord("§"): elif char == ord("§"):
self.toggle_render_mode() 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): 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:
@ -483,9 +505,9 @@ class Browser:
If the click is on a link (appropriate line and columns), open it. 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 page = self.page_pad.current_page
if not page:
return
px, py = self.page_pad.current_column, self.page_pad.current_line px, py = self.page_pad.current_column, self.page_pad.current_line
line_pos = y + py line_pos = y + py
if line_pos >= len(page.metalines): if line_pos >= len(page.metalines):
@ -664,7 +686,10 @@ class Browser:
return return
command = self.config["source_editor"] + [source_filename] 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: if delete_source_after:
os.unlink(source_filename) os.unlink(source_filename)
self.refresh_windows() self.refresh_windows()
@ -713,13 +738,14 @@ class Browser:
def show_page_info(self): def show_page_info(self):
"""Show some page informations in the status bar.""" """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 page = self.page_pad.current_page
if not page:
return
mime = page.mime.short if page.mime else "(unknown MIME type)" mime = page.mime.short if page.mime else "(unknown MIME type)"
encoding = page.encoding or "(unknown encoding)" encoding = page.encoding or "(unknown encoding)"
size = f"{len(page.source)} chars" 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) self.set_status(info)
def set_render_mode(self, mode): def set_render_mode(self, mode):
@ -744,9 +770,9 @@ class Browser:
def toggle_render_mode(self): def toggle_render_mode(self):
"""Switch to the next render mode for the current page.""" """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 page = self.page_pad.current_page
if not page:
return
if page.render is None or page.render not in RENDER_MODES: if page.render is None or page.render not in RENDER_MODES:
next_mode = RENDER_MODES[0] next_mode = RENDER_MODES[0]
else: else:
@ -759,3 +785,46 @@ class Browser:
) )
self.load_page(new_page) self.load_page(new_page)
self.set_status(f"Using render mode '{next_mode}'.") 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.""" """Local files browser."""
import logging
from pathlib import Path
from urllib.parse import quote, unquote
from bebop.browser.browser import Browser from bebop.browser.browser import Browser
from bebop.page import Page from bebop.page import Page
@ -7,10 +11,9 @@ from bebop.page import Page
def open_file(browser: Browser, filepath: str, encoding="utf-8"): def open_file(browser: Browser, filepath: str, encoding="utf-8"):
"""Open a file and render it. """Open a file and render it.
This should be used only on Gemtext files or at least text files. This should be used only text files or directories. Anything else will
Anything else will produce garbage and may crash the program. In the produce garbage and may crash the program. In the future this should be able
future this should be able to use a different parser according to a MIME to use a different parser according to a MIME type or something.
type or something.
Arguments: Arguments:
- browser: Browser object making the request. - browser: Browser object making the request.
@ -20,13 +23,29 @@ def open_file(browser: Browser, filepath: str, encoding="utf-8"):
Returns: Returns:
The loaded file URI on success, None otherwise (e.g. file not found). The loaded file URI on success, None otherwise (e.g. file not found).
""" """
path = Path(unquote(filepath))
if not path.exists():
logging.error(f"File {path} does not exist.")
return None
if path.is_file():
try: try:
with open(filepath, "rt", encoding=encoding) as f: with open(path, "rt", encoding=encoding) as f:
text = f.read() text = f.read()
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
browser.set_status_error(f"Failed to open file: {exc}") browser.set_status_error(f"Failed to open file: {exc}")
return None return None
browser.load_page(Page.from_text(text)) browser.load_page(Page.from_text(text))
file_url = "file://" + filepath 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 browser.current_url = file_url
return file_url return file_url

View file

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

View file

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

View file

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

View file

@ -48,23 +48,29 @@ def get_downloads_path() -> Path:
@lru_cache(None) @lru_cache(None)
def get_identities_list_path(): def get_identities_list_path() -> Path:
"""Return the identities JSON file path.""" """Return the identities JSON file path."""
return get_user_data_path() / "identities.json" return get_user_data_path() / "identities.json"
@lru_cache(None) @lru_cache(None)
def get_identities_path(): def get_identities_path() -> Path:
"""Return the directory where identities are stored.""" """Return the directory where identities are stored."""
return get_user_data_path() / "identities" return get_user_data_path() / "identities"
@lru_cache(None) @lru_cache(None)
def get_capsule_prefs_path(): def get_capsule_prefs_path() -> Path:
"""Return the directory where identities are stored.""" """Return the directory where identities are stored."""
return get_user_data_path() / "capsule_prefs.json" 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]: 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.

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 * 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 * 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 * C-c: cancel current operation
## Commands ## Commands
@ -58,18 +61,19 @@ Bebop uses a JSON file (usually in ~/.config). It is created with default values
Here are the available options: 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. * command_editor (see note 1): command to use for editing cli input.
* history_limit (int): maximum entries in history. * connect_timeout (int): seconds before connection times out.
* external_commands (see note 2): commands to open various files. * download_path (string): download path.
* 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. * external_commands (see note 2): commands to open various files.
* render_mode (string): default render mode to use ("fancy" or "dumb").
* generate_client_cert_command (see note 3): command to generate a client cert. * 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. * 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: Notes:

View file

@ -1,10 +1,12 @@
"""History management.""" """History management."""
import logging
from bebop.fs import get_history_path
class History: class History:
"""Basic browsing history manager. """Basic browsing history manager."""
"""
def __init__(self, limit): def __init__(self, limit):
self.urls = [] self.urls = []
@ -55,3 +57,28 @@ class History:
urls.append(url) urls.append(url)
seen.add(url) seen.add(url)
return "# History\n\n" + "\n".join("=> " + url for url in urls) 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. - max_width: line length limit for the pad.
Returns: Returns:
The tuple of integers (error, height, width), error being a non-zero value The tuple of integers (height, width), the new dimensions of the resized
if an error occured during rendering, and height and width being the new window.
dimensions of the resized window.
""" """
num_lines = len(metalines) num_lines = len(metalines)
new_dimensions = max(num_lines, 1), max_width new_dimensions = max(num_lines, 1), max_width

View file

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