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.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))
|
||||||
|
|
|
@ -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():
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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
|
||||||
|
|
Reference in a new issue