9.7. Tkinter Integration¶
Wire a ComponentVMOf[M] to a Tkinter widget through StringVar,
IntVar, and friends. Tkinter is included with CPython — no extra
dependency.
9.7.1. Reactivity primitive¶
Tkinter variables (StringVar, IntVar, BooleanVar, DoubleVar)
are observable: any widget bound via textvariable= re-renders when
the variable's .set(value) is called. There is no collection
observable; lists need to be re-applied to a Listbox. Tk widgets and
variables may only be touched from the thread that runs mainloop().
9.7.2. Mapping¶
| Tkinter | VMx |
|---|---|
StringVar.set(x) |
apply a queued property_changed name on the Tk thread |
tk.Button(command=fn) |
command.execute(None) inside fn |
Listbox.insert/delete |
CollectionChangedMessage handler, applied on the Tk thread |
root.after(ms, fn) |
drain a thread-safe queue that VM notifications are posted to |
9.7.3. Adapter skeleton¶
The code below is the exact module that CI runs under Xvfb
(examples/python/tk/note_recipe, run with python -m note_recipe). The run
shows the initial values, follows model and status changes made on the Tk
thread and on a worker, runs the Save command, and stops on dispose().
Subscribe to the VM's own property_changed stream. It carries every model
assignment and every lifecycle status change, on the thread that made the
change. Tk is not thread-safe, so the binding queues each change and applies it
on the Tk thread from root.after:
import queue
import tkinter as tk
from dataclasses import dataclass
from vmx import ComponentVMOf, RelayCommand
@dataclass(frozen=True)
class Note:
title: str
class NoteBinding:
"""Shows a borrowed note VM in Tk variables.
Whoever created the VM constructs and disposes it; ``dispose()`` only stops
this binding.
"""
def __init__(self, root: tk.Misc, vm: ComponentVMOf[Note], poll_ms: int = 50) -> None:
self.title = tk.StringVar(master=root)
self.status = tk.StringVar(master=root)
self._root = root
self._vm = vm
self._poll_ms = poll_ms
# `property_changed` carries `model` assignments and every lifecycle
# `status` change, on the thread that made the change. Tk is not
# thread-safe, so queue the names and apply them on the Tk thread.
self._changes: queue.SimpleQueue[str] = queue.SimpleQueue()
self._subscription = vm.property_changed.subscribe(self._changes.put)
# Read after subscribing, so a change in between is not lost.
self._show("model")
self._show("status")
self._after_id: str | None = root.after(poll_ms, self._drain)
def _drain(self) -> None:
while True:
try:
property_name = self._changes.get_nowait()
except queue.Empty:
break
self._show(property_name)
self._after_id = self._root.after(self._poll_ms, self._drain)
def _show(self, property_name: str) -> None:
if property_name == "model":
self.title.set(self._vm.model.title)
elif property_name == "status":
self.status.set(self._vm.status.name)
def dispose(self) -> None:
self._subscription.dispose()
if self._after_id is not None:
self._root.after_cancel(self._after_id)
self._after_id = None
Build the window around the binding, and stop the binding before the window closes:
def build_window(root: tk.Tk, vm: ComponentVMOf[Note], save_command: RelayCommand) -> NoteBinding:
binding = NoteBinding(root, vm)
tk.Label(root, textvariable=binding.title).pack()
tk.Label(root, textvariable=binding.status).pack()
tk.Button(root, text="Save", command=lambda: save_command.execute(None)).pack()
def close() -> None:
binding.dispose()
root.destroy()
root.protocol("WM_DELETE_WINDOW", close)
return binding
Run it on the main thread:
root = tk.Tk()
build_window(root, vm, save_command)
root.mainloop()
- Current values at once. The binding reads
modelandstatusright after subscribing, so no change in between is lost. - Tk thread only. A change made on a worker reaches the variables on the
next
aftertick, on the Tk thread. - Borrowed lifetime.
dispose()stops the subscription and the polling, and nothing else. The code that created the VM constructs and disposes it.
If this host also owns an asyncio loop, capture it while running before creating
RxDispatcher.asyncio(loop) and explicitly clean up its background pool as
shown in Python asyncio dispatcher ownership.
9.7.4. Fuller example¶
examples/python/tk/todo_app/ — a
working Tkinter Todo app backed by a CompositeVM[TodoItemVM].