Module: Teek::UI::WidgetDSL

Included in:
Session
Defined in:
lib/teek/ui/widget_dsl.rb

Overview

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.

Names are deliberately Tk-free (the litmus test: if decoding a name needs Tk knowledge, the name is wrong) - see the design sketch for the full vocabulary rationale.

Mixed into Session rather than living on a separate accessor, so the DSL reads as ui.button(...), not ui.widgets.button(...).

Included classes must provide @document (a Document), @stack (an Array of Node, current-parent stack seeded with @document.root), @scope_stack (an Array of Scope, current-scope stack seeded with [Scope::TOP_LEVEL] - see #component), @vars (an Array of Var), and @images (an Array of Image) - Session sets all five up in initialize. 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).

Constant Summary collapse

ORIENTATIONS =

Orientation values a ui.split accepts - the same plain words Tk's own -orient option uses, so no translation is needed at realize.

%i[horizontal vertical].freeze
%i[root window].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

A push/pop stack for modal window handles - see ModalStack. nil until assigned; unlike #screens it isn't created automatically, since its callbacks (on_enter:/on_exit:) are mandatory and app-specific: ui.modal = Teek::UI::ModalStack.new(on_enter:, on_exit:).

Returns:



348
349
350
# File 'lib/teek/ui/widget_dsl.rb', line 348

def modal
  @modal
end

Instance Method Details

#[](name) ⇒ Handle?

Look up a named widget declared in the CURRENT scope: at the top level outside any #component, that's everything built outside one; inside a component's own block, only that component's own names - a sibling component's (or the top level's) same-named node is never found this way, and vice versa. See #component.

Parameters:

  • name (Symbol)

Returns:



72
73
74
75
# File 'lib/teek/ui/widget_dsl.rb', line 72

def [](name)
  node = @document.find(name, scope: current_scope)
  node && Handle.new(node)
end

#box(name = nil, **opts, &block) ⇒ Object

box is a bare alternate spelling of panel - same node type, so the realizer only ever has to know about :panel.



52
53
54
# File 'lib/teek/ui/widget_dsl.rb', line 52

def box(name = nil, **opts, &block)
  append_container(:panel, name, opts, &block)
end

#cell(row:, col:, span: 1) {|_self| ... } ⇒ void

This method returns an undefined value.

Position the single widget declared in the block at (row, col) in the enclosing ui.grid. Only valid directly inside a grid's block.

Parameters:

  • row (Integer)
  • col (Integer)
  • span (Integer) (defaults to: 1)

    how many columns this cell spans

Yields:

  • (_self)

Yield Parameters:



95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/teek/ui/widget_dsl.rb', line 95

def cell(row:, col:, span: 1)
  grid_node = current_grid!('cell')

  before = grid_node.children.length
  yield self if block_given?
  placed = grid_node.children[before..]

  unless placed.length == 1
    raise ArgumentError, "cell needs exactly one widget declared in its block (got #{placed.length})"
  end

  node = placed.first
  node.layout = (node.layout || {}).merge(cell: { row: row, col: col, span: span })
end

#component(label = nil) {|c| ... } ⇒ ComponentHandle

Opens a fresh Scope around the block, so names declared inside it (ui.button(:save), ...) never collide with the same name used elsewhere - in another component, or at the top level - no matter how many components share the same (or no) label, since Scope identity, not label, is what makes two scopes distinct. Splices its content directly into whatever's currently open - this is scope isolation only, not an extra layer of nesting, so a component built inside ui.panel(:p) { } attaches as an ordinary child of :p, exactly like any other widget declared right there would. The common 80% case - a plain method that takes ui and appends into whatever's already open (def toolbar(ui) = ui.row { ... }) - needs none of this; reach for #component only when scope isolation itself is the point (reuse across files, avoiding name collisions).

The returned ComponentHandle is the disciplined way for the caller to reach into the component's own named widgets afterward (screen.handle(:action)/screen[:action]) - the global ui[] never sees into a component's scope (see #[]), so a component built in one file and mounted from another stays reachable only through the facade it hands back, not by guessing its internal names.

Parameters:

  • label (Symbol, String, nil) (defaults to: nil)

    a human-readable label for error messages/debugging - has no bearing on uniqueness

Yield Parameters:

  • c (self)

    build the component's content with the ordinary widget DSL, same as any other block

Returns:



324
325
326
327
328
329
330
331
332
333
334
# File 'lib/teek/ui/widget_dsl.rb', line 324

def component(label = nil, &block)
  raise_if_closed!
  scope = Scope.new(label, parent: current_scope)
  @scope_stack.push(scope)
  begin
    block.call(self) if block
  ensure
    @scope_stack.pop
  end
  ComponentHandle.new(@document, scope)
end

#context_menu(name = nil, **opts) {|m| ... } ⇒ Handle

A standalone popup menu - built the same declarative way as a menu_bar's dropdowns, but not attached to anything automatically. Wire it to a widget with handle.on_right_click(this).

Parameters:

  • name (Symbol, nil) (defaults to: nil)

Yield Parameters:

Returns:



239
240
241
242
243
244
245
# File 'lib/teek/ui/widget_dsl.rb', line 239

def context_menu(name = nil, **opts, &block)
  raise_if_closed!
  node = @document.create(type: :context_menu, name: name, opts: opts, scope: current_scope)
  @stack.last.add_child(node)
  build_menu_subtree(node, &block)
  Handle.new(node)
end

#current_pathString

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 (+"X added outside a Y; current parent: ##current_path"+) or just to orient yourself while poking around mid-build.

Returns:

  • (String)

    "(top level)" when nothing is currently open



84
85
86
87
# File 'lib/teek/ui/widget_dsl.rb', line 84

def current_path
  crumbs = @stack.reject { |node| node.type == :root }.map(&:display_name)
  crumbs.empty? ? '(top level)' : crumbs.join(' > ')
end

#dialog(name = nil, modal: true, resizable: false, **opts, &block) ⇒ Handle

window with dialog-appropriate defaults - modal and fixed-size, for the common "small modal window" case (confirmations, pickers). Same underlying node type as window, just different defaults for modal:/resizable: - both still overridable.

Returns:



61
62
63
# File 'lib/teek/ui/widget_dsl.rb', line 61

def dialog(name = nil, modal: true, resizable: false, **opts, &block)
  append_container(:window, name, opts.merge(modal: modal, resizable: resizable), &block)
end

#image(path, **opts) ⇒ Image

Declare an image, loaded from a file. Its Tcl image name is allocated now (no interpreter needed - it's just a string); the backing Photo - and the actual file load - only becomes real at realize. Pass it as a widget's image: option (or a later handle.configure(image: ...)) to display it.

Parameters:

  • path (String)

    path to an image file (any format Tk's own image create photo -file accepts - PNG, GIF, ...)

  • opts (Hash)

    forwarded to Photo.new - e.g. +width:+/+height:+/+format:+/+palette:+/+gamma:+

Returns:



289
290
291
292
293
294
295
# File 'lib/teek/ui/widget_dsl.rb', line 289

def image(path, **opts)
  raise_if_closed!
  @image_count = (@image_count || 0) + 1
  img = Image.new("teek_ui_image_#{@image_count}", path, opts)
  @images << img
  img
end

A window's menu bar - the row of top-level dropdowns (File/Edit/...) along its top edge. Valid at the top level of a build or directly inside ui.window - attaches to whichever of those it's declared in once realized.

Parameters:

  • name (Symbol, nil) (defaults to: nil)

Yield Parameters:

Returns:

Raises:

  • (ArgumentError)

    if declared anywhere other than the top level or directly inside ui.window



220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/teek/ui/widget_dsl.rb', line 220

def menu_bar(name = nil, **opts, &block)
  raise_if_closed!
  parent = @stack.last
  unless MENU_BAR_HOSTS.include?(parent.type)
    raise ArgumentError, "menu_bar can only be declared at the top level of a build or directly inside ui.window"
  end

  node = @document.create(type: :menu_bar, name: name, opts: opts, scope: current_scope)
  parent.add_child(node)
  build_menu_subtree(node, &block)
  Handle.new(node)
end

#overlay(at:) ⇒ void

This method returns an undefined value.

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.

Parameters:

Raises:

  • (ArgumentError)

    if declared anywhere other than directly inside ui.canvas, given an unrecognized at:, or its block builds anything other than exactly one widget



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/teek/ui/widget_dsl.rb', line 138

def overlay(at:)
  canvas_node = current_canvas!('overlay')
  unless OverlayAnchors::POSITIONS.key?(at)
    raise ArgumentError, "overlay's at: must be one of #{OverlayAnchors::POSITIONS.keys.join(', ')} (got #{at.inspect})"
  end

  before = canvas_node.children.length
  yield if block_given?
  placed = canvas_node.children[before..]

  unless placed.length == 1
    raise ArgumentError, "overlay needs exactly one widget declared in its block (got #{placed.length})"
  end

  node = placed.first
  node.layout = (node.layout || {}).merge(overlay: { at: at })
end

#pane(name = nil, weight: nil, **opts, &block) ⇒ Handle

One region of an enclosing ui.split. Only valid directly inside a ui.split block; its own block builds the pane's content with the ordinary widget DSL, same as any other container.

Parameters:

  • name (Symbol, nil) (defaults to: nil)

    for ui[:name] lookup, same as any widget

  • weight (Integer, nil) (defaults to: nil)

    how much of the leftover space this pane absorbs when the split is resized, relative to its sibling panes' weights - unset panes get Tk's own default (0, fixed size until dragged). The same plain word ttk::panedwindow itself uses for this.

Returns:

Raises:

  • (ArgumentError)

    if declared anywhere other than directly inside ui.split



200
201
202
203
204
# File 'lib/teek/ui/widget_dsl.rb', line 200

def pane(name = nil, weight: nil, **opts, &block)
  current_split!('pane')
  opts = weight.nil? ? opts : opts.merge(pane_weight: weight)
  append_container(:pane, name, opts, &block)
end

#raw {|app| ... } ⇒ nil

The build-time escape hatch. A widget has no Tk path yet during build, so app.command(handle.path, ...) mid-build can't work - ui.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 (ui[:other].path) 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 event target: gets). For anything after realize, a live Handle/session.app is the escape hatch instead - see the README for the full split.

Yield Parameters:

  • app (Teek::App)

Returns:

  • (nil)


258
259
260
261
262
263
# File 'lib/teek/ui/widget_dsl.rb', line 258

def raw(&block)
  raise_if_closed!
  node = @document.create(type: :raw_op, opts: { block: block })
  @stack.last.add_child(node)
  nil
end

#screensScreens

A push/pop stack for content screens - see Screens. One stack per build, created on first access.

Returns:



339
340
341
# File 'lib/teek/ui/widget_dsl.rb', line 339

def screens
  @screens ||= Screens.new(document: @document)
end

#split(name = nil, orientation: :horizontal, **opts) {|s| ... } ⇒ Handle

A draggable split - two or more #pane-declared regions, resizable by dragging the sash between them. Maps to ttk::panedwindow.

Parameters:

  • name (Symbol, nil) (defaults to: nil)

    for ui[:name] lookup, same as any widget

  • orientation (Symbol) (defaults to: :horizontal)

    :horizontal (panes side by side, a vertical sash) or :vertical (panes stacked, a horizontal sash)

Yield Parameters:

  • s (self)

    build panes with s.pane { ... }

Returns:

Raises:

  • (ArgumentError)

    if orientation isn't :horizontal or :vertical



181
182
183
184
185
186
187
# File 'lib/teek/ui/widget_dsl.rb', line 181

def split(name = nil, orientation: :horizontal, **opts, &block)
  unless ORIENTATIONS.include?(orientation)
    raise ArgumentError, "split's orientation must be :horizontal or :vertical (got #{orientation.inspect})"
  end

  append_container(:split, name, opts.merge(orient: orientation.to_s), &block)
end

#stretch(columns: [], rows: []) ⇒ void

This method returns an undefined value.

Mark which columns/rows of the enclosing ui.grid absorb leftover space - the named replacement for grid columnconfigure -weight. Only valid directly inside a grid's block.

Parameters:

  • columns (Array<Integer>) (defaults to: [])
  • rows (Array<Integer>) (defaults to: [])


116
117
118
119
120
121
# File 'lib/teek/ui/widget_dsl.rb', line 116

def stretch(columns: [], rows: [])
  grid_node = current_grid!('stretch')

  grid_node.opts[:stretch_columns] = Array(columns) if columns.any?
  grid_node.opts[:stretch_rows] = Array(rows) if rows.any?
end

#tab(label, name = nil, **opts, &block) ⇒ Handle

One page of an enclosing ui.tabs, labeled label in the tab bar. Only valid directly inside a ui.tabs block; its own block builds the pane's content with the ordinary widget DSL, same as any other container.

Parameters:

  • label (String)

    the tab's title, shown in the tab bar

  • name (Symbol, nil) (defaults to: nil)

    for ui[:name] lookup, same as any widget

Returns:

Raises:

  • (ArgumentError)

    if declared anywhere other than directly inside ui.tabs



164
165
166
167
# File 'lib/teek/ui/widget_dsl.rb', line 164

def tab(label, name = nil, **opts, &block)
  current_tabs!('tab')
  append_container(:tab, name, opts.merge(tab_label: label), &block)
end

#var(initial) ⇒ Var

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. Bind it to widgets with bind:.

Parameters:

  • initial (Object)

    initial value - its class decides how Var#value coerces later (Integer/Float/Boolean typed, else String)

Returns:



271
272
273
274
275
276
277
# File 'lib/teek/ui/widget_dsl.rb', line 271

def var(initial)
  raise_if_closed!
  @var_count = (@var_count || 0) + 1
  v = Var.new("::teek_ui_var_#{@var_count}", initial)
  @vars << v
  v
end