screen: move some stuff in separate modules

This commit is contained in:
dece 2021-02-12 23:29:51 +01:00
parent 6d676d0471
commit e6686265d7
6 changed files with 228 additions and 182 deletions

61
bebop/command_line.py Normal file
View file

@ -0,0 +1,61 @@
import curses
import curses.textpad
class CommandLine:
def __init__(self, window):
self.window = window
self.textbox = None
def clear(self):
self.window.clear()
self.window.refresh()
def focus(self, command_char, validator=None, prefix=""):
"""Give user focus to the command bar.
Show the command char and give focus to the command textbox. The
validator function is passed to the textbox.
Arguments:
- command_char: char to display before the command line.
- validator: function to use to validate the input chars.
- prefix: string to insert before the cursor in the command line.
Returns:
User input as string. The string will be empty if the validator raised
an EscapeInterrupt.
"""
self.window.clear()
self.window.refresh()
self.textbox = curses.textpad.Textbox(self.window)
self.window.addstr(command_char + prefix)
curses.curs_set(1)
try:
command = self.textbox.edit(validator)[1:]
except EscapeCommandInterrupt:
command = ""
except TerminateCommandInterrupt as exc:
command = exc.command
curses.curs_set(0)
self.window.clear()
self.window.refresh()
return command
def gather(self):
"""Return the string currently written by the user in command line."""
return self.textbox.gather()[1:].rstrip()
class EscapeCommandInterrupt(Exception):
"""Signal that ESC has been pressed during command line."""
pass
class TerminateCommandInterrupt(Exception):
"""Signal that validation ended command line input early. Use `command`."""
def __init__(self, command: str, *args, **kwargs):
super().__init__(*args, **kwargs)
self.command = command

View file

@ -1,4 +1,3 @@
import re
import urllib.parse import urllib.parse

76
bebop/page.py Normal file
View file

