Skip to content

UI builder - #7

Merged
jamescook merged 79 commits into
mainfrom
dsl
Jul 16, 2026
Merged

UI builder#7
jamescook merged 79 commits into
mainfrom
dsl

Conversation

@jamescook

@jamescook jamescook commented Jul 14, 2026

Copy link
Copy Markdown
Owner

teek-ui: a friendly DSL for building Tk apps in Ruby

What this is

Introduces teek-ui, a sibling gem that becomes the recommended way to build a Tk
app in Ruby — "the friendly way to build Tk apps," sugar over Tk's plumbing rather
than a wall around it. teek itself gave us a leak-free, safe bridge to Tcl/Tk; this
branch is the layer above it: a declarative DSL so app authors stop hand-rolling
widget plumbing, layout math, and their own window/navigation/event frameworks.

Why

gemba — my real, non-trivial Tk app — had to hand-build ChildWindow,
ModalStack, FrameStack, and an event bus itself, because teek offered nothing above
app.command. Those hand-rolled frameworks are exactly what this DSL generalizes and
ships as first-class.

The shape of it

  • Retained mode. You describe the UI as a plain-Ruby tree; nothing touches Tk
    until .run realizes it. A build is therefore headless-testable, and the whole tree
    gets validated (dangling event targets, grid collisions, …) into one clear error
    before a single Tk call happens.
  • Tk vocabulary stays hidden. Flexbox-style column/row/grid instead of
    pack/grid/sticky/-weight; intent-named events (on_click/on_key) instead
    of Tk event syntax; reactive variables; friendly names throughout, with the original
    Tk names kept as aliases for anyone fluent.
  • The frameworks you used to hand-roll, built in. Components + name-scoping for
    splitting a big app across files, screens and modal-stack navigation, an event bus,
    menus, managed windows/dialogs, canvas drawing, rich text, and images.
  • Leak-free by construction, and never a wall. Everything routes through teek's
    callback-cleanup layer, and there's always an escape hatch (ui.raw, session.app)
    to drop straight to raw teek.

Where it lands (honest)

The core goal — hide Tk's plumbing and vocabulary behind a friendly, leak-safe DSL — is
met, and gemba's hand-built frameworks now have first-class equivalents. "For mere
humans" slightly oversells it: this is really Tk without the Tcl. It removes the
Tk-specific pain, but building a non-trivial GUI still carries some inherent load —
chiefly the build-then-realize lifecycle. That's mitigated (events and timers queue and
wire themselves; the lifecycle is explained once, not per-section) rather than
eliminated, and it's the honest edge of the abstraction.

Status

Alpha, and the foundation for a first teek-ui release. Almost entirely new code in a
sibling gem, so the effect on existing teek is minimal. A few things are deliberately
deferred (overlay layout, some long-tail widgets, Tcl/Tk 9.1-only features) and tracked
separately.

jamescook added 30 commits July 13, 2026 06:34
Introduces Teek::UI.app as the entrypoint for a DSL layer built on top of
teek, following the same monorepo pattern as teek-sdl2. This first slice
covers app construction, the escape hatch to the underlying Teek::App, a
blocking run loop and a non-blocking run_async for interactive use, and
thin timer delegates - the widget/layout/event DSL builds on this next.
Lays the substrate the DSL's build phase will construct into: a plain-Ruby
tree with no dependency on Tk, so a build can be created and traversed
headlessly. Document owns node construction and name indexing but stays
agnostic to tree shape - callers attach nodes to a parent themselves - so
it works underneath whatever parent-tracking scheme the widget/layout DSL
ends up using.
Teek::UI.app no longer constructs a Teek::App - it builds a Session (with
an empty Document) and yields it, so the block runs with no interpreter
at all. The app is created lazily and idempotently at realize, which #run
and #run_async both trigger before doing anything else. Runtime-only
Session methods raise a clear NotRealizedError if called before realize
rather than queuing, so nothing pretends to talk to Tk before it exists.

