Repos / mcross / 66667f050d
commit 66667f050d7dd31f8453fe7899bb117327e5a0df
Author: Bùi Thành Nhân <hi@imnhan.com>
Date: Fri May 15 08:41:06 2020 +0700
clickable links
next step: back-forward buttons
diff --git a/src/mcross/gui/controller.py b/src/mcross/gui/controller.py
index 2edc5aa..3f1dca9 100644
--- a/src/mcross/gui/controller.py
+++ b/src/mcross/gui/controller.py
@@ -1,6 +1,12 @@
-from tkinter import Tk
+from ssl import SSLCertVerificationError
+from tkinter import Tk, messagebox
-from .. import transport
+from ..transport import (
+ GeminiUrl,
+ NonAbsoluteUrlWithoutContextError,
+ UnsupportedProtocolError,
+ get,
+)
from .model import Model
from .view import View
@@ -11,6 +17,7 @@ def __init__(self):
self.model = Model()
self.view = View(self.root, self.model)
self.view.go_callback = self.go_callback
+ self.view.link_click_callback = self.link_click_callback
def run(self):
self.root.title("McRoss Browser")
@@ -19,10 +26,33 @@ def run(self):
def go_callback(self, url: str):
# TODO more visual indications
- # TODO url validation
+ url = GeminiUrl.parse_absolute_url(url)
+ self.visit_link(url)
+
+ def link_click_callback(self, raw_url):
+ # FIXME ugh
+ try:
+ url = GeminiUrl.parse(raw_url, self.model.current_url)
+ self.visit_link(url)
+ except NonAbsoluteUrlWithoutContextError:
+ messagebox.showwarning(
+ "Ambiguous link",
+ "Cannot visit relative urls without a current_url context",
+ )
+ except UnsupportedProtocolError as e:
+ messagebox.showinfo(
+ "Unsupported protocol", f"{e} links are unsupported (yet?)"
+ )
+ except SSLCertVerificationError:
+ messagebox.showerror(
+ "Invalid server certificate",
+ "Server is NOT using a valid CA-approved TLS certificate.",
+ )
+
+ def visit_link(self, url: GeminiUrl):
print("Requesting", url)
- resp = transport.get(url)
+ resp = get(url)
print("Received", resp)
if resp.status.startswith("2"):
@@ -37,5 +67,5 @@ def go_callback(self, url: str):
]
)
)
-
+ self.model.current_url = url
self.view.render_page()
diff --git a/src/mcross/gui/model.py b/src/mcross/gui/model.py
index 6b08883..5164785 100644
--- a/src/mcross/gui/model.py
+++ b/src/mcross/gui/model.py
@@ -10,6 +10,16 @@
* おぼえていますか 手と手触れ会った時
* Do you remember? The time when our hands first touched?
+## Links
+
+=> gemini://gemini.circumlunar.space/ Gemini homepage
+=> gemini://gus.guru/ Gemini Universal Search engine
+=> gemini://gemini.conman.org/test/torture/ Gemini client torture test
+
+=> relative/ Relative link
+=> /relative/ Relative link starting with "/"
+=> https://lists.orbitalfox.eu/listinfo/gemini?foo=bar HTTP link
+
## Codes
```
@@ -20,21 +30,11 @@
authors = ["nhanb <hi@imnhan.com>"]
license = "MIT"
```
-
-## Links
-
-=> gemini.circumlunar.space/docs/ Gemini documentation
-=> gemini://gemini.circumlunar.space/software/ Gemini software
-=> gemini.circumlunar.space/servers/ Known Gemini servers
-=> gemini://gus.guru/ Gemini Universal Search engine
-=> https://lists.orbitalfox.eu/listinfo/gemini Gemini mailing list
-=> https://portal.mozz.us/?url=gemini%3A%2F%2Fgemini.circumlunar.space%2F&fmt=fixed Gemini-to-web proxy service
-=> https://proxy.vulpes.one/gemini/gemini.circumlunar.space Another Gemini-to-web proxy service
-=> gemini://gemini.conman.org/test/torture/ Gemini client torture test
"""
class Model:
+ current_url = None
plaintext = ""
gemini_nodes = None
diff --git a/src/mcross/gui/view.py b/src/mcross/gui/view.py
index 602187a..f47e637 100644
--- a/src/mcross/gui/view.py
+++ b/src/mcross/gui/view.py
@@ -47,7 +47,6 @@ def __init__(self, root: Tk, model: Model):
# Address bar
address_bar = ttk.Entry(row1)
- address_bar.insert(0, "gemini.circumlunar.space/")
self.address_bar = address_bar
address_bar.pack(side="left", fill="both", expand=True, padx=3, pady=3)
address_bar.bind("<Return>", self._on_go)
@@ -82,15 +81,18 @@ def __init__(self, root: Tk, model: Model):
)
mono_font = pick_font(["Ubuntu Mono", "Consolas", "Courier", "TkFixedFont"])
text.config(
- font=(text_font, 13), bg="#fff8dc", fg="black", padx=5, pady=5,
- )
- text.tag_config("link", foreground="blue", underline=1)
- text.tag_config(
- "pre",
- font=(mono_font, 13),
- # background="#ffe4c4",
- # selectbackground=text.cget("selectbackground"),
+ font=(text_font, 13),
+ bg="#fff8dc",
+ fg="black",
+ padx=5,
+ pady=5,
+ insertontime=0, # hide blinking insertion cursor
)
+ text.tag_config("link", foreground="brown")
+ text.tag_bind("link", "<Enter>", self._on_link_enter)
+ text.tag_bind("link", "<Leave>", self._on_link_leave)
+ text.tag_bind("link", "<Button-1>", self._on_link_click)
+ text.tag_config("pre", font=(mono_font, 13))
text.pack(side="left", fill="both", expand=True)
text_scrollbar = ttk.Scrollbar(viewport, command=text.yview)
@@ -109,9 +111,24 @@ def _on_go(self, ev=None):
if self.go_callback is not None:
self.go_callback("gemini://" + self.address_bar.get())
+ def _on_link_enter(self, ev):
+ self.text.config(cursor="hand1")
+
+ def _on_link_leave(self, ev):
+ self.text.config(cursor="xterm")
+
+ def _on_link_click(self, ev):
+ raw_url = get_content_from_tag_click_event(ev)
+ self.link_click_callback(raw_url)
+
def render_page(self):
- self.text.delete("1.0", "end")
+ # Update url in address bar
+ if self.model.current_url is not None:
+ self.address_bar.delete(0, "end")
+ self.address_bar.insert(0, self.model.current_url.without_protocol())
+ # Update viewport
+ self.text.delete("1.0", "end")
if not self.model.gemini_nodes:
self.text.insert("end", self.model.plaintext)
else:
@@ -133,3 +150,20 @@ def render_node(node: GeminiNode, widget: Text):
widget.insert("end", f"```\n{node.text}\n```\n", ("pre",))
else:
widget.insert("end", node.text + "\n")
+
+
+def get_content_from_tag_click_event(event):
+ # get the index of the mouse click
+ index = event.widget.index("@%s,%s" % (event.x, event.y))
+
+ # get the indices of all "link" tags
+ tag_indices = list(event.widget.tag_ranges("link"))
+
+ # iterate them pairwise (start and end index)
+ for start, end in zip(tag_indices[0::2], tag_indices[1::2]):
+ # check if the tag matches the mouse click index
+ if event.widget.compare(start, "<=", index) and event.widget.compare(
+ index, "<", end
+ ):
+ # return string between tag start and end
+ return event.widget.get(start, end)
diff --git a/src/mcross/transport.py b/src/mcross/transport.py
index 8b252d6..10e4ff0 100644
--- a/src/mcross/transport.py
+++ b/src/mcross/transport.py
@@ -23,25 +23,7 @@ def __repr__(self):
return f"Response(status={repr(self.status)}, meta={repr(self.meta)})"
-def get(absolute_url="gemini://gemini.circumlunar.space/"):
- url = parse_absolute_url(absolute_url)
- port = url.port or 1965
-
- context = ssl.create_default_context()
- with socket.create_connection((url.netloc, port)) as sock:
- with context.wrap_socket(sock, server_hostname=url.netloc) as ssock:
- ssock.send(f"gemini://{url.netloc}{url.path}\r\n".encode())
- header = ssock.recv(MAX_RESP_HEADER_BYTES).decode()
- status, meta = _parse_resp_header(header)
- resp = Response(status=status, meta=meta)
-
- if status.startswith("2"):
- resp.body = ssock.recv(MAX_RESP_BODY_BYTES)
-
- return resp
-
-
-def _parse_resp_header(header, pattern=re.compile(r"^(\d\d) (.{,1024})\r\n$")):
+def _parse_resp_header(header, pattern=re.compile(r"^(\d\d)\s+(.{,1024})\r\n$")):
match = pattern.match(header)
assert match is not None, f"Malformed response header: {header}"
status = match.group(1)
@@ -50,7 +32,90 @@ def _parse_resp_header(header, pattern=re.compile(r"^(\d\d) (.{,1024})\r\n$")):
def parse_absolute_url(absolute_url):
- # TODO: this is not exactly safe. Do proper parsing later.
assert absolute_url.startswith("gemini://"), f"Malformed url: {absolute_url}"
parsed = urlparse(absolute_url)
return parsed
+
+
+# TODO: GeminiUrl's context-aware parse() method probably doesn't belong
+# in a "transport" module.
+
+
+class UnsupportedProtocolError(Exception):
+ pass
+
+
+class NonAbsoluteUrlWithoutContextError(Exception):
+ pass
+
+
+class GeminiUrl:
+ PROTOCOL = "gemini"
+ host: str
+ port: int
+ path: str
+
+ def __init__(self, host, port, path):
+ """
+ You probably don't want to use this constructor directly.
+ Use one of the parse methods instead.
+ """
+ self.host = host
+ self.port = port
+ self.path = path
+
+ def __repr__(self):
+ return f"{self.PROTOCOL}://{self.host}:{self.port}{self.path}"
+
+ def without_protocol(self):
+ if self.port == 1965:
+ return f"{self.host}{self.path}"
+ else:
+ return f"{self.host}:{self.port}{self.path}"
+
+ @classmethod
+ def parse(cls, text, current_url):
+ assert not re.search(r"\s", text), "Url should not contain any whitespace!"
+
+ protocol = urlparse(text).scheme
+ if protocol == cls.PROTOCOL:
+ return cls.parse_absolute_url(text)
+
+ if protocol:
+ raise UnsupportedProtocolError(protocol)
+
+ if current_url is None:
+ raise NonAbsoluteUrlWithoutContextError(text)
+
+ # relative url starting from top level
+ if text.startswith("/"):
+ return GeminiUrl(current_url.host, current_url.port, text)
+
+ # just relative url:
+ # trim stuff after the last `/` - for example:
+ # current url: gemini://example.com/foo/bar
+ # raw url text: yikes
+ # => parsed url: gemini://example.com/foo/yikes
+ current_path = current_url.path[: current_url.path.rfind("/") + 1]
+ return GeminiUrl(current_url.host, current_url.port, current_path + text)
+
+ @staticmethod
+ def parse_absolute_url(text):
+ # TODO: urlparse doesn't seem that foolproof. Revisit later.
+ parsed = urlparse(text)
+ return GeminiUrl(parsed.hostname, parsed.port or 1965, parsed.path)
+
+
+def get(url: GeminiUrl):
+ context = ssl.create_default_context()
+ with socket.create_connection((url.host, url.port)) as sock:
+ with context.wrap_socket(sock, server_hostname=url.host) as ssock:
+ ssock.send(f"gemini://{url.host}{url.path}\r\n".encode())
+ header = ssock.recv(MAX_RESP_HEADER_BYTES).decode()
+ status, meta = _parse_resp_header(header)
+ resp = Response(status=status, meta=meta)
+
+ if status.startswith("2"):
+ resp.body = ssock.recv(MAX_RESP_BODY_BYTES)
+
+ return resp