Python compatibility

The shared framework and PySide6 backend support Python 3.9. Shared and Qt regression suites pass on Python 3.9.24 with PySide6 6.7.2 on Linux; embedded-host integration and other platforms need separate validation. Optional backends can require newer Python: the supported Flet 1.0.4 runtime requires Python 3.10+. See Python 3.9 compatibility for installation and the repeatable make check-python39 baseline check.

The Qt test runtime is pinned to PySide6==6.7.2 in tests/requirements-python39.txt; make check-python39 rejects a different version.

Backend installation

Installation extras: QPUIQ[pyside6], QPUIQ[wx], QPUIQ[flet], and QPUIQ[textual]. For Tkinter, install plain QPUIQ with native Python/OS Tk support; QPUIQ[tkinter] adds no pip dependency. See installation.

Backend alignment status and pinned PySide6 baselines: wx, flet, tkinter, and Textual. Textual has native headless Views/Tabs checks; broader alignment and terminal validation remain pending. wx, Qt and Tk have native regression coverage; Flet has serialized Session checks and live web Table editing/scrolling checks. Recorded env/ runtimes are PySide6 6.12.0, Flet 1.0.4, Tk 8.6 and Textual 8.2.8. Progress documents list the remaining checks.

Declarative Components

See the separate Web backend reference for installation, startup, authentication, deployment, browser widgets and transport limits. For Python running inside the browser, see the Pyodide reference, including the static cookbook entry and its supported pages. Browser columns below list implemented controls; see their references for feature limits. A dash (—) means unavailable. See installation for backend/development extras.

Implemented Partial / fallback Not implemented Cannot be supported

Flow Application QApplication App App Server worker Browser worker Page Tk
Window QMainWindow Frame (Single) DOM root DOM root (Single) Toplevel
Views QStackedWidget Simplebook ContentSwitcher Retained DOM views Retained DOM views Deferred ttk.Frame Programmatically selected named views
Tabs QTabWidget Notebook Tabs DOM tabs DOM tabs Tabs Notebook
Tab Panel DOM pane DOM pane Tab
Modal Dialog ModalScreen dialog dialog ⚠ AlertDialog Toplevel + grab
MdiSubWindow QMdiSubWindow — — — — — —
MdiArea QMdiArea — — — — — —
Layout HBox QHBoxLayout BoxSizer Horizontal CSS flex CSS flex Row Frame(grid)
VBox QVBoxLayout BoxSizer Vertical CSS flex CSS flex Column Frame(grid)
Grid QGridLayout GridBagSizer Shared-placement layout CSS grid CSS grid — Frame(grid)
Stack QStackedLayout (StackAll) Panel + overlay sizer Reversed VBox fallback CSS overlay CSS overlay Stack Frame + grid/lift Overlapping children
Spacer QSpacerItem CSS flex CSS flex default weight=1
Splitter QSplitter SplitterWindow (nested) cell layout + capture DOM separators DOM separators Row/Column + gestures ttk.Panedwindow drag resizable
Widget Divider StaticLine Rule DOM separator DOM separator Divider ttk.Separator
Label QLabel StaticText Label/Button DOM text DOM text Text Label single line
Button QPushButton Button Button button button Button Button Clickable single line with visual hint
Checkbox QCheckBox Checkbox Checkbox input checkbox input checkbox Checkbox Checkbutton
RadioButton QRadioButton RadioButton RadioButton input radio input radio Radio Radiobutton
Canvas (QWidget) (Panel) N/A Canvas 2D Canvas 2D ⚠ Canvas ⚠ Canvas
Image QLabel StaticBitmap N/A img img Image —
MatplotlibCanvas FigureCanvas FigureCanvasWxAgg N/A Canvas adapter Canvas adapter Canvas adapter FigureCanvasTkAgg
TextField QLineEdit TextCtrl Input input input TextField Entry single line text editor
TextEdit QPlainTextEdit TextCtrl (TE_MULTILINE) TextArea textarea textarea TextField (multiline) Text multiline plain text editor
MaskedField QLineEdit (Password) TextCtrl (TE_PASSWORD) Input (password) input password input password TextField (password) Entry (show) single line password editor
ProgressBar QProgressBar Gauge ProgressBar progress progress ProgressBar Progressbar
Scroll QScrollArea ScrolledPanel ScrollableContainer CSS overflow CSS overflow default to vertical-only scrollable
Text QLabel StaticText/TextCtrl Text DOM text DOM text Text Label multiline
Html QLabel WebView or fallback — Plain text only HTML fragment HTML fragment — Plain text only — Plain text only
MarkDown QLabel — Plain text only Markdown — — Markdown — Plain text only
ComboBox QComboBox Combobox Select / Input + OptionList select / input + popup select / input + popup Dropdown / TextField + list ttk.Combobox Editable Dropdown
Table QTableView wx.grid.Grid DataTable HTML table + editors HTML table + editors DataTable ttk.Treeview
Tree QTreeView DataViewCtrl Tree DOM tree DOM tree ListView ttk.Treeview
MenuBar QMenuBar MenuBar — Navbar Navbar MenuBar Menu
Menu QMenu Menu — Dropdown Dropdown SubmenuButton Menu cascade
MenuAction QAction MenuItem — Menu button Menu button MenuItemButton Menu command
ToolBar QToolBar — — — — — —
ToolBarAction QToolBarAction — — — — — —
Interop QtInPui — — N/A N/A — —
PuiInQt — — N/A N/A — —

Colours describe the PUI adapter, not every capability of its underlying toolkit. Red includes missing adapters, deferred work and plain-text placeholders that do not render the requested format. Yellow means an implemented subset; green does not promise complete parity. Black is reserved for incompatible backend contracts: DOM backends cannot directly participate in a desktop native widget hierarchy. Textual widget interop remains red because the PUI adapter is not implemented. Embedding a WebView or streaming a desktop window would be a different integration, not native-widget interop. Textual Image, Canvas and MatplotlibCanvas are also black because its current character-cell renderer cannot provide pixel graphics; adding a terminal graphics protocol would require a separate integration. Missing browser MDI adapters remain red; they are not classified as impossible. See the backend progress documents above for remaining work.


MenuBar(*, native=True) controls native menu-bar integration. On PySide6, it calls QMenuBar.setNativeMenuBar(native); native=False keeps the menu bar inside the window instead of using the platform's global menu bar. Native integration depends on platform support.

MenuAction(text, *, native=True) controls platform menu-role integration. On PySide6, native=True uses QAction.MenuRole.TextHeuristicRole for automatic role detection. native=False sets NoRole, preventing Qt from automatically relocating actions such as About, Preferences or Quit into macOS application menus based on their labels. MenuBar and MenuAction settings are independent and reapplied during reconciliation. wx, Tkinter, Flet, Web and Pyodide accept both keywords for API compatibility and ignore their values.

Tkinter attaches tk.Menu to the window; its placement follows the platform conventions. Flet renders MenuBar, SubmenuButton and MenuItemButton inside the window. Both support nested menus and MenuAction(...).trigger(callback, *args, **kwargs), including retained updates, reordering and removal. Declare the MenuBar directly inside Window, alongside the content container.

Web and Pyodide render MenuBar as a horizontal navbar inside Window, using its existing vertical flex layout. The navbar spans the window width with a bottom divider and sits flush with the top when declared first; ordinary content retains the Window padding. The navbar keeps its content height and the content container can fill the remaining space with .layout(weight=1); no extra root wrapper is added. Menu dropdowns use the native browser Popover API so they are not clipped by the window. A browser with Popover API support is required. Clicking a top-level item opens its menu; clicking it again closes it. While a menu is open, hovering another top-level item switches menus. Nested submenus open on mouse hover; touch users can tap to open them. Enter/Space opens a menu or activates an action; arrow keys navigate items and nested menus, Home/End select the first/last item, Escape closes the current menu, and Tab or clicking outside dismisses menus. Opening a menu is client-local; actions use the existing Python event transport.

MenuBar also accepts Spacer() children. Web/Pyodide use them to share the remaining horizontal space and skip them during keyboard navigation. PySide6, wx, Tkinter and Flet ignore these spacers. This applies to MenuBar, not dropdown Menu contents.

with Window():
    with MenuBar():
        with Menu("File"):
            MenuAction("Save").trigger(save)
            with Menu("Export"):
                MenuAction("CSV").trigger(export_csv)
        Spacer()
        with Menu("Help"):
            MenuAction("About").trigger(show_about)
    with VBox().layout(weight=1):
        Label("Application content")

For explicit Qt roles, use MenuAction(...).qt(role=QAction.MenuRole.AboutRole). An explicit role takes precedence over native; without one, native selects TextHeuristicRole or NoRole. Pass a QAction.MenuRole enum, not a string or integer. Calling .qt() again with no role or role=None preserves the previously specified option on that declaration; omitting the role on a new reconciled declaration restores the native-based default. Other backends ignore this Qt-specific modifier.

from PySide6.QtGui import QAction

MenuAction("About this app").qt(role=QAction.MenuRole.AboutRole)
MenuAction("Settings").qt(role=QAction.MenuRole.PreferencesRole)

Set roles in the initial declaration: Qt requires macOS menu roles to be set before actions enter the menu bar. Updating the QAction property later does not guarantee that an already displayed native menu will relocate the item.

wx MenuBar contains Menu declarations and ignores Spacer children; Menu contains nested Menu or MenuAction. .trigger(callback, *args, **kwargs) calls back without an event argument, matching PySide6. Labels and callbacks update during reconciliation. wx toolbar, MDI, rich Markdown and native interop remain pending design choices; see wx progress.

Imperative Functions

OpenURL

OpenURL(url) opens an absolute HTTP(S) URL using the backend's browser. PySide6, wx, Tkinter, Flet and Textual use the system default browser on the Python host. Web and Pyodide request a new tab in the user's browser; Web never launches a browser on the server. Browser preferences may open a window instead. mailto: URLs use the configured mail handler.

# Inside content(), including an About view.
Button("Project website").click(
    lambda event: OpenURL("https://github.com/buganini/PUI")
)

The function returns None; it does not wait for navigation. Web and Pyodide need an active runtime and do not require JSPI for this operation. If the browser blocks the new tab after an asynchronous callback, a dialog shows an ordinary link for the user to click. Relative URLs and schemes other than http, https and mailto raise ValueError; non-string arguments raise TypeError. Desktop launch failure raises RuntimeError.

File filters

types accepts an extension list or an ordered mapping of labels to extension lists, across PySide6, wx, Tkinter, Flet, Web, Pyodide and Textual:

OpenFile(types=["png", "jpg"])
OpenFiles(types={"Images": ["png", "jpg"], "Text": ["txt"]})
SaveFile(default="report.csv", types=["csv"])

Extensions may include a leading dot (".txt"); "*" means all files. Omitted or empty filters allow all files; an All files group is not added implicitly. Structured filters accept extensions, not arbitrary globs or MIME types. Breaking change: legacy Qt/wx filter strings are no longer accepted and raise TypeError. Migrate types="Text (*.txt)" or types="Text|*.txt" to types={"Text": ["txt"]} (or types=["txt"]). PySide6, wx and Tkinter preserve named groups. Web, Pyodide, Textual and Flet merge the groups into one set of allowed patterns/extensions. Browser accept hints are backed by import validation. SaveFile suffix handling remains native/backend-dependent; PUI does not add a common automatic extension policy.

Flet implements Information, Warning, Critical, Confirm and Prompt with AlertDialog, and desktop file dialogs with FilePicker. PUI node events, menu callbacks and get_poster() callbacks run serially on a background executor; Flet rendering and dialog operations remain on the asyncio UI loop. Call these helpers from those callbacks; calling outside their context raises RuntimeError. Native control access must be scheduled onto the application loop (for example, app.loop.call_soon_threadsafe(callback)). Confirm cancellation returns False; other cancellations return None, including application shutdown/disconnect. An accepted empty Prompt returns an empty string. File helpers return paths (OpenFiles returns a list); SaveFile returns a destination without writing data. The dir/directory aliases and SaveFile default path behave like wx/Tkinter. Linux desktop FilePicker requires Zenity. Multi-selection behavior is determined by the system picker, with no PUI cross-directory selection basket. Flet browser and mobile file helpers raise RuntimeError because their file APIs cannot provide the desktop path contract; message dialogs do not have this restriction.

Web Information, Warning, Critical, Confirm and Prompt are imperative, blocking functions, not declarative nodes. Call them from application callbacks after startup. Confirm returns a bool; Prompt returns a string (possibly empty), or None on Cancel; alert helpers return None. The application callback waits for the native browser dialog, while a separate transport thread receives its answer. Other UI callbacks and timers wait until the call returns. Disconnect or takeover raises ConnectionError. The title is included in the message, because native browser dialogs have no custom window title or severity icons. Browser suppression rules still apply. See the Web reference. Pyodide implements these five helpers with HTML dialogs over a shared JSPI bridge, also used by SaveFile. See the Pyodide dialog reference.

Textual implements Information, Warning, Critical, Confirm and Prompt with ModalScreen. Call them from PUI node event callbacks or get_poster() callbacks: these run serially on a single background executor while native events and rendering stay on the UI thread. Confirm returns False on cancellation; Prompt returns None (an accepted empty string stays empty); alerts return None. App shutdown releases pending dialog waits with cancellation results. Direct native widget access from these callbacks must use the Textual app's call_from_thread(). Calling a helper outside this callback context raises RuntimeError; no nested event loop is used. Textual also implements OpenDirectory, OpenFile, OpenFiles and SaveFile using a shared terminal file picker. Up / editable path / Go navigates the filesystem of the machine running the application (the remote host when using SSH). Enter in the path field navigates; Enter selects a list entry, and Space toggles multiple selections. OpenFiles retains selections across directories and restores checked items when revisiting a directory. File filters accept the shared types formats above; directories remain navigable. OpenFiles returns a list of paths; the other helpers return a single path. Cancel, Escape or app shutdown returns None. SaveFile only returns a destination, without creating it; an existing file requires a second Replace confirmation. The dir and directory aliases set the initial directory, otherwise cwd is used; SaveFile also uses the parent of default when no directory is supplied. Relative paths entered in the navigation field resolve from the current displayed directory. No device upload/download or file drag/drop is provided. Directory listing and final path validation run on a separate I/O worker, keeping the terminal responsive.

