Tryst::UI::WidgetDSL
The build surface: ui.<widget> methods that APPEND nodes to the Document tree. They never touch Tk - widgets become live only when the realizer runs at realize.
Included classes must provide @document (a Document) and @stack (an Array(Node), current-parent stack seeded with @document.root) - the real Session sets these up in its own #initialize, same as ruby's version. They must also provide #build_open? (a predicate the tree-mutating methods below check via #raise_if_closed! - true before the initial realize and again for the duration of an #add block, false otherwise).
@vars and @images default to empty arrays here (like @stack below), so Session needs no separate initialization for them - Session#realize reads both directly to realize every declared Var and Image before the widget tree itself realizes. @scope_stack is the naming-scope stack #component pushes and pops (seeded with Scope::TOP_LEVEL, so a build that never calls #component is one flat namespace) - see #component and #current_scope.
Only the generic leaf/container append machinery and the widget types built up across the tryst-ui epic's phases are ported here - see widget_type.cr's own doc comment for what's deferred and why. Every ui.<type> method below returns a Handle, matching ruby's own DSL exactly - most are one-line leaf_widget/container_widget macro calls (see those macros' own doc comments), left hand-written only where a type's real signature doesn't fit that plain shape (table's show: default, window's on_close:, tab/pane's positional label/ weight params, split's orientation:, spacer/cell/stretch/overlay's own bespoke shapes).
Instance methods
Look up a named widget declared in the current scope, raising KeyError if there's no such name - Crystal's convention throughout, where #[] raises and #[]? is the one that hands back nil.
Raising is what most lookups want, and reads far better where a
handle is being kept: @board = ui[:board] types as a Handle, so
a class can hold its widgets without a nil check per field. Reach
for #[]? when a name legitimately might not be there.
Look up a named widget declared in the current scope. nil if nothing by that name exists (yet, or ever).
Position the single widget declared in the block at (row, col) in the enclosing ui.grid. Only valid directly inside a grid's block.
colspan/rowspan widen the cell across neighbouring columns/rows. sticky/padx/pady/ipadx/ipady override this one cell's placement; left alone, the grid's own defaults apply (see Realizer#arrange_grid).
Raises ArgumentError if this cell's block builds anything other than exactly one widget.
Stack children top to bottom (#column) or left to right (#row). gap: is the pixels between children, pad: the pixels between the container's edge and its content, align: where each child sits on the cross axis (:start/:center/:end, or :stretch to fill it); a child declared grow: true takes the leftover space on the main axis. #panel/#group/#tab/#pane/#window stack like a column and take the same options; anywhere else these are rejected at validation - see LayoutIntentValidator for what honours what.
Precise, single-column control over grid columnconfigure - weight: and min_size: (-minsize), independently of #stretch's flat every-listed-column-gets-weight-1 shape. Only valid directly inside a grid's block.
Overloads ui.column (the flow container widget, defined above via container_widget) rather than colliding with it: that one's name: is Symbol?, so a call passing an Int32 index here - grid.column(0, ...) - only ever matches this overload.
Stack children top to bottom (#column) or left to right (#row). gap: is the pixels between children, pad: the pixels between the container's edge and its content, align: where each child sits on the cross axis (:start/:center/:end, or :stretch to fill it); a child declared grow: true takes the leftover space on the main axis. #panel/#group/#tab/#pane/#window stack like a column and take the same options; anywhere else these are rejected at validation - see LayoutIntentValidator for what honours what.
Opens an isolated naming scope for the duration of the block, so a reusable subtree (a card, a settings tab, a row template) can name its own widgets freely without colliding with another copy of itself, or with anything else in the document:
ui.component(:gamepad) { |comp| comp.button(:reset, text: "Reset") }
ui.component(:achievements) { |comp| comp.button(:reset, text: "Reset") }
Purely a naming boundary - no node of its own. Whatever the block declares attaches to the enclosing container exactly as it would without the wrapper, and realizes to the same Tk paths (two mounts requesting the same path segment under one parent get disambiguated - see Document#claim_path_segment). Every call opens a genuinely fresh Scope, even with the same label: label is for error messages and debugging, never identity.
Names are exactly as visible as the scope they were declared in. Inside the block, #[]/#[]? and an event's target: see only names declared inside this same component - NOT the enclosing scope's or the top level's, there is no lexical fallback up the chain - and from outside, a component's names are invisible. Handles are the way across the boundary: every ui.<widget> call still returns one, and a component's caller keeps whichever it needs, same as any other build.
Ordinary containers (row/column/panel/tab/...) never scope - a name declared inside a plain row is addressable from outside it. Only this method opens a scope, deliberately: the flat namespace stays the default for any build that doesn't ask for more.
The current build-parent ancestry, as a readable breadcrumb (e.g. "column > row") - derived from @stack, the one thing only the builder (not the Document) knows: which containers are currently open. Useful in a build-time error message or just to orient yourself while poking around mid-build. Returns "(top level)" when nothing is currently open.
A rule between sections. orientation: mirrors #split's own param name and values for API consistency.
A one-of-many chooser. values: lists the choices; bind: a Var to read or set the chosen one.
A Font value, buildable and inspectable with no interpreter at all (a plain record - see Font) - the canonical way to spell font: anywhere a widget or ui.style takes it, instead of Tk's own font-list syntax and brace quoting.
ui.font(size: 24) # default family at 24
ui.font("Helvetica", 18) # positional family, size
ui.font("Comic Sans MS", 14, bold: true) # spaces safe, modifiers as bools
A titled container: a panel with a caption drawn into its border, passed as text:. Stacks its children like #panel does, with the same spacing options.
A titled container: a panel with a caption drawn into its border, passed as text:. Stacks its children like #panel does, with the same spacing options.
Declare an image loaded from a file - any format Tk's own
image create photo -file accepts (PNG, GIF, ...). Its Tcl image
name is allocated now (no interpreter needed - it's just a
string), so a widget can name it as an image: option straight
away; the backing Tryst::Photo and the file load itself only
happen at realize (Session#realize runs Image#realize on every
declared Image before the widget tree itself realizes).
Pass it along as image: img.name - see Image on why an Image can't be an option value directly the way ruby-tryst's can.
The remaining arguments are forwarded to Tryst::Photo.new. Ruby forwards an opts Hash; they're spelled out here because Crystal can't splat one into a method with named parameters.
Owned by whichever container is currently open (the top of @stack) - Handle#destroy! releases every image a destroyed subtree owns this way, so a thumbnail declared inside a row that later gets destroyed and rebuilt doesn't accumulate a live Tk photo per cycle. See Node#images.
A numeric stepper: from:/to: bound the range, increment: sets the step, and bind: a Var to read or set the value.
An application-wide key binding, attached to the root window so it fires wherever the focus happens to be - the keyboard counterpart to a menu entry, and what actually makes a menu's shortcut: label true (that label draws the accelerator text and nothing more).
Takes the same spec as Handle#on_key: a friendly Symbol (:f2, :enter, :escape) or a raw Tk sequence ("<Control-s>").
Floats the single widget declared in the block on top of the enclosing ui.canvas, positioned at a fixed corner/edge/center anchor via Tk's place geometry manager - a "use sparingly" escape valve for the one legitimate absolute-position case (a status readout or button bar layered over canvas content), not a general-purpose layout mode. Stays correctly positioned across a canvas resize with nothing to redo by hand - place's relative coordinates are fractions of the canvas's current size, recomputed live by Tk on every resize. Only valid directly inside a ui.canvas block.
Raises ArgumentError if this overlay's block builds anything other than exactly one widget.
One region of a #split. weight: is how much of the leftover space this pane takes when the split is resized, relative to its siblings' weights; left unset, ttk::panedwindow's own default applies - a pane that keeps its size until the sash is dragged.
Only valid directly inside a ui.split block; raises ArgumentError anywhere else.
A plain frame. Stacks its children top to bottom exactly as #column does - the same gap:/pad:/align: apply, and a child's grow: takes the leftover height - so it needs no column inside for the common case; put a row/grid inside for any other shape.
A plain frame. Stacks its children top to bottom exactly as #column does - the same gap:/pad:/align: apply, and a child's grow: takes the leftover height - so it needs no column inside for the common case; put a row/grid inside for any other shape.
A progress bar: mode: (determinate/indeterminate), maximum: and value:, or bind: a Var to drive the position from code.
The build-time escape hatch. A widget has no Tk path yet during build, so acting on it directly mid-build can't work - #raw defers the block instead, running it at realize with the live app in scope. It's a closure, so it can still reference sibling widgets by name even if they're declared later - by the time any raw block runs, the whole tree has already been realized once over (same forward-reference guarantee an event target: gets).
Ditto, for a grid row.
A scrolling region for ORDINARY widgets - a long column of them, a form taller than its window. Only needed for content Tk can't scroll on its own: a list/text_area/tree/table/canvas already attaches its own scrollbar with no wrapper at all, driven by its own scroll: - so wrapping one in a ui.scrollable would nest two scrolling regions, not improve the one.
y: (default true) and x: (default false) pick which scrollbars it gets, and each auto-hides while its content fits. Wheel scrolling works anywhere over the region, including over a nested child.
With x: false, content is held at the visible width rather than its natural one, so it never ends up narrower than the region.
A scrolling region for ORDINARY widgets - a long column of them, a form taller than its window. Only needed for content Tk can't scroll on its own: a list/text_area/tree/table/canvas already attaches its own scrollbar with no wrapper at all, driven by its own scroll: - so wrapping one in a ui.scrollable would nest two scrolling regions, not improve the one.
y: (default true) and x: (default false) pick which scrollbars it gets, and each auto-hides while its content fits. Wheel scrolling works anywhere over the region, including over a nested child.
With x: false, content is held at the visible width rather than its natural one, so it never ends up narrower than the region.
A flexible gap - the named replacement for the "invisible spring row" trick (an empty row/column given all the leftover weight).
A draggable split - two or more #pane regions with a sash between them to resize by. orientation: :horizontal puts the panes side by side, so the sash is vertical; :vertical stacks them.
Mark which columns/rows of the enclosing ui.grid absorb leftover space - the named replacement for grid columnconfigure -weight. min_size:, if given, applies the same minimum pixel width/height (-minsize) to every listed column AND row - the common "these N columns share a floor" case. For a floor that differs per column, or a weight that isn't the flat 1 every listed index gets here, use #column/#row instead. Only valid directly inside a grid's block.
The named variant: configures "name.TWidgetClass" and returns a StyleRef to pass as a widget's own style: (ui.button(style: calc)), so nothing downstream ever has to know the ttk class or spell out the Prefix.TWidgetClass convention by hand. Same hover:/pressed:/ disabled:/focused: as the app-wide overload above.
Configure a ttk style, for the options ttk keeps on a style rather
than on the widget - a ttk::button has no -font of its own, so a
bigger label means a named style and style: on the widget. The
raw ttk name overload - name is exactly what ttk::style configure
takes, "Calc.TButton" lore and all. Stays working unchanged as the
escape hatch; #style(type:, name:) below is the DSL-language
spelling that doesn't require knowing ttk's own naming convention.
Deferred to realize like #raw, since it's a live-interpreter call.
Restyle a DSL widget type app-wide, by its type: (:button, :label, ...) - see TtkStyleNames for the full vocabulary - instead of ttk's own Prefix.TWidgetClass spelling. Every widget of that type picks up the change immediately; there's no name involved, and nothing to pass to a widget's own style: - see the two-arg overload below for a named variant instead.
hover:/pressed:/disabled:/focused: are each a NamedTuple of the
same options, translated into a ttk::style map state entry
(ttk's own per-state color mechanism, the one feature every ttk
app needs and nobody remembers the syntax for) - e.g.
style(:button, background: "#a00", hover: {background: "#c00"}).
One page of a #tabs notebook. label is positional and required - it's the text on the tab itself, and a page with none is meaningless. Give a name too to address the page later, including as what #on_tab_changed reports when this one is selected.
Only valid directly inside a ui.tabs block; raises ArgumentError anywhere else.
The same widget as #tree, for rows of fields rather than a hierarchy: name the fields with columns:. Scrolls itself unless scroll: false.
A tabbed notebook. Declare its pages with #tab inside the block.
A tabbed notebook. Declare its pages with #tab inside the block.
A multi-line text widget. Scrolls itself unless scroll: false - see #scrollable, which is for the widgets that can't.
A hierarchical treeview, showing the tree column Tk displays by default. Scrolls itself unless scroll: false.
Declare a reactive variable. Its Tcl variable name is allocated now (no interpreter needed - it's just a string); the variable itself only becomes real at realize (Session#realize runs Var#realize on every declared Var before the widget tree itself realizes). Bind it to a widget with bind:.
Owned by whichever container is currently open (the top of @stack) - Handle#destroy! releases every var a destroyed subtree owns this way, same as #image. See Node#vars.
Declares a widget of any registered type, by type name. The way to use a type registered from outside this library - every type built in here has its own method above, which reads better and is worth preferring where one exists.
Takes the same name:/bind:/**opts as those methods, and a leaf or a container depending on what the type registered itself as; pass a block for a container's children.
WidgetTypes.register(WidgetType.new(type: :gauge, tk_command: "ttk::progressbar"))
ui.widget(:gauge, :cpu, maximum: 100)
A shard wanting ui.gauge(...) instead can define it: WidgetDSL is a module, so reopening it puts a method alongside the built-in ones, calling the same #widget.
ditto, for a container with children.
A separate toplevel window. Configure it with title:, geometry: ("WxH+X+Y", or just "+X+Y"), resizable: (one Bool for both axes, {width:, height:}, or [width, height]), min_size:/max_size: ({width, height} Tuples, wm minsize/maxsize), transient: false to make it independent of its parent rather than subordinate to it, and modal: true to have #show grab input when it opens.
on_close: runs when the window manager's close button is used; handle.on_close { } does the same thing after the fact. It is its own parameter rather than one of **opts for the same reason bind: is: a value that has to arrive with its type intact, not flattened into TclArgValue and recovered.
Created withdrawn, so a build can declare every window the app will ever need without them all appearing at realize - reveal one with handle.show, hide it again with handle.hide.
Macros
ditto, for a container - both the with-block and without-block overloads every built-in container method below has.
module Tryst::UI::WidgetDSL
container_widget panel_deck
end
ui.panel_deck(:cards) { |dsl| dsl.button(:ok, text: "OK") }
Stamps a leaf ui.<type> method with the same name:/bind:/**opts shape every built-in leaf method below has - the macro version of what #widget's own doc comment describes doing by hand. See CUSTOM_WIDGETS.md at the repo root for the full guide; in short:
module Tryst::UI::WidgetDSL
leaf_widget gauge
end
ui.gauge(:cpu, maximum: 100)
reads exactly like a built-in #progress/#label/etc call, once type: :gauge is registered (see WidgetType's own doc comment for how). Defined before every method below so they can use it too - Crystal requires a macro to be defined before its call site.