Conversation
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.
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.
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
marked this pull request as ready for review
July 16, 2026 01:30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 andships as first-class.
The shape of it
until
.runrealizes it. A build is therefore headless-testable, and the whole treegets validated (dangling event targets, grid collisions, …) into one clear error
before a single Tk call happens.
column/row/gridinstead ofpack/grid/sticky/-weight; intent-named events (on_click/on_key) insteadof Tk event syntax; reactive variables; friendly names throughout, with the original
Tk names kept as aliases for anyone fluent.
splitting a big app across files, screens and modal-stack navigation, an event bus,
menus, managed windows/dialogs, canvas drawing, rich text, and images.
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.