Compare commits

...

5 commits

6 changed files with 142 additions and 74 deletions

View file

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

View file

@ -25,7 +25,7 @@ class Browser:
self.tab = None
self.status_line = None
self.command_line = None
self.status_data = ("", 0)
self.status_data = ("", 0, 0)
self.current_url = ""
self.running = True
self.history = History()
@ -65,55 +65,65 @@ class Browser:
)
self.command_line = CommandLine(command_line_window)
pending_url = start_url
while self.running:
if pending_url:
self.open_url(pending_url)
pending_url = None
if start_url:
self.open_url(start_url, assume_absolute=True)
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()
if char == ord(":"):
self.quick_command("")
elif char == ord("r"):
self.reload_page()
elif char == ord("s"):
self.set_status(f"h {self.h} w {self.w}")
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()
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()
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()
elif char == curses.ascii.ESC: # Can be ESC or ALT char.
self.screen.nodelay(True)
ch = self.screen.getch()
if ch == -1:
self.set_status(self.current_url)
else:
pass # No alt-key shortcuts for now!
self.screen.nodelay(False)
@property
def page_pad_size(self):
@ -143,20 +153,21 @@ class Browser:
def refresh_status_line(self):
"""Refresh status line contents."""
text, pair = self.status_data
text, pair, attributes = self.status_data
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.refresh()
def set_status(self, text):
"""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()
def set_status_error(self, text):
"""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()
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)
elif parts.scheme.startswith("http"):
self.open_web_url(url)
elif parts.scheme == "file":
self.open_file(parts.path)
else:
self.set_status_error(f"Protocol {parts.scheme} not supported.")
@ -244,6 +257,9 @@ class Browser:
self.set_status_error(error)
elif response.generic_code == 10:
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):
"""Load Gemtext data as the current page."""
@ -461,3 +477,15 @@ class Browser:
"""Open a Web URL. Currently relies in Python's webbrowser module."""
self.set_status(f"Opening {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):
# Colors for specific Gemtext line type.
NORMAL = 0
ERROR = 1
LINK = 2
@ -12,6 +13,9 @@ class ColorPair(IntEnum):
TITLE_3 = 6
PREFORMATTED = 7
BLOCKQUOTE = 8
# Colors for other usage in the browser.
LINK_PREVIEW = 9
DEBUG = 99
@ -26,4 +30,5 @@ def init_colors():
curses.init_pair(ColorPair.TITLE_3, curses.COLOR_MAGENTA, -1)
curses.init_pair(ColorPair.PREFORMATTED, curses.COLOR_YELLOW, -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)

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
misses either the // delimiter.
"""
url = url.strip()
if url.startswith("file://"):
return urllib.parse.urlparse(url)
if url.startswith("gemini://"):
url = url[7:]
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."""
quoted_input = urllib.parse.quote(user_input)
if "?" in url:
url = url.rsplit("?", maxsplit=1)[0]
url = url.split("?", maxsplit=1)[0]
return url + "?" + quoted_input

View file

@ -46,9 +46,12 @@ def format_elements(elements, width):
context = {"last_link_id": 0, "width": width}
separator = ({"type": LineType.NONE}, "")
has_margins = False
thin_type = None
for index, element in enumerate(elements):
previous_had_margins = has_margins
last_thin_type = thin_type
has_margins = False
thin_type = None
if isinstance(element, Title):
element_metalines = format_title(element, context)
has_margins = True
@ -57,6 +60,7 @@ def format_elements(elements, width):
has_margins = True
elif isinstance(element, Link):
element_metalines = format_link(element, context)
thin_type = LineType.LINK
elif isinstance(element, Preformatted):
element_metalines = format_preformatted(element, context)
has_margins = True
@ -65,17 +69,21 @@ def format_elements(elements, width):
has_margins = True
elif isinstance(element, ListItem):
element_metalines = format_list_item(element, context)
thin_type = LineType.LIST_ITEM
else:
continue
# If current element requires margins and is not the first elements,
# 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
# 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 (
(has_margins and index > 0)
or (not has_margins and previous_had_margins)
or (not has_margins and thin_type != last_thin_type)
):
metalines.append(separator)
# Append the element metalines now.
metalines += element_metalines
return metalines
@ -223,27 +231,49 @@ def render_lines(metalines, window, max_width):
new_dimensions = num_lines, max_width
window.resize(*new_dimensions)
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:
window.addstr(line, attr)
render_line(metaline, window, max_width)
except ValueError:
return new_dimensions
if line_index < num_lines - 1:
window.addstr("\n")
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.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):
url = join_url("gemini://dece.space/", "some-file.gmi")
self.assertEqual(url, "gemini://dece.space/some-file.gmi")