page: move page content to a separate class
This commit is contained in:
parent
3d15074bdd
commit
05c54eff48
|
@ -4,17 +4,16 @@ import curses
|
|||
import curses.ascii
|
||||
import curses.textpad
|
||||
import os
|
||||
import webbrowser
|
||||
from math import inf
|
||||
from webbrowser import open_new_tab
|
||||
|
||||
from bebop.colors import ColorPair, init_colors
|
||||
from bebop.command_line import (CommandLine, EscapeCommandInterrupt,
|
||||
TerminateCommandInterrupt)
|
||||
from bebop.command_line import CommandLine
|
||||
from bebop.history import History
|
||||
from bebop.links import Links
|
||||
from bebop.mouse import ButtonState
|
||||
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
|
||||
|
||||
|
||||
|
@ -25,12 +24,12 @@ class Browser:
|
|||
self.stash = cert_stash or {}
|
||||
self.screen = None
|
||||
self.dim = (0, 0)
|
||||
self.page = None
|
||||
self.page_pad = None
|
||||
self.status_line = None
|
||||
self.command_line = None
|
||||
self.running = True
|
||||
self.status_data = ("", 0, 0)
|
||||
self.current_url = ""
|
||||
self.running = True
|
||||
self.history = History()
|
||||
|
||||
@property
|
||||
|
@ -57,7 +56,7 @@ class Browser:
|
|||
init_colors()
|
||||
|
||||
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.line_dim,
|
||||
*self.status_line_pos,
|
||||
|
@ -161,7 +160,7 @@ class Browser:
|
|||
|
||||
def refresh_page(self):
|
||||
"""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):
|
||||
"""Refresh status line contents."""
|
||||
|
@ -282,7 +281,9 @@ class Browser:
|
|||
return
|
||||
|
||||
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:
|
||||
self.history.push(self.current_url)
|
||||
self.current_url = url
|
||||
|
@ -298,11 +299,11 @@ class Browser:
|
|||
error = f"Unhandled response code {response.code}"
|
||||
self.set_status_error(error)
|
||||
|
||||
def load_page(self, gemtext: bytes):
|
||||
def load_page(self, page: Page):
|
||||
"""Load Gemtext data as the current page."""
|
||||
old_pad_height = self.page.dim[0]
|
||||
self.page.show_gemtext(gemtext)
|
||||
if self.page.dim[0] < old_pad_height:
|
||||
old_pad_height = self.page_pad.dim[0]
|
||||
self.page_pad.show_page(page)
|
||||
if self.page_pad.dim[0] < old_pad_height:
|
||||
self.screen.clear()
|
||||
self.screen.refresh()
|
||||
self.refresh_windows()
|
||||
|
@ -311,9 +312,9 @@ class Browser:
|
|||
|
||||
def handle_digit_input(self, init_char: int):
|
||||
"""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
|
||||
links = self.page.links
|
||||
links = self.page_pad.current_page.links
|
||||
err, val = self.command_line.focus_for_link_navigation(init_char, links)
|
||||
if err == 0:
|
||||
self.open_link(links, val) # type: ignore
|
||||
|
@ -369,7 +370,7 @@ class Browser:
|
|||
self.command_line.window.mvwin(*self.command_line_pos)
|
||||
# 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.
|
||||
if self.page.dim[0] < self.h - 2:
|
||||
if self.page_pad.dim[0] < self.h - 2:
|
||||
self.screen.clear()
|
||||
self.screen.refresh()
|
||||
self.refresh_windows()
|
||||
|
@ -384,11 +385,11 @@ class Browser:
|
|||
window_height = self.h - 2
|
||||
require_refresh = False
|
||||
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:
|
||||
require_refresh = self.page.go_to_beginning()
|
||||
require_refresh = self.page_pad.go_to_beginning()
|
||||
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:
|
||||
self.refresh_page()
|
||||
|
||||
|
@ -402,7 +403,7 @@ class Browser:
|
|||
|
||||
def scroll_page_horizontally(self, by_columns):
|
||||
"""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()
|
||||
|
||||
def reload_page(self):
|
||||
|
@ -418,16 +419,20 @@ class Browser:
|
|||
def open_web_url(self, url):
|
||||
"""Open a Web URL. Currently relies in Python's webbrowser module."""
|
||||
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.
|
||||
|
||||
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:
|
||||
with open(filepath, "rb") as f:
|
||||
self.load_page(f.read())
|
||||
with open(filepath, "rt", encoding=encoding) as f:
|
||||
text = f.read()
|
||||
except (OSError, ValueError) as exc:
|
||||
self.set_status_error(f"Failed to open file: {exc}")
|
||||
return
|
||||
self.load_page(Page.from_gemtext(text))
|
||||
|
|
|
@ -47,9 +47,8 @@ class ListItem:
|
|||
RE = re.compile(r"\*\s(.*)")
|
||||
|
||||
|
||||
def parse_gemtext(data):
|
||||
"""Parse UTF-8 encoded Gemtext as a list of elements."""
|
||||
text = data.decode(encoding="utf8", errors="ignore")
|
||||
def parse_gemtext(text: str):
|
||||
"""Parse a string of Gemtext into a list of elements."""
|
||||
elements = []
|
||||
preformatted = None
|
||||
for line in text.splitlines():
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
"""Links manager."""
|
||||
|
||||
from typing import List
|
||||
|
||||
|
||||
class Links(dict):
|
||||
|
||||
|
@ -11,3 +13,11 @@ class Links(dict):
|
|||
link_id for link_id, url in self.items()
|
||||
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
|
||||
|
|
|
@ -1,42 +1,49 @@
|
|||
"""Single Gemini page curses management."""
|
||||
|
||||
import curses
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from bebop.gemtext import parse_gemtext
|
||||
from bebop.links import Links
|
||||
from bebop.rendering import format_elements, render_lines
|
||||
from bebop.rendering import generate_metalines, render_lines
|
||||
|
||||
|
||||
@dataclass
|
||||
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."""
|
||||
|
||||
MAX_COLS = 1000
|
||||
|
||||
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.scrollok(True)
|
||||
self.pad.idlok(True)
|
||||
self.metalines = []
|
||||
self.current_line = 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."""
|
||||
# Parse and format Gemtext.
|
||||
elements = parse_gemtext(gemtext)
|
||||
self.metalines = format_elements(elements, 80)
|
||||
# Render metalines.
|
||||
self.current_page = page
|
||||
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_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):
|
||||
"""Refresh content pad's view using the current line/column."""
|
||||
|
|
|
@ -36,7 +36,7 @@ class LineType(IntEnum):
|
|||
LIST_ITEM = 8
|
||||
|
||||
|
||||
def format_elements(elements, width):
|
||||
def generate_metalines(elements, width):
|
||||
"""Format elements into a list of lines with metadata.
|
||||
|
||||
The returned list ("metalines") are tuples (meta, line), meta being a
|
||||
|
|
Reference in a new issue