This is what makes a build constructible and inspectable with no display -
needed for headless UI-structure testing once the widget DSL lands on top.
ui.<widget> methods declare widgets by appending nodes to the Document
tree - leaf widgets (button, label, text_box, ...) and containers that
take a block and nest children (panel/box, group, canvas, window). A
container's block yields the same session object back rather than a
separate scoped builder, so a name declared inside it is addressable from
outside via ui[:name] too.

Also adds Handle, the one handle type valid across both build and
post-realize phases: .path/.configure raise NotRealizedError until a
widget is actually realized, then act on the live thing through
RealizedNode - the small app+path contract the realizer will fill in once
it exists.
…gets

Session#realize now actually walks the Document and creates real Tk
widgets, instead of just constructing an empty App. Pass one creates
every widget and allocates a hierarchical path from its name (nested
under its parent's real path, not an auto-incremented junk name); pass
two runs only once the whole tree exists, so it can pack children and
wire event bindings that reference another widget declared later in the
build - forward references resolve because every name is already real by
the time pass two runs.

Realize is atomic: the root window stays withdrawn throughout, and a
failure destroys the partially-built app and re-raises rather than
leaving the session claiming to be realized.

Layout is intentionally a placeholder (plain top-to-bottom pack, no
options) until the real layout DSL lands, and there's no event DSL yet to
produce bindings through the public API - both are separate, already
scoped pieces of work.
on_click, on_right_click, on_drag, and on_key give widget handles an
intent-named way to wire real Tk events instead of raw bind calls. Right
click covers every platform's actual event spelling (Button-2/Button-3/
Control-Button-1); drag delivers typed Integer coordinates, converted
through canvasx/canvasy automatically when bound to a canvas; on_key
resolves friendly names (:enter, :escape, ...) and "Ctrl-s"-style strings
through a small Keysyms lookup, including binding every plausible
spelling of Shift-Tab so it fires regardless of platform.

All four share one mechanism: called before realize they queue onto the
node (reusing the same forward-reference-capable path the realizer
already wires), called after they bind immediately - the same handle
behaves correctly in either phase rather than silently no-oping post-realize.
ui.var wraps a Tcl variable so multiple widgets can share and stay in
sync with a single value, via Tk's own -textvariable/-variable machinery
rather than manual set_variable/get_variable calls scattered everywhere.
Its Tcl name is allocated at build time (a plain string, no interpreter
needed) so bind: can capture it before realize; the variable itself, its
initial value, and its change trace become real at realize, before the
widget tree does, so bound widgets never start blank.

bind: maps to the right Tk option per widget type and raises for widget
types with no sensible single bindable value rather than silently doing
nothing. var.value/var.value= give typed Ruby access, and on_change fires
with the coerced value on every change regardless of what caused it.
column/row hide all three of Tk's geometry managers behind flexbox-style
vocabulary - gap between children, align on the cross axis (:start/
:center/:end/:stretch), pad around the whole stack, and grow: true on any
child to consume leftover main-axis space. spacer is a flexible gap, the
named replacement for the classic invisible spring-row trick. None of
pack/grid/sticky/anchor/rowconfigure/-weight ever appears in app code.

Validated against goldberg's actual control panel structure (Start/Pause/
Step/BigStep/Reset/Details/spacer/msg_entry/speed_scale/About) realizing
correctly with real widget geometry checks, not just structural
assertions - the prototype the design sketch asked for before locking the
vocabulary.
A real, runnable proof that the retained-mode build -> realize concept
works against a representative screen: goldberg.rb's control panel
(Start/Pause/Step/Big Step/Reset/Details/message/speed/About), rebuilt
with column/gap/align/spacer, on_click/on_key, and reactive vars instead
of raw tcl_eval, magic grid row numbers, and hand-tracked callback ids.
Confirmed working interactively, not just by an automated check.
ui.grid covers the minority of screens flow layout doesn't fit - labeled
forms, tables of inputs. g.cell(row:, col:, span:) positions the single
widget its block declares; g.stretch(columns:, rows:) says which absorb
leftover space, in English instead of grid columnconfigure -weight.
Neither call means anything outside a grid's block and both raise if used
elsewhere.

