Repos / mcross / 10b5974908
commit 10b5974908270d84bd21a240d5a74a845f586319
Author: Bùi Thành Nhân <hi@imnhan.com>
Date:   Sun May 17 01:47:26 2020 +0700

    run tkinter via curio instead - we async now!

diff --git a/poetry.lock b/poetry.lock
index c250d42..4262ef3 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -25,6 +25,17 @@ optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 version = "0.4.3"
 
+[[package]]
+category = "main"
+description = "Curio"
+name = "curio"
+optional = false
+python-versions = ">= 3.6"
+version = "1.2"
+
+[package.extras]
+test = ["pytest", "sphinx"]
+
 [[package]]
 category = "dev"
 description = "Decorators for Humans"
@@ -298,7 +309,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
 testing = ["jaraco.itertools", "func-timeout"]
 
 [metadata]
-content-hash = "aa819a24de25e1f54368130e31d3524825aea37a1de2632a1eabd6cb6bafaa42"
+content-hash = "6aa72780c0ec9d3ed80370f35c09d1b1915c49675ab97732c49e2af47c5aa08f"
 python-versions = "^3.7"
 
 [metadata.files]
@@ -314,6 +325,9 @@ colorama = [
     {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
     {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
 ]
+curio = [
+    {file = "curio-1.2.tar.gz", hash = "sha256:90f320fafb3f5b791f25ffafa7b561cc980376de173afd575a2114380de7939b"},
+]
 decorator = [
     {file = "decorator-4.4.2-py2.py3-none-any.whl", hash = "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760"},
     {file = "decorator-4.4.2.tar.gz", hash = "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"},
diff --git a/pyproject.toml b/pyproject.toml
index 9bd58a8..9380af9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,6 +13,7 @@ mcross = "mcross:run"
 
 [tool.poetry.dependencies]
 python = "^3.7"
+curio = "^1.2"
 
 [tool.poetry.dev-dependencies]
 python-language-server = "^0.31.10"
diff --git a/src/mcross/gui/controller.py b/src/mcross/gui/controller.py
index 6bcaefb..03acb19 100644
--- a/src/mcross/gui/controller.py
+++ b/src/mcross/gui/controller.py
@@ -1,5 +1,7 @@
 from ssl import SSLCertVerificationError
-from tkinter import Tk, messagebox
+from tkinter import TclError, Tk, messagebox
+
+import curio
 
 from ..transport import (
     GeminiUrl,
@@ -16,27 +18,54 @@ def __init__(self):
         self.root = Tk()
         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
-        self.view.back_callback = self.back_callback
-        self.view.forward_callback = self.forward_callback
-
-    def run(self):
         self.root.title("McRoss Browser")
         self.root.geometry("800x600")
-        self.root.mainloop()
 
-    def go_callback(self, url: str):
+        # Coroutine magic follows:
+
+        self.pending_coros = []
+
+        def schedule_as_coro(func):
+            return lambda *args: self.pending_coros.append(curio.spawn(func, *args))
+
+        self.view.go_callback = schedule_as_coro(self.go_callback)
+        self.view.link_click_callback = schedule_as_coro(self.link_click_callback)
+        self.view.back_callback = schedule_as_coro(self.back_callback)
+        self.view.forward_callback = schedule_as_coro(self.forward_callback)
+
+    def run(self):
+        # Instead of running tkinter's root.mainloop() directly,
+        # we rely on curio's event loop instead.
+        # The main() coroutine does these things in an infinite loop:
+        #   - do tk's necessary GUI with root.update()
+        #   - run pending coroutines if there's any. This is used to run callbacks
+        #     triggered by the view.
+        #   - sleep a little so we don't loop root.update() too quickly.
+        async def main():
+            try:
+                while True:
+                    self.root.update()
+                    for coroutine in self.pending_coros:
+                        await coroutine
+                    self.pending_coros = []
+                    await curio.sleep(0.05)
+            except TclError as e:
+                if "application has been destroyed" not in str(e):
+                    raise
+
+        curio.run(main)
+
+    async def go_callback(self, url: str):
         # TODO more visual indications
 
         url = GeminiUrl.parse_absolute_url(url)
-        self.visit_link(url)
+        await self.visit_link(url)
 
-    def link_click_callback(self, raw_url):
+    async def link_click_callback(self, raw_url):
         # FIXME ugh
         try:
             url = GeminiUrl.parse(raw_url, self.model.history.get_current_url())
-            self.visit_link(url)
+            await self.visit_link(url)
         except NonAbsoluteUrlWithoutContextError:
             messagebox.showwarning(
                 "Ambiguous link",
@@ -52,24 +81,24 @@ def link_click_callback(self, raw_url):
                 "Server is NOT using a valid CA-approved TLS certificate.",
             )
 
-    def visit_link(self, url: GeminiUrl):
-        resp = self.load_page(url)
+    async def visit_link(self, url: GeminiUrl):
+        resp = await self.load_page(url)
         self.model.history.visit(resp.url)
         self.view.render_page()
 
-    def back_callback(self):
+    async def back_callback(self):
         self.model.history.go_back()
-        self.load_page(self.model.history.get_current_url())
+        await self.load_page(self.model.history.get_current_url())
         self.view.render_page()
 
-    def forward_callback(self):
+    async def forward_callback(self):
         self.model.history.go_forward()
-        self.load_page(self.model.history.get_current_url())
+        await self.load_page(self.model.history.get_current_url())
         self.view.render_page()
 
-    def load_page(self, url: GeminiUrl):
+    async def load_page(self, url: GeminiUrl):
         print("Requesting", url)
-        resp = get(url)
+        resp = await get(url)
         print("Received", resp)
 
         if resp.status.startswith("2"):
diff --git a/src/mcross/transport.py b/src/mcross/transport.py
index ae103f5..0af07a0 100644
--- a/src/mcross/transport.py
+++ b/src/mcross/transport.py
@@ -1,8 +1,8 @@
 import re
-import socket
-import ssl
 from urllib.parse import urlparse
 
+import curio
+
 MAX_RESP_HEADER_BYTES = 2 + 1 + 1024 + 2  # <STATUS><whitespace><META><CR><LF>
 MAX_RESP_BODY_BYTES = 1024 * 1024 * 5
 MAX_REDIRECTS = 3
@@ -102,32 +102,33 @@ def parse_absolute_url(text):
         return GeminiUrl(parsed.hostname, parsed.port or 1965, parsed.path)
 
 
-def raw_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, url=url)
-
-            if status.startswith("2"):
-                body = b""
-                msg = ssock.recv(4096)
+async def raw_get(url: GeminiUrl):
+    sock = await curio.open_connection(
+        url.host, url.port, ssl=True, server_hostname=url.host
+    )
+    async with sock:
+        await sock.sendall(f"gemini://{url.host}{url.path}\r\n".encode())
+        header = (await sock.recv(MAX_RESP_HEADER_BYTES)).decode()
+        status, meta = _parse_resp_header(header)
+        resp = Response(status=status, meta=meta, url=url)
+
+        if status.startswith("2"):
+            body = b""
+            msg = await sock.recv(4096)
+            body += msg
+            while msg and len(body) <= MAX_RESP_BODY_BYTES:
+                msg = await sock.recv(4096)
                 body += msg
-                while msg and len(body) <= MAX_RESP_BODY_BYTES:
-                    msg = ssock.recv(4096)
-                    body += msg
-                resp.body = body
+            resp.body = body
 
-            return resp
+        return resp
 
 
-def get(url: GeminiUrl, redirect_count=0):
-    resp = raw_get(url)
+async def get(url: GeminiUrl, redirect_count=0):
+    resp = await raw_get(url)
     if resp.status.startswith("3") and redirect_count < MAX_REDIRECTS:
         redirect_count += 1
         new_url = GeminiUrl.parse_absolute_url(resp.meta)
         print(f"Redirecting to {new_url}, count={redirect_count}")
-        return get(new_url, redirect_count)
+        return await get(new_url, redirect_count)
     return resp