Compare commits
5 commits
84764644df
...
89abbc6fd9
Author | SHA1 | Date | |
---|---|---|---|
dece | 89abbc6fd9 | ||
dece | 2db721d48a | ||
dece | 398f526b82 | ||
dece | 84221a7816 | ||
dece | 85934eedcf |
|
@ -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
|
||||
|
||||
|
|
132
bebop/browser.py
132
bebop/browser.py
|
@ -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}")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
Reference in a new issue