Verified against the canonical 2-column labeled-field form, checking real
Tk grid info/columnconfigure output rather than just structural
assertions.
Session#realize now validates the whole build tree first - a dangling
event target, two widgets in the same grid cell, or a named widget that's
declared but never actually placed anywhere all get collected and raised
together as one ValidationError, instead of a cycle of "run, hit the
next cryptic Tcl error, fix, repeat." A validation failure means no
Teek::App/interpreter is ever constructed at all.

The classic "mixed pack+grid in one container hangs Tk" hazard turned out
to already be structurally impossible given how the realizer picks
exactly one arrangement strategy per container - so that check was
replaced with the closest real analog (grid-position intent under a
non-grid parent) instead of guarding against something that can't happen.
Orphan widgets warn by default; strict: true (now on realize/run/
run_async) raises on those too.
A dev doing gem install teek and reaching for UI hits a confusing
NameError - bare teek is only the low-level Tcl/Tk binding. Both READMEs
now tell the same story from either direction: teek's leads with "start
with teek-ui" and gem install teek-ui as the first install instruction
shown, Rails/Rack framing, and a constructive note on what bare teek is
actually for; teek-ui's states the depends-on-teek relationship and
positions itself as the recommended entry point for building an app.
A widget has no Tk path during build, so app.command(handle.path, ...)
mid-build can't work - ui.raw { |app| ... } defers its block to realize
instead, where it runs with the real, live app. Same forward-reference
guarantee event target: already has: since it runs after the whole tree
has been created once over, it can reference a sibling widget by name
even if that sibling is declared later in the build.

Documents the full build-time (ui.raw) vs post-realize (session.app)
escape-hatch split in the README, which previously only covered the
post-realize half.
"Nothing happens until realize" describes the initial declaration only -
session.add(parent_name) { } builds a subtree with the same widget DSL
and realizes it immediately as a child of an already-realized widget, for
UIs that grow after the window is already up. Reuses the realizer's
existing create/link machinery unchanged (it was already written
generically over (node, parent_path) rather than hardcoded to root), just
entered at an arbitrary node instead of the document root, then
re-arranges the parent so gap:/align: positioning accounts for the new
child alongside its existing siblings.

Fixes a real path-collision bug this surfaced: unnamed nodes' auto-path
segment was a per-Realizer-instance counter that reset on every new
Realizer, harmless for the one-shot initial realize but a genuine
collision risk across separate incremental adds. Now uses the node's own
Document-assigned key instead, which is unique for the whole document.

Also drops Realizer.realize/.realize_subtree, two class methods that were
pure new(...).method(...) pass-throughs adding no logic over just
constructing the instance at the two call sites directly.
…test

Builds a representative nested tree (column > grid > row > panel) and
walks the REAL realized Tk widget tree (winfo children/winfo manager) to
confirm no master ever ends up managed by more than one geometry manager
- verifies the actual outcome, not just the source structure, so it
catches a violation regardless of how a future realizer change might
introduce one.

While proving the check has teeth, discovered Tk's own geometry-manager
framework already refuses a second manager on an active master with a
clear, immediate error - symmetrically, in either order - rather than the
silent hang the original framing assumed. A real mixed master turns out
to be impossible to construct via pack/grid at all; place coexists safely
with either, confirming the planned place-based overlay feature is safe.
Add Handle#on_close(&block), valid only on window handles, wired
through the same queue-before-realize/wire-immediately-after pattern
every other event uses, backed by App#on_close underneath. Also fixes
a latent bug: the realizer was trying to pack/grid toplevel (:window)
children into their nominal parent, which Tk rejects - toplevels are
placed by the window manager, not a geometry manager.
Add ui.menu_bar/ui.context_menu plus a MenuBuilder scope for
item/separator/checkbox/radio entries and recursive nested .menu
cascades - kept as its own vocabulary (not mixed into the top-level
widget DSL) since menu-entry checkbox/radio would otherwise collide
with the existing ttk widget methods of the same name. Context menus
wire to any widget via handle.on_right_click(menu). Everything routes
through App#menu/App#command so entry-callback cleanup applies
automatically, including across rebuilds.
Ridge border + padding on the control column, centered message entry
text - both already expressible with the existing widget DSL, no new
teek-ui capability needed.
Window groups every wm subcommand alongside composite window-lifecycle
behaviors (on_close, grab_set/grab_release, modal) into one object
scoped by path, reached via App#window/Widget#window - the same
window-scoped operations were starting to pile up as more and more
window:-kwarg flat methods on App, most recently with the addition of
grab_set/grab_release/modal for gemba-style modal dialogs.

