2021-02-12 19:01:42 +01:00
|
|
|
import curses
|
|
|
|
import curses.ascii
|
|
|
|
import curses.textpad
|
|
|
|
import os
|
|
|
|
|
|
|
|
from bebop.colors import ColorPair, init_colors
|
2021-02-12 23:29:51 +01:00
|
|
|
from bebop.command_line import (CommandLine, EscapeCommandInterrupt,
|
|
|
|
TerminateCommandInterrupt)
|
2021-02-12 19:01:42 +01:00
|
|
|
from bebop.mouse import ButtonState
|
|
|
|
from bebop.navigation import join_url, parse_url
|
2021-02-12 23:29:51 +01:00
|
|
|
from bebop.page import Page
|
2021-02-12 19:01:42 +01:00
|
|
|
from bebop.protocol import Request, Response
|
|
|
|
|
|
|
|
|
|
|
|
class Screen:
|
|
|
|
|
|
|
|
def __init__(self, cert_stash):
|
|
|
|
self.stash = cert_stash
|
|
|
|
self.screen = None
|
|
|
|
self.dim = (0, 0)
|
2021-02-12 23:29:51 +01:00
|
|
|
self.tab = None
|
|
|
|
self.status_line = None
|
|
|
|
self.command_line = None
|
2021-02-12 19:01:42 +01:00
|
|
|
self.status_data = ("", 0)
|
2021-02-12 23:29:51 +01:00
|
|
|
self.current_url = ""
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
@property
|
|
|
|
def h(self):
|
|
|
|
return self.dim[0]
|
|
|
|
|
|
|
|
@property
|
|
|
|
def w(self):
|
|
|
|
return self.dim[1]
|
|
|
|
|
|
|
|
def run(self, *args, **kwargs):
|
|
|
|
"""Use curses' wrapper around _run."""
|
|
|
|
os.environ.setdefault("ESCDELAY", "25")
|
|
|
|
curses.wrapper(self._run, *args, **kwargs)
|
|
|
|
|
|
|
|
def _run(self, stdscr, start_url=None):
|
|
|
|
"""Start displaying content and handling events."""
|
|
|
|
self.screen = stdscr
|
|
|
|
self.screen.clear()
|
|
|
|
self.screen.refresh()
|
2021-02-12 23:29:51 +01:00
|
|
|
|
2021-02-12 19:01:42 +01:00
|
|
|
curses.mousemask(curses.ALL_MOUSE_EVENTS)
|
2021-02-12 23:29:51 +01:00
|
|
|
curses.curs_set(0)
|
|
|
|
init_colors()
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
self.dim = self.screen.getmaxyx()
|
2021-02-12 23:29:51 +01:00
|
|
|
self.page = Page(self.h - 2)
|
|
|
|
self.status_line = self.screen.subwin(
|
2021-02-12 19:01:42 +01:00
|
|
|
*self.line_dim,
|
2021-02-12 23:29:51 +01:00
|
|
|
*self.status_line_pos,
|
2021-02-12 19:01:42 +01:00
|
|
|
)
|
2021-02-12 23:29:51 +01:00
|
|
|
command_line_window = self.screen.subwin(
|
2021-02-12 19:01:42 +01:00
|
|
|
*self.line_dim,
|
2021-02-12 23:29:51 +01:00
|
|
|
*self.command_line_pos,
|
2021-02-12 19:01:42 +01:00
|
|
|
)
|
2021-02-12 23:29:51 +01:00
|
|
|
self.command_line = CommandLine(command_line_window)
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
pending_url = start_url
|
|
|
|
running = True
|
|
|
|
while running:
|
|
|
|
if pending_url:
|
|
|
|
self.open_gemini_url(pending_url)
|
|
|
|
pending_url = None
|
|
|
|
|
|
|
|
char = self.screen.getch()
|
|
|
|
if char == ord("q"):
|
|
|
|
running = False
|
|
|
|
elif char == ord(":"):
|
|
|
|
command = self.input_common_command()
|
|
|
|
self.set_status(f"Command: {command}")
|
|
|
|
elif char == ord("s"):
|
2021-02-12 23:29:51 +01:00
|
|
|
self.set_status(f"h {self.h} w {self.w}")
|
2021-02-12 19:01:42 +01:00
|
|
|
elif char == ord("h"):
|
2021-02-12 23:29:51 +01:00
|
|
|
if self.page.scroll_left():
|
|
|
|
self.refresh_page()
|
2021-02-12 19:01:42 +01:00
|
|
|
elif char == ord("j"):
|
2021-02-12 23:29:51 +01:00
|
|
|
if self.page.scroll_v(1, self.h - 2):
|
|
|
|
self.refresh_page()
|
2021-02-12 19:01:42 +01:00
|
|
|
elif char == ord("k"):
|
2021-02-12 23:29:51 +01:00
|
|
|
if self.page.scroll_v(-1, self.h - 2):
|
|
|
|
self.refresh_page()
|
2021-02-12 19:01:42 +01:00
|
|
|
elif char == ord("l"):
|
2021-02-12 23:29:51 +01:00
|
|
|
if self.page.scroll_right(self.w):
|
|
|
|
self.refresh_page()
|
2021-02-12 19:01:42 +01:00
|
|
|
elif curses.ascii.isdigit(char):
|
|
|
|
self.handle_digit_input(char)
|
|
|
|
elif char == curses.KEY_MOUSE:
|
|
|
|
self.handle_mouse(*curses.getmouse())
|
|
|
|
elif char == curses.KEY_RESIZE:
|
|
|
|
self.handle_resize()
|
|
|
|
|
|
|
|
@property
|
2021-02-12 23:29:51 +01:00
|
|
|
def page_pad_size(self):
|
2021-02-12 19:01:42 +01:00
|
|
|
return self.h - 3, self.w - 1
|
|
|
|
|
|
|
|
@property
|
2021-02-12 23:29:51 +01:00
|
|
|
def status_line_pos(self):
|
2021-02-12 19:01:42 +01:00
|
|
|
return self.h - 2, 0
|
|
|
|
|
|
|
|
@property
|
2021-02-12 23:29:51 +01:00
|
|
|
def command_line_pos(self):
|
2021-02-12 19:01:42 +01:00
|
|
|
return self.h - 1, 0
|
|
|
|
|
|
|
|
@property
|
|
|
|
def line_dim(self):
|
|
|
|
return 1, self.w
|
|
|
|
|
|
|
|
def refresh_windows(self):
|
2021-02-12 23:29:51 +01:00
|
|
|
self.refresh_page()
|
|
|
|
self.refresh_status_line()
|
|
|
|
self.command_line.clear()
|
|
|
|
|
|
|
|
def refresh_page(self):
|
|
|
|
self.page.refresh_content(*self.page_pad_size)
|
2021-02-12 19:01:42 +01:00
|
|
|
|
2021-02-12 23:29:51 +01:00
|
|
|
def refresh_status_line(self):
|
2021-02-12 19:01:42 +01:00
|
|
|
"""Refresh status line contents."""
|
|
|
|
text, pair = self.status_data
|
|
|
|
text = text[:self.w - 1]
|
2021-02-12 23:29:51 +01:00
|
|
|
self.status_line.addstr(0, 0, text, curses.color_pair(pair))
|
|
|
|
self.status_line.clrtoeol()
|
|
|
|
self.status_line.refresh()
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
def set_status(self, text):
|
|
|
|
"""Set a regular message in the status bar."""
|
|
|
|
self.status_data = text, ColorPair.NORMAL
|
2021-02-12 23:29:51 +01:00
|
|
|
self.refresh_status_line()
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
def set_status_error(self, text):
|
|
|
|
"""Set an error message in the status bar."""
|
|
|
|
self.status_data = f"Error: {text}", ColorPair.ERROR
|
2021-02-12 23:29:51 +01:00
|
|
|
self.refresh_status_line()
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
def open_url(self, url):
|
|
|
|
"""Try to open an URL.
|
|
|
|
|
|
|
|
If the URL is not strictly absolute, it will be opened relatively to the
|
|
|
|
current URL, unless there is no current URL yet.
|
|
|
|
"""
|
|
|
|
if self.current_url:
|
|
|
|
parts = parse_url(url)
|
|
|
|
else:
|
|
|
|
parts = parse_url(url, absolute=True)
|
|
|
|
if parts.scheme == "gemini":
|
|
|
|
if not parts.netloc:
|
|
|
|
url = join_url(self.current_url, url)
|
|
|
|
self.open_gemini_url(url)
|
|
|
|
else:
|
|
|
|
self.set_status_error(f"protocol {parts.scheme} not supported.")
|
|
|
|
|
|
|
|
def open_gemini_url(self, url):
|
|
|
|
"""Open a Gemini URL and set the formatted response as content."""
|
|
|
|
self.set_status(f"Loading {url}")
|
|
|
|
req = Request(url, self.stash)
|
|
|
|
connected = req.connect()
|
|
|
|
if not connected:
|
|
|
|
if req.state == Request.STATE_ERROR_CERT:
|
|
|
|
self.set_status_error("certificate was missing or corrupt.")
|
|
|
|
elif req.state == Request.STATE_UNTRUSTED_CERT:
|
|
|
|
self.set_status_error("certificate has been changed.")
|
|
|
|
# TODO propose the user ways to handle this.
|
|
|
|
else:
|
|
|
|
self.set_status_error("connection failed.")
|
|
|
|
return
|
|
|
|
|
|
|
|
if req.state == Request.STATE_INVALID_CERT:
|
|
|
|
# TODO propose abort / temp trust
|
|
|
|
pass
|
|
|
|
elif req.state == Request.STATE_UNKNOWN_CERT:
|
|
|
|
# TODO propose abort / temp trust / perm trust
|
|
|
|
pass
|
|
|
|
else:
|
|
|
|
pass # TODO
|
|
|
|
|
|
|
|
response = Response.parse(req.proceed())
|
|
|
|
if not response:
|
|
|
|
self.set_status_error("server response parsing failed.")
|
|
|
|
return
|
|
|
|
|
2021-02-12 23:29:51 +01:00
|
|
|
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)
|
|
|
|
|
|
|
|
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()
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
def input_common_command(self):
|
|
|
|
"""Focus command line to type a regular command. Currently useless."""
|
2021-02-12 23:29:51 +01:00
|
|
|
return self.command_line.focus(":", self.validate_common_char)
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
def validate_common_char(self, ch: int):
|
|
|
|
"""Generic input validator, handles a few more cases than default.
|
|
|
|
|
|
|
|
This validator can be used as a default validator as it handles, on top
|
|
|
|
of the Textbox defaults:
|
|
|
|
- Erasing the first command char, i.e. clearing the line, cancels the
|
|
|
|
command input.
|
|
|
|
- Pressing ESC also cancels the input.
|
|
|
|
|
|
|
|
This validator can be safely called at the beginning of other validators
|
|
|
|
to handle the keys above.
|
|
|
|
"""
|
|
|
|
if ch == curses.KEY_BACKSPACE: # Cancel input if all line is cleaned.
|
2021-02-12 23:29:51 +01:00
|
|
|
text = self.command_line.gather()
|
2021-02-12 19:01:42 +01:00
|
|
|
if len(text) == 0:
|
|
|
|
raise EscapeCommandInterrupt()
|
|
|
|
elif ch == curses.ascii.ESC: # Could be ESC or ALT
|
|
|
|
self.screen.nodelay(True)
|
|
|
|
ch = self.screen.getch()
|
|
|
|
if ch == -1:
|
|
|
|
raise EscapeCommandInterrupt()
|
|
|
|
self.screen.nodelay(False)
|
|
|
|
return ch
|
|
|
|
|
|
|
|
def handle_digit_input(self, init_char: int):
|
|
|
|
"""Handle a initial digit input by the user.
|
|
|
|
|
|
|
|
When a digit key is pressed, the user intents to visit a link (or
|
|
|
|
dropped something on the numpad). To reduce the number of key types
|
|
|
|
needed, Bebop uses the following algorithm:
|
|
|
|
- If the highest link ID on the page is less than 10, pressing the key
|
|
|
|
takes you to the link.
|
|
|
|
- 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.
|
|
|
|
|
2021-02-12 23:29:51 +01:00
|
|
|
Examples:
|
2021-02-12 19:01:42 +01:00
|
|
|
- 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
|
|
|
|
ambiguity, so Enter is not required).
|
|
|
|
- I have 456 links. Pressing "1", "2" and Enter takes me to link 12.
|
|
|
|
- I have 456 links. Pressing "1", "2" and "6" takes me to link 126 (no
|
|
|
|
ambiguity as well).
|
|
|
|
"""
|
|
|
|
digit = init_char & 0xf
|
2021-02-12 23:29:51 +01:00
|
|
|
links = self.page.links
|
|
|
|
num_links = len(links)
|
2021-02-12 19:01:42 +01:00
|
|
|
if num_links < 10:
|
2021-02-12 23:29:51 +01:00
|
|
|
self.open_link(links, digit)
|
2021-02-12 19:01:42 +01:00
|
|
|
return
|
|
|
|
required_digits = 0
|
|
|
|
while num_links:
|
|
|
|
required_digits += 1
|
|
|
|
num_links //= 10
|
2021-02-12 23:29:51 +01:00
|
|
|
validator = lambda ch: self._validate_link_digit(ch, required_digits)
|
|
|
|
link_input = self.command_line.focus("&", validator, chr(init_char))
|
2021-02-12 19:01:42 +01:00
|
|
|
try:
|
|
|
|
link_id = int(link_input)
|
|
|
|
except ValueError:
|
|
|
|
self.set_status_error("invalid link ID")
|
|
|
|
return
|
2021-02-12 23:29:51 +01:00
|
|
|
self.open_link(links, link_id)
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
def _validate_link_digit(self, ch: int, required_digits: int):
|
|
|
|
"""Handle input chars to be used as link ID."""
|
|
|
|
# Handle common chars.
|
|
|
|
ch = self.validate_common_char(ch)
|
|
|
|
# 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):
|
2021-02-12 23:29:51 +01:00
|
|
|
digits = self.command_line.gather()
|
2021-02-12 19:01:42 +01:00
|
|
|
if len(digits) + 1 == required_digits:
|
|
|
|
raise TerminateCommandInterrupt(digits + chr(ch))
|
|
|
|
return ch
|
|
|
|
# If not a digit but a printable character, ignore it.
|
|
|
|
if curses.ascii.isprint(ch):
|
|
|
|
return 0
|
|
|
|
# Everything else could be a control character and should be processed.
|
|
|
|
return ch
|
|
|
|
|
|
|
|
def disambiguate_link_id(self, digits: str, max_digits: int):
|
|
|
|
if len(digits) == max_digits:
|
|
|
|
return digits
|
|
|
|
|
2021-02-12 23:29:51 +01:00
|
|
|
def open_link(self, links, link_id: int):
|
2021-02-12 19:01:42 +01:00
|
|
|
"""Open the link with this link ID."""
|
2021-02-12 23:29:51 +01:00
|
|
|
if not link_id in links:
|
2021-02-12 19:01:42 +01:00
|
|
|
self.set_status_error(f"unknown link ID {link_id}.")
|
|
|
|
return
|
2021-02-12 23:29:51 +01:00
|
|
|
self.open_url(links[link_id])
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
def handle_mouse(self, mouse_id: int, x: int, y: int, z: int, bstate: int):
|
|
|
|
"""Handle mouse events.
|
|
|
|
|
|
|
|
Right now, only vertical scrolling is handled.
|
|
|
|
"""
|
|
|
|
if bstate & ButtonState.SCROLL_UP:
|
2021-02-12 23:29:51 +01:00
|
|
|
self.page.scroll_v(-3)
|
2021-02-12 19:01:42 +01:00
|
|
|
elif bstate & ButtonState.SCROLL_DOWN:
|
2021-02-12 23:29:51 +01:00
|
|
|
self.page.scroll_v(3, self.h - 2)
|
2021-02-12 19:01:42 +01:00
|
|
|
|
|
|
|
def handle_resize(self):
|
|
|
|
"""Try to not make everything collapse on resizes."""
|
|
|
|
# Refresh the whole screen before changing windows to avoid random
|
|
|
|
# blank screens.
|
|
|
|
self.screen.refresh()
|
|
|
|
old_dim = self.dim
|
|
|
|
self.dim = self.screen.getmaxyx()
|
|
|
|
# Avoid work if the resizing does not impact us.
|
|
|
|
if self.dim == old_dim:
|
|
|
|
return
|
|
|
|
# Resize windows to fit the new dimensions. Content pad will be updated
|
|
|
|
# on its own at the end of the function.
|
2021-02-12 23:29:51 +01:00
|
|
|
self.status_line.resize(*self.line_dim)
|
|
|
|
self.command_line.window.resize(*self.line_dim)
|
2021-02-12 19:01:42 +01:00
|
|
|
# Move the windows to their new position if that's still possible.
|
2021-02-12 23:29:51 +01:00
|
|
|
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)
|
2021-02-12 19:01:42 +01:00
|
|
|
# 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.
|
2021-02-12 23:29:51 +01:00
|
|
|
if self.page.dim[0] < self.h - 2:
|
2021-02-12 19:01:42 +01:00
|
|
|
self.screen.clear()
|
|
|
|
self.screen.refresh()
|
|
|
|
self.refresh_windows()
|