9.5. Textual Integration¶
Wire a ComponentVMOf[M] to a Textual
TUI widget through Textual's reactive descriptors.
9.5.1. Reactivity primitive¶
Textual widgets re-render when a reactive(...) attribute changes. A VMx VM
reports its own changes on property_changed, an observable of snake_case
property names: a setter such as model publishes a PropertyChangedMessage
to the hub and then emits its name there, and every lifecycle transition
publishes a ConstructionStatusChangedMessage and then emits status. The hub
never carries a PropertyChangedMessage for status.
9.5.2. Mapping¶
| Textual | VMx |
|---|---|
reactive("value") |
vm.property_changed (VM-local names) |
Binding + action_* |
RelayCommand.execute() |
ListView items |
ObservableList[T] + CollectionChangedMessage |
post_message |
thread-safe hand-off of a change to the App thread |
9.5.3. Adapter skeleton¶
The widget below is the exact module that the Textual Notes-Showcase tests run
on a live App (examples/python/textual/notes_showcase/tests/views/), including
a change made on a worker thread.
from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar
from reactivex.abc import DisposableBase
from textual.app import RenderResult
from textual.binding import Binding, BindingType
from textual.message import Message as TextualMessage
from textual.reactive import reactive
from textual.widget import Widget
from vmx import ComponentVMOf, RelayCommand
@dataclass
class Note:
title: str
class NoteWidget(Widget, can_focus=True):
"""Shows a borrowed note VM; whoever created the VM constructs and disposes it."""
BINDINGS: ClassVar[list[BindingType]] = [Binding("s", "save", "Save")]
title: reactive[str] = reactive("")
status_text: reactive[str] = reactive("")
class VmChanged(TextualMessage):
"""Carries one VM-local property change onto the App thread."""
def __init__(self, property_name: str) -> None:
super().__init__()
self.property_name = property_name
def __init__(self, vm: ComponentVMOf[Note], save_command: RelayCommand) -> None:
super().__init__()
self._vm = vm
self._save_command = save_command
self._subscription: DisposableBase | None = None
def on_mount(self) -> None:
# `property_changed` is the VM-local stream: it carries `model`
# assignments and every lifecycle `status` change, on the thread that
# made the change. `post_message` is thread-safe and queues each change
# for this widget on the App thread.
self._subscription = self._vm.property_changed.subscribe(self._forward)
# Read after subscribing, so a change in between is not lost.
self._show("model")
self._show("status")
def _forward(self, property_name: str) -> None:
self.post_message(self.VmChanged(property_name))
def on_note_widget_vm_changed(self, message: NoteWidget.VmChanged) -> None:
self._show(message.property_name)
def _show(self, property_name: str) -> None:
if property_name == "model":
self.title = self._vm.model.title
elif property_name == "status":
self.status_text = self._vm.status.name
def render(self) -> RenderResult:
return f"{self.title} · {self.status_text}"
def action_save(self) -> None:
self._save_command.execute(None)
def on_unmount(self) -> None:
if self._subscription is not None:
self._subscription.dispose()
self._subscription = None
- One notification path. Subscribe to
property_changedonly. Also handling the hub'sPropertyChangedMessagenotifiesmodeltwice, and the hub never carriesstatus. - Current values at once.
on_mountsubscribes, then reads the VM, so the first render shows the current title and status and a change made in between is not lost. - App thread only.
property_changedemits on the thread that made the change.post_messageis thread-safe, so the widget reads the VM and assigns its reactive attributes only on the App thread. Each handled message shows the VM's current value, so a transient status such asCONSTRUCTINGmay be skipped when it is already over. - Borrowed lifetime.
on_unmountdisposes the subscription and nothing else. The code that created the VM constructs and disposes it.
A host that runs VMx work on its own threads owns that machinery:
TextualDispatcher captures the running App loop and uses
AsyncIOThreadSafeScheduler(loop) for worker-to-loop delivery. The host must
stop admissions, await admitted hooks while the loop remains responsive,
dispose resources, then explicitly release any ThreadPoolScheduler it
created; use the shared
Python asyncio dispatcher ownership
teardown sequence.
9.5.4. Fuller example¶
examples/python/textual/inspector/— a Textual viewer for any VMx tree, demonstrating the hub-subscription pattern at scale.examples/python/textual/notes_showcase/— the Notes-Showcase Textual flagship: fullWorkspaceVMwithbind_property/bind_commandhelpers (shipped in v2.2.0; ThemeVM added in v2.4.0).