Wm and App's existing wm-related convenience methods now delegate to
Window internally without any change to their own behavior or
signatures - each is marked with a short @note pointing new code at
app.window(path) instead.
Exercises the new menu DSL (menu_bar/menu/item/checkbox/on_right_click)
against a real, already-working screen - File/Edit/Help dropdowns and a
right-click menu on the status label, sharing logic (and, for Details,
a bound var) with the equivalent buttons/checkbox already in the ctrl
panel.
Thin delegates to base teek's Teek::Window#modal/#grab_release - no
grab/focus/destroy-safety-net logic reimplemented here. Unlike
click/key/on_close, these don't queue before realize since a
not-yet-realized window has no Tk path to grab.
Generalizes gemba's ChildWindow module into a DSL primitive: title/
geometry/resizable/transient-to-parent/macOS shared-menubar setup at
realize, withdrawn by default, shown/hidden via Handle#show/#hide
(position near parent, deiconify+raise, and - when declared
modal: true - grab+focus via the existing modal primitive). ui.dialog
is the same underlying window with modal/resizable defaults flipped
for the common small-dialog case.

Windows previously realized immediately visible with nothing to
withdraw them; test_modal_handle.rb is updated to call .show before
modal/grab_release now that Tk's grab set requires a viewable window.
Screens works directly against DSL panel/window handles instead of
requiring a bespoke per-screen class - push conceals the current screen
and reveals the new one, pop reverses it, replace_current swaps the
current screen in place without changing stack depth. A :window handle
reveals/conceals through its own show/hide; anything else is packed to
fill its parent, or pack-forgotten. Adds Handle#app as the small escape
hatch Screens needed to issue those pack calls generically.

Also drops references to the private reference project this DSL was
modeled after from doc comments and changelogs, in favor of describing
the actual behavior directly.
ModalStack lets one dialog push another (Settings -> Replay Player) with
the previous one auto-re-shown once the new one is dismissed. It wraps
ui.screens internally for the actual reveal/conceal bookkeeping and adds
an on_enter/on_exit/on_focus_change callback lifecycle on top, for
pause/resume-style hooks around the whole modal session. Exposed as a
plain assignable ui.modal accessor, since its callbacks are mandatory and
app-specific rather than something that can be lazily defaulted.
…mise

ui.scrollable(x: false, y: true) { } auto-wires a ttk::scrollbar to its
content with zero -yscrollcommand/-xscrollcommand/scrollbar-widget code
in app code. A lone list/text_area/table/tree/canvas child gets a
scrollbar wired straight into its native Tk scrolling protocol; anything
else is wrapped in an embedded canvas + viewport frame instead, since
arbitrary widgets have no scrolling protocol to hook into - the viewport
tracks the canvas's width automatically unless horizontal scrolling is
on.

Adds sample/scrollable_ui.rb demoing both cases side by side.
The embedded canvas + viewport frame ui.scrollable uses for arbitrary
content had no mousewheel handling at all - a bare canvas has no default
MouseWheel binding, and neither do the widgets nested inside it, so
dragging the scrollbar worked but wheeling over the content did nothing.