@ -0,0 +1,76 @@
import curses
from bebop.gemtext import parse_gemtext
from bebop.rendering import format_elements, render_lines
class Page:
"""Window containing page content."""
MAX_COLS = 1000
def __init__(self, initial_num_lines):
self.dim = (initial_num_lines, Page.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 = {}
def show_gemtext(self, gemtext: bytes):
"""Render Gemtext data in the content pad."""
elements = parse_gemtext(gemtext)
self.metalines = format_elements(elements, 80)
self.links = {
meta["link_id"]: meta["url"]
for meta, _ in self.metalines
if "link_id" in meta and "url" in meta
}
self.pad.clear()
self.dim = render_lines(self.metalines, self.pad, Page.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 =None):
"""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_left(self):
if self.current_column > 0:
self.current_column -= 1
return True
return False
def scroll_right(self, window_width):
if self.current_column < Page.MAX_COLS - window_width:
self.current_column += 1
return True
return False

View file

@ -149,6 +149,10 @@ class Response:
HEADER_RE = re.compile(r"(\d{2}) (\S*)") HEADER_RE = re.compile(r"(\d{2}) (\S*)")
@property
def generic_code(self):
return Response.get_generic_code(self.code)
@staticmethod @staticmethod
def parse(data): def parse(data):
"""Parse a received response.""" """Parse a received response."""
@ -161,7 +165,7 @@ class Response:
if not match: if not match:
return None return None
code, meta = match.groups() code, meta = match.groups()
response = Response(StatusCode(code), meta=meta) response = Response(StatusCode(int(code)), meta=meta)
if Response.get_generic_code(response.code) == StatusCode.SUCCESS: if Response.get_generic_code(response.code) == StatusCode.SUCCESS:
content_offset = response_header_len + len(LINE_TERM) content_offset = response_header_len + len(LINE_TERM)
response.content = data[content_offset:] response.content = data[content_offset:]

View file

@ -2,7 +2,7 @@ import curses
import string import string
from enum import IntEnum from enum import IntEnum
from bebop.colors import ColorPairs from bebop.colors import ColorPair
from bebop.gemtext import Blockquote, Link, Paragraph, Preformatted, Title from bebop.gemtext import Blockquote, Link, Paragraph, Preformatted, Title
@ -83,9 +83,9 @@ def format_title(title: Title, context: dict):
lines = (line_template.format(line) for line in wrapped) lines = (line_template.format(line) for line in wrapped)
else: else:
if title.level == 2: if title.level == 2:
text = title.text
else:
text = " " + title.text text = " " + title.text
else:
text = title.text
lines = wrap_words(text, context["width"]) lines = wrap_words(text, context["width"])
# Title levels match the type constants of titles. # Title levels match the type constants of titles.
return [({"type": LineType(title.level)}, line) for line in lines] return [({"type": LineType(title.level)}, line) for line in lines]
@ -205,20 +205,20 @@ def render_lines(metalines, window, max_width):
line = line[:max_width - 1] line = line[:max_width - 1]
line_type = meta["type"] line_type = meta["type"]
if line_type == LineType.TITLE_1: if line_type == LineType.TITLE_1:
attributes = curses.color_pair(ColorPairs.TITLE_1) | curses.A_BOLD attributes = curses.color_pair(ColorPair.TITLE_1) | curses.A_BOLD
window.addstr(line, attributes) window.addstr(line, attributes)
elif line_type == LineType.TITLE_2: elif line_type == LineType.TITLE_2:
attributes = curses.color_pair(ColorPairs.TITLE_2) | curses.A_BOLD attributes = curses.color_pair(ColorPair.TITLE_2) | curses.A_BOLD
window.addstr(line, attributes) window.addstr(line, attributes)
elif line_type == LineType.TITLE_3: elif line_type == LineType.TITLE_3:
window.addstr(line, curses.color_pair(ColorPairs.TITLE_3)) window.addstr(line, curses.color_pair(ColorPair.TITLE_3))
elif line_type == LineType.LINK: elif line_type == LineType.LINK:
window.addstr(line, curses.color_pair(ColorPairs.LINK)) window.addstr(line, curses.color_pair(ColorPair.LINK))
elif line_type == LineType.PREFORMATTED: elif line_type == LineType.PREFORMATTED:
window.addstr(line, curses.color_pair(ColorPairs.PREFORMATTED)) window.addstr(line, curses.color_pair(ColorPair.PREFORMATTED))
elif line_type == LineType.BLOCKQUOTE: elif line_type == LineType.BLOCKQUOTE:
attributes = ( attributes = (
curses.color_pair(ColorPairs.BLOCKQUOTE) curses.color_pair(ColorPair.BLOCKQUOTE)
| curses.A_ITALIC | curses.A_ITALIC
) )
window.addstr(line, attributes) window.addstr(line, attributes)

View file

@ -4,32 +4,25 @@ import curses.textpad
import os import os
from bebop.colors import ColorPair, init_colors from bebop.colors import ColorPair, init_colors
from bebop.gemtext import parse_gemtext from bebop.command_line import (CommandLine, EscapeCommandInterrupt,
TerminateCommandInterrupt)
from bebop.mouse import ButtonState from bebop.mouse import ButtonState
from bebop.navigation import join_url, parse_url from bebop.navigation import join_url, parse_url
from bebop.page import Page
from bebop.protocol import Request, Response from bebop.protocol import Request, Response
from bebop.rendering import format_elements, render_lines
class Screen: class Screen:
MAX_COLS = 1000
def __init__(self, cert_stash): def __init__(self, cert_stash):
self.stash = cert_stash self.stash = cert_stash
self.screen = None self.screen = None
self.dim = (0, 0) self.dim = (0, 0)
self.content_pad = None self.tab = None
self.content_pad_dim = (0, 0) self.status_line = None
self.status_window = None self.command_line = None
self.command_window = None
self.command_textbox = None
self.metalines = []
self.current_url = ""
self.current_line = 0
self.current_column = 0
self.links = {}
self.status_data = ("", 0) self.status_data = ("", 0)
self.current_url = ""
@property @property
def h(self): def h(self):
@ -49,23 +42,22 @@ class Screen:
self.screen = stdscr self.screen = stdscr
self.screen.clear() self.screen.clear()
self.screen.refresh() self.screen.refresh()
init_colors()
curses.mousemask(curses.ALL_MOUSE_EVENTS) curses.mousemask(curses.ALL_MOUSE_EVENTS)
curses.curs_set(0)
init_colors()
self.dim = self.screen.getmaxyx() self.dim = self.screen.getmaxyx()
self.content_pad_dim = (self.h - 2, Screen.MAX_COLS) self.page = Page(self.h - 2)
self.content_pad = curses.newpad(*self.content_pad_dim) self.status_line = self.screen.subwin(
self.content_pad.scrollok(True)
self.content_pad.idlok(True)
self.status_window = self.screen.subwin(
*self.line_dim, *self.line_dim,
*self.status_window_pos, *self.status_line_pos,
) )
self.command_window = self.screen.subwin( command_line_window = self.screen.subwin(
*self.line_dim, *self.line_dim,
*self.command_window_pos, *self.command_line_pos,
) )
curses.curs_set(0) self.command_line = CommandLine(command_line_window)
pending_url = start_url pending_url = start_url
running = True running = True
@ -81,21 +73,19 @@ class Screen:
command = self.input_common_command() command = self.input_common_command()
self.set_status(f"Command: {command}") self.set_status(f"Command: {command}")
elif char == ord("s"): elif char == ord("s"):
self.set_status(f"h {self.h} w {self.w} cl {self.current_line} cc {self.current_column}") self.set_status(f"h {self.h} w {self.w}")
elif char == ord("r"):
self.refresh_content()
elif char == ord("h"): elif char == ord("h"):
if self.current_column > 0: if self.page.scroll_left():
self.current_column -= 1 self.refresh_page()
self.refresh_content()
elif char == ord("j"): elif char == ord("j"):
self.scroll_content(1) if self.page.scroll_v(1, self.h - 2):
self.refresh_page()
elif char == ord("k"): elif char == ord("k"):
self.scroll_content(1, scroll_up=True) if self.page.scroll_v(-1, self.h - 2):
self.refresh_page()
elif char == ord("l"): elif char == ord("l"):
if self.current_column < Screen.MAX_COLS - self.w: if self.page.scroll_right(self.w):
self.current_column += 1 self.refresh_page()
self.refresh_content()
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:
@ -104,15 +94,15 @@ class Screen:
self.handle_resize() self.handle_resize()
@property @property
def content_window_refresh_size(self): def page_pad_size(self):
return self.h - 3, self.w - 1 return self.h - 3, self.w - 1
@property @property
def status_window_pos(self): def status_line_pos(self):
return self.h - 2, 0 return self.h - 2, 0
@property @property
def command_window_pos(self): def command_line_pos(self):
return self.h - 1, 0 return self.h - 1, 0
@property @property
@ -120,49 +110,30 @@ class Screen:
return 1, self.w return 1, self.w
def refresh_windows(self): def refresh_windows(self):
self.refresh_content() self.refresh_page()
self.refresh_status() self.refresh_status_line()
self.clear_command() self.command_line.clear()
def refresh_content(self): def refresh_page(self):
"""Refresh content pad's view using the current line/column.""" self.page.refresh_content(*self.page_pad_size)
refresh_size = self.content_window_refresh_size
if refresh_size[0] <= 0 or refresh_size[1] <= 0:
return
self.content_pad.refresh(
self.current_line, self.current_column, 0, 0, *refresh_size
)
def refresh_status(self): def refresh_status_line(self):
"""Refresh status line contents.""" """Refresh status line contents."""
text, pair = self.status_data text, pair = self.status_data
text = text[:self.w - 1] text = text[:self.w - 1]
self.status_window.addstr(0, 0, text, curses.color_pair(pair)) self.status_line.addstr(0, 0, text, curses.color_pair(pair))
self.status_window.clrtoeol() self.status_line.clrtoeol()
self.status_window.refresh() self.status_line.refresh()
def scroll_content(self, num_lines: int, scroll_up: bool =False):
"""Make the content pad scroll up and down by *num_lines*."""
if scroll_up:
min_line = 0
if self.current_line > min_line:
self.current_line = max(self.current_line - num_lines, min_line)
self.refresh_content()
else:
max_line = self.content_pad_dim[0] - self.h + 2
if self.current_line < max_line:
self.current_line = min(self.current_line + num_lines, max_line)
self.refresh_content()
def set_status(self, text): def set_status(self, text):
"""Set a regular message in the status bar.""" """Set a regular message in the status bar."""
self.status_data = text, ColorPair.NORMAL self.status_data = text, ColorPair.NORMAL
self.refresh_status() self.refresh_status_line()
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 = f"Error: {text}", ColorPair.ERROR self.status_data = f"Error: {text}", ColorPair.ERROR
self.refresh_status() self.refresh_status_line()
def open_url(self, url): def open_url(self, url):
"""Try to open an URL. """Try to open an URL.
@ -210,76 +181,27 @@ class Screen:
self.set_status_error("server response parsing failed.") self.set_status_error("server response parsing failed.")
return return
if response.code != 20: if response.code == 20:
self.set_status_error(f"unknown response code {response.code}.") self.load_page(response.content)
return
self.set_status(url)
self.current_url = url self.current_url = url
self.show_gemtext(response.content) self.set_status(url)
elif response.generic_code == 30 and response.meta:
self.open_gemini_url(response.meta)
def show_gemtext(self, gemtext: bytes): def load_page(self, gemtext: bytes):
"""Render Gemtext data in the content pad.""" """Load Gemtext data as the current page."""
elements = parse_gemtext(gemtext) old_pad_height = self.page.dim[0]
self.metalines = format_elements(elements, 80) self.page.show_gemtext(gemtext)
self.links = { if self.page.dim[0] < old_pad_height:
meta["link_id"]: meta["url"] self.screen.clear()
for meta, _ in self.metalines
if "link_id" in meta and "url" in meta
}
self.content_pad.clear()
h, w = render_lines(self.metalines, self.content_pad, Screen.MAX_COLS)
self.content_pad_dim = (h, w)
self.current_line = 0
self.current_column = 0
self.refresh_content()
def focus_command(self, command_char, validator=None, prefix=""):
"""Give user focus to the command bar.
Show the command char and give focus to the command textbox. The
validator function is passed to the textbox.
Arguments:
- command_char: char to display before the command line.
- validator: function to use to validate the input chars.
- prefix: string to insert before the cursor in the command line.
Returns:
User input as string. The string will be empty if the validator raised
an EscapeInterrupt.
"""
assert self.command_window is not None
self.command_window.clear()
self.command_window.refresh()
self.command_textbox = curses.textpad.Textbox(self.command_window)
self.command_window.addstr(command_char + prefix)
curses.curs_set(1)
try:
command = self.command_textbox.edit(validator)[1:]
except EscapeCommandInterrupt:
command = ""
except TerminateCommandInterrupt as exc:
command = exc.command
curses.curs_set(0)
self.clear_command()
return command
def gather_current_command(self):
"""Return the string currently written by the user in command line."""
return self.command_textbox.gather()[1:].rstrip()
def clear_command(self):
"""Clear the command line """
self.command_window.clear()
self.command_window.refresh()
self.screen.delch(self.h - 1, 0)
self.screen.refresh() self.screen.refresh()
self.refresh_windows()
else:
self.refresh_page()
def input_common_command(self): def input_common_command(self):
"""Focus command line to type a regular command. Currently useless.""" """Focus command line to type a regular command. Currently useless."""
return self.focus_command(":", self.validate_common_char) return self.command_line.focus(":", self.validate_common_char)
def validate_common_char(self, ch: int): def validate_common_char(self, ch: int):
"""Generic input validator, handles a few more cases than default. """Generic input validator, handles a few more cases than default.
@ -294,7 +216,7 @@ class Screen:
to handle the keys above. to handle the keys above.
""" """
if ch == curses.KEY_BACKSPACE: # Cancel input if all line is cleaned. if ch == curses.KEY_BACKSPACE: # Cancel input if all line is cleaned.
text = self.gather_current_command() text = self.command_line.gather()
if len(text) == 0: if len(text) == 0:
raise EscapeCommandInterrupt() raise EscapeCommandInterrupt()
elif ch == curses.ascii.ESC: # Could be ESC or ALT elif ch == curses.ascii.ESC: # Could be ESC or ALT
@ -316,7 +238,7 @@ class Screen:
- If it's higher than 10, the user either inputs as many digits required - If it's higher than 10, the user either inputs as many digits required
to disambiguate the link ID, or press enter to validate her input. to disambiguate the link ID, or press enter to validate her input.
Examples Examples:
- I have 3 links. Pressing "2" takes me to link 2. - I have 3 links. Pressing "2" takes me to link 2.
- I have 15 links. Pressing "3" and Enter takes me to link 2. - I have 15 links. Pressing "3" and Enter takes me to link 2.
- I have 15 links. Pressing "1" and "2" takes me to link 12 (no - I have 15 links. Pressing "1" and "2" takes me to link 12 (no
@ -326,25 +248,23 @@ class Screen:
ambiguity as well). ambiguity as well).
""" """
digit = init_char & 0xf digit = init_char & 0xf
num_links = len(self.links) links = self.page.links
num_links = len(links)
if num_links < 10: if num_links < 10:
self.open_link(digit) self.open_link(links, digit)
return return
required_digits = 0 required_digits = 0
while num_links: while num_links:
required_digits += 1 required_digits += 1
num_links //= 10 num_links //= 10
link_input = self.focus_command( validator = lambda ch: self._validate_link_digit(ch, required_digits)
"~", link_input = self.command_line.focus("&", validator, chr(init_char))
validator=lambda ch: self._validate_link_digit(ch, required_digits),
prefix=chr(init_char),
)
try: try:
link_id = int(link_input) link_id = int(link_input)
except ValueError: except ValueError:
self.set_status_error("invalid link ID") self.set_status_error("invalid link ID")
return return
self.open_link(link_id) self.open_link(links, link_id)
def _validate_link_digit(self, ch: int, required_digits: int): def _validate_link_digit(self, ch: int, required_digits: int):
"""Handle input chars to be used as link ID.""" """Handle input chars to be used as link ID."""
@ -353,7 +273,7 @@ class Screen:
# Only accept digits. If we reach the amount of required digits, open # Only accept digits. If we reach the amount of required digits, open
# link now and leave command line. Else just process it. # link now and leave command line. Else just process it.
if curses.ascii.isdigit(ch): if curses.ascii.isdigit(ch):
digits = self.gather_current_command() digits = self.command_line.gather()
if len(digits) + 1 == required_digits: if len(digits) + 1 == required_digits:
raise TerminateCommandInterrupt(digits + chr(ch)) raise TerminateCommandInterrupt(digits + chr(ch))
return ch return ch
@ -367,13 +287,12 @@ class Screen:
if len(digits) == max_digits: if len(digits) == max_digits:
return digits return digits
def open_link(self, links, link_id: int):
def open_link(self, link_id: int):
"""Open the link with this link ID.""" """Open the link with this link ID."""
if not link_id in self.links: if not link_id in links:
self.set_status_error(f"unknown link ID {link_id}.") self.set_status_error(f"unknown link ID {link_id}.")
return return
self.open_url(self.links[link_id]) self.open_url(links[link_id])
def handle_mouse(self, mouse_id: int, x: int, y: int, z: int, bstate: int): def handle_mouse(self, mouse_id: int, x: int, y: int, z: int, bstate: int):
"""Handle mouse events. """Handle mouse events.
@ -381,9 +300,9 @@ class Screen:
Right now, only vertical scrolling is handled. Right now, only vertical scrolling is handled.
""" """
if bstate & ButtonState.SCROLL_UP: if bstate & ButtonState.SCROLL_UP:
self.scroll_content(3, scroll_up=True) self.page.scroll_v(-3)
elif bstate & ButtonState.SCROLL_DOWN: elif bstate & ButtonState.SCROLL_DOWN:
self.scroll_content(3) self.page.scroll_v(3, self.h - 2)
def handle_resize(self): def handle_resize(self):
"""Try to not make everything collapse on resizes.""" """Try to not make everything collapse on resizes."""
@ -397,29 +316,16 @@ class Screen:
return return
# Resize windows to fit the new dimensions. Content pad will be updated # Resize windows to fit the new dimensions. Content pad will be updated
# on its own at the end of the function. # on its own at the end of the function.
self.status_window.resize(*self.line_dim) self.status_line.resize(*self.line_dim)
self.command_window.resize(*self.line_dim) self.command_line.window.resize(*self.line_dim)
# Move the windows to their new position if that's still possible. # Move the windows to their new position if that's still possible.
if self.status_window_pos[0] >= 0: if self.status_line_pos[0] >= 0:
self.status_window.mvwin(*self.status_window_pos) self.status_line.mvwin(*self.status_line_pos)
if self.command_window_pos[0] >= 0: if self.command_line_pos[0] >= 0:
self.command_window.mvwin(*self.command_window_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.content_pad_dim[0] < self.h - 2: if self.page.dim[0] < self.h - 2:
self.screen.clear() self.screen.clear()
self.screen.refresh() self.screen.refresh()
self.refresh_windows() self.refresh_windows()
class EscapeCommandInterrupt(Exception):
"""Signal that ESC has been pressed during command line."""
pass
class TerminateCommandInterrupt(Exception):
"""Signal that validation ended command line input early. Use `command`."""
def __init__(self, command: str, *args, **kwargs):
super().__init__(*args, **kwargs)
self.command = command