Compare commits
2 commits
40133a1e6b
...
5b3e91336f
Author | SHA1 | Date | |
---|---|---|---|
dece | 5b3e91336f | ||
dece | d6bcd1f706 |
60
bebop/bookmarks.py
Normal file
60
bebop/bookmarks.py
Normal 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
|
|
@ -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()
|
||||||
|
|
110
bebop/page.py
110
bebop/page.py
|
@ -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
100
bebop/page_pad.py
Normal 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
|
Reference in a new issue