diff --git a/bebop/command_line.py b/bebop/command_line.py new file mode 100644 index 0000000..b245e74 --- /dev/null +++ b/bebop/command_line.py @@ -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 diff --git a/bebop/navigation.py b/bebop/navigation.py index 9d75d0d..960af6c 100644 --- a/bebop/navigation.py +++ b/bebop/navigation.py @@ -1,4 +1,3 @@ -import re import urllib.parse diff --git a/bebop/page.py b/bebop/page.py new file mode 100644 index 0000000..678290d --- /dev/null +++ b/bebop/page.py @@ -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 diff --git a/bebop/protocol.py b/bebop/protocol.py index fa734fb..6b31a90 100644 --- a/bebop/protocol.py +++ b/bebop/protocol.py @@ -149,6 +149,10 @@ class Response: HEADER_RE = re.compile(r"(\d{2}) (\S*)") + @property + def generic_code(self): + return Response.get_generic_code(self.code) + @staticmethod def parse(data): """Parse a received response.""" @@ -161,7 +165,7 @@ class Response: if not match: return None 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: content_offset = response_header_len + len(LINE_TERM) response.content = data[content_offset:] diff --git a/bebop/rendering.py b/bebop/rendering.py index 375181c..3aec5c2 100644 --- a/bebop/rendering.py +++ b/bebop/rendering.py @@ -2,7 +2,7 @@ import curses import string from enum import IntEnum -from bebop.colors import ColorPairs +from bebop.colors import ColorPair 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) else: if title.level == 2: - text = title.text - else: text = " " + title.text + else: + text = title.text lines = wrap_words(text, context["width"]) # Title levels match the type constants of titles. 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_type = meta["type"] 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) 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) 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: - window.addstr(line, curses.color_pair(ColorPairs.LINK)) + window.addstr(line, curses.color_pair(ColorPair.LINK)) 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: attributes = ( - curses.color_pair(ColorPairs.BLOCKQUOTE) + curses.color_pair(ColorPair.BLOCKQUOTE) | curses.A_ITALIC ) window.addstr(line, attributes) diff --git a/bebop/screen.py b/bebop/screen.py index edeefa6..8556b08 100644 --- a/bebop/screen.py +++ b/bebop/screen.py @@ -4,32 +4,25 @@ import curses.textpad import os 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.navigation import join_url, parse_url +from bebop.page import Page from bebop.protocol import Request, Response -from bebop.rendering import format_elements, render_lines class Screen: - MAX_COLS = 1000 - def __init__(self, cert_stash): self.stash = cert_stash self.screen = None self.dim = (0, 0) - self.content_pad = None - self.content_pad_dim = (0, 0) - self.status_window = 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.tab = None + self.status_line = None + self.command_line = None self.status_data = ("", 0) + self.current_url = "" @property def h(self): @@ -49,23 +42,22 @@ class Screen: self.screen = stdscr self.screen.clear() self.screen.refresh() - init_colors() + curses.mousemask(curses.ALL_MOUSE_EVENTS) + curses.curs_set(0) + init_colors() self.dim = self.screen.getmaxyx() - self.content_pad_dim = (self.h - 2, Screen.MAX_COLS) - self.content_pad = curses.newpad(*self.content_pad_dim) - self.content_pad.scrollok(True) - self.content_pad.idlok(True) - self.status_window = self.screen.subwin( + self.page = Page(self.h - 2) + self.status_line = self.screen.subwin( *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.command_window_pos, + *self.command_line_pos, ) - curses.curs_set(0) + self.command_line = CommandLine(command_line_window) pending_url = start_url running = True @@ -81,21 +73,19 @@ class Screen: command = self.input_common_command() self.set_status(f"Command: {command}") elif char == ord("s"): - self.set_status(f"h {self.h} w {self.w} cl {self.current_line} cc {self.current_column}") - elif char == ord("r"): - self.refresh_content() + self.set_status(f"h {self.h} w {self.w}") elif char == ord("h"): - if self.current_column > 0: - self.current_column -= 1 - self.refresh_content() + if self.page.scroll_left(): + self.refresh_page() elif char == ord("j"): - self.scroll_content(1) + if self.page.scroll_v(1, self.h - 2): + self.refresh_page() 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"): - if self.current_column < Screen.MAX_COLS - self.w: - self.current_column += 1 - self.refresh_content() + if self.page.scroll_right(self.w): + self.refresh_page() elif curses.ascii.isdigit(char): self.handle_digit_input(char) elif char == curses.KEY_MOUSE: @@ -104,15 +94,15 @@ class Screen: self.handle_resize() @property - def content_window_refresh_size(self): + def page_pad_size(self): return self.h - 3, self.w - 1 @property - def status_window_pos(self): + def status_line_pos(self): return self.h - 2, 0 @property - def command_window_pos(self): + def command_line_pos(self): return self.h - 1, 0 @property @@ -120,49 +110,30 @@ class Screen: return 1, self.w def refresh_windows(self): - self.refresh_content() - self.refresh_status() - self.clear_command() + self.refresh_page() + self.refresh_status_line() + self.command_line.clear() - def refresh_content(self): - """Refresh content pad's view using the current line/column.""" - 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_page(self): + self.page.refresh_content(*self.page_pad_size) - def refresh_status(self): + def refresh_status_line(self): """Refresh status line contents.""" text, pair = self.status_data text = text[:self.w - 1] - self.status_window.addstr(0, 0, text, curses.color_pair(pair)) - self.status_window.clrtoeol() - self.status_window.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() + self.status_line.addstr(0, 0, text, curses.color_pair(pair)) + self.status_line.clrtoeol() + self.status_line.refresh() def set_status(self, text): """Set a regular message in the status bar.""" self.status_data = text, ColorPair.NORMAL - self.refresh_status() + self.refresh_status_line() def set_status_error(self, text): """Set an error message in the status bar.""" self.status_data = f"Error: {text}", ColorPair.ERROR - self.refresh_status() + self.refresh_status_line() def open_url(self, url): """Try to open an URL. @@ -210,76 +181,27 @@ class Screen: self.set_status_error("server response parsing failed.") return - if response.code != 20: - self.set_status_error(f"unknown response code {response.code}.") - return + if response.code == 20: + self.load_page(response.content) + self.current_url = url + self.set_status(url) + elif response.generic_code == 30 and response.meta: + self.open_gemini_url(response.meta) - self.set_status(url) - self.current_url = url - self.show_gemtext(response.content) - - 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.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() + def load_page(self, gemtext: bytes): + """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: + self.screen.clear() + self.screen.refresh() + self.refresh_windows() + else: + self.refresh_page() def input_common_command(self): """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): """Generic input validator, handles a few more cases than default. @@ -294,7 +216,7 @@ class Screen: to handle the keys above. """ 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: raise EscapeCommandInterrupt() 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 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 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 @@ -326,25 +248,23 @@ class Screen: ambiguity as well). """ digit = init_char & 0xf - num_links = len(self.links) + links = self.page.links + num_links = len(links) if num_links < 10: - self.open_link(digit) + self.open_link(links, digit) return required_digits = 0 while num_links: required_digits += 1 num_links //= 10 - link_input = self.focus_command( - "~", - validator=lambda ch: self._validate_link_digit(ch, required_digits), - prefix=chr(init_char), - ) + validator = lambda ch: self._validate_link_digit(ch, required_digits) + link_input = self.command_line.focus("&", validator, chr(init_char)) try: link_id = int(link_input) except ValueError: self.set_status_error("invalid link ID") return - self.open_link(link_id) + self.open_link(links, link_id) def _validate_link_digit(self, ch: int, required_digits: int): """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 # link now and leave command line. Else just process it. if curses.ascii.isdigit(ch): - digits = self.gather_current_command() + digits = self.command_line.gather() if len(digits) + 1 == required_digits: raise TerminateCommandInterrupt(digits + chr(ch)) return ch @@ -367,13 +287,12 @@ class Screen: if len(digits) == max_digits: return digits - - def open_link(self, link_id: int): + def open_link(self, links, link_id: int): """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}.") 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): """Handle mouse events. @@ -381,9 +300,9 @@ class Screen: Right now, only vertical scrolling is handled. """ if bstate & ButtonState.SCROLL_UP: - self.scroll_content(3, scroll_up=True) + self.page.scroll_v(-3) elif bstate & ButtonState.SCROLL_DOWN: - self.scroll_content(3) + self.page.scroll_v(3, self.h - 2) def handle_resize(self): """Try to not make everything collapse on resizes.""" @@ -397,29 +316,16 @@ class Screen: return # Resize windows to fit the new dimensions. Content pad will be updated # on its own at the end of the function. - self.status_window.resize(*self.line_dim) - self.command_window.resize(*self.line_dim) + self.status_line.resize(*self.line_dim) + self.command_line.window.resize(*self.line_dim) # Move the windows to their new position if that's still possible. - if self.status_window_pos[0] >= 0: - self.status_window.mvwin(*self.status_window_pos) - if self.command_window_pos[0] >= 0: - self.command_window.mvwin(*self.command_window_pos) + if self.status_line_pos[0] >= 0: + self.status_line.mvwin(*self.status_line_pos) + if self.command_line_pos[0] >= 0: + 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.content_pad_dim[0] < self.h - 2: + if self.page.dim[0] < self.h - 2: self.screen.clear() self.screen.refresh() 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