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