page: move page content to a separate class

This commit is contained in:
dece 2021-03-13 20:37:13 +01:00
parent 3d15074bdd
commit 05c54eff48
5 changed files with 65 additions and 44 deletions

View file

@ -4,17 +4,16 @@ import curses
import curses.ascii import curses.ascii
import curses.textpad import curses.textpad
import os import os
import webbrowser
from math import inf from math import inf
from webbrowser import open_new_tab
from bebop.colors import ColorPair, init_colors from bebop.colors import ColorPair, init_colors
from bebop.command_line import (CommandLine, EscapeCommandInterrupt, from bebop.command_line import CommandLine
TerminateCommandInterrupt)
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 join_url, parse_url, sanitize_url, set_parameter from bebop.navigation import join_url, parse_url, sanitize_url, set_parameter
from bebop.page import Page from bebop.page import Page, PagePad
from bebop.protocol import Request, Response from bebop.protocol import Request, Response
@ -25,12 +24,12 @@ class Browser:
self.stash = cert_stash or {} self.stash = cert_stash or {}
self.screen = None self.screen = None
self.dim = (0, 0) self.dim = (0, 0)
self.page = None self.page_pad = None
self.status_line = None self.status_line = None
self.command_line = None self.command_line = None
self.running = True
self.status_data = ("", 0, 0) self.status_data = ("", 0, 0)
self.current_url = "" self.current_url = ""
self.running = True
self.history = History() self.history = History()
@property @property
@ -57,7 +56,7 @@ class Browser:
init_colors() init_colors()
self.dim = self.screen.getmaxyx() self.dim = self.screen.getmaxyx()
self.page = Page(self.h - 2) self.page_pad = PagePad(self.h - 2)
self.status_line = self.screen.subwin( self.status_line = self.screen.subwin(
*self.line_dim, *self.line_dim,
*self.status_line_pos, *self.status_line_pos,
@ -161,7 +160,7 @@ class Browser:
def refresh_page(self): def refresh_page(self):
"""Refresh the current page pad; it does not reload the page.""" """Refresh the current page pad; it does not reload the page."""
self.page.refresh_content(*self.page_pad_size) self.page_pad.refresh_content(*self.page_pad_size)
def refresh_status_line(self): def refresh_status_line(self):
"""Refresh status line contents.""" """Refresh status line contents."""
@ -282,7 +281,9 @@ class Browser:
return return
if response.code == 20: if response.code == 20:
self.load_page(response.content) # TODO handle MIME type; assume it's gemtext for now.
text = response.content.decode("utf-8", errors="replace")
self.load_page(Page.from_gemtext(text))
if self.current_url and history: if self.current_url and history:
self.history.push(self.current_url) self.history.push(self.current_url)
self.current_url = url self.current_url = url
@ -298,11 +299,11 @@ class Browser:
error = f"Unhandled response code {response.code}" error = f"Unhandled response code {response.code}"
self.set_status_error(error) self.set_status_error(error)
def load_page(self, gemtext: bytes): def load_page(self, page: Page):
"""Load Gemtext data as the current page.""" """Load Gemtext data as the current page."""
old_pad_height = self.page.dim[0] old_pad_height = self.page_pad.dim[0]
self.page.show_gemtext(gemtext) self.page_pad.show_page(page)
if self.page.dim[0] < old_pad_height: if self.page_pad.dim[0] < old_pad_height:
self.screen.clear() self.screen.clear()
self.screen.refresh() self.screen.refresh()
self.refresh_windows() self.refresh_windows()
@ -311,9 +312,9 @@ class Browser:
def handle_digit_input(self, init_char: int): def handle_digit_input(self, init_char: int):
"""Focus command-line to select the link ID to follow.""" """Focus command-line to select the link ID to follow."""
if not self.page or self.page.links is None: if not self.page_pad or self.page_pad.current_page.links is None:
return return
links = self.page.links links = self.page_pad.current_page.links
err, val = self.command_line.focus_for_link_navigation(init_char, links) err, val = self.command_line.focus_for_link_navigation(init_char, links)
if err == 0: if err == 0:
self.open_link(links, val) # type: ignore self.open_link(links, val) # type: ignore
@ -369,7 +370,7 @@ class Browser:
self.command_line.window.mvwin(*self.command_line_pos) self.command_line.window.mvwin(*self.command_line_pos)
# If the content pad does not fit its whole place, we have to clean the # If the content pad does not fit its whole place, we have to clean the
# gap between it and the status line. Refresh all screen. # gap between it and the status line. Refresh all screen.
if self.page.dim[0] < self.h - 2: if self.page_pad.dim[0] < self.h - 2:
self.screen.clear() self.screen.clear()
self.screen.refresh() self.screen.refresh()
self.refresh_windows() self.refresh_windows()
@ -384,11 +385,11 @@ class Browser:
window_height = self.h - 2 window_height = self.h - 2
require_refresh = False require_refresh = False
if by_lines == inf: if by_lines == inf:
require_refresh = self.page.go_to_end(window_height) require_refresh = self.page_pad.go_to_end(window_height)
elif by_lines == -inf: elif by_lines == -inf:
require_refresh = self.page.go_to_beginning() require_refresh = self.page_pad.go_to_beginning()
else: else:
require_refresh = self.page.scroll_v(by_lines, window_height) require_refresh = self.page_pad.scroll_v(by_lines, window_height)
if require_refresh: if require_refresh:
self.refresh_page() self.refresh_page()
@ -402,7 +403,7 @@ class Browser:
def scroll_page_horizontally(self, by_columns): def scroll_page_horizontally(self, by_columns):
"""Scroll page horizontally.""" """Scroll page horizontally."""
if self.page.scroll_h(by_columns, self.w): if self.page_pad.scroll_h(by_columns, self.w):
self.refresh_page() self.refresh_page()
def reload_page(self): def reload_page(self):
@ -418,16 +419,20 @@ class Browser:
def open_web_url(self, url): def open_web_url(self, url):
"""Open a Web URL. Currently relies in Python's webbrowser module.""" """Open a Web URL. Currently relies in Python's webbrowser module."""
self.set_status(f"Opening {url}") self.set_status(f"Opening {url}")
open_new_tab(url) webbrowser.open_new_tab(url)
def open_file(self, filepath): def open_file(self, filepath, 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 on Gemtext files or at least text files.
Anything else will produce garbage and may crash the program. 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.
""" """
try: try:
with open(filepath, "rb") as f: with open(filepath, "rt", encoding=encoding) as f:
self.load_page(f.read()) text = f.read()
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
self.set_status_error(f"Failed to open file: {exc}") self.set_status_error(f"Failed to open file: {exc}")
return
self.load_page(Page.from_gemtext(text))

View file

@ -47,9 +47,8 @@ class ListItem:
RE = re.compile(r"\*\s(.*)") RE = re.compile(r"\*\s(.*)")
def parse_gemtext(data): def parse_gemtext(text: str):
"""Parse UTF-8 encoded Gemtext as a list of elements.""" """Parse a string of Gemtext into a list of elements."""
text = data.decode(encoding="utf8", errors="ignore")
elements = [] elements = []
preformatted = None preformatted = None
for line in text.splitlines(): for line in text.splitlines():

View file

@ -1,5 +1,7 @@
"""Links manager.""" """Links manager."""
from typing import List
class Links(dict): class Links(dict):
@ -11,3 +13,11 @@ class Links(dict):
link_id for link_id, url in self.items() link_id for link_id, url in self.items()
if str(link_id).startswith(digits) if str(link_id).startswith(digits)
] ]
@staticmethod
def from_metalines(metalines: List):
links = Links()
for meta, _ in metalines:
if "link_id" in meta and "url" in meta:
links[meta["link_id"]] = meta["url"]
return links

View file

@ -1,42 +1,49 @@
"""Single Gemini page curses management.""" """Single Gemini page curses management."""
import curses import curses
from dataclasses import dataclass, field
from bebop.gemtext import parse_gemtext from bebop.gemtext import parse_gemtext
from bebop.links import Links from bebop.links import Links
from bebop.rendering import format_elements, render_lines from bebop.rendering import generate_metalines, render_lines
@dataclass
class Page: class Page:
"""Page-related data."""
metalines: list = field(default_factory=list)
links: Links = field(default_factory=Links)
@staticmethod
def from_gemtext(gemtext: str):
"""Produce a Page from a Gemtext file or string."""
elements = parse_gemtext(gemtext)
metalines = generate_metalines(elements, 80)
links = Links.from_metalines(metalines)
return Page(metalines, links)
class PagePad:
"""Window containing page content.""" """Window containing page content."""
MAX_COLS = 1000 MAX_COLS = 1000
def __init__(self, initial_num_lines): def __init__(self, initial_num_lines):
self.dim = (initial_num_lines, Page.MAX_COLS) self.dim = (initial_num_lines, PagePad.MAX_COLS)
self.pad = curses.newpad(*self.dim) self.pad = curses.newpad(*self.dim)
self.pad.scrollok(True) self.pad.scrollok(True)
self.pad.idlok(True) self.pad.idlok(True)
self.metalines = []
self.current_line = 0 self.current_line = 0
self.current_column = 0 self.current_column = 0
self.links = Links() self.current_page = None
def show_gemtext(self, gemtext: bytes): def show_page(self, page: Page):
"""Render Gemtext data in the content pad.""" """Render Gemtext data in the content pad."""
# Parse and format Gemtext. self.current_page = page
elements = parse_gemtext(gemtext)
self.metalines = format_elements(elements, 80)
# Render metalines.
self.pad.clear() self.pad.clear()
self.dim = render_lines(self.metalines, self.pad, Page.MAX_COLS) self.dim = render_lines(page.metalines, self.pad, PagePad.MAX_COLS)
self.current_line = 0 self.current_line = 0
self.current_column = 0 self.current_column = 0
# Aggregate links for navigation.
self.links = Links()
for meta, _ in self.metalines:
if "link_id" in meta and "url" in meta:
self.links[meta["link_id"]] = meta["url"]
def refresh_content(self, x, y): def refresh_content(self, x, y):
"""Refresh content pad's view using the current line/column.""" """Refresh content pad's view using the current line/column."""

View file

@ -36,7 +36,7 @@ class LineType(IntEnum):
LIST_ITEM = 8 LIST_ITEM = 8
def format_elements(elements, width): def generate_metalines(elements, width):
"""Format elements into a list of lines with metadata. """Format elements into a list of lines with metadata.
The returned list ("metalines") are tuples (meta, line), meta being a The returned list ("metalines") are tuples (meta, line), meta being a