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:
|
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
|
||||||
|
|
||||||
|
|
132
bebop/browser.py
132
bebop/browser.py
|
@ -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}")
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
Reference in a new issue