Compare commits

...

2 commits

Author SHA1 Message Date
dece 5b3e91336f bookmarks: basic bookmark management 2021-03-18 01:56:24 +01:00
dece d6bcd1f706 page: split page/page_pad modules
Also add title to Page but really this needs a big cleanup.
2021-03-18 01:55:31 +01:00
4 changed files with 233 additions and 109 deletions

60
bebop/bookmarks.py Normal file
View file

@ -0,0 +1,60 @@
import io
from pathlib import Path
from bebop.fs import get_user_data_path
TEMPLATE = """\
# Bookmarks
Welcome to your bookmark page! This file has been created in "{original_path}" \
and you can edit it as you wish. New bookmarks will be added on a new \
line at the end. Always keep an empty line at the end!
"""
def get_bookmarks_path() -> Path:
"""Return the path to the bookmarks file."""
return get_user_data_path() / "bookmarks.gmi"
def init_bookmarks(filepath):
"""Create the bookmarks file and return its initial content.
Raises OSError if the file could not be written.
"""
content = TEMPLATE.format(original_path=filepath)
with open(filepath, "wt") as bookmark_file:
bookmark_file.write(content)
return content
def get_bookmarks_document():
"""Return the bookmarks content, or None or failure.
If no bookmarks file exist yet, it is created. If accessing or creating the
file fails, or if it is unreadable, return None.
"""
filepath = get_bookmarks_path()
try:
if not filepath.exists():
content = init_bookmarks(filepath)
else:
with open(filepath, "rt") as bookmark_file:
content = bookmark_file.read()
except OSError:
return None
return content
def save_bookmark(url, title):
"""Append this URL/title pair to the bookmarks, return True on success."""
filepath = get_bookmarks_path()
try:
if not filepath.exists():
init_bookmarks(filepath)
with open(filepath, "at") as bookmark_file:
bookmark_file.write(f"=> {url} {title}\n")
except OSError:
return False
return True

View file

@ -7,13 +7,15 @@ import os
import webbrowser import webbrowser
from math import inf from math import inf
from bebop.bookmarks import get_bookmarks_document, save_bookmark
from bebop.colors import ColorPair, init_colors from bebop.colors import ColorPair, init_colors
from bebop.command_line import CommandLine from bebop.command_line import CommandLine
from bebop.history import History from bebop.history import History
from bebop.links import Links from bebop.links import Links
from bebop.mouse import ButtonState from bebop.mouse import ButtonState
from bebop.navigation import * from bebop.navigation import *
from bebop.page import Page, PagePad from bebop.page import Page
from bebop.page_pad import PagePad
from bebop.protocol import Request, Response from bebop.protocol import Request, Response
@ -29,9 +31,9 @@ class Browser:
self.command_line = None self.command_line = None
self.running = True self.running = True
self.status_data = ("", 0, 0) self.status_data = ("", 0, 0)
self.current_url = ""
self.history = History() self.history = History()
self.cache = {} self.cache = {}
self._current_url = ""
@property @property
def h(self): def h(self):
@ -41,6 +43,17 @@ class Browser:
def w(self): def w(self):
return self.dim[1] return self.dim[1]
@property
def current_url(self):
"""Return the current URL."""
return self._current_url
@current_url.setter
def current_url(self, url):
"""Set the current URL and show it in the status line."""
self._current_url = url
self.set_status(url)
def run(self, *args, **kwargs): def run(self, *args, **kwargs):
"""Use curses' wrapper around _run.""" """Use curses' wrapper around _run."""
os.environ.setdefault("ESCDELAY", "25") os.environ.setdefault("ESCDELAY", "25")
@ -115,6 +128,10 @@ class Browser:
self.go_to_parent_page() self.go_to_parent_page()
elif char == ord("U"): elif char == ord("U"):
self.go_to_root_page() self.go_to_root_page()
elif char == ord("b"):
self.open_bookmarks()
elif char == ord("B"):
self.add_bookmark()
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:
@ -125,7 +142,7 @@ class Browser:
self.screen.nodelay(True) self.screen.nodelay(True)
char = self.screen.getch() char = self.screen.getch()
if char == -1: if char == -1:
self.set_status(self.current_url) self.reset_status()
else: # ALT keybinds. else: # ALT keybinds.
if char == ord("h"): if char == ord("h"):
self.scroll_page_horizontally(-1) self.scroll_page_horizontally(-1)
@ -181,6 +198,10 @@ class Browser:
self.status_data = text, ColorPair.NORMAL, curses.A_ITALIC self.status_data = text, ColorPair.NORMAL, curses.A_ITALIC
self.refresh_status_line() self.refresh_status_line()
def reset_status(self):
"""Reset status line, e.g. after a cancelled action."""
self.set_status(self.current_url)
def set_status_error(self, text): def set_status_error(self, text):
"""Set an error message in the status bar.""" """Set an error message in the status bar."""
self.status_data = text, ColorPair.ERROR, 0 self.status_data = text, ColorPair.ERROR, 0
@ -208,7 +229,8 @@ class Browser:
if command in ("o", "open"): if command in ("o", "open"):
self.open_url(words[1], assume_absolute=True) self.open_url(words[1], assume_absolute=True)
def open_url(self, url, base_url=None, redirects=0, assume_absolute=False): def open_url(self, url, base_url=None, redirects=0, assume_absolute=False,
history=True, use_cache=True):
"""Try to open an URL. """Try to open an URL.
This function assumes that the URL can be from an user and thus tries a This function assumes that the URL can be from an user and thus tries a
@ -223,6 +245,8 @@ class Browser:
- base_url: an URL string to use as base in case `url` is relative. - base_url: an URL string to use as base in case `url` is relative.
- redirections: number of redirections we did yet for the same request. - redirections: number of redirections we did yet for the same request.
- assume_absolute: assume we intended to use an absolute URL if True. - assume_absolute: assume we intended to use an absolute URL if True.
- history: whether the URL should be pushed to history on success.
- use_cache: whether we should look for an already cached document.
""" """
if redirects > 5: if redirects > 5:
self.set_status_error(f"Too many redirections ({url}).") self.set_status_error(f"Too many redirections ({url}).")
@ -237,11 +261,15 @@ class Browser:
# If there is no netloc, this is a relative URL. # If there is no netloc, this is a relative URL.
if join or base_url: if join or base_url:
url = join_url(base_url or self.current_url, url) url = join_url(base_url or self.current_url, url)
self.open_gemini_url(sanitize_url(url), redirects) self.open_gemini_url(sanitize_url(url), redirects=redirects,
history=history, use_cache=use_cache)
elif parts.scheme.startswith("http"): elif parts.scheme.startswith("http"):
self.open_web_url(url) self.open_web_url(url)
elif parts.scheme == "file": elif parts.scheme == "file":
self.open_file(parts.path) self.open_file(parts.path, history=history)
elif parts.scheme == "bebop":
if parts.netloc == "bookmarks":
self.open_bookmarks()
else: else:
self.set_status_error(f"Protocol {parts.scheme} not supported.") self.set_status_error(f"Protocol {parts.scheme} not supported.")
@ -460,7 +488,7 @@ class Browser:
def reload_page(self): def reload_page(self):
"""Reload the page, if one has been previously loaded.""" """Reload the page, if one has been previously loaded."""
if self.current_url: if self.current_url:
self.open_gemini_url( self.open_url(
self.current_url, self.current_url,
history=False, history=False,
use_cache=False use_cache=False
@ -469,7 +497,7 @@ class Browser:
def go_back(self): def go_back(self):
"""Go back in history if possible.""" """Go back in history if possible."""
if self.history.has_links(): if self.history.has_links():
self.open_gemini_url(self.history.pop(), history=False) self.open_url(self.history.pop(), history=False)
def go_to_parent_page(self): def go_to_parent_page(self):
"""Go to the parent URL if possible.""" """Go to the parent URL if possible."""
@ -486,7 +514,7 @@ class Browser:
self.set_status(f"Opening {url}") self.set_status(f"Opening {url}")
webbrowser.open_new_tab(url) webbrowser.open_new_tab(url)
def open_file(self, filepath, encoding="utf-8"): def open_file(self, filepath, encoding="utf-8", history=True):
"""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 on Gemtext files or at least text files.
@ -501,3 +529,29 @@ class Browser:
self.set_status_error(f"Failed to open file: {exc}") self.set_status_error(f"Failed to open file: {exc}")
return return
self.load_page(Page.from_gemtext(text)) self.load_page(Page.from_gemtext(text))
file_url = "file://" + filepath
if history:
self.history.push(file_url)
self.current_url = file_url
def open_bookmarks(self):
"""Open bookmarks."""
content = get_bookmarks_document()
if content is None:
self.set_status_error("Failed to open bookmarks.")
return
self.load_page(Page.from_gemtext(content))
self.current_url = "bebop://bookmarks"
def add_bookmark(self):
"""Add the current URL as bookmark."""
if not self.current_url:
return
self.set_status("Title?")
current_title = self.page_pad.current_page.title or ""
title = self.command_line.focus(">", prefix=current_title)
if title:
title = title.strip()
if title:
save_bookmark(self.current_url, title)
self.reset_status()

View file

@ -1,11 +1,8 @@
"""Single Gemini page curses management."""
import curses
from dataclasses import dataclass, field from dataclasses import dataclass, field
from bebop.gemtext import parse_gemtext from bebop.gemtext import parse_gemtext, Title
from bebop.rendering import generate_metalines
from bebop.links import Links from bebop.links import Links
from bebop.rendering import generate_metalines, render_lines
@dataclass @dataclass
@ -13,6 +10,7 @@ class Page:
"""Page-related data.""" """Page-related data."""
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 = ""
@staticmethod @staticmethod
def from_gemtext(gemtext: str): def from_gemtext(gemtext: str):
@ -20,98 +18,10 @@ class Page:
elements = parse_gemtext(gemtext) elements = parse_gemtext(gemtext)
metalines = generate_metalines(elements, 80) metalines = generate_metalines(elements, 80)
links = Links.from_metalines(metalines) links = Links.from_metalines(metalines)
return Page(metalines, links) # TODO this is horrible; merge parsing with page generation directly
title = ""
for element in elements:
class PagePad: if isinstance(element, Title) and element.level == 1:
"""Window containing page content.""" title = element.text
break
MAX_COLS = 1000 return Page(metalines, links, title)
def __init__(self, initial_num_lines):
self.dim = (initial_num_lines, PagePad.MAX_COLS)
self.pad = curses.newpad(*self.dim)
self.pad.scrollok(True)
self.pad.idlok(True)
self.current_line = 0
self.current_column = 0
self.current_page = None
def show_page(self, page: Page):
"""Render Gemtext data in the content pad."""
self.current_page = page
self.pad.clear()
self.dim = render_lines(page.metalines, self.pad, PagePad.MAX_COLS)
self.current_line = 0
self.current_column = 0
def refresh_content(self, x, y):
"""Refresh content pad's view using the current line/column."""
if x <= 0 or y <= 0:
return
content_position = self.current_line, self.current_column
self.pad.refresh(*content_position, 0, 0, x, y)
def scroll_v(self, num_lines: int, window_height: int =0):
"""Make the content pad scroll up and down by num_lines.
Arguments:
- num_lines: amount of lines to scroll, can be negative to scroll up.
- window_height: total window height, used to limit scrolling down.
Returns:
True if scrolling occured and the pad has to be refreshed.
"""
if num_lines < 0:
num_lines = -num_lines
min_line = 0
if self.current_line > min_line:
self.current_line = max(self.current_line - num_lines, min_line)
return True
else:
max_line = self.dim[0] - window_height
if self.current_line < max_line:
self.current_line = min(self.current_line + num_lines, max_line)
return True
return False
def scroll_h(self, num_columns: int, window_width: int =0):
"""Make the content pad scroll left and right by num_columns.
Arguments:
- num_columns: amount of columns to scroll, can be negative to scroll
left.
- window_width: total window width, used to limit scrolling right.
Returns:
True if scrolling occured and the pad has to be refreshed.
"""
if num_columns < 0:
num_columns = -num_columns
min_column = 0
if self.current_column > min_column:
new_column = self.current_column - num_columns
self.current_column = max(new_column, min_column)
return True
else:
max_column = self.dim[1] - window_width
if self.current_column < max_column:
new_column = self.current_column + num_columns
self.current_column = min(new_column, max_column)
return True
return False
def go_to_beginning(self):
"""Make the pad show its start; return True if a refresh is needed."""
if self.current_line:
self.current_line = 0
return True
return False
def go_to_end(self, window_height):
"""Make the pad show its bottom; return True if a refresh is needed."""
max_line = self.dim[0] - window_height
if self.current_line != max_line:
self.current_line = max_line
return True
return False

100
bebop/page_pad.py Normal file
View file

@ -0,0 +1,100 @@
"""Single Gemini page curses management."""
import curses
from bebop.page import Page
from bebop.rendering import render_lines
class PagePad:
"""Window containing page content."""
MAX_COLS = 1000
def __init__(self, initial_num_lines):
self.dim = (initial_num_lines, PagePad.MAX_COLS)
self.pad = curses.newpad(*self.dim)
self.pad.scrollok(True)
self.pad.idlok(True)
self.current_line = 0
self.current_column = 0
self.current_page = None
def show_page(self, page: Page):
"""Render Gemtext data in the content pad."""
self.current_page = page
self.pad.clear()
self.dim = render_lines(page.metalines, self.pad, PagePad.MAX_COLS)
self.current_line = 0
self.current_column = 0
def refresh_content(self, x, y):
"""Refresh content pad's view using the current line/column."""
if x <= 0 or y <= 0:
return
content_position = self.current_line, self.current_column
self.pad.refresh(*content_position, 0, 0, x, y)
def scroll_v(self, num_lines: int, window_height: int =0):
"""Make the content pad scroll up and down by num_lines.
Arguments:
- num_lines: amount of lines to scroll, can be negative to scroll up.
- window_height: total window height, used to limit scrolling down.
Returns:
True if scrolling occured and the pad has to be refreshed.
"""
if num_lines < 0:
num_lines = -num_lines
min_line = 0
if self.current_line > min_line:
self.current_line = max(self.current_line - num_lines, min_line)
return True
else:
max_line = self.dim[0] - window_height
if self.current_line < max_line:
self.current_line = min(self.current_line + num_lines, max_line)
return True
return False
def scroll_h(self, num_columns: int, window_width: int =0):
"""Make the content pad scroll left and right by num_columns.
Arguments:
- num_columns: amount of columns to scroll, can be negative to scroll
left.
- window_width: total window width, used to limit scrolling right.
Returns:
True if scrolling occured and the pad has to be refreshed.
"""
if num_columns < 0:
num_columns = -num_columns
min_column = 0
if self.current_column > min_column:
new_column = self.current_column - num_columns
self.current_column = max(new_column, min_column)
return True
else:
max_column = self.dim[1] - window_width
if self.current_column < max_column:
new_column = self.current_column + num_columns
self.current_column = min(new_column, max_column)
return True
return False
def go_to_beginning(self):
"""Make the pad show its start; return True if a refresh is needed."""
if self.current_line:
self.current_line = 0
return True
return False
def go_to_end(self, window_height):
"""Make the pad show its bottom; return True if a refresh is needed."""
max_line = self.dim[0] - window_height
if self.current_line != max_line:
self.current_line = max_line
return True
return False