Repos / mcross / 701c862061
commit 701c862061ada0373b1d027e8861843e249b779b
Author: Bùi Thành Nhân <hi@imnhan.com>
Date: Thu May 28 16:03:34 2020 +0700
run curio in a separate thread
So that tkinter's mainloop() can take full control of the main thread
and we don't need to repeatedly run the heavy update() method anymore.
diff --git a/README.md b/README.md
index 4393e55..59d310d 100644
--- a/README.md
+++ b/README.md
@@ -81,12 +81,7 @@ ## Responsive & pleasant to use
## Lightweight
In terms of both disk space & memory/cpu usage.
-It's completely unoptimized at the moment.
-Actually the way I'm hooking up tkinter and curio is... not ideal. See comments
-in controller.py for more info. TL;DR I'm prioritizing gui responsiveness and
-code simplicity, sacrificing a non-negligible amount of CPU cycles even at
-idle.
-
+The python/tkinter combo already puts us at a pretty good starting point.
# Server bugs/surprises
diff --git a/pyproject.toml b/pyproject.toml
index 566be1e..cc302fe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "mcross"
-version = "0.5.5"
+version = "0.5.6"
description = "Do you remember www?"
authors = ["nhanb <hi@imnhan.com>"]
license = "MIT"
diff --git a/src/mcross/gui/controller.py b/src/mcross/gui/controller.py
index d7da00a..fea0ab5 100644
--- a/src/mcross/gui/controller.py
+++ b/src/mcross/gui/controller.py
@@ -1,7 +1,8 @@
import logging
+import threading
import traceback
from ssl import SSLCertVerificationError
-from tkinter import TclError, Tk, messagebox
+from tkinter import READABLE, Tk, messagebox
import curio
@@ -25,57 +26,52 @@ def __init__(self):
self.root.title("McRoss Browser")
self.root.geometry("800x600")
- # Coroutine magic follows:
+ self.gui_ops = curio.UniversalQueue(withfd=True)
+ self.coro_ops = curio.UniversalQueue()
- self.pending_coros = []
+ # Set up event handler for queued GUI updates
+ self.root.createfilehandler(self.gui_ops, READABLE, self.process_gui_ops)
- def schedule_as_coro(func):
- def do_schedule(*args):
- task = curio.spawn(
- self.show_waiting_cursor_during_task(func, *args), daemon=True
- )
- self.pending_coros.append(task)
+ def put_coro_op(func):
+ def inner(*args):
+ self.coro_ops.put(self.show_waiting_cursor_during_task(func, *args))
- return do_schedule
+ return inner
- 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)
+ self.view.go_callback = put_coro_op(self.go_callback)
+ self.view.link_click_callback = put_coro_op(self.link_click_callback)
+ self.view.back_callback = put_coro_op(self.back_callback)
+ self.view.forward_callback = put_coro_op(self.forward_callback)
+
+ async def main(self):
+ while True:
+ coro = await self.coro_ops.get()
+ await coro
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.016)
- # 16ms = 1/60 - we're targeting around 60fps
- # Yes it's wasteful to call root.update() that fast.
- # In practice CPU usage idles around 4% on my i5 but hey it's not
- # spinning up my laptop fans yet.
- # Doesn't seem like there's a better way atm. The alternative
- # described at [1] is multithreading which I'm not a fan of.
- # [1] https://github.com/dabeaz/curio/issues/111
- except TclError as e:
- if "application has been destroyed" not in str(e):
- raise
-
- curio.run(main)
+ threading.Thread(target=curio.run, args=(self.main,), daemon=True).start()
+ self.root.mainloop()
+
+ async def put_gui_op(self, func, *args, **kwargs):
+ await self.gui_ops.put((func, args, kwargs))
+
+ def process_gui_ops(self, file, mask):
+ while not self.gui_ops.empty():
+ func, args, kwargs = self.gui_ops.get()
+ func(*args, **kwargs)
async def show_waiting_cursor_during_task(self, func, *args):
- self.view.text.config(cursor=WAITING_CURSOR)
- self.root.config(cursor=WAITING_CURSOR)
- self.view.allow_changing_cursor = False
+ async def show():
+ self.view.text.config(cursor=WAITING_CURSOR)
+ self.root.config(cursor=WAITING_CURSOR)
+ self.view.allow_changing_cursor = False
+
+ async def hide():
+ self.view.text.config(cursor="xterm")
+ self.root.config(cursor="arrow")
+ self.view.allow_changing_cursor = True
+
+ await show()
try:
await func(*args)
@@ -83,10 +79,7 @@ async def show_waiting_cursor_during_task(self, func, *args):
# a catch-all here so that our show_waiting...() coroutine can be yielded
traceback.print_exc()
- # reset cursor to default values
- self.view.text.config(cursor="xterm")
- self.root.config(cursor="arrow")
- self.view.allow_changing_cursor = True
+ await hide()
async def go_callback(self, url: str):
url = GeminiUrl.parse_absolute_url(url)
@@ -97,16 +90,20 @@ async def link_click_callback(self, raw_url):
url = GeminiUrl.parse(raw_url, self.model.history.get_current_url())
await self.visit_link(url)
except NonAbsoluteUrlWithoutContextError:
- messagebox.showwarning(
+ await self.put_gui_op(
+ 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?)"
+ await self.put_gui_op(
+ messagebox.showinfo,
+ "Unsupported protocol",
+ f"{e} links are unsupported (yet?)",
)
except SSLCertVerificationError:
- messagebox.showerror(
+ await self.put_gui_op(
+ messagebox.showerror,
"Invalid server certificate",
"Server is NOT using a valid CA-approved TLS certificate.",
)
@@ -115,15 +112,16 @@ async def visit_link(self, url: GeminiUrl):
try:
resp = await self.load_page(url)
self.model.history.visit(resp.url)
- self.view.render_page()
+ await self.put_gui_op(self.view.render_page)
+
except ConnectionError as e:
- statusbar_logger.info(str(e))
+ await self.put_gui_op(statusbar_logger.info, str(e))
raise
async def back_callback(self):
self.model.history.go_back()
await self.load_page(self.model.history.get_current_url())
- self.view.render_page()
+ await self.put_gui_op(self.view.render_page)
async def forward_callback(self):
self.model.history.go_forward()
@@ -131,26 +129,27 @@ async def forward_callback(self):
self.view.render_page()
async def load_page(self, url: GeminiUrl):
- statusbar_logger.info(f"Requesting {url}...")
+ await self.put_gui_op(statusbar_logger.info, f"Requesting {url}...")
resp = await get(url)
- statusbar_logger.info(f"{resp.status} {resp.meta}")
+ await self.put_gui_op(statusbar_logger.info, f"{resp.status} {resp.meta}")
async def clear_status_bar_later():
await curio.sleep(2)
- statusbar_logger.info("")
+ await self.put_gui_op(statusbar_logger.info, "")
await curio.spawn(clear_status_bar_later(), daemon=True)
if resp.status.startswith("2"):
- self.model.update_content(resp.body.decode())
+ await self.put_gui_op(self.model.update_content, resp.body.decode())
else:
- self.model.update_content(
+ await self.put_gui_op(
+ self.model.update_content,
"\n".join(
[
"Error:",
f"{resp.status} {resp.meta}",
resp.body.decode() if resp.body else "",
]
- )
+ ),
)
return resp