wx and tkinter dialog helpers accept dir and the compatibility alias directory. File filters use the shared extension list or named-group mapping above. SaveFile(default=None, ...) splits the default path into directory and filename; an explicit directory overrides its directory. Tkinter uses native filedialog/messagebox/simpledialog. wx and tkinter cancellation returns None; PySide6 OpenDirectory currently returns an empty string.

Web and Pyodide implement OpenDirectory, OpenFile and OpenFiles. Web combines device selection/upload with server browsing in one modal; Pyodide imports device files into its virtual filesystem using JSPI. Single selections return a path string, multiple files return a list of strings, and cancellation returns None. Device files may be selected or dropped; folders must be selected with the button, not dropped. Folder imports preserve relative file paths but cannot preserve empty directories. See Web file selection and Pyodide file selection.

Web SaveFile offers Download staging, Save to Server browsing, and Cancel. start(webroot=...) restricts browsing and selected paths. A dismissible download notification shows creation time and size refreshed every second. Cancellation or disconnect returns None. See SaveFile workflow.

Example

Implemented Partial / fallback Not implemented Cannot be supported


Application and view classes

Classes are the recommended API. Import Application and PUIView from the selected backend. Implement content() to declare the UI; initialize local state in setup(), which runs once when the view is first built. Constructors can accept explicit parameters and must call super().__init__().

Application classes


class Example(Application):
    def content(self):
        with Window(title="Example"):
            Label("Hello")

Example().run()

Reusable view classes

On wx, import PUIView from PUI.wx to schedule reactive redraws on the wx GUI queue. Redraw bursts are coalesced; removed views ignore queued work. Native widgets are reused during reconciliation and destroyed with their owner. Removing or replacing a virtual subview destroys its native roots, including controls inside wx sizer layouts. Keyed moves preserve those controls.


class Counter(PUIView):
    def setup(self):
        self.state = State()
        self.state.count = 0

    def content(self):
        with HBox():
            Label(str(self.state.count))
            Button("+").click(self.increment)

    def increment(self, event):
        self.state.count += 1

Deprecated decorators

Each decorator emits DeprecationWarning at its use site when it is applied; creating instances and redrawing existing views do not repeat that warning. Use Python's -Wd option to show deprecation warnings during migration.

@PUI and @PUIApp are deprecated but remain available for existing code. Replace a decorated function with a PUIView or Application subclass and move its body into content(self). Move parameters into an explicit constructor, initialize persistent local state in setup(), and use member event handlers. For example, @PUI def Greeting(text) becomes:


class Greeting(PUIView):
    def __init__(self, text):
        super().__init__()
        self.text = text

    def content(self):
        Label(self.text)

Existing decorator argument forwarding, backend scheduling, and functools.wraps metadata remain supported during deprecation. inspect.signature() displays the builder signature and inspect.unwrap() returns the builder for source inspection. Calling the wrapper still creates a view/application; a builder's return annotation does not describe the wrapper's return value.

Callback Interface

All callback interfaces accept an event object as the first argument. Other args/kwargs will be passed through to the callback function. For example

def button_cb(event, data):
    print(data)

Button("button").click(button_cb, 1)
will invoke

button_cb(event, 1)
when the event is emitted.

Event Object

click

dblclick

mousedown

mouseup

mousemove

wheel

input

Triggered when typing in TextField

change

Triggered when editing is finished in TextField

Declarative Components

Responsive / Adaptive Layout

PUI adapts declarations to container size across desktop, browser and terminal backends. Conditions read the nearest non-virtual parent's measured content area, not necessarily the window or the space remaining after siblings. A nested panel therefore responds to its own available area. See how this interacts with sizing.

GoalAPI
Switch row/column directionHVBox / VHBox
Override visibility by width.wideVisible(minWidth, value)
Override visibility for Textual.tVisible(value) / .tWideVisible(minWidth, value)
Override Grid child position or span.wideGrid(minWidth, ...) / .tWideGrid(minWidth, ...)
Override width, height, weight or spacing.wideLayout(minWidth, ...)
Override color or font settings.wideStyle(minWidth, ...)
Custom Python decisionscontainer.size().value or Textual container.tsize().value

For responsive column counts and span-aware automatic placement, see Grid. Try the online Adaptive Layout and Adaptive Grid cookbook pages.

Declare base settings first, then overrides with breakpoints from small to large. Width thresholds include the boundary; when several match, later declarations win, not necessarily the largest threshold. Resizing below a threshold rebuilds the declaration with its base settings. Initially the size is None, so wide overrides do not apply and visibility keeps its baseline. Each wide* / tWide* method checks its own threshold sequence per node. Descending thresholds log a warning even when the rule does not match; equal thresholds are allowed. Warnings include the declaration location and are deduplicated using a bounded 128-entry cache. Rules are never automatically sorted.

# Inside content():
# Declare each method’s breakpoints from small to large.
with VBox().layout(weight=1):
    Label("Title").style(fontSize=14).wideStyle(
        600, fontSize=18,
    ).wideStyle(1000, fontSize=24).tStyle(
        fontWeight=FontWeight.Normal,
    ).tWideStyle(80, fontWeight=FontWeight.Bold)
    Button("Save").layout(width=120).wideLayout(
        600, width=160, padding=8,
    ).tLayout(width=12).tWideLayout(80, width=18)
    with HVBox(minWidth=600, tMinWidth=60):
        Label("Name")
        Label("Details").visible(False).wideVisible(600, True).tWideVisible(60, True)

Graphical thresholds and dimensions use logical pixels; Textual uses character cells, .tVisible(), .tWideLayout() and .tWideStyle(). HVBox/VHBox use tMinWidth/tMinHeight for their terminal thresholds. Graphical wide overrides do not apply to Textual, and terminal overrides do not apply to graphical backends.

Size observation starts lazily when needed. Backend layout reports a measurement, State notifies subscribers when it changes, and the view reruns content(). Rules are evaluated there in Python; there is no stored breakpoint table. Existing size State is retained across matched nodes, and identical measurements do not notify. For Web, see the browser/worker update path.

These modifiers do not change node keys. Native widgets can be retained, subject to backend behavior (including the Flet direction-switch limitation documented under HVBox). Overrides support the same properties and native limitations as ordinary layout/style; Textual does not control terminal font size. Extremely small containers may still overflow. See the Adaptive Layout cookbook for a runnable example and Common Modifiers for API signatures.

Nested expansion demand follows the shared layout policy: visible child priority ≥ 2 promotes its container to 2 on that axis, recursively, without inheriting the full priority or numeric weight. Fixed-axis dimensions and main-axis weight=0 block promotion.

Common Modifiers

Public APIs include inline type annotations and preserve concrete node types through method chaining. See type annotation coverage and development checks; dynamic State/native options remain permissive.

container.size() returns a binding to the container's measured content size: None before layout, then (width, height). It lazily creates internal ._state and starts observing native layout changes. Matched nodes inherit the same State during update(prev); removed containers release their observer. Reading .size().value in content() subscribes the view to changed dimensions. Graphical backends measure logical pixels. Textual uses .tsize() for character cells; .size() on Textual and .tsize() on graphical backends return a binding with value None without starting a measurement. It excludes native borders and content padding; layout margins lie outside it. Window(size=...) still specifies a preferred initial size, stored as preferred_size, independently of the measured .size() binding. Web and Pyodide ignore this desktop initial size and use the available browser viewport or mount-container space.

.visible(value) sets shared boolean visibility. .wideVisible(minWidth, value) overrides it when the nearest physical parent's content width is at least minWidth logical pixels, skipping virtual PUIViews. Textual ignores this pixel rule. .tVisible(value) overrides the shared boolean on Textual; .tVisible(None) restores inheritance. .tWideVisible(minWidth, value) applies the width rule in terminal cells. Other backends ignore both terminal modifiers.

Declare the baseline first, then width overrides. Matching rules apply in declaration order, like wideStyle/wideLayout. Unknown parent size leaves the baseline unchanged. Size observation uses ordinary State subscriptions and remains active while children are frozen. Conditions use the whole parent content area, not remaining sibling space; prefer an externally sized parent. The old min/max width/height arguments on visible and tVisible have been removed.

Label("Details").visible(False).wideVisible(600, True).tWideVisible(60, True)
Label("Compact").wideVisible(600, False).tWideVisible(60, False)
Label("Desktop only").tVisible(False)
Label("Terminal only").visible(False).tVisible(True)

.visible(value: bool) declaratively shows or hides an ordinary widget or layout. Hidden elements do not reserve layout space, including their padding/margin, and cannot receive normal user input. The node itself still updates. Effective hidden visibility, after any Textual override and matching width rules, also sets pui_frozen=True: existing children are retained without DOM synchronization, and children of an initially hidden node are not mounted. Independent PUIView updates inside a frozen subtree are deferred. Effective visible state clears the flag, and the subtree catches up to current State when synchronized. Timers/background work are not stopped. Parent content() still executes Python declarations; freezing skips child mounting/reconciliation, not arbitrary Python code in that parent. Read State in content(), for example Label("Hello").visible(self.state.show); the setters accept booleans, not bindings (.tVisible() also accepts None for inheritance). Omitting both modifiers restores the default visible state. Window, Tab, ComboBoxItem, TableNode and virtual PUIView visibility are outside this API's supported scope. An explicitly positioned Grid keeps the other children's declared coordinates. Flet retains Python controls but its client may reset cursor/scroll and other transient native state on reappearance. Textual uses display, not its space-preserving native visible property. See the Label cookbook for a State-driven toggle.

PUINode.pui_frozen: bool = False can also freeze child synchronization without hiding the node. Set it on the node returned by a declaration. Its own properties continue to update; removing the node still destroys the retained subtree. Unfreezing a directly modified retained node requires a normal view update. Each call to .visible(), or .tVisible() on Textual, sets this flag to the inverse of its effective visibility, including matching width overrides.

APIBackendUsage / notes
.layout()Graphical backends.layout(width=320, height=240, minWidth=160, minHeight=100, weight=1, padding=0, margin=0). Textual also inherits weight unless overridden with .tLayout().
.tLayout()Textual.tLayout(width=40, height=10, minWidth=20, minHeight=5, weight=1, padding=1, margin=0). Dimensions and spacing use character cells.
.style()Graphical backends.style(color=0xFF0000, bgColor=0x333333, fontSize=16, fontWeight=FontWeight.Bold, fontFamily="Arial")
.tStyle()Textual.tStyle(color=0xFFFFFF, bgColor=0x222222, fontWeight=FontWeight.Bold)
.qt()PySide6.qt(Style=None, HorizontalPolicy=None, VerticalPolicy=None, SizeConstraint=None, StyleSheet=None, **kwargs)
.flet()Flet.flet(k=v). Pass native Flet control options.

Textual ignores dimensional settings in .layout() and ignores .style(); graphical backends ignore .tLayout() and .tStyle(). Chain both to share a declaration across backends. Textual width/height and padding/margin use whole cells; padding/margin retain top/right/bottom/left ordering. Positive weights map to native fractional layout units. Textual inherits .layout(weight=...) unless .tLayout(weight=...) explicitly overrides it (including zero). Omitted terminal overrides restore native CSS defaults on the next declaration. .tStyle() accepts RGB integer colors and normal/bold font weight (numeric weights at least 600 use bold); font size and family are controlled by the terminal and are not parameters of .tStyle().

width/height are desired sizes that stop automatic expansion on the corresponding axis, not hard maximums. Positive weights and parent allocation still apply. minWidth/minHeight supply only minimum hints and never imply desired dimensions or stop expansion. Desired dimensions may also become minimum hints, using the larger value when both are supplied. See desired and minimum sizes for backend constraints and Canvas examples.

FontWeight

FontWeight is exported by PUI and every backend. Members follow CPPUI's CSS/OpenType numeric scale:

ValueMembers
100Thin / Hairline
200ExtraLight / UltraLight
300Light
400Normal / Regular
500Medium
600DemiBold / SemiBold
700Bold
800ExtraBold / UltraBold
900Black / Heavy
950ExtraBlack / UltraBlack
Label("Heading").style(fontWeight=FontWeight.Bold)
Label("Custom").style(fontWeight=550)
Label("Terminal").tStyle(fontWeight=FontWeight.SemiBold)

style/wideStyle and tStyle/tWideStyle accept FontWeight or integers from 1 to 1000. Strings such as "normal" and "bold" are not accepted; use FontWeight.Normal and FontWeight.Bold. PySide6 applies stylesheet weights clamped to 100–900; wx uses native numeric weights; Flet rounds to the nearest supported hundred (100–900); Tkinter and Textual use bold at 600 or above and normal below. Web/Pyodide emit numeric CSS font-weight. Actual appearance depends on available font faces. Omitting the override restores the backend's default styling.

Anchor and Side members use PascalCase, matching CPPUI: for example Anchor.LeftTop, Anchor.Center and Side.Top. Previous uppercase member names have been removed.

.wideLayout(minWidth, *, width=None, height=None, weight=None, padding=None, margin=None) and .wideStyle(minWidth, *, color=None, bgColor=None, fontSize=None, fontWeight=None, fontFamily=None) apply the corresponding settings when the nearest non-virtual parent's content width is at least minWidth. Textual uses .tWideLayout() with cell-based dimensions and .tWideStyle(minWidth, *, color=None, bgColor=None, fontWeight=None); the graphical variants are ignored by Textual and the terminal variants by graphical backends.

Declare base settings first, then conditional overrides in content(). Calls apply in declaration order, so later matching calls win, regardless of threshold order. An unknown parent size does not match. Reading the parent's size subscribes the view through State; resizing rebuilds declarations from their base values, restoring them below the threshold. Rules are evaluated in Python, not stored as a breakpoint table or compiled to CSS. These methods do not change node identity. None leaves a setting unchanged, as in layout/style. The same extreme-size overflow limits as ordinary layout apply.

Label("Title").style(fontSize=12).wideStyle(600, fontSize=18)
Label("Terminal title").tStyle(fontWeight=FontWeight.Normal).tWideStyle(80, fontWeight=FontWeight.Bold)

Widget sizing is automatic by default. PUI assigns each component a preferred size and axis-specific expansion behavior, then maps that policy to the selected backend's native layout system. A TextField expands horizontally but keeps its native height; a Spacer takes flexible space on its container's main axis. Exact dimensions depend on the toolkit, platform, font and theme.

Use .layout() for intentional overrides: width and height provide explicit dimensions or native size hints, weight controls participation in flexible space along the parent layout's main axis, and padding/margin add spacing. Most ordinary controls should not need explicit dimensions merely to fit their container.

Linear-container alignment

The common target is approximate cross-axis centering: HBox vertically centers compact children, and VBox left-aligns them. Expandable children may still fill their available space. This positions widget bounds, not text inside a Label or input; fonts, native dimensions and rounding differ.

BackendHBoxVBoxLimits / verification
PySide6Per-child vertical centerPer-child left alignmentLinear containers apply per-axis expansion priorities and sibling competition through native stretch/alignment. Static children keep their preferred size; weighted children and winning expandable siblings fill the main axis. Qt 6.7.2/6.12 geometry checked, including nested views, Canvas, Scroll and reversal. Native minimum sizes and explicit .qt policies still apply.
wxPer-child vertical centerPer-child left alignmentNative sizer flags preserve wx.EXPAND for growing children. Explicit dimensions remain minimum sizes; Linux GTK resize/redraw geometry checked.
FletNative Row CENTERNative Column START (left)Flet 1.0.4 Session configuration checked; client geometry unverified.
tkinterCompact children vertically centeredCompact children left-aligned; expandable children fill widthGrid sticky follows per-axis expansion priorities; native geometry is regression-tested.
WebPer-child vertical centerPer-child left alignmentSee Web layout and sizing.
PyodidePer-child vertical centerPer-child left alignmentUses the shared browser DOM layout; see Pyodide reference.
TextualChild group vertically centeredChild group left-alignedNative align centers the group's bounding rectangle. Different sizes or a full-size child can leave smaller siblings off-center; headless resize/redraw geometry checked.

See the backend progress documents for outstanding differences. Expandable children retain their backend-native stretching behavior.

Web-specific sizing priorities and browser layout rules are in Web layout and sizing.

PySide6's named .qt options are keyword-only. Omitting an option or passing None preserves its previously stored value on that declaration; pass StyleSheet={} to clear stylesheet overrides. Extra keywords remain accepted.

StateList.sort(*args, key=None, reverse=False, **kwargs) exposes the standard list sort options as keyword-only parameters. Defaults and any extra arguments are forwarded to the backing collection; ordinary lists reject extra arguments.

wx supports four-sided padding in top/right/bottom/left order for children of HBox/VBox/Grid. Desired width and height suppress automatic expansion and also set native minimum-size hints; minWidth/minHeight only set minimum hints. Weights are recomputed during reconciliation, including zero. Generic font size/family, numeric font weight, foreground and background apply to wx controls. Omitting a style on the next declaration restores its original native value.

State Transactions Across Threads

State objects, lists, dictionaries and bindings share one process-wide reentrant lock. Each supported API operation is synchronized; use with state: to protect a multi-step operation or read several fields consistently:

with state:
    state.count += 1
    state.rows.append(State({"count": state.count}))

Nested scopes batch all State changes on the current thread, including child containers, and release the lock at the outermost exit. Other threads wait even when accessing unrelated State objects. Keep scopes short: do not wait for I/O, threads or UI events while holding the lock.

Binding .change() callbacks run after unlocking, on the updating thread. Reentrant callback writes append notifications after those already queued on that thread. Pending redraws are coalesced per view and dispatched after the callback queue drains; mutations during redraw can schedule another notification round. Callbacks from different threads can run concurrently and have no global delivery-order guarantee. Callback code that accesses shared data must provide its own synchronization.

Callbacks receive the value recorded for each mutation; the current State may already contain a newer value. Structural collection callback values are shallow snapshots; referenced mutable children are not frozen. Exceptions release the lock and notify queued partial changes without rollback or suppressing the original exception. All queued observers are attempted if a callback raises an ordinary exception; the first observer error is raised after dispatch, or logged if the transaction body already raised. Bound setters and custom comparison/sort functions still run synchronously under the lock.

Iterators and dictionary keys(), values() and items() are shallow snapshots rather than live views. Hold with state: to read State children consistently, or use Unstate() to snapshot a State list/dictionary. Direct changes to a backing collection or ordinary mutable value bypass synchronization; use State(value, deep=True) or explicit State containers for nested data. State synchronization does not marshal native widget calls onto the UI thread or make an entire content() rebuild atomic.

Named State Subscriptions

For page selection and URL restoration using Views or Tabs, see View Router: State-driven navigation.

state(key).ref(name) returns the same State binding and declares a named subscription during content(). Names are nonempty strings; use a namespace such as example.filters.symbol. Attribute, list-item and dictionary-item State bindings support this method. These subscriptions are separate from node references: PUI.ref(name) still looks up nodes only.

Each backend exposes a replaceable state_ref_publish(name, value) function. Web's default collects values for batched URL synchronization. PySide6, wx, Tkinter and Flet forward values to an AI supervisor when running as a managed child; otherwise their default is a no-op. Textual and Pyodide default to a no-op. Calls outside content() do not publish. See URL Options and State refs for Web initialization, ordering, subscription lifetime and URL encoding.

from PUI import PySide6

def publish(name, value):
    print(name, value)

PySide6.state_ref_publish = publish

Assign the callback on the backend module, not to a locally imported function name. Every State .ref() declaration in that backend, including existing and nested views, looks up the current callback. It runs synchronously during content(), receives the original Python value, and may run again with an unchanged value on another rebuild. Reactive lists/dictionaries are passed as State containers; reading their entries in the callback records nested dependencies. Keep callbacks short and do not mutate State during view construction. To extend the default behavior, save default_publish = PySide6.state_ref_publish before replacing it and call default_publish(name, value) from the replacement.

Web: URL options

PUI.Options is a dictionary of host-provided string application input. On Web, supply options in the URL fragment using #name=value#other=value; percent-encode each name and value. Web ignores the PUI_OPTIONS environment variable.

https://example.com/#cookbook.page=State%20%26%20Binding

The session worker fills Options before application import/construction and first sync. Read, validate and apply these strings during application initialization; they do not automatically write State.

import PUI
from PUI.web import *

class Example(Application):
    def setup(self):
        self.state = State()
        self.state.page = PUI.Options.get('cookbook.page', 'Vbox')

Changing the fragment after connection does not navigate views or write State. Resuming an existing session does not rerun setup(). Node refs and State refs do not expose arbitrary State properties to browser writes. See URL Options and State refs for encoding, session lifecycle and State-to-URL publishing.

Non-Web: PUI_OPTIONS environment variable

Importing a non-Web backend (PySide6, wx, Tkinter, Flet, Textual or Pyodide) fills the same dictionary from the PUI_OPTIONS environment variable. Set it before importing the backend. Use a=b#c=d or #a=b#c=d; the leading # is optional. Names and values are percent-decoded, empty values are allowed, and the last occurrence of a name wins. All values remain strings, including numbers, CSV and JSON text. Invalid entries are skipped with a warning. A missing or empty environment variable gives an empty dictionary. Web ignores this variable and receives options from the browser instead. In Pyodide, the variable belongs to that Python interpreter's os.environ, not the host operating system.

PUI_OPTIONS='cookbook.page=Button' python -m PUI.cookbook PySide6
import PUI
from PUI.PySide6 import *  # Loads PUI_OPTIONS before application construction.

page = PUI.Options.get('cookbook.page', 'Vbox')

Named Node References

node.ref(name) declares a nonempty, fixed string name and returns the node. After a successful update, PUI.ref(name) returns node.__pui_binder__(), whose default implementation returns a binder with .node storing the node that created it. Every backend exports the same lookup function: PUI.PySide6.ref is PUI.ref, and likewise for wx, Tkinter, Flet, Textual, Web and Pyodide. The registry is process-local, is not reactive State, and must be accessed on the UI thread. Names must be unique among live nodes, including across applications in one process. Use an application/component namespace, such as cookbook.refs.tabs; the dot is a naming convention, not a separate registry scope. The registry is internal; use ref(name) for lookup. PUI.refs is the helper module, not the registry dictionary.

import PUI
from PUI.PySide6 import *

# In content():
with Window(title="Example").ref("example.main_window"):
    Button("Maximize").click(self.maximize_window)

# In an event handler:
def maximize_window(self, event):
    ref("example.main_window").maximize(True)

A ref participates in the VDOM key. Updating the same key overwrites the registry entry; changing or removing the ref rebuilds the widget. Use constant names rather than names that change during rendering, to preserve native state. Declare refs while building nodes; do not change the name on an already-published node, as its previous registry entry cannot then be cleared by normal destruction. Ref names are not guarded against in-place mutation. Destroying a node removes its entry only if it still owns it. Missing names raise the usual dictionary KeyError. Avoid duplicate names yourself: PUI does not check for collisions, and the most recent update overwrites the entry. Replacement does not clear the old entry before publishing the new one; the later destruction of the old node cannot remove the new entry.

Custom nodes may override __pui_binder__(self) to return a specialized interface. Publication occurs after successful update, not during declaration. Every binder stores its original node in .node. Each successful update registers a new binder; queued commands use binder.node.get_node() to follow reconciliation and skip destroyed nodes. Custom binders should inherit PUI.refs.NodeBinder to preserve this contract. Window updates create a fresh binder in the registry. Retrieve it with ref(name) for each action instead of keeping it across renders; nodes do not store binders. Calls after destruction or replacement raise RuntimeError.

The VDOM key is ancestor|node[:grid:row,column,rowspan,columnspan][#tag=internal_tag][#id=id][#ref=ref], with optional suffixes only when set. The path is relative to the current PUIView. #tag= is the internal tag, while public .tag() remains a debug annotation and does not affect identity.

PUI.ref(name) returns the current binder from the shared registry, without caching. Every backend exports this same function. Missing names raise KeyError. The lookup helper does not add synchronization or make ordinary node methods thread-safe.

Selection binder commands

Tabs and Views expose select(key) on their binder, not on the node. Declare a fixed namespaced .ref(name) and call ref(name).select(key). Commands are queued on the UI thread/event loop, including calls originating there. External State bindings and native tab clicks retain their existing behavior. Obtain binders on the UI thread before passing them to workers. The .node property does not make direct node access thread-safe.

Window binder commands

Desktop Window commands are asynchronous, including when called on the UI thread: returning means the request was queued, not that the window has changed. Obtain the binder with ref(name) on the UI thread and pass it to background workers. This does not make ordinary node methods or registry mutation thread-safe. PySide6 uses a queued signal, wx uses CallAfter, Flet uses its page event loop's call_soon_threadsafe, and Tk uses a Python queue drained on its UI thread every 10 ms (at most 256 callbacks per batch). Tk workers never call after() or other Tk APIs. An active event loop is required; there is no synchronous completion result. A request queued before same-key reconciliation operates on the updated node. Queued requests are discarded after destruction/replacement; submitting through an already-invalid binder raises RuntimeError. Tk cancels its queue timer and clears pending work when the application closes.

fullscreen(enabled: bool), maximize(enabled: bool) and minimize(enabled: bool) request live native state changes without writing State. This is separate from Window's constructor arguments and declarative .maximize()/.fullscreen() setters, which remain initial-only.

Each backend Window overrides __pui_binder__() to return its own binder, whose methods schedule the native backend operations on the UI thread/event loop. The shared helper validates arguments, tracks the current live node and skips queued work whose node has been destroyed. Each update publishes a new binder; no binder cache or separate reference-target object is kept on the node. PySide6, wx, desktop Flet and Tkinter implement these commands. Minimization preserves the restore state: unminimizing should restore the maximized/fullscreen window rather than force normal size. Changing maximize/fullscreen does not intentionally unminimize a window. Native window-manager behavior and available state combinations vary; Tkinter/wx use their platform APIs. Textual and browser windows expose the same methods but log warnings for unsupported commands. See the Web reference for browser limitations. The cookbook's Refs example demonstrates node selection and desktop window commands.

Application

Application.start(*, codex=None, claude=None, ...) enables optional AI development on PySide6, wx, Flet, Tkinter and Web. Use {} for existing SDK/CLI credentials, or {"key": "API_KEY"}; optional model and effort entries select provider settings. Textual and Pyodide log a warning. python -m PUI creates and starts an empty application interactively. See AI development.

All backends accept Application.start(*, landlock=None). On Linux, an explicit filesystem allowlist restricts startup and later operations; other platforms log a warning. See Landlock configuration and limitations. Web applies it only in session workers, before importing the application.

Every backend accepts keyword-only Application(title=''). The cookbook sets title="PUI Cookbook". PySide6/wx/Flet retain Application(icon=None, *, title=''); Textual uses Application(*, title=''), and Web/Tkinter retain positional forwarding with Application(*args, title=''). This is startup configuration, not a State binding.

app = MyApplication(title="My app")
app.run()

Web and desktop Window(title=None) inherits a nonempty Application title. An explicit Window title, including an empty string, wins. Omitting Application.title preserves existing backend defaults.

Textual uses the existing Rich dependency's public Console.set_window_title on the launch output stream, strips terminal control characters and skips headless mode. Visibility depends on terminal support/settings; the prior title is not restored on exit.

Tkinter configures its theme before creating controls with .tkinter(theme=None). When no theme is specified, Tk keeps its default theme. Select an available native theme with, for example, MyApplication().tkinter(theme="clam"). The theme is initialized once per root; redraws do not reset it.

Applications can load the external Sun Valley package themselves. Import sv_ttk and call sv_ttk.set_theme("dark", root=self.ui) in your Application subclass's setup(), after the Tk root exists and before content() creates controls. Use "light" for its light variant. Sun Valley mainly styles ttk controls; regular tk controls only benefit from its color scheme. See the runnable Sun Valley dark example:

python -m pip install sv-ttk
python -m PUI.examples.tkinter_sun_valley

The Tkinter cookbook uses the same setup() hook to try Sun Valley light, falling back to classic when the package is absent. This is cookbook configuration; the backend keeps the native default for other applications.

Closing the last Tkinter Window, removing it without a replacement, or calling Application.quit() ends the main loop and destroys the hidden Tk root, dialogs, timers and pending redraws. With multiple windows, closing one hides it while the others remain open; redraws retain its controls without reopening it. Replacing a window during reconciliation does not quit the application.

wx reactive views use the native GUI queue; import PUI and PUIView from PUI.wx. Redraw bursts are coalesced and removed views ignore queued work. Flet uses its session loop. Tkinter queues redraws and quit outside geometry idle flushes to prevent reconciliation reentry. Application.quit() is also queued on wx. TimelineView timers follow reconciled nodes and stop after removal.

Removing or replacing a virtual subview destroys its native roots, including controls inside wx sizer layouts. Keyed moves preserve those controls.

Top level element of an application

Application().qt(Style="fusion")

Window

On PySide6, wx, Flet, and tkinter, .maximize(enabled=True) and .fullscreen(enabled=True) are declarative setters equivalent to the maximize= and fullscreen= constructor arguments. They return the node for chaining; the last value declared before native creation wins. Both flags accept initial constants only: True enables the initial state, False clears it, and None leaves it unspecified.

These setters do not control a live window. Later declarations and calls on a mounted node preserve the native/user window state. State binding and imperative window-state methods are not provided. wx supports application icons, keypress events, and close/quit accelerators.

flet and textual backends only support single window

Window([title=str][,size=(w,h)][,maximize=bool][,fullscreen=bool])

# Equivalent initial declarations:
Window(maximize=True, fullscreen=False)
Window().maximize().fullscreen(False)

Across backends, the bound status is the source of truth for a Modal. Applications should use ordinary PUI components and State bindings without connecting backend-specific dismissal signals.

ActionExpected behavior
OpenSetting the bound status to a truthy value opens the modal and runs .open(callback) once for the closed-to-open transition.
User dismissalEscape, a native close button, or another supported native dismissal action closes the modal and writes offValue (default None) to the binding. Choose a falsy offValue. The dialog must not disappear while leaving its status truthy.
State-driven closeSetting the status to a falsy value closes the modal without requiring a native close event.
Close callback.close(callback) runs once per open-to-closed transition. Multiple native notifications for the same dismissal must not invoke it twice.
ReopenAfter dismissal, setting the status truthy again reopens the modal. Dismissal must still work after declarative updates reuse its native widget, and must update the current node's binding and callback.

PySide6 handles both closeEvent() and QDialog.rejected internally, including Escape. Flet routes Escape through its Page keyboard event to the topmost open PUI Modal and synchronizes its binding. An active imperative dialog or file picker prevents Escape from dismissing a declarative Modal underneath.

PySide6 Modal follows the same initial-only contract, including the declarative .maximize() and .fullscreen() setters.

wx, Tkinter, Flet, Web/Pyodide and Textual implement state-driven, non-blocking Modal(status, offValue=None, title=None, size=None). A truthy binding opens the modal; supported dismiss actions write offValue. Escape dismisses the topmost modal. State changes open/close it without waiting for a result. .open(callback) and .close(callback) run once per transition, including removal of an open modal. Content survives close/reopen and is reconciled while open. A Modal is outside its parent's layout and may contain ordinary PUI components.

Closing/hiding an ancestor also hides nested modals without changing their bindings; reopening the ancestor restores those still requested. Tk uses Toplevel/transient/grab with delayed acquisition and grab/focus restoration. wx uses a retained, resizable Dialog shown with Show(), without a nested event loop. It disables other shown top-level windows, restores only those it disabled, and gives the innermost modal input. Wrap multiple content items in a layout container; Frame-only features such as MenuBar are not supported inside Modal. Flet uses a retained AlertDialog; close it through Escape, its State binding or a content button. Web/Pyodide use native dialog.showModal() with a title, close button, Escape handling and browser focus management. Textual retains an installed ModalScreen and mounts its contents on first opening; Escape closes the top screen. Use .tLayout(width=..., height=...) for Textual dimensions. Browser/Flet size is a desired content/viewport size; maximize/fullscreen are desktop-window options and are not applied by Flet, Web/Pyodide or Textual. These modals are separate from blocking Confirm/Prompt.

Modal window
Example

Modal(model[,offValue=None][,title=str][,size=(w,h)][,maximize=bool][,fullscreen=bool])

HBox / VBox

HBox(*, inverse=False)
VBox(*, inverse=False)

HBox arranges children left to right and distributes flexible space horizontally. VBox arranges children top to bottom and distributes flexible space vertically. Compact children align vertically in the middle of HBox and to the left of VBox; children that expand on the cross axis can fill it. Alignment applies to widget bounds, not the text inside them. Textual aligns the child group rather than each child individually. See backend alignment details and Web sizing rules.

The keyword-only inverse defaults to False; setting it to True reverses child order. It is declarative: pass a State value and changes update the existing container. Virtual views are transparent to the ordering; nested physical containers keep their own direction. Frozen subtrees apply pending fallback ordering on thaw.

PySide6 uses QBoxLayout direction and Web/Pyodide use CSS flex reversal; native reverse flow can start at the opposite edge, and browser DOM/tab order stays in declaration order. wx, Flet, Tkinter and Textual reorder retained children through DOM synchronization, keeping the container's normal starting edge. This API changes visual order, not text direction or a guaranteed keyboard traversal order.

wx and tkinter apply four-sided padding in top/right/bottom/left order. Weights reset during reconciliation. wx desired width/height also supply minimum-size hints; minimum-only hints do not suppress automatic expansion. Tk converts pixel dimensions to native character/line counts, rounding up using the current font/theme geometry. Generic wx styles and Tk native/theme styles restore when removed.

Examples: HBox cookbook and VBox cookbook.

HVBox / VHBox

HVBox(minWidth, *, inverseH=False, inverseV=False, tMinWidth=None)
VHBox(minHeight, *, inverseH=False, inverseV=False, tMinHeight=None)
ParameterDefaultBehavior
minWidth (HVBox)Required; positional or keywordHorizontal when the nearest physical parent's content width is at least this threshold; vertical otherwise. Logical pixels.
minHeight (VHBox)Required; positional or keywordVertical when that parent's content height is at least this threshold; horizontal otherwise. Logical pixels.
inverseHFalse; keyword-onlyReverse children while in horizontal mode, in either container.
inverseVFalse; keyword-onlyReverse children while in vertical mode, in either container.
tMinWidth (HVBox)None; keyword-onlyTextual width threshold in terminal columns. None keeps vertical mode; it does not inherit minWidth.
tMinHeight (VHBox)None; keyword-onlyTextual height threshold in terminal rows. None keeps horizontal mode; it does not inherit minHeight.

Unknown sizes use the second direction: vertical for HVBox, horizontal for VHBox. Virtual views are skipped when locating the parent. Graphical backends use minWidth/minHeight; Textual uses only tMinWidth/tMinHeight.

Both containers use inverseH while horizontal and inverseV while vertical, independently of which direction is primary. Both default to False. Their reversal behavior follows HBox/VBox.

The container and its children remain mounted when direction changes. These are parent-size breakpoints, not measurements of the space remaining after siblings. Supported by every backend. Flet retains PUI State and Python controls, but replaces its Row/Column container when direction changes; Flutter client-only state such as cursor/selection and scroll position may reset. Methods and sizing follow HBox/VBox.

with HVBox(minWidth=600, tMinWidth=60, inverseV=True):
    Label("Name")
    TextField(form("name"))
    Button("Save")

with VHBox(minHeight=400, tMinHeight=24, inverseH=True):
    Label("First")
    Label("Second")

Grid

Columns and rows follow the Grid priority sizing policy on every backend that implements Grid. Static tracks fit their content; only tracks at the highest nonzero priority on each axis expand. Spans contribute demand to every covered track, hidden cells contribute none, and child weight applies on both axes. Automatic columns use the same policy rather than forcing equal widths.

Grid follows container precedence: children fill their assigned cells and expanded spans. Explicit child dimensions provide minimum sizes rather than preventing cell filling.

Without .columns(), Grid keeps its explicit child .grid(row=..., column=..., rowspan=..., columnspan=...) coordinates. Automatic mode overrides child row/column, preserving spans and declaration order for reconciliation. It is available on PySide6, wx, Tkinter, Web, Pyodide and Textual; Flet Grid remains unsupported.

with Grid().columns(1, dense=None).wideColumns(400, 2).wideColumns(600, 3):
    Label("A").id("a").grid(columnspan=2)
    Label("B").id("b").grid(rowspan=2, columnspan=2)
    Label("C").id("c")  # dense=True in columns() would backfill the first-row hole.

Children can override their base .grid() placement responsively:

.wideGrid(minWidth, *, row=None, column=None, rowspan=None, columnspan=None)
.tWideGrid(minWidth, *, row=None, column=None, rowspan=None, columnspan=None)

TextField(form("text")).grid(row=0, column=0, columnspan=1).wideGrid(
    600, columnspan=2,
).tWideGrid(60, columnspan=2)

Declare .grid() first, then overrides. Compare the nearest physical parent's content width with an inclusive threshold; graphical backends use logical pixels and Textual uses only .tWideGrid() terminal columns. Matching calls apply in declaration order, later values win, and None leaves a field unchanged. Unknown widths use the base declaration; rebuilding below a threshold restores its base values. Responsive values never enter the vnode key, including when id/ref is chained afterwards, so moving/resizing a cell retains its control and editing state. With automatic .columns(), row/column remain ignored; effective spans feed the selected dense placement rule and column spans are clamped to the current column count. Support follows the backend's Grid implementation; Flet Grid remains unavailable.

.columns(n, *, dense=None, expandChildren=True) requires a positive integer. Textual inherits its base column count, dense mode and expandChildren setting unless .tColumns(n, *, dense=None, expandChildren=True) explicitly overrides them, regardless of the order of those two calls. Calling .tColumns(n) sets the terminal dense mode to None, even when columns() specifies another mode.

.wideColumns(minWidth, n, *, expandChildren=None) follows wideLayout: compare the nearest non-virtual parent's available width, use an inclusive threshold, and apply matching calls in declaration order (last matching call wins). Declare a base columns count before breakpoint overrides. Unknown initial geometry uses the base count. Textual ignores pixel-based wideColumns() breakpoints; use .tWideColumns(minWidth, n, *, expandChildren=None) with terminal-cell thresholds instead. A matching breakpoint preserves the current expandChildren setting when omitted (None), or overrides it with True/False; dense remains inherited unless tColumns() sets it explicitly. Terminal overrides take precedence over shared defaults. Graphical backends ignore the terminal declarations. Container size subscriptions use the existing responsive mechanism.

with Grid().columns(1, dense=True).wideColumns(600, 3).tWideColumns(60, 3):
    # Textual inherits one column and dense=True, then uses three columns at 60 cells.
    Label("A").grid(columnspan=2)
    Label("B").grid(columnspan=2)
    Label("C")

expandChildren=True fills available holes after every child's row/column has been assigned. In declaration order, each visible child first grows its columnspan to the right, then its rowspan downward. Expansion requires the whole new edge to be empty, stays within the existing row count and column count, and never moves a child's origin or overlaps another child. Holes that cannot be covered by rectangular expansion remain. Only computed spans change; declared spans and vnode keys remain intact, and disabling expansion restores ordinary placement. Manual Grid placement is unaffected.

Grid().columns(2, expandChildren=False).wideColumns(800, 4, expandChildren=True)
Grid().columns(2, expandChildren=False).tWideColumns(80, 4, expandChildren=True)

The keyword-only dense parameter has three states. None (the default) searches from the previous visible sibling's starting row and never places a later item above that row; it can backfill holes to the left within the same row. True searches from the top-left without that row bound. False keeps the placement cursor moving forward without backfilling earlier holes. True and None can make left-to-right visual order differ from declaration and Tab order. Hidden items retain their controls but occupy no automatic cell. Column spans larger than the current column count are clamped for placement only and restored when the Grid widens; row spans remain intact. This is a tile mosaic on shared row tracks, not independent masonry columns.

Computed cells do not enter vnode keys, so resizing/reflow preserves native controls and editor state. Use stable item IDs when reordering application data. Virtual PUIView children are flattened into the Grid and independent subview updates trigger reflow. Keep explicit desktop/terminal sizing units separate using layout/tLayout. Automatic columns follow the same Grid priority policy as explicit placement. Web/Pyodide tracks with equal expansion weights retain their individual content sizes and share only the extra space equally; they do not force equal intrinsic widths or heights. Native minimum-size hints and rounding can still differ by backend. A spanning cell contributes its size requirement across the tracks it covers.

The existing Grid cookbook shows manually positioned colored tiles. The separate Adaptive Grid cookbook uses muted colors and tall/narrow plus wide cards to leave gaps in four columns. Its 2/3/4-column breakpoints are 384/576/768 logical pixels or 38/58/77 terminal cells, measured against the parent content width. These are 3.2 times the original breakpoints, rounded to whole cells for Textual. With the original order and C shown, blue F lands on row 4 for dense=None (same-row backfill), row 3 for True (earlier-row backfill), or row 5 for False (forward only), counting rows from 1. Narrower demos reduce the column count, so those placements need not differ. The Expand children checkbox toggles the post-placement expansion pass. Tile labels describe their declared spans; expanded spans may be larger. It also demonstrates hidden-item compaction, keyed reordering and a retained editor whose column span changes from one to two at 576 px / 58 terminal columns using wideGrid/tWideGrid.

Spacer

PySide6 and wx spacers default to layout weight 1. A spacer shares main-axis space with siblings that also have an explicit weight; for example, a VBox with weight 1 and a sibling Spacer receive equal shares when minimum sizes permit. Use .layout(weight=...) to change that proportion.

Tkinter exposes its inherited Spacer(layout=None, side=None) parameters; both accept positional or keyword arguments. Its default layout weight is 1.

Spacer inside linear layout containers
Example

with HBox():
    Text("Left")
    Spacer()
    Text("Right")

Divider

wx uses native wx.StaticLine and Tkinter uses ttk.Separator. In HBox the divider is vertical and fills the available height; in VBox it is horizontal and fills the available width, excluding layout padding. The theme determines its default thickness. Direct children of HVBox/VHBox update their orientation and expansion when the container changes direction, retaining the native separator.

Textual uses native Rule with the same orientation switching and one-cell thickness. It keeps the theme's line color and default margins: one row above/below horizontal rules and two columns beside vertical rules. Use .tLayout(margin=0) for a compact separator or .tStyle(color=...) to override its color. A divider fills its parent's content width/height without making an otherwise compact HBox expand to the terminal height.

Divider inside linear layout containers
VBox example HBox example

with VBox():
    Text("Top")
    Divider()
    Text("Bottom")

Scroll

wx, PySide6 and Web expose .scrollX(pos) and .scrollY(pos): positive values are logical-pixel offsets from the start; negative values are offsets from the end. Scroll.END is negative zero. wx follows content growth at the end and preserves manual positions on redraw. Axis options are None (as needed), True (always show) or False (disabled). wx uses native one-pixel scroll units.

Tk Scroll owns its outer container, handles empty bounds, preserves offset requests and filters wheel events to its descendants instead of changing global wheel bindings; broader resize/end-following checks remain pending. Flet uses native async scroll_to and END auto_scroll; live scroll interaction checks remain pending.

Scrollable container
Example

with Scroll().layout(weight=1).scrollY(Scroll.END):
    with VBox():
        for i in range(100):
            Label(f"Row {i+1}")

Button

Single line clickable text with button appearance
Example

Button(text, *, disabled=False).click(callback, *cb_args, **cb_kwargs)

disabled is keyword-only and defaults to False on all backends. True disables native user interaction; later declarations can re-enable the retained control. For reactive state, use Button("Save", disabled=not state.can_save). Register handlers with .click(); Textual's unused constructor callback argument has been removed.

Buttons default to fitting their content plus theme padding and borders. PySide6 removes the standard text-button width floor, wx uses BU_EXACTFIT for native and styled buttons, Tkinter uses width=0, and Textual removes its 16-cell minimum width. Flet sends minimum_size=(0, 0) to the Flutter client while retaining theme padding and platform tap-target behavior. Web and Pyodide already use min-width: 0. Explicit layout dimensions, weights and container allocation still apply.

PySide6 retains native height and painting; icons or menus added through Qt interop retain native sizing. Explicit Flet .flet(style=...) overrides the generated button style, including its content-fit minimum size. Textual remeasures retained buttons when their labels change.

The Button cookbook includes a disabled button with a registered callback that cannot be activated by the user.

Callbacks

Label

Labels default to fitting their text plus any native padding. Textual's clickable labels use a Button with its minimum width removed and remeasure when the text changes. Explicit layout dimensions, weights and container allocation still apply.

wx selectable=True uses a read-only TextCtrl. Changing selectability remounts the native control. Flet preserves native text selection: ordinary labels use Container.on_click and selectable labels use Text.on_tap. Both refresh or remove callbacks on redraw and commit focused editors before calling them. Native event tests and live web-client clicks pass.

Single line clickable text
Example

Label(text).click(callback, *cb_args, **cb_kwargs)

Callbacks

Text

Multiple line text viewer
Example

Text(text)

Html

Html(text, selectable=False, *, openURL=True) opens hyperlinks by default. Set openURL=False to make link clicks do nothing. With openURL=True, PySide6 and wx (including HtmlWindow fallback) open links using the system browser, as OpenURL does. Web/Pyodide use native links with target="_blank" and rel="noopener noreferrer", preserving the browser's click/keyboard behavior without window.open() or a server round trip. HTTP(S) and mailto links are supported; use absolute URLs for portable desktop behavior. Browser-relative links resolve against the page URL. Changing openURL updates the retained widget. Unsupported HTML backends accept the option but still display plain text.

Html('<a href="https://example.com">Website</a>', openURL=True)

Web/Pyodide support Html(text, selectable=False, openURL=True) with native browser formatting inside a Shadow DOM. Fragment CSS is scoped; PUI font and colour styles inherit. Only changed HTML replaces the fragment. Input is trusted application markup, not sanitized HTML; escape untrusted values. Shadow DOM is style isolation, not a security sandbox. Links and image URLs resolve relative to the page. See the Web reference for details.

Flet's plain-text fallback accepts Html(text) or Html(text=...) and reports pui_supported=False. Flet MarkDown(text) accepts the same argument forms; native options belong in .flet(**kwargs).

wx prefers wx.html2.WebView (Edge WebView2 on Windows, WebKit on macOS/Linux) for HTML, CSS and JavaScript. The wxPython build and system must provide the selected engine. If importing, locating or creating the engine fails, PUI emits a RuntimeWarning with the reason and retains an HtmlWindow fallback: only an HTML subset is rendered; browser CSS, JavaScript and disabling text selection are unavailable. Ordinary page/resource load errors do not trigger this fallback.

WebView inherits PUI font/colour defaults through CSS; authored HTML styles can override them. Unchanged markup and styles do not reload the page. It uses viewport sizing with expansion priority 4; explicit width/height set the desired viewport, while HtmlWindow fallback retains its synchronous content-size hint. WebView does not measure HTML intrinsic size. Relative resources have an about:blank base; use absolute URLs. HTML is trusted application content, including scripts. wx rich Markdown remains pending a dependency/renderer decision.

HTML viewer (PySide6, Web/Pyodide and wx WebView with an HTML-subset fallback)
Example

Html(html, openURL=True)

Markdown

Markdown viewer (not supported by tkinter backend)
Example

MarkDown(md)

Combobox

The ComboBox cookbook includes non-editable/editable controls, disabled examples of both modes, and a destination autocomplete with separate State, explicit editable=True, and distinct labels/airport-code values. Non-editable controls still allow selecting an option and changing the bound value; editable controls also accept free text. Application code defines matching and declares the resulting ComboBoxItem children; see Web selection controls for browser suggestions and commit timing.

ComboBox(editable=False, *, index_model=None, text_model=None, edit_model=None, disabled=False, autoexpand=True) accepts a keyword-only disabled flag on PySide6, wx, Flet, tkinter, Web and Pyodide. Disabling prevents user selection/typing while preserving values and allowing programmatic model updates. Re-enabling restores the editable mode; omitting disabled on a later declaration restores False. Textual supports Select for non-editable choices and Input with candidate overlay for editing. Flet's explicit .flet(disabled=...) option overrides the disabled constructor argument for Button and ComboBox; removing it restores the current declaration's disabled value.

autoexpand=True automatically opens candidates while typing; it does not filter them or insert a completion. PUI displays the application-declared ComboBoxItem children in order. Bind edit_model=state("query") to receive typed text directly in application State used to build the children. All three model arguments are keyword-only. edit_model observes user typing (before the optional .input(callback)); it does not drive the displayed value or receive item values on selection. Existing index_model/text_model update timing is unchanged. Selection still maps to the current child index/value. The Cookbook splits its query with re.split(r"\s+", ...) and uses any(keyword in label for keyword in keywords); this is example application logic, not a backend rule.

autoexpand=False suppresses automatic expansion while preserving free text and manual choices. Escape closes candidates without changing the draft. Web/Pyodide edit_model writes and input callbacks run during typing (after IME composition), without committing text_model until selection or Enter/blur. Other backends retain their existing typed model updates. Flet uses a retained TextField with candidates below it for editable controls; non-editable controls retain native Dropdown. The inline list is an undesired but accepted compromise: opening it increases layout height and pushes following content instead of overlaying it. Compact HBox siblings can move as the row grows, and constrained parents may need scrolling. Flet 1.0.4 lacks a public generic anchored-overlay/position-query API; the intended anchored popup remains future work. The arrow opens declared candidates even with autoexpand=False; Up/Down, Enter, Escape and focus changes control selection/dismissal. Shared styles apply to the TextField; Dropdown-only .flet(...) parameters do not apply to editable controls. See Flet progress for unverified IME/accessibility/platform cases and outside-click/long-list limitations.

Autocomplete acceptance criteria

These are acceptance requirements, not a statement that every backend/platform has passed. Validate the actual PUI control with State binding and VDOM updates, not only an isolated native widget. Each implementation still needs the platform-specific validation below.

Use distinct labels and values: Taipei Taoyuan/TPE, Taipei Songshan/TSA, Tokyo Haneda/HND, Tokyo Narita/NRT and Amsterdam/AMS. Also test empty options, duplicate labels with different values, long labels and enough results to scroll.

AreaRequired checks
Application matchingDisplay exactly the declared candidates, including labels that do not contain the query. No backend applies a second filter. In the Cookbook, application-defined whitespace/OR matching makes tai tok return four Taipei/Tokyo items. Also test another application rule, no candidates, candidate reordering, and additions/removals while typing. Preserve declared order and item values.
Typing and focusType characters individually after the popup opens. The input keeps receiving every character, with no automatic first-result insertion. Click back into the editor, move the caret, select text, insert in the middle, Backspace/Delete, and copy/paste. Candidates update without losing the draft, caret or selection. Repeat while State changes trigger VDOM synchronization.
KeyboardUp/Down changes the highlighted candidate without accepting it. Enter accepts the highlighted candidate once. With no highlighted candidate, retain the backend's documented free-text submission behavior. Escape dismisses suggestions without changing text or stealing focus. Tab closes suggestions and moves to the next control; no focus trap.
Mouse and dismissalClick a candidate, then continue typing without another focus click. The selected label is displayed and its value is committed once; blur must not submit the preceding draft as an extra selection action. Click outside or switch windows: dismiss the popup without leaving an orphan window or preventing the intended control from receiving focus.
BindingMap selection to the current application-declared child's value and index. An application that filters a larger dataset should use item values as stable identifiers. Free text retains the existing backend commit timing and clears index_model to -1. Typing text equal to an item value must not unexpectedly replace it with its label. Test bound/unbound models, external model changes and the backend's documented duplicate-label rule.
Synchronization and lifecycleUnrelated redraws preserve the editor, draft and highlighted candidate. Updating/removing/reordering options must not accept an obsolete candidate. Disable, hide, remove or destroy the control while suggestions are open: close the popup and release handlers/timers. Re-enable and recreate without duplicate callbacks. Web/Pyodide must also tolerate stale replies, rebinding and disconnects without overwriting newer input or submitting stale actions.
Modesautoexpand=False suppresses typing suggestions while preserving free typing and access to all choices. Disabled prevents input and selection; programmatic updates still work. Non-editable controls retain ordinary selection behavior. Test switching these settings on retained controls.
IMEUse a real Chinese input method, not just synthetic composition events. Candidate navigation/Enter must not consume IME composition keys or commit partial composition. Test confirming and cancelling composition, then resuming ordinary typing and selecting an autocomplete result.
Geometry and appearanceTest scrolling containers, window move/resize, viewport edges, DPI/font scaling and available themes. Reposition or dismiss suggestions rather than leaving them detached from the editor; keep long/large result lists scrollable and avoid stealing focus while scrolling candidates.
Browser accessibilityExpose combobox/listbox/option roles, expanded state and the active candidate; keep DOM focus on the input. Verify keyboard-only use and screen-reader announcement separately. Test desktop and mobile browsers, including the virtual keyboard and touch selection.

Record backend/toolkit version, OS/display system, browser (where applicable), input method and test method with each result. Mark cases as passed, failed or unverified. Linux tests do not establish macOS/Windows support; simulated browser composition does not establish real IME support; shared Web/Pyodide source does not replace a Pyodide runtime test. Keep unresolved coverage in the backend progress documents rather than treating API availability as a pass.

PySide6/wx/flet/tkinter share label/value resolution. text_model stores item values for a selection while the widget displays its label. Free typing clears index_model to -1 and survives redraws with both models bound. Unbound selections preserve keyed identity. Tk uses native ttk.Combobox and idle notification to avoid double-reporting selection text.

Textual editable ComboBox publishes typed text to its model and emits input callbacks while typing; Enter accepts a highlighted option or submits free text, and emits change. Candidate clicks emit change with the original option index. Down opens all choices when autoexpand is disabled. Suggestions keep focus in Input; Escape closes them and Tab continues focus traversal. Terminal IME and accessibility validation remain tracked in the Textual progress document.

wx safely handles empty/invalid selections and refreshes item labels. Changing native editability remounts the control. Flet uses native Dropdown for non-editable controls and TextField with an inline candidate list for editable controls; Tkinter uses ttk.Combobox.

Editable Dropdown List
Example

with ComboBox(editable=True, index_model=state("index"), text_model=state("text")):
    ComboBoxItem("Item 1")
    ComboBoxItem("Item 2")
    ComboBoxItem("Item 3")

with ComboBox(editable=False, text_model=state("text")):
    ComboBoxItem("Item 1", "Value 1")
    ComboBoxItem("Item 2", "Value 2")
    ComboBoxItem("Item 3", "Value 3")

TextField

Supported controls commit focused edits before click callbacks. Flet retains native focus where its public API has no blur operation; it does not invoke undocumented client methods.

wx, flet, and tkinter silently apply programmatic updates, preserve active edit buffers, and commit once on Enter/submit or blur. input/change events carry .value. Native wx/Tk and Flet session tests pass; Flet editor commits also pass a live web-client smoke check.

Single line text editor
Example

TextField(model, edit_model=None, *, placeholder=None)

placeholder supplies native hint text while the field is empty on PySide6, wx, Flet, and Textual. It updates on redraw without changing the model or active edit buffer. None, an empty string, or omission on the next declaration clears it. Visibility while focused follows the native backend. Tkinter accepts the argument without displaying it because the current Tk 8.6 Entry has no native placeholder; emulation is deferred.

TextField(state("name"), placeholder="Enter a name")

Flet retains its existing third positional label and native keyword forwarding. An explicit hint_text keyword or .flet(hint_text=...) overrides placeholder; removing it restores the current placeholder. Textual suppresses programmatic Input.Changed messages with the native API so the first keystroke in an initially empty field reaches .input() normally.

Callbacks

TextEdit

Multiline plain text editor on PySide6, wx, Flet, tkinter, Textual, Web and Pyodide.

TextEdit(model, edit_model=None, *, placeholder=None, readonly=False, wrap=True)
TextEdit(state("text"), state("draft"), placeholder="Enter multiple lines")
TextEdit(state("live"), state("live"))  # immediate synchronization
TextEdit(state("result"), readonly=True)

Uses TextField's model/edit_model split: typing updates the optional draft binding and emits .input(event); focus loss commits model and emits .change(event), with text in event.value. Enter always inserts a newline. Tab moves focus. Programmatic model updates do not emit input/change callbacks. An active draft survives unrelated redraws and model updates until it is committed. Unchanged editor contents retain native selection, scroll position and undo history on redraw.

readonly prevents user editing while allowing selection/copy and programmatic updates. Switching an actively edited control to readonly discards its uncommitted draft and displays model, without emitting change. wrap controls visual soft wrapping only; it never inserts newline characters. TextEdit expands in both axes; use .layout() for sizing and .style() for fonts and colors.

Backend limits: Flet's native multiline TextField has no soft-wrap switch, so wrap=False raises NotImplementedError rather than silently wrapping. wx multiline hints and wrap behavior depend on the native platform; wx documents hints for multiline controls only on MSW and GTK+ 2. Tk uses a non-editable overlay for its placeholder. Textual uses TextArea's placeholder when the installed Textual version provides it. These limits do not alter model or event semantics.

Example: draft/commit bindings and readonly

MaskedField

Single-line password editor available on PySide6, wx, Flet, tkinter, Textual and Web.

MaskedField(model, edit_model=None, *, placeholder=None)
MaskedField(state("password"), placeholder="Password")

Inherits TextField's binding/commit behavior, input/change callbacks, style and layout. Optional edit_model receives live drafts; model and event.value contain the original text. Masking affects display only. Native password controls supply mask glyphs and clipboard behavior. See Web text inputs for browser editor handling.

Flet retains the third positional label and native keyword forwarding. Tkinter accepts placeholder without showing a hint, as with TextField.

Example: plain and buffered bindings, lengths and clearing without displaying or logging passwords

ProgressBar

wx and PySide6 convert fractional progress/ranges to native integer hundredths.

Linear progress indicator
Example

ProgressBar(progress, maximum=1)

Flet clamps progress to its range; maximum=0 uses the native indeterminate indicator.

Checkbox

Example

Checkbox(text, model, value=None, *, disabled=False)

disabled defaults to False on every backend. True disables user interaction while preserving model updates. The cookbook includes a disabled checkbox mirroring an editable checkbox's model and an unchecked disabled checkbox.

Callbacks

RadioButton

Example

RadioButton(text, value, model, *, disabled=False)

disabled defaults to False on every backend. True prevents user selection; assigning the model can still select the disabled option. The cookbook demonstrates this with a separate button. For Checkbox and RadioButton, later declarations can re-enable the retained control; Flet's explicit .flet(disabled=...) overrides the disabled constructor argument as it does for Button and ComboBox.

Canvas

wx reuses pens, brushes and fonts within a paint callback and skips redundant DC pen/brush setters. Its push()/pop() saves and restores only the native pen, brush and PUI style tracking, with nested scopes supported. Clipping, transforms, fonts and other raw DC state remain the application’s responsibility. Wrap native pen/brush changes in balanced push/pop scopes and pop before resuming PUI helpers; direct DC access is not intercepted. When mixing helpers with raw canvas.dc drawing, set the raw operation's pen/brush explicitly. Text, paths and images retain their local GraphicsContext; native style caches are cleared after each paint, including errors.

PySide6 captures the native pen/brush defaults at the beginning of each paint and reuses matching styles within that frame. Consecutive helpers with identical cached styles skip redundant pen/brush setters. Geometry helpers set their pen/brush directly; they do not save and restore the entire painter for each primitive. Text without a color uses the frame's default pen, and text transforms/fonts and image opacity remain scoped to each call. PySide6 Canvas provides push()/pop() to save and restore native painter state together with PUI style tracking, with nested scopes supported. Wrap raw canvas.qpainter operations in a push/pop scope and pop before returning to PUI drawing. Set raw pen/brush explicitly; helper styles may remain active. Native access is not intercepted: applications are responsible for state consistency when mixing raw operations and helpers within a scope. These methods must be balanced within the paint callback; pop without push raises RuntimeError. All backends expose push()/pop(). On Flet, Tkinter, Web/Pyodide and the unsupported Textual Canvas placeholder, these are compatibility no-ops: they do not save or restore native state. PUI draw calls specify their own styles, so shared painters can use balanced scopes without backend checks. PySide6 and wx provide state stacks and validate scope balance; wx saves only pen/brush and style tracking as described above. Frame caches are discarded when painting finishes, including when the callback raises.


# Inside a PySide6 Canvas painter callback:
canvas.push()
try:
    painter = canvas.qpainter
    painter.setPen(QColor("red"))
    painter.drawLine(0, 0, 100, 100)
finally:
    canvas.pop()
# Resume PUI drawing after native state and style tracking are restored.
canvas.drawLine(0, 100, 100, 0, color=0x0000FF)

See Web Canvas for browser rendering, command diffs, pointer/wheel events, text semantics, command-error handling, limits and performance measurements. Web and Pyodide log individual drawing exceptions in the browser console and skip that command.

The HeavyCanvas cookbook uses a static PUIView class and standard-library seeded random data. Its English chart shows OHLC candles, 20-session Bollinger bands (two population standard deviations), volume, smoothed stochastic K/D (9-session range, smoothing 3) and Wilder RSI 14. It defaults to 1,000 sessions, with 1,000/3,000/5,000/7,500/10,000/15,000/20,000/25,000/30,000 presets on all supported backends. PySide6, wx, Flet and Tkinter also offer 120,000/250,000/350,000/500,000/1,000,000 sessions, producing roughly nine Painter commands per session (up to nine million per frame). Web/Pyodide keep the 30,000-session maximum and existing resource limits. Datasets of at most 500,000 sessions retain weekday dates ending on 2026-10-13; larger datasets use zero-based indices in the range label, X axes and tooltip. These labels follow the original dataset when zooming. Regenerate advances the seed. Vertical wheel input zooms around the pointer: up keeps 80% of visible sessions, down expands by 1.25x, rounded and bounded to 20 sessions through the full dataset. Only direction is used, with no pixel-to-angle conversion. Price and volume axes autoscale to visible rows. Reset zoom, changing the preset or Regenerate restores the full range. Visible session counts, zoom and draw-call estimates are displayed. Generated data and indicators are cached through zoom/hover; visible row ranges and axis limits are reused between zoom changes. The painter redraws all panels, a crosshair and a tooltip, producing large command replacements on zoom and small tooltip deltas on hover. The scrollable chart requests a 720-logical-pixel height and uses its measured drawing width. Tkinter uses its allocated drawing size but lacks native Canvas hover/wheel events, so its controls update a static chart without crosshair/tooltips or wheel zoom.

Flet canvases with .layout(weight=1) fill their available native drawing space. Paint callbacks wait until both dimensions are positive and finite, so the first callback can fit a drawing. Identical resize notifications do not repaint; model redraws still invoke the current painter.

Flet 1.x uses native Text/Path/Rect/Oval, including cubic paths and rotated text. Matching geometry styles share a Paint value within a frame; PUI does not mutate these values, and each shape remains an independent Flet control. Shapes and their serialized styles are still rebuilt on repaint. Treat the shared Paint values as read-only when inspecting native shapes. Canvas image crop/resources and text height clipping remain pending. Native pointer/gesture behavior is described under Callbacks.

wx includes ImageResource.load/crop/scale, loadImage/drawImage source rectangles and opacity, cubic paths, enter/leave, and wheel modifiers. Source image opacity is preserved; native wheel pixel deltas are zero when unavailable. Queued paints after removal are ignored. Tk Canvas source adds native polygon/rectangle/ellipse and text size/color/rotation/anchors; cubic paths, images, and text height clipping are incomplete.

Tkinter clears all native Canvas items before each paint, including items created directly through canvas.ui. Within the callback, it reuses color strings and reads the default font and text color on their first use. These lookups are discarded after the callback, including on errors; the next paint picks up font and theme changes. Native items are rebuilt every frame.

Example

def painter(canvas):
    # Physical pixels per logical canvas pixel; 1.0 when unavailable.
    pixel_density = canvas.pixel_density
    canvas.drawText(x, y, text, w=None, h=None, size=12, color=None, rotate=0, anchor=Anchor.LeftTop)
    canvas.drawLine(x1, y1, x2, y2, color=0xFF0000, width=1)
    canvas.drawPolyline([x1, y2, ..., xn, yn], color=0xFF0000, width=1)
    canvas.drawCubicBezier(start, control1, control2, end, color=0xFF0000, width=1)
    canvas.drawRect(x1, y1, x2, y2, fill=0xFF0000, stroke=0x00FF00, width=1)
    canvas.drawShapely(shape, fill=0xFF0000, stroke=0x00FF00, width=1)

Canvas(painter)

Canvas drawing coordinates, canvas.width and canvas.height use logical pixels. canvas.pixel_density reports the number of physical display pixels per logical pixel and is refreshed before painting where supported. Use it to select the resolution of raster content for HiDPI displays. Its fallback value is 1.0.

drawCubicBezier() is available on PySide6, wx, Flet and Web. start, control1, control2, and end are (x, y) coordinate pairs defining a cubic Bézier curve.

wx supports ImageResource.load(path), .crop(x, y, width, height), .scale(width, height, keepAspectRatio=True, quality=0), canvas.loadImage(path) and:

canvas.drawImage(resource, x=0, y=0, width=None, height=None, src_x=0,
                 src_y=0, src_width=None, src_height=None, opacity=1.0)

Image operations return new resources; opacity does not mutate the source. wx wheel events provide angular deltas and modifiers; pixel deltas are zero.

Callbacks

Flet dispatches all seven Canvas callbacks through native GestureDetector events. Coordinates are local logical pixels. Left/right press, drag and release continue outside the Canvas; middle-button taps use the tertiary tap recognizer. Hover has button=MouseButton.NONE, while recognized drags report their button. Registration and callbacks update on redraw; removed canvases ignore queued events, and drawing-only canvases omit the gesture wrapper.

Flet follows native gesture timing and thresholds. Double-click is primary-button only and fires after recognition at the second press position. Cancelled or consumed presses report mouseup with cancelled=True at the last reported position; normal releases have cancelled=False. Clear drag state on cancellation without treating it as a completed edit. Middle-button dragging, extra buttons and simultaneous-button tracking are unavailable.

Wheel x_delta/y_delta contain pixel deltas, positive left/up to match PySide6. Angular h_delta/v_delta stay 0; pixels are never converted to angles. modifiers=None means Flet did not report modifiers. Consumers using only v_delta need an application-level pixel-delta path.

Web, Pyodide, PySide6 and wx accept Canvas(painter, *args, show_fps=False). With show_fps=True, a small overlay counts completed drawing passes and updates about twice per second. Browser counting happens after Canvas command replay; Qt counts after QPainter.end(), and wx after releasing its paint DC. This measures completed drawing calls, not the OS compositor or physical screen presentation. Idle canvases keep the last reading. The HeavyCanvas cookbook's Run continuously (FPS) checkbox makes its painter return True to request successive frames through the normal redraw/ACK scheduling, without synthetic mouse events. Enable Full-frame changes to cycle a subtle color tint on every command, forcing complete frame updates without regenerating source data. Compression can still reuse history. Other backends do not expose these profiling options.

Canvas Functions

Calls run inside the painter callback. Coordinates use logical pixels, with the origin at the top left and Y increasing downwards. None means no fill/stroke; text without an explicit color uses its backend's theme/default text color.

Colors

Integer colors are 0xTTRRGGBB: transparency 00 is opaque, FF is transparent. Existing 0xRRGGBB values remain opaque, including black 0. Color(rgb, alpha=1) returns this integer; alpha is opacity from 0 to 1. Invalid RGB integers or nonfinite/out-of-range alpha raise ValueError.

Color(0x336699)             # 0x00336699
Color(0x336699, alpha=0.5)  # 0x80336699 (8-bit opacity quantization)
Color(0x336699, alpha=0)    # 0xFF336699

Qt, browser and Flet graphics support alpha. wx GraphicsContext supports alpha, and transparent primitives use it; native widgets may ignore opacity. Tk native vector items and widget styles use RGB only; RGBA images preserve alpha. Native widget themes may ignore foreground/background opacity independently of Canvas.

Drawing API

drawLine(x1, y1, x2, y2, color=None, width=1)
drawPolyline(coords, color=None, width=1)
drawCubicBezier(start, control1, control2, end, color=None, width=1)
drawPolygon(coords, fill=None, stroke=None, width=1)
drawRect(x1, y1, x2, y2, fill=None, stroke=None, width=1)
drawEllipse(x, y, rx, ry, fill=None, stroke=None, width=1)
drawText(x, y, text, w=None, h=None, size=12, color=None,
         rotate=0, anchor=Anchor.LeftTop)
drawShapely(shape, fill=None, stroke=None, width=1)
drawPath(path, fill=None, stroke=None, width=1,
         fillRule=Canvas.FillRule.NonZero)
loadImage(path)
loadRGBA(width, height, data)
drawImage(image, x=0, y=0, width=None, height=None, src_x=0, src_y=0,
          src_width=None, src_height=None, opacity=1.0)
push()
pop()
clipRect(x, y, width, height)
clipPath(path, fillRule=Canvas.FillRule.NonZero)

Line/polyline coordinates are points; ellipse coordinates specify its center and radii. drawRect takes two corners, while clipRect takes an origin and dimensions. For PySide6/wx, text size is in points (fractional sizes supported); None retains the current painter font on Qt or the widget font on wx. Text is converted with str(). Explicit newlines produce a left-aligned text block, including empty lines; there is no automatic wrapping. Anchor locates that natural block at (x,y), then rotate turns it clockwise in degrees around (x,y). Optional w/h clip from the block's top-left in its rotated local coordinates, intersecting any existing clip; they do not change anchor measurement or wrap text. Empty text, nonpositive size, or nonpositive supplied bounds draw nothing. Text calls restore painter state. Flet/Tkinter instead use w for wrapping and ignore h; Tk also ignores text alpha, and Flet's size uses its native logical units. Images use a source rectangle and destination dimensions, defaulting to the remaining source size.

Paths and enums

Enums live on the shared Canvas mixin and are accessible through every graphical backend's Canvas. Path commands require PathOp values, not string literals. Web serialization converts enums to protocol values.

O = Canvas.PathOp
canvas.drawPath([
    (O.MoveTo, 10, 20),
    (O.LineTo, 80, 20),
    (O.QuadTo, 100, 20, 100, 40),
    (O.CubicTo, 100, 70, 40, 90, 10, 60),
    (O.Close,),
], fill=Color(0x336699, alpha=0.5), stroke=0,
   fillRule=Canvas.FillRule.NonZero)

MoveTo takes x/y; LineTo takes an endpoint; QuadTo takes one control point and an endpoint; CubicTo takes two control points and an endpoint. Close returns to the current subpath's start. A path starts with MoveTo and may contain multiple subpaths. Coordinates must be finite, bounded by ±1e9, with at most 20,000 commands. Fills implicitly close open subpaths; strokes close only on Close. Fill rules are NonZero and EvenOdd. Fill and stroke cover the whole path, not each segment separately. cap/join/dash remain backend defaults.

Stroke styles — not implemented

cap (line endpoints), join (segment joins), miterLimit (maximum miter extension), dash (dash/gap lengths) and dashOffset (dash starting offset) are planned capabilities, not accepted public Canvas parameters yet. Their matrix entries are red for every graphical backend, even where the native toolkit supports them. Current drawing uses the backend's existing defaults; Textual has no pixel Canvas.

Clipping and image resources

Supported clipping intersects the current clip, affecting subsequent paths, text and images. Use push/pop to scope it. Qt saves native painter state; wx saves pen/brush and PUI-managed native Region clips; browsers save Canvas 2D state. Flet/Tkinter push/pop and clipping are currently no-ops. wx also provides clipRegion(wx.Region); arbitrary path clipping raises NotImplementedError. There is no mask or geometry workaround for clipping.

canvas.push()
try:
    canvas.clipRect(20, 20, 200, 100)
    canvas.drawPath(path, fill=0x336699)
finally:
    canvas.pop()

loadRGBA copies top-to-bottom, left-to-right RGBA bytes (standard alpha: 0 transparent, 255 opaque) into an immutable resource. Exactly width × height × 4 bytes are required; dimensions are positive integers up to 16,384, and at most 1,048,576 pixels per image. Resources are independent of frames and require no files. Tk image resampling uses Pillow. Flet uses immutable RGBA cropping without an imaging dependency. Tk and Flet currently accept memory resources in drawImage, but do not yet provide the file-resource load/crop/scale API.

Implemented Partial / fallback Not implemented Cannot be supported

Flet uses native NonZero paths; EvenOdd filling raises NotImplementedError because Flet does not expose Flutter's Path.fillType. Tk drawPath supports M/L/Z strokes only; curves and path fills raise NotImplementedError. Use drawPolygon for Tk's native polygon drawing, which does not expose this API's explicit fill rules. No custom rasterizer or path approximation is used.

MatplotlibCanvas

Install qpuiq[matplotlib] (included by the cookbook extra). PySide6, wx and Tkinter use native Matplotlib canvases (QtAgg, WxAgg and TkAgg). Flet, Web and Pyodide use the shared RendererPUI adapter. Figure layout and paths run in Python; Canvas draws on the selected backend. Web sends drawing commands to the browser, not a PNG of the whole figure. Pyodide executes both Python and rendering in the browser.

Text and mathtext use Matplotlib glyph outlines. Resize updates the Figure size in logical pixels; each Canvas backend applies its device density. draw/draw_idle request a backend repaint. Plotters rerun on PUI reconciliation; retain modifications in state if they must survive that redraw. Basic paths, markers, collections and RGBA images are supported. Flet clipping is no-op. The cookbook labels this limitation. Hatch, Gouraud shading and exact cap/join/dash styles are not implemented in the shared adapter. Gouraud raises NotImplementedError; hatch and stroke styles are not rendered. Native QtAgg, WxAgg and TkAgg retain Matplotlib's rendering and mouse/keyboard event handling; PUI does not currently add a navigation toolbar. Their text and arbitrary path clipping are handled by Agg, independently of PUI Canvas limitations.

Web/Pyodide and Flet forward mouse press/release, motion, enter/leave, vertical scroll and keyboard press/release to figure.canvas.mpl_connect(). Matplotlib supplies inaxes, xdata, ydata and picking; coordinates use logical pixels with a bottom-left origin. Click the chart to focus keyboard input. Web/Pyodide support double clicks, pointer capture and releasing held input on blur/reconnect. Flet uses GestureDetector and KeyboardListener; modifier keys pressed outside the chart, focus-loss notifications, double-click parity and middle-button dragging remain limited by this adapter. Flet clears held keys on pointer exit. Horizontal wheel deltas have no Matplotlib scroll axis and are ignored; vertical pixel deltas use 100 pixels per step, DOM line/page units use one unit per step. A scroll event does not automatically enable wheel zoom. Navigation UI and default navigation shortcuts remain pending.

Connect callbacks once per retained Figure (or disconnect the previous callback before reconnecting). Call event.canvas.draw_idle() after modifying artists; this repaints without rerunning the PUI plotter.

The shared Canvas adapter exposes get_renderer() for synchronous annotation/text measurements, including before the first paint and after size/DPI changes. It does not support blitting; annotation tools use full redraws through draw_idle(). With the optional mplcursors package installed, version 0.7.1 selection/highlight, transient hover, visibility keys and removal are covered by Web runtime tests. Keep the cursor alive and call cursor.remove() before replacing it when rebuilding a plot; there is no need to replace it with custom pick_event handling solely because the backend is Web. Platform-specific interaction limits above still apply.

Example

data = [(0,0), (1,3), (2,2)]
def plot(figure, data):
    figure.clear()
    sp = figure.add_subplot(111)
    sp.axes.plot([d[0] for d in data], [d[1] for d in data])

MatplotlibCanvas(plot, data)

Splitter

Splitter(vertical=False)

Every backend provides draggable panes: PySide6 uses QSplitter, wx uses nested SplitterWindow controls, Tkinter uses ttk.Panedwindow, Textual uses a cell-based Layout with mouse capture, and Flet composes Row/Column with native drag gestures. Web/Pyodide use DOM separators; see the Web reference for browser sizing and keyboard behavior. The Cookbook uses Splitter between Code and Result on every backend. vertical=False places panes side by side; vertical=True stacks them.

wx composes native SplitterWindow controls for any number of panes. Extra children nest in the right pane, A | (B | C), or the bottom pane with vertical=True. Moving an earlier sash resizes the remaining group. Declare nested Splitter blocks explicitly for another grouping; there is no automatic nesting-direction option.

In wx, widgets, layouts, and nested splitters can be panes. Keyed moves retain controls and state; ordinary redraws retain sash positions. Changes to pane order/count or orientation redistribute space equally, and later window resizes share size changes equally among panes. A one-pixel minimum prevents native dragging or double-clicking from unsplitting declared content. Empty and single-pane splitters have no sash.

Tkinter keeps child widgets and pane frames under a stable host, retaining them when its orientation-specific native Panedwindow is recreated. Its native sashes retain positions on redraw; structural/orientation changes reset equal initial sizes. Hidden panes do not consume space. Native Linux geometry, stacking/hit-testing and reconciliation are checked; other OSes/themes remain unverified.

Textual, Flet and Web resize adjacent panes while keeping the other panes' shares unchanged. Window/container resize preserves ratios; visible pane order/count or orientation changes reset equal shares. Textual uses a one-cell separator, mouse capture, arrow keys (one cell, five with Shift) and Home/End, retaining native child widgets across redraw/reordering and direction changes. Flet uses a six-pixel separator and public drag/size events; keyboard resizing is not implemented. Flet retains Python controls, but changing Row/Column orientation can recreate Flutter widgets and reset client-only cursor/scroll state. These implementations do not write pane ratios into application State.


with Splitter():
    Label("Left")
    Label("Right")

Views

See Web Views and Tabs for browser page retention and keyboard behavior.

Views(model=None) displays one child view without navigation controls. Each direct child needs a unique non-empty string .id(). Widgets, layouts, and PUIView instances are supported; multiple root controls in one view are grouped vertically. Declared views stay alive while hidden; removing a declaration destroys that view.

The optional model is a writable binding containing the selected ID. Without one, Views owns an internal model initialized to None and preserved across redraws. ref(name).select(key) queues a model update and redraw on the backend UI thread/event loop; .select(None) clears the selection. None or an undeclared ID shows an empty container without rewriting the model. Keyed reordering retains views and selection; older handles follow reconciliation, and commands already queued for removed containers are discarded.

with Views().ref("example.views"):
    EditorView().id("editor")
    SettingsView().id("settings")

# In an event handler:
ref("example.views").select("editor")

Use Views(model=state("current_view")) for external state. Views supports PySide6 through QStackedWidget, tkinter through retained ttk.Frame pages, Textual through ContentSwitcher, and wx through Simplebook; remaining native validation is tracked in the backend progress documents.

The cookbook Views page demonstrates both .select() with an internal model and selection by assigning a State field, with a stateful editor in each container.

State bindings preserve None, so .select(None) also clears an external model.

Flet Views is deferred: its current native controls do not provide both programmatic-only switching and guaranteed retention of inactive native pages.

Tabs and Tab

For State-bound page navigation and URL restoration, see View Router: State-driven navigation.

See Web Views and Tabs for browser headers, keyboard navigation and retained pages.

Tabs(tabposition=Side.Top, model=None) uses an internal selection model by default. Pass model=optional_state("selection") for an external binding. Native tab clicks, .select(key), and bound-state assignments share the selection; older handles follow redraws. All five backends support this model.

Selection uses each Tab's explicit .id(), otherwise its text. The first match wins duplicate IDs/text, including clicks on later duplicates. Explicit IDs survive label changes; keyed moves retain pages and selection. The internal model initially selects the first tab. None or unknown keys display the first available tab without rewriting the requested value; an empty Tabs has no page. .select(None) returns to the first tab. Restoring a removed selected key restores its requested selection.

The cookbook demonstrates both Tabs() with .select() buttons and Tabs(model=...) controlled by optional_state, using both explicit IDs and text fallback. Textual uses native Tabs and ContentSwitcher with TOP/BOTTOM placement; RIGHT/LEFT raises NotImplementedError.

PySide6 applies QTabWidget placement; Tk uses native ttk tabposition style. Flet supports TOP/BOTTOM. Theme/platform appearance can differ.

Flet 1.x uses Tabs/TabBar/TabBarView, supports TOP/BOTTOM, and preserves keyed page selection. Native Container shells apply generic layout parameters. Install with QPUIQ[flet].

PySide6 uses native QTabWidget placement. Tkinter applies ttk tabposition style for TOP/BOTTOM/RIGHT/LEFT; theme/platform appearance may differ. Flet supports TOP/BOTTOM and preserves keyed selection.

wx uses a native Notebook with one content widget or layout per Tab. Keyed pages can be reordered/removed and labels update reactively. Side.Top/Side.Right/Side.Bottom/Side.Left select placement (Side enum only; raw strings and the former Tabs direction constants are not accepted); changing placement remounts the native notebook. wx Label(..., selectable=True) and Text(..., selectable=True) use read-only TextCtrl controls; changing selectability remounts the control.

Example

with Tabs():
    with Tab("Tab 1"):
        Label("Tab Content 1")
    with Tab("Tab 2"):
        Label("Tab Content 2")

View Router: State-driven navigation

PUI does not provide a dedicated View Router. On supported backends, combine Views or Tabs, a State binding and Named State Subscriptions to cover part of a View Router's role: selecting a page, retaining declared page content, and publishing the current page for URL restoration on Web/Pyodide.

# In content(), with state.page initialized to a declared page ID:
with Views(model=state("page").ref("example.page")):
    EditorView().id("editor")
    SettingsView().id("settings")

# In an event handler:
state.page = "settings"

Use Views when application controls drive navigation. Use Tabs when pages need a built-in tab bar:

with Tabs(model=state("page").ref("example.page")):
    with Tab("Editor").id("editor"):
        EditorView()
    with Tab("Settings").id("settings"):
        SettingsView()

Switching tabs writes the selected Tab's ID to state.page, which the named subscription publishes to the URL on Web/Pyodide. Without an explicit ID, Tabs uses the tab label; explicit stable IDs keep navigation independent of label changes or translation. Declaring children does not populate the external State. If its value is None or an unknown ID, Views shows no page, whereas Tabs displays the first available tab without rewriting the State.

During application setup, read PUI.Options.get("example.page", "editor"), validate it against the allowed page IDs, and assign it to state.page. On Web/Pyodide, the named subscription publishes changes to the URL fragment; a new application can restore the selected page from those options. Named State publishes the selection; the State binding itself drives Views or Tabs.

This is not a full View Router: PUI does not supply route-pattern matching, a push/pop navigation stack or browser Back/Forward navigation. URL publication uses history.replaceState, without adding history entries; editing the fragment does not change an existing State. Resuming an existing Web session does not rerun setup. See Web URL options and Pyodide URL options for initialization and lifecycle details.

Stack

Tkinter uses a ttk.Frame with all children in one expanding grid cell; later declarations are lifted above earlier ones. Natural sizing follows the largest child plus its padding. Widgets and nested layouts retain keyed controls across updates/reordering. Spacer does not add a covering layer. Tk controls generally paint opaque backgrounds, so upper controls cover lower ones; transparent text composition is not guaranteed.

wx uses a native Panel with an overlay sizer. Children share the available rectangle with their own padding; the minimum size is the maximum child minimum size. Later declarations are raised above earlier ones. Widgets and nested layouts support keyed updates, reordering and removal. Native hit testing applies: the upper control receives input in its occupied area. Transparent backgrounds depend on the native control and platform; opaque controls cover lower layers, so text overlap may differ from Qt/Web.

Except on Textual, Stack overlays children, with later declarations above earlier ones. See Web Stack for browser layout and hit testing.

On Windows, non-selectable wx labels hosted directly by Stack (including through nested sizers) use generic text painting and compose text layers in declaration order on every repaint. This preserves upper text layers even when Windows repaints transparent siblings in reverse order. Other native controls retain platform-dependent background composition.

PySide6 and Flet have native implementations. Textual uses VBox as a fallback: children are arranged vertically in reverse declaration order (later children above earlier ones), without overlap.

Image

Image(path)

See Web Image for HTTP resources, caching, browser crop/scale behavior and resource limits.

Flet Image sends file bytes, reloads when mtime changes and uses native aspect-preserving containment within explicit dimensions.

wx Image uses StaticBitmap, reloads when the file modification time changes and scales to explicit width/height while preserving aspect ratio. Weighted fill/stretch behavior remains pending.

Example

TimelineView

TimelineView(ttl_sec) requests reactive redraws periodically. Timers follow reconciled nodes and stop after removal. Native backends and Web use daemon timer threads; backend views marshal redraws onto their native UI/session queue. wx, Flet, Tkinter and Web do so. Tkinter queues redraws outside geometry idle flushes to prevent reconciliation reentry.

See Web TimelineView for worker scheduling. Pyodide uses its existing asyncio loop instead of timer threads; see Pyodide lifecycle. Native validation and remaining work are tracked in Flet progress and Tkinter progress.

Table

See Web Table for inline editors, commit timing, draft retention, sizing and browser limitations.

TableNode(data, *, display=None, choices=None, prefix='', suffix='', placeholder=None) keeps the raw editable value separate from presentation. display replaces only the displayed value; prefix and suffix surround it in both the cell and its editor and are never written back. Decorations are literal, with no automatic punctuation or spacing: TableNode(100, prefix="Price: $", suffix=" USD") displays Price: $100 USD while editing only 100. placeholder is passed only to an editable cell's native text/choice editor and never affects display or committed data. Boolean editors have no placeholder. Tkinter's Tk 8.6 Entry, wx choice editors, and Qt numeric editors accept the shared API but have no native placeholder.

Textual supports TableNode rows and BaseTableAdapter with native DataTable, literal text, row/column labels, a cell cursor, keyboard navigation and scrolling. Enter opens a Save/Cancel ModalScreen: choices use Select, booleans use Checkbox, and str/int/float/None use Input. Values retain their types, duplicate labels stay distinct, and None is a valid choice separate from no selection. Escape cancels the dialog (or first closes an expanded Select). Draft validation/cancellation follows the Tkinter rules below.

Keyed Textual rows retain the cursor and unchanged drafts. Updates reuse DataTable; row-order/schema changes reset its data and restore the cursor by row key and scroll offsets. autofit=True follows native content sizing; False retains initial column widths in terminal cells. Callbacks follow native activation: clicking a different cell moves the cursor; clicking the highlighted cell or pressing Enter invokes .click(). A double-click activation invokes .dblclick() and opens an editable cell. Navigation and programmatic updates do not invoke callbacks. Cell widgets, drag/drop or automatic header sorting, native column resizing and Qt-style multi-selection are not implemented. See Textual progress for remaining terminal/platform validation.

Tkinter supports TableNode rows and BaseTableAdapter with ttk.Treeview, headers, native row selection, and horizontal/vertical scrollbars. Clicks dispatch cell callbacks. Double-click an editable cell for a Save/Cancel dialog; Enter edits the focused row's last clicked column (initially the first). Choices use a readonly Combobox in the dialog, preserving typed values and duplicate labels. Other editors support str/int/float/bool and None (as text). Invalid numbers or missing choices do not write back. Bindings or .set() callbacks enable editing; choices alone do not. Display stays separate from raw data; ragged cells are inert blanks.

The Tk editing dialog takes modal input and focus once visible; it can be canceled safely if the cell/table disappears before it opens. Keyed Tk rows retain selection and drafts through reordering. Removing a cell/table, changing its value/choices/binding, or making it read-only cancels the draft. Adapter drafts cancel on reconciliation because adapters lack stable row keys. autofit=True measures displayed text; False retains native/user widths. Native selection is row-based. Cell widgets, drag/drop or automatic header sorting, and per-cell styles are not implemented. See Tk progress for pending platform and keyboard checks.

wx uses wx.grid.Grid with TableNode rows or BaseTableAdapter data. Text/numbers edit inline; choices and booleans use native dropdowns. Raw types and duplicate labels are preserved; display text stays separate. Bindings or .set() callbacks enable declarative editing, while missing cells and arbitrary objects without choices are read-only. Invalid numeric edits leave the model unchanged. Headers, selection/scrolling, click/double-click callbacks, and both autofit modes are supported. Redraw/removal cancels unfinished edits; autofit=False preserves native/user column widths. See wx progress for remaining validation and large-data work.

PySide6 refreshes reused adapters with a native model reset, including row/column count changes. A reset may clear selection or an active editor.

Flet supports TableNode rows and BaseTableAdapter using its built-in DataTable. Editable choices cells use Dropdowns; other editable cells open a Save/Cancel dialog. Dialog values support strings, integers, floats, booleans, and None (edited as text). Raw choice values keep their types even with duplicate labels. Display text is separate from raw editing data. Choices alone do not enable editing; missing cells are inert blanks. Choice cells give gestures to the Dropdown, so cell click/double-click callbacks apply to ordinary cells. An explicit choice display value appears as the Dropdown label.

Keyed TableNode rows/cells retain drafts on reorder. Removing a cell/table, changing its raw value, or disabling editing cancels its draft. Adapter dialogs cancel on table reconciliation because adapters have no stable row keys. Native horizontal/vertical scrolling is provided; headers scroll with the data. Only content sizing (autofit=True) is supported; autofit=False raises NotImplementedError. Qt cell selection, additional editor types, and paging remain outside this implementation. See Flet progress.

Table widget
Example with TableNode

with Table():
    with TableNode():
        TableNode(state("price"), prefix="Price: $", suffix=" USD",
                  placeholder="Enter price")
        TableNode("A2")
    with TableNode():
        TableNode("B1")
        TableNode("B2")
Example with adapter

Table(adapter)

Tree

Single column tree widget

Desired interaction behavior

All backends target the following pointer behavior. Selection and expansion are separate states; a click callback does not itself imply that a branch expands.

InteractionSelectionExpansion
Single-click arrowDoes not activate the label callback; native focus/selection effects may differToggle that branch once
Single-click labelSelect the node and dispatch its click callbackUnchanged
Double-click labelSelect the node and dispatch its double-click callbackToggle that branch once; do not also apply a second native toggle
Leaf nodeSupports selection and click/double-click callbacksNo expansion action or disclosure arrow
expandable(False)Selection and activation callbacks remain availableDisable user toggles from pointer/keyboard; explicit expandAll()/collapseAll() remain allowed
expandAll() / collapseAll()Do not deliberately change selection; native focus may move to a visible ancestorOne-shot operation on every current branch, including descendants; not a persistent expansion policy
Redraw / reorderPreserve surviving keyed nodes or stable adapter-object selectionPreserve surviving branch state unless the declaration explicitly issues another expansion command

A native double-click sequence may include click callbacks before the double-click callback; applications should not treat these as mutually exclusive. Keyboard bindings remain backend-specific and must respect expandable(False). Browser disclosure controls provide at least a 32×32 CSS-pixel hit target.

wx uses DataViewCtrl with TreeNode declarations or BaseTreeAdapter data. The single content column uses the larger of its native content width and the available viewport width. Model updates and branch expansion refresh content measurements; resizing keeps the column filling the viewport, with horizontal scrolling when content is wider. Keyed nodes and stable adapter objects retain selection/expansion across redraws, reordering, relabeling, and adapter moves. Node click/double-click callbacks receive None; expand/collapse callbacks have no event argument. Adapter callbacks receive the node object. expandAll(), collapseAll(), and expandable(enabled) work before mounting and on a live Tree; disabling user expansion still permits imperative changes. Inbound drops use the shared DropEvent contract below; outgoing drags and row reordering are not provided.

expandAll() and collapseAll() are one-shot commands over the current tree, including nested branches; they do not lock future user interaction or automatically apply to later nodes. A chained command in content(), such as Tree().expandAll(), runs again each time that declaration is rebuilt. Textual’s native Shift+Space shortcut toggles siblings of the cursor node, so it is not equivalent to PUI expandAll(). Programmatic expansion callback delivery currently differs by backend: PySide6/wx use native expansion notifications, while Tkinter/Textual/Flet/Web/Pyodide suppress callbacks for these commands.

Web and Pyodide share a dependency-free DOM Tree renderer for TreeNode declarations and BaseTreeAdapter data (up to 10,000 nodes). It supports single selection, disclosure arrows, single-click label selection, double-click branch toggling and callbacks and expandAll()/collapseAll()/expandable(). Arrow keys navigate or expand/collapse; Home/End jump to the first/last visible item, Enter/Space activate and toggle branches, and typing searches visible labels by prefix. Retained rows preserve focus and selection, with ARIA tree/treeitem semantics and automatic scrolling to keyboard targets. Local pending actions remain visible until their server/worker acknowledgement, so older responses do not undo newer clicks. Labels are plain text. Multi-selection, drag sorting, inline editing, lazy loading, virtualization and TreeTable are not implemented. Both cookbook pages are enabled.

Textual uses its native Tree with an invisible root for TreeNode declarations and BaseTreeAdapter data. Mouse clicks/double-clicks and keyboard activation use the shared callback contract. Clicking a branch label or pressing Enter selects it without toggling; double-clicking the label, clicking its arrow or pressing Space toggles expansion. Native cursor navigation remains available. Labels are literal text. Selection and expansion survive keyed redraws and stable adapter-object moves. Structural changes rebuild native nodes because Textual has no public reparent/reorder API; stale queued events are ignored and the cursor is restored. expandAll(), collapseAll() and expandable(enabled) work before mounting and from callbacks. Both Tree cookbook pages are enabled and tested with Textual Pilot, including mouse/keyboard input and adding nodes.

Flet composes a single-column Tree from native ListView, IconButton, GestureDetector and KeyboardListener controls. TreeNode declarations and BaseTreeAdapter data support selection, click/double-click, expansion callbacks, expandAll(), collapseAll() and expandable(enabled). Labels select on single-click and toggle branches on double-click. Keyed declarations and stable adapter objects preserve selection and expansion through redraws and reordering. Arrow keys navigate and expand/collapse; Home/End select the first/last visible node and Enter/Space activate it. This is a composed control, not a native TreeView. Automatic scrolling to keyboard-selected off-screen rows and full tree accessibility semantics are not yet provided. Session tests cover serialization and events; real Flutter focus, gesture timing and scrolling still require validation.

There should be a `TreeTable` for multi/fixed-column trees and a `NestedTable` for variable-column trees, but they have not been implemented yet
Example with TreeNode

with Tree():
    with TreeNode("Root"):
        with TreeNode("Sub 1"):
            TreeNode("Sub 1-1")
            TreeNode("Sub 1-2")
        TreeNode("Sub 2")
        with TreeNode("Sub 3"):
            TreeNode("Sub 3-1")
Example with adapter

Tree(adapter)

Interop

QtInPui

Wrapper for embedding native widget instance into PUI Currently only supported by PySide6 backend
Example

PuiInQt

Wrapper for embedding PUI view into existing QT view hierarchy Currently only supported by PySide6 backend
Example

Backend alignment status

Flet alignment status

The backend targets Flet 1.x (tested with 1.0.4); install with pip install 'QPUIQ[flet]'. It uses ft.run, Button(content) and Tabs/TabBar/TabBarView. Reactive FView/PUI redraws queue on the session loop and commit one page update after reconciliation. Native Container shells apply weights, dimensions, padding/margins and background; text styles reset when omitted. Window uses Page.window with initial-only constructor flags and equivalent declarative setters.

Live web-client smoke checks cover counter updates, editor commits, Tabs, fixed-size Canvas drawing, weighted Canvas initial fit/resizing and Table editing. Scroll interaction and broader visual checks remain pending. See Flet progress.

Tkinter alignment status

Tkinter reuses the native interpreter, coalesces subview redraws, resolves retired controls, emits TextField event values, rebinds/suppresses traces and deduplicates commits. Checkbox/RadioButton update labels and clicks; Tabs uses native destruction. HBox/VBox apply four-sided grid padding and reset removed weights/styles. Application.quit() requests native teardown. Redraws and quit run outside geometry idle flushes, preventing reentrant reconciliation.

The recorded baseline includes twelve native regression checks and all 17 then-supported cookbook pages in the local env/ runtime. Text widgets still use native character/line sizing units; pixel-dimension wrappers need an agreed design. The Canvas cookbook skips cubic drawing on Tkinter. Broader resize and platform checks remain open; see Tkinter progress.

Textual alignment status

Views and Tabs have native headless interaction tests on Textual 8.2.8. Text applies foreground/background colors, including the cookbook's fixed dark code palette, and restores theme colors when styles are removed. Lifecycle, reconciliation, input, layout and scroll gaps, plus broader runtime and terminal validation, are tracked in Textual progress. Matrix entries identify native mappings, not completed parity.

Scheduling callbacks

On the UI thread, call node.get_poster() on a mounted backend node to obtain post(callback, *, delay=0). Pass a zero-argument callable to schedule work on the backend UI/event loop. Textual and Flet execute these callbacks on their serial background callback executor so synchronous dialogs can wait without blocking rendering; native UI access must be dispatched separately to their UI loop. Optional delay is a finite non-negative number of milliseconds (for example, delay=500), measured from submission; execution may be later when the loop is busy. Timer creation also runs on the UI loop, so posting with a delay is safe from a background thread. No cancellation handle is returned. Obtain the poster before handing it to a background thread; do not look up refs or access native UI from that thread. The application is responsible for stopping background work before the target or application is destroyed. See the Cookbook's Refs page for a button that queues a State update after 500 ms.

Inbound drag and drop

.drop(callback, *args, extensions=None, **kwargs) enables a target. Optional .dragEnter(callback) receives a notification; it is not required for acceptance. Callbacks receive a portable DropEvent, replacing the former raw Qt event.

def dropped(event):
    for file in event.files:
        print(file.name, file.path, file.temporary)
    event.accept()

Label("Drop text files here").drop(dropped, extensions=(".txt",))

Events expose files, text, urls, local x/y coordinates and action (copy, move or link). Each DropFile has name, path and temporary. Call event.accept() explicitly; returning without acceptance rejects the batch. event.reject(reason) supplies a reason, shown as an alert in browsers. Extension matching ignores case; one invalid filename rejects the entire batch. On PySide6/wx, include "/" in extensions to allow directories; extensions=None allows both files and directories. Directories appear in event.files with their original path and temporary=False; PUI does not enumerate or copy their contents. Use pathlib.Path(file.path).is_dir() to distinguish them. Web/Pyodide still reject directories, even with this filter.

PySide6 and wx preserve supported source actions and reference original files (temporary=False); acceptance itself never moves or deletes files. Web and Pyodide support copy only, with temporary=True files in server or virtual storage. Applications must copy/move them to retain them, and may delete them early. Text and URLs create no files.

Supported targets: PySide6 widgets/trees, wx native Window controls, Web and Pyodide DOM controls. wx sizer-only layouts cannot be targets. Tkinter and Flet require external integrations not included here; Textual does not support this API. This does not implement outgoing drags or table row reordering. See the Web and Pyodide references for upload limits and lifecycle.