Fixed by applying a shared bindtag to the canvas, its viewport, and every
widget already inside it, with the wheel handler bound once on that tag
rather than per-widget - the standard fix for wheel events not reaching
descendants layered on top of their container. Wires <MouseWheel>
(scaled to match Tk's own Scrollbar class binding) plus <Button-4>/
<Button-5> for X11/Tcl-8.6, and Shift+wheel for horizontal scrolling.
Native scrollable widgets (list/text_area/table/tree) already wheel-
scroll via Tk's own class bindings - confirmed unchanged, no fix needed
there.
Native scrollable widgets (list/text_area/table/tree) now auto-attach a
scrollbar wherever they're declared, opt-out via scroll: false; canvas
defaults the other way (scroll: false) since it's as often fixed drawing
as scrollable content. Three-level override, most specific wins: a
widget's own scroll:, then Teek::UI.app's own scroll: for the build, then
the global Teek::UI.auto_scroll/auto_scroll_canvas.

ui.scrollable narrows to its actual remaining job: wrapping arbitrary
content that has no Tk scrolling protocol of its own to hook into. The
silent native-vs-frame dispatch it used to do is gone.

Scrollbars now genuinely auto-hide when their content fits and reappear
when it overflows, for both cases, instead of always being shown.

Auto-attaching a scrollbar to a widget nobody explicitly wrapped means
inventing a wrapper frame it wasn't asking for - since Tk widgets can't
be reparented, that wrapper has to become the widget's real Tk parent.
RealizedNode now tracks that split: path (what a Handle acts on - always
the real widget) vs arrange_path (what the surrounding layout actually
packs/grids - the wrapper, when there is one).

Also condenses CHANGELOG.md into a terse feature list across the board -
mechanism/rationale explanations belong in the README, not there.
test.yml (both Tcl 9.0 and 8.6 jobs), ruby3-test.yml, and
windows-test.yml all built and ran the base and SDL2 suites but never
rake ui:test - teek-ui tests weren't running in CI at all. Added a "Run
UI tests" step to each, mirroring the existing SDL2 step's pattern.
ui.tabs is a container mapping to ttk::notebook; t.tab(label, name) { }
declares one page, valid only directly inside it. Each tab's frame is
added via ttk::notebook add, not pack/grid; its content is an ordinary
addressable DSL subtree. Handle#on_tab_changed surfaces
<<NotebookTabChanged>>, delivering the tab's own name if it has one,
otherwise its index. New tabs can be added at runtime via session.add,
reusing the same create() path the initial build already goes through.
Build methods (ui.button, ui.panel, ui.raw, ui.var, menu_bar,
context_menu) now raise Teek::UI::ClosedBuilderError if called after the
session has already realized, instead of silently appending a dead node
that never shows up. session.add's own scoped re-entry stays exempt.

Also documents the build block's actual model in the README: plain Ruby
run via one .call, keep it pure/fast, build on one thread.
jamescook added 25 commits July 14, 2026 21:10
Implements the same marked-address shape MenuEntryAddressing uses for
menu entries - a canvas item has no independent Tk path either, only
the canvas does. Public API and discovery (never through ui[:name],
since items are created dynamically post-realize with no Node)
unchanged.
EventBus is plain pub/sub with no Tk involvement, so it works before
realize. App-scoped rather than a global singleton - each session owns
its own instance, so two Teek::UI.app instances in the same process
never see each other's events.

For widgets that need to react to something without holding a direct
reference to whoever caused it - a shared handle or reactive var would
otherwise couple things that should stay decoupled.

Adds sample/event_bus_demo.rb - three independent panels reacting to
one event with zero direct references between them, contrasted against
the tangled alternative in its header comment.
Enables splitting a large build across files/components without a
flat global namespace fighting back. Scope is a real object (not
nil/a bare string) - TOP_LEVEL is one unmistakable sentinel checked
by identity, and every ui.component call gets a genuinely fresh Scope
instance, so two components never collide just because they share a
label (or none). Node#scope mirrors the existing Node#parent, giving
later scope-aware work (event resolution, cross-scope validation)
something to consume directly.

Document#create/#find gain scope: (default Scope::TOP_LEVEL), so
every existing caller - event-target resolution, session.add, the
whole existing test suite - is unaffected. ui.component splices its
content into whatever's already open rather than adding a container
layer, and threads the current scope through every node-creating
call.

Plain threaded-builder methods (def foo(ui) = ui.row { ... }) need
none of this and keep working exactly as before - ui.component is
opt-in, for when scope isolation itself is the point.
wire_event and the dangling-target validator both did a flat,
always-top-level document lookup for target:, so a binding declared
inside a component could never resolve a target within its own
component, and (once components share names) could in principle
resolve the wrong node entirely. Both now look up target: scoped to
the declaring node's own scope, preserving intra-component forward
references while treating a target that only exists in another scope
as genuinely dangling.
…ssing

ui.component previously returned nil, so a parent had no disciplined
way to reach a mounted component's own named widgets - only the flat
global ui[] (which deliberately never sees into a component's scope)
or a hand-threaded local variable. ui.component now returns a
ComponentHandle wrapping the component's own scope: .handle(:name) /
[:name] resolves within that scope only, mirroring ui[]'s nil-on-miss
behavior. This is the parent side of the component boundary - a
component can move to another file or mount under a different parent
with nothing external breaking, since callers never reach in by name.
…r one parent

Two mounts of the same component already got independent Document
entries via scope isolation, but allocate_path derived a node's Tk
path segment straight from its name, so mounting the same component
twice directly under one shared parent made both realize the same
path and Tcl raised "window name already exists". allocate_path now
tracks segments already used under each real parent path and only
suffixes a repeat the second time it sees one - the common,
non-colliding case keeps its exact path unchanged.
…tack push

Screens and ModalStack required every candidate screen's widgets to
exist up front, forcing a large multi-screen app into one build block.
A container built with lazy: true now stays out of the ambient
create/link tree walk entirely - Screens#push/ModalStack#push realize
it on demand, right before revealing, reusing the existing incremental
Realizer#realize_subtree. Handle gains a public #destroy! so a popped
screen can be torn down for good (releasing its callbacks) instead of
just concealed, and Screens#pop/ModalStack#pop now return the popped
screen so `ui.screens.pop&.destroy!` reads as one step. A reusable
component mounted from a lazy handle - the "fresh dialog every open"
ChildWindow pattern - just builds a new lazy: true mount per open.

Document#claim_path_segment (moved from Realizer, where it lived
per-instance) keeps Tk path disambiguation correct across every
separate realize pass a session creates over its lifetime, including a
lazily-realized screen sharing an internal name with another one
realized earlier in a different pass.

Promoted the ad hoc FakeApp/FakeWindow test doubles (previously
duplicated across two files) into shared test support, with a
signature-contract test that already caught two small stub/real
mismatches before they could mask a broken test.
A widget's own close button tearing down its containing window from
its own click handler is a real Tk hazard: ttk::button (and others)
queue their own internal bindings for that same click, which then run
against a widget destroy! already tore down, surfacing as an
"invalid command name" Application Error dialog. destroy! now checks
Teek.in_callback? and defers the actual teardown to the next after_idle
tick when called from inside one, so this pattern just works with no
manual ui.after wrapper needed; outside a callback it still destroys
synchronously. A defer: keyword overrides either way, and calling
destroy! again on the same handle while its own deferred teardown is
still pending is a safe no-op instead of double-scheduling.
Timers previously raised NotRealizedError if declared inside the build
block, forcing every tick loop out to a separate post-run_async step -
the one place teek-ui's own queue-then-wire pattern (already used for
on_* event bindings) hadn't been applied. #every/#after now queue a
Timer before realize and register against the live app inside
Session#realize's own atomic begin block; called after realize they
still delegate immediately as before. Genuinely realize-only methods
(session.app, modal/grab_release, dialogs, clipboard) are unchanged
but now share one uniform NotRealizedError message that names the fix,
and the README states that lifecycle rule once instead of per section.
Every other collaborator class (Wm, Winfo, Window, Clipboard, ...)
already lives under lib/teek/ - RepeatingTimer was the lone holdout,
defined inline at the tail of lib/teek.rb. Pure structural move, no
behavior change.
New Image class, modeled on Var's own build-vs-realize shape: the Tcl
image name is allocated in Ruby at build time, the backing
Teek::Photo (and the actual file load) only becomes real at realize.
Image#to_s returns the Tcl name, so passing it as a widget's image:
option - at build time or via a later handle.configure(image: ...) -
works through teek's existing generic option-value serialization with
no special-casing needed anywhere else.

