Compare commits

...

5 commits

6 changed files with 142 additions and 74 deletions

View file

@ -11,8 +11,6 @@ def main():
if args.url: if args.url:
start_url = args.url start_url = args.url
if not start_url.startswith("gemini://"):
start_url = "gemini://" + start_url
else: else:
start_url = None start_url = None

View file

@ -25,7 +25,7 @@ class Browser:
self.tab = None self.tab = None
self.status_line = None self.status_line = None
self.command_line = None self.command_line = None
self.status_data = ("", 0) self.status_data = ("", 0, 0)
self.current_url = "" self.current_url = ""
self.running = True self.running = True
self.history = History() self.history = History()
@ -65,55 +65,65 @@ class Browser:
) )
self.command_line = CommandLine(command_line_window) self.command_line = CommandLine(command_line_window)
pending_url = start_url if start_url:
while self.running: self.open_url(start_url, assume_absolute=True)
if pending_url:
self.open_url(pending_url)
pending_url = None
while self.running:
try:
self.handle_inputs()
except KeyboardInterrupt:
self.set_status("Cancelled.")
def handle_inputs(self):
char = self.screen.getch()
if char == ord(":"):
self.quick_command("")
elif char == ord("r"):
self.reload_page()
elif char == ord("h"):
self.scroll_page_horizontally(-1)
elif char == ord("H"):
self.scroll_page_horizontally(-3)
elif char == ord("j"):
self.scroll_page_vertically(1)
elif char == ord("J"):
self.scroll_page_vertically(3)
elif char == ord("k"):
self.scroll_page_vertically(-1)
elif char == ord("K"):
self.scroll_page_vertically(-3)
elif char == ord("l"):
self.scroll_page_horizontally(1)
elif char == ord("L"):
self.scroll_page_horizontally(3)
elif char == ord("f"):
self.scroll_page_vertically(self.page_pad_size[0])
elif char == ord("b"):
self.scroll_page_vertically(-self.page_pad_size[0])
elif char == ord("o"):
self.quick_command("open")
elif char == ord("p"):
self.go_back()
elif char == ord("g"):
char = self.screen.getch() char = self.screen.getch()
if char == ord(":"): if char == ord("g"):
self.quick_command("") self.scroll_page_vertically(-inf)
elif char == ord("r"): elif char == ord("G"):
self.reload_page() self.scroll_page_vertically(inf)
elif char == ord("s"): elif curses.ascii.isdigit(char):
self.set_status(f"h {self.h} w {self.w}") self.handle_digit_input(char)
elif char == ord("h"): elif char == curses.KEY_MOUSE:
self.scroll_page_horizontally(-1) self.handle_mouse(*curses.getmouse())
elif char == ord("H"): elif char == curses.KEY_RESIZE:
self.scroll_page_horizontally(-3) self.handle_resize()
elif char == ord("j"): elif char == curses.ascii.ESC: # Can be ESC or ALT char.
self.scroll_page_vertically(1) self.screen.nodelay(True)
elif char == ord("J"): ch = self.screen.getch()
self.scroll_page_vertically(3) if ch == -1:
elif char == ord("k"): self.set_status(self.current_url)
self.scroll_page_vertically(-1) else:
elif char == ord("K"): pass # No alt-key shortcuts for now!
self.scroll_page_vertically(-3) self.screen.nodelay(False)
elif char == ord("l"):
self.scroll_page_horizontally(1)
elif char == ord("L"):
self.scroll_page_horizontally(3)
elif char == ord("f"):
self.scroll_page_vertically(self.page_pad_size[0])
elif char == ord("b"):
self.scroll_page_vertically(-self.page_pad_size[0])
elif char == ord("o"):
self.quick_command("open")
elif char == ord("p"):
self.go_back()
elif char == ord("g"):
char = self.screen.getch()
if char == ord("g"):
self.scroll_page_vertically(-inf)
elif char == ord("G"):
self.scroll_page_vertically(inf)
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 @property
def page_pad_size(self): def page_pad_size(self):
@ -143,20 +153,21 @@ class Browser:
def refresh_status_line(self): def refresh_status_line(self):
"""Refresh status line contents.""" """Refresh status line contents."""
text, pair = self.status_data text, pair, attributes = self.status_data
text = text[:self.w - 1] text = text[:self.w - 1]
self.status_line.addstr(0, 0, text, curses.color_pair(pair)) color = curses.color_pair(pair)
self.status_line.addstr(0, 0, text, color | attributes)
self.status_line.clrtoeol() self.status_line.clrtoeol()
self.status_line.refresh() self.status_line.refresh()
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, curses.A_ITALIC
self.refresh_status_line() 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 = text, ColorPair.ERROR self.status_data = text, ColorPair.ERROR, 0
self.refresh_status_line() self.refresh_status_line()
def open_url(self, url, base_url=None, redirects=0, assume_absolute=False): def open_url(self, url, base_url=None, redirects=0, assume_absolute=False):
@ -191,6 +202,8 @@ class Browser:
self.open_gemini_url(sanitize_url(url), redirects) self.open_gemini_url(sanitize_url(url), redirects)
elif parts.scheme.startswith("http"): elif parts.scheme.startswith("http"):
self.open_web_url(url) self.open_web_url(url)
elif parts.scheme == "file":
self.open_file(parts.path)
else: else:
self.set_status_error(f"Protocol {parts.scheme} not supported.") self.set_status_error(f"Protocol {parts.scheme} not supported.")
@ -244,6 +257,9 @@ class Browser:
self.set_status_error(error) self.set_status_error(error)
elif response.generic_code == 10: elif response.generic_code == 10:
self.handle_input_request(url, response) self.handle_input_request(url, response)
else:
error = f"Unhandled response code {response.code}"
self.set_status_error(error)
def load_page(self, gemtext: bytes): def load_page(self, gemtext: bytes):
"""Load Gemtext data as the current page.""" """Load Gemtext data as the current page."""
@ -461,3 +477,15 @@ class Browser:
"""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) open_new_tab(url)
def open_file(self, filepath):
"""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.
"""
try:
with open(filepath, "rb") as f:
self.load_page(f.read())
except (OSError, ValueError) as exc:
self.set_status_error(f"Failed to open file: {exc}")

View file

@ -3,6 +3,7 @@ from enum import IntEnum
class ColorPair(IntEnum): class ColorPair(IntEnum):
# Colors for specific Gemtext line type.
NORMAL = 0 NORMAL = 0
ERROR = 1 ERROR = 1
LINK = 2 LINK = 2
@ -12,6 +13,9 @@ class ColorPair(IntEnum):
TITLE_3 = 6 TITLE_3 = 6
PREFORMATTED = 7 PREFORMATTED = 7
BLOCKQUOTE = 8 BLOCKQUOTE = 8
# Colors for other usage in the browser.
LINK_PREVIEW = 9
DEBUG = 99 DEBUG = 99
@ -26,4 +30,5 @@ def init_colors():
curses.init_pair(ColorPair.TITLE_3, curses.COLOR_MAGENTA, -1) curses.init_pair(ColorPair.TITLE_3, curses.COLOR_MAGENTA, -1)
curses.init_pair(ColorPair.PREFORMATTED, curses.COLOR_YELLOW, -1) curses.init_pair(ColorPair.PREFORMATTED, curses.COLOR_YELLOW, -1)
curses.init_pair(ColorPair.BLOCKQUOTE, curses.COLOR_CYAN, -1) curses.init_pair(ColorPair.BLOCKQUOTE, curses.COLOR_CYAN, -1)
curses.init_pair(ColorPair.LINK_PREVIEW, curses.COLOR_WHITE, -1)
curses.init_pair(ColorPair.DEBUG, curses.COLOR_BLACK, curses.COLOR_GREEN) curses.init_pair(ColorPair.DEBUG, curses.COLOR_BLACK, curses.COLOR_GREEN)

View file

@ -17,6 +17,9 @@ def parse_url(url: str, absolute: bool =False):
though it technically is not, e.g. "dece.space" is not absolute as it though it technically is not, e.g. "dece.space" is not absolute as it
misses either the // delimiter. misses either the // delimiter.
""" """
url = url.strip()
if url.startswith("file://"):
return urllib.parse.urlparse(url)
if url.startswith("gemini://"): if url.startswith("gemini://"):
url = url[7:] url = url[7:]
parts = urllib.parse.urlparse(url, scheme="gemini") parts = urllib.parse.urlparse(url, scheme="gemini")
@ -42,5 +45,5 @@ def set_parameter(url: str, user_input: str):
"""Return a new URL with the user input escaped (RFC 3986) appended.""" """Return a new URL with the user input escaped (RFC 3986) appended."""
quoted_input = urllib.parse.quote(user_input) quoted_input = urllib.parse.quote(user_input)
if "?" in url: if "?" in url:
url = url.rsplit("?", maxsplit=1)[0] url = url.split("?", maxsplit=1)[0]
return url + "?" + quoted_input return url + "?" + quoted_input

View file

@ -46,9 +46,12 @@ def format_elements(elements, width):
context = {"last_link_id": 0, "width": width} context = {"last_link_id": 0, "width": width}
separator = ({"type": LineType.NONE}, "") separator = ({"type": LineType.NONE}, "")
has_margins = False has_margins = False
thin_type = None
for index, element in enumerate(elements): for index, element in enumerate(elements):
previous_had_margins = has_margins previous_had_margins = has_margins
last_thin_type = thin_type
has_margins = False has_margins = False
thin_type = None
if isinstance(element, Title): if isinstance(element, Title):
element_metalines = format_title(element, context) element_metalines = format_title(element, context)
has_margins = True has_margins = True
@ -57,6 +60,7 @@ def format_elements(elements, width):
has_margins = True has_margins = True
elif isinstance(element, Link): elif isinstance(element, Link):
element_metalines = format_link(element, context) element_metalines = format_link(element, context)
thin_type = LineType.LINK
elif isinstance(element, Preformatted): elif isinstance(element, Preformatted):
element_metalines = format_preformatted(element, context) element_metalines = format_preformatted(element, context)
has_margins = True has_margins = True
@ -65,17 +69,21 @@ def format_elements(elements, width):
has_margins = True has_margins = True
elif isinstance(element, ListItem): elif isinstance(element, ListItem):
element_metalines = format_list_item(element, context) element_metalines = format_list_item(element, context)
thin_type = LineType.LIST_ITEM
else: else:
continue continue
# If current element requires margins and is not the first elements, # If current element requires margins and is not the first elements,
# separate from previous element. Also do it if the current element does # separate from previous element. Also do it if the current element does
# not require margins but follows an element that required it (e.g. link # not require margins but follows an element that required it (e.g. link
# after a paragraph). # after a paragraph). Also do it if both the current and previous
# elements do not require margins but differ in type.
if ( if (
(has_margins and index > 0) (has_margins and index > 0)
or (not has_margins and previous_had_margins) or (not has_margins and previous_had_margins)
or (not has_margins and thin_type != last_thin_type)
): ):
metalines.append(separator) metalines.append(separator)
# Append the element metalines now.
metalines += element_metalines metalines += element_metalines
return metalines return metalines
@ -223,27 +231,49 @@ def render_lines(metalines, window, max_width):
new_dimensions = num_lines, max_width new_dimensions = num_lines, max_width
window.resize(*new_dimensions) window.resize(*new_dimensions)
for line_index, metaline in enumerate(metalines): for line_index, metaline in enumerate(metalines):
meta, line = metaline
line = line[:max_width - 1]
line_type = meta["type"]
if line_type == LineType.TITLE_1:
attr = curses.color_pair(ColorPair.TITLE_1) | curses.A_BOLD
elif line_type == LineType.TITLE_2:
attr = curses.color_pair(ColorPair.TITLE_2) | curses.A_BOLD
elif line_type == LineType.TITLE_3:
attr = curses.color_pair(ColorPair.TITLE_3)
elif line_type == LineType.LINK:
attr = curses.color_pair(ColorPair.LINK)
elif line_type == LineType.PREFORMATTED:
attr = curses.color_pair(ColorPair.PREFORMATTED)
elif line_type == LineType.BLOCKQUOTE:
attr = curses.color_pair(ColorPair.BLOCKQUOTE) | curses.A_ITALIC
else: # includes LineType.PARAGRAPH
attr = curses.color_pair(ColorPair.NORMAL)
try: try:
window.addstr(line, attr) render_line(metaline, window, max_width)
except ValueError: except ValueError:
return new_dimensions return new_dimensions
if line_index < num_lines - 1: if line_index < num_lines - 1:
window.addstr("\n") window.addstr("\n")
return new_dimensions return new_dimensions
def render_line(metaline, window, max_width):
"""Write a single line to the window."""
meta, line = metaline
line_type = meta["type"]
attributes = get_base_line_attributes(line_type)
line = line[:max_width - 1]
window.addstr(line, attributes)
if meta["type"] == LineType.LINK and "url" in meta:
url_text = f' {meta["url"]}'
attributes = (
curses.color_pair(ColorPair.LINK_PREVIEW)
| curses.A_DIM
| curses.A_ITALIC
)
window.addstr(url_text, attributes)
def get_base_line_attributes(line_type):
"""Return the base attributes for this line type.
Other attributes may be freely used later for this line type but this is
what is used at the start of most lines of the given type.
"""
if line_type == LineType.TITLE_1:
return curses.color_pair(ColorPair.TITLE_1) | curses.A_BOLD
elif line_type == LineType.TITLE_2:
return curses.color_pair(ColorPair.TITLE_2) | curses.A_BOLD
elif line_type == LineType.TITLE_3:
return curses.color_pair(ColorPair.TITLE_3)
elif line_type == LineType.LINK:
return curses.color_pair(ColorPair.LINK)
elif line_type == LineType.PREFORMATTED:
return curses.color_pair(ColorPair.PREFORMATTED)
elif line_type == LineType.BLOCKQUOTE:
return curses.color_pair(ColorPair.BLOCKQUOTE) | curses.A_ITALIC
else: # includes LineType.PARAGRAPH
return curses.color_pair(ColorPair.NORMAL)

View file

@ -24,6 +24,10 @@ class TestNavigation(unittest.TestCase):
self.assertEqual(res.netloc, "dece.space") self.assertEqual(res.netloc, "dece.space")
self.assertEqual(res.path, "/index.html") self.assertEqual(res.path, "/index.html")
res = parse_url("file:///home/dece/gemini/index.gmi")
self.assertEqual(res.scheme, "file")
self.assertEqual(res.path, "/home/dece/gemini/index.gmi")
def test_join_url(self): def test_join_url(self):
url = join_url("gemini://dece.space/", "some-file.gmi") url = join_url("gemini://dece.space/", "some-file.gmi")
self.assertEqual(url, "gemini://dece.space/some-file.gmi") self.assertEqual(url, "gemini://dece.space/some-file.gmi")