Also fixes a bug this surfaced: Session#add never flushed vars/images
declared inside its own block, only newly-created widget nodes, so a
ui.var/ui.image declared inside session.add stayed permanently
unrealized. This broke the ChildWindow-style fresh-mount-per-open
pattern for anything referencing a var or a fresh image.
… attached

Handle#destroy! only ever cleared a node's own .realized slot - the
node stayed in its parent's children array and in Document's name
index forever, for the rest of the session's life. This broke the
exact "fresh mount per open, destroy on close" pattern the README
itself documents for repeatedly-opened dialogs: adding a new sibling
to a parent with any previously-destroyed (non-window) child crashed
arrange_children outright, a destroyed named widget permanently
blocked reusing its own name, and anything the destroyed node's opts
referenced (e.g. a loaded image) stayed reachable and uncollectable
indefinitely.

Node gained a document back-reference and #remove_child (symmetric
with #add_child); Document gained #unregister. destroy! now walks the
destroyed subtree unregistering every named node, then removes the
node from its own parent's children - clean removal, so a removed
child is simply never iterated again, no special-casing needed
anywhere else.
Aggregates tracked callback ids by their existing container tag
(:bind, :menu, :canvas_bind, :tag_bind, :widget_option, :wm_protocol),
counting individual ids rather than containers. A tag with nothing
currently tracked is absent from the result rather than present at
zero.
debug_info wraps CallbackRegistry#counts_by_tag with friendly key
names (event_bindings, menu_entries, canvas_item_binds, tag_binds,
widget_option_callbacks, window_close_handlers), realize-only like
the other diagnostics, and drops zero-count entries. run/run_async
gain a debug: false keyword that prints the same summary to STDERR -
run_async prints once after show, run prints once before and once
after mainloop.
Session#add's post-block flush calls realize_subtree once per new
child, so on the first child's own arrange_children call, later
siblings declared in the same add block are unrealized but not
lazy - the old filter (lazy? && !realized) let them through and
crashed on child.realized.arrange_path being nil. The correct,
more general condition is simply "not realized yet," regardless of
why - covers the lazy case and the mid-batch case uniformly.
Session#find_by_path(path) resolves a real Tk path back to a Handle -
the reverse of ui[:name] - via Document#find_by_path, matching only a
node's own realized path (never a scrollbar wrapper's arrange_path or
a menu entry's synthesized virtual path). Handle#events exposes every
event binding declared on a node so far; bind_event now records a
binding regardless of realize state instead of only when queued, so an
event bound after realize shows up too, not just build-time ones.
Handle#options dumps a widget's (or menu entry's) current options
straight from Tk's own bare configure/entryconfigure, parsed by a
small shared OptionDumpParsing module reused by both addressing
strategies.
A ttk::label floated over the bottom of the root window via place,
auto-dismissing after a default (overridable) duration through
Teek::App#after. The label is created once and reused on every call
rather than rebuilt, so calling toast again while one is showing
replaces its text and restarts the timer instead of stacking a second
one; the earlier pending dismiss is explicitly cancelled so it can
never fire late and hide the replacement.
All 27 draw/move method pairs, the state machine, timer, and geometry
math move into a new GoldbergEngine, ported onto the teek-ui DSL:
shape creation calls Handle's own line/oval/polygon/arc/rectangle/
text/bitmap directly, while tag-based operations (move/coords/delete/
bbox/scale/itemconfigure/itemcget/raise/lower/item-click) go through
same-named private wrappers over CanvasItem, keeping the dense
per-shape coordinate data a direct, checkable translation of the
original rather than a line-by-line rewrite.

The layout adds a canvas alongside the existing control panel (now
collapsible, matching the original), with the info message and a
Dismiss/show-panel button bar floated over the canvas via ui.overlay.
Works around two ttk quirks along the way: ttk::button doesn't accept
-background/-font as direct widget options (style-only), and
ttk::label's -background is silently ignored under macOS's Aqua theme,
so the floating info message uses a classic label instead, matching
what the original demo did here for the same reason.

Adds test_goldberg_ui alongside the existing goldberg smoke test.
Teek::UI::TreeInspector renders a Document's current shape as an ASCII
tree (type/name/text), and can optionally subscribe to a new minimal,
always-on internal event hook (Document#subscribe/#notify, the same
EventBus class Session's own public bus already uses) to record every
build-stack push/pop and node append as they happen. Document and Node
stay ignorant of tracing as a concept - they just notify a normally
empty subscriber list, so nothing is recorded unless a TreeInspector
has actually asked to trace.

WidgetDSL#current_path gives the current build-parent ancestry as a
readable breadcrumb (e.g. "column > row"), backed by a new
Node#display_name shared with the tree printer.

sample/dialogs/dialogs_ui_demo.rb now prints its own tree right after
building, as a live demonstration.
Thin delegation to Teek::App#busy, which already restores the cursor
even if the block raises - the wrapper only adds the realize-only
gating consistent with clipboard/message/open_file and the rest of
Session's escape-hatch methods.
TextContent is a new companion object (mirrors CanvasItem's own
shape) reached through one guarded Handle accessor - Handle itself
gains no widget-specific methods beyond that single entry point.

Covers content editing (insert/get/delete/replace/value/clear), named
formats (Tk's own "tag" concept, applied/removed/queried by range,
with leak-safe click/event bindings routed through the same tag_bind
interceptor teek core already registers), markers, search, view/
cursor/read-only state, and embedded images. Friendly names are
primary (format/apply_format/scroll_to/add_marker/insert_image, ...)
with the underlying Tk-named methods kept as plain aliases. Every
content-mutating method transparently lifts a read-only (state:
disabled) widget's restriction for its own duration and restores it
after, so an appending read-only log pane needs no caller-side state
handling.

Text indices pass through as Tk's own index strings verbatim (no
wrapper type), with :end/:cursor symbol shortcuts for the common
cases.
Direction A (friendly existed, Tk name was missing): CanvasItem
bring_to_front/send_to_back/bounds gain tk_raise/lower/bbox aliases
(not plain `raise`, which would shadow Kernel#raise).

Direction B (Tk name existed, friendly was missing): CanvasItem's
coords/coords= renamed to points/points= as primary (coords/coords=
kept as aliases); Handle#grab_release renamed to release_focus
(grab_release kept); Handle#oval renamed to ellipse (oval kept); menu
item/checkbox/radio's accelerator: option gains a shortcut: alias,
normalized before node creation.

All additive - every existing call site using either name keeps
working unchanged. README examples updated to lead with the friendly
names, plus a new alias reference table.
@jamescook jamescook changed the title Tk for mere humans UI builder Jul 16, 2026
SDL_mixer forbids freeing a Mix_Chunk that's still playing on any
channel - a chunk freed out from under a live channel corrupts
SDL_mixer's own internal channel state for every test that runs
afterward, not just the one that hit it. A single test failing before
reaching its own cleanup was enough to trigger this and cascade into
unrelated later tests.

Sound#destroy now halts any channel still playing its own chunk before
freeing it. A shared before_teardown (chained via Minitest's own
lifecycle hooks, guarded by the new Teek::SDL2.audio_open?) halts every
channel before any per-test teardown runs, so no chunk is ever freed
while playing regardless of how a test exits. playing?/channel_paused?
now raise for a -1 channel instead of silently returning SDL_mixer's
own aggregate count across all channels; halt/pause_channel/
resume_channel still accept -1 to mean "every channel", which the new
teardown itself relies on. Dropped wait_until from the synchronous
pause/resume assertions - it only masked the corruption, not a real
timing issue.
@jamescook
jamescook marked this pull request as ready for review July 16, 2026 01:30
@jamescook
jamescook merged commit 90f228e into main Jul 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant