Class: Teek::UI::Session

Inherits:
Object
  • Object
show all
Includes:
WidgetDSL
Defined in:
lib/teek/ui/session.rb

Overview

The object yielded to (and returned by) app - owns the build-phase Document and the realize/run lifecycle, and (via WidgetDSL) the ui.<widget> build surface itself.

Building is Tk-free: app never constructs a App, so the block runs (and #document is buildable/inspectable) with no interpreter at all. Nothing talks to Tk until #realize (called by #run and #run_async, or directly) actually creates one and walks the tree into it via Realizer.

Defined Under Namespace

Classes: Timer

Constant Summary collapse

DEFAULT_TOAST_DURATION_MS =

Milliseconds a #toast stays visible when no duration: is given.

1500

Constants included from WidgetDSL

WidgetDSL::MENU_BAR_HOSTS, WidgetDSL::ORIENTATIONS

Instance Attribute Summary collapse

Attributes included from WidgetDSL

#modal

Instance Method Summary collapse

Methods included from WidgetDSL

#[], #box, #cell, #component, #context_menu, #current_path, #dialog, #image, #menu_bar, #overlay, #pane, #raw, #screens, #split, #stretch, #tab, #var

Constructor Details

#initialize(title: nil, scroll: nil, app_opts: {}) ⇒ Session

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns a new instance of Session.



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/teek/ui/session.rb', line 42

def initialize(title: nil, scroll: nil, app_opts: {})
  @title = title
  @scroll = scroll
  @app_opts = app_opts
  @document = Document.new
  @stack = [@document.root]
  @scope_stack = [Scope::TOP_LEVEL]
  @vars = []
  @images = []
  @app = nil
  @in_add = false
  @bus = EventBus.new
  @timers = []
  @toast_path = nil
  @toast_timer_id = nil
end

Instance Attribute Details

#documentDocument (readonly)

Returns the build-phase tree - constructible and traversable with no interpreter, before or after realize.

Returns:

  • (Document)

    the build-phase tree - constructible and traversable with no interpreter, before or after realize.



31
32
33
# File 'lib/teek/ui/session.rb', line 31

def document
  @document
end

#imagesArray<Image> (readonly)

Returns images declared in this build - retained here for the session's whole lifetime, so a widget's image: never outlives the Photo it points at (see Image).

Returns:

  • (Array<Image>)

    images declared in this build - retained here for the session's whole lifetime, so a widget's image: never outlives the Photo it points at (see Image).



39
40
41
# File 'lib/teek/ui/session.rb', line 39

def images
  @images
end

#varsArray<Var> (readonly)

Returns reactive variables declared in this build.

Returns:

  • (Array<Var>)

    reactive variables declared in this build



34
35
36
# File 'lib/teek/ui/session.rb', line 34

def vars
  @vars
end

Instance Method Details

#add(parent_name) {|ui| ... } ⇒ nil

Build and immediately realize a subtree into the already-running app, as a child of an already-realized widget named parent_name - for dynamic UIs (adding cards/rows/menu entries at runtime), not just the initial build. The block uses the exact same widget DSL as everywhere else (a.button(...), a.column(...) { }, ...); new widgets show up immediately, routed through the same App#command/leak-cleanup path the initial realize uses, so destroying an added widget reclaims its callbacks the normal way.

Unlike the initial #realize, this does not run Validator - it's already-known-good territory (the session realized once already); validating one small addition on every call would be wasted work.

Parameters:

  • parent_name (Symbol)

    an already-realized widget's name

Yield Parameters:

  • ui (Session)

    the same builder, block-scoped under parent_name

Returns:

  • (nil)

Raises:

  • (NotRealizedError)

    if the session, or the named parent, isn't realized yet

  • (ArgumentError)

    if no widget is declared under parent_name



329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
# File 'lib/teek/ui/session.rb', line 329

def add(parent_name)
  raise_unless_realized!

  parent_node = @document.find(parent_name) or
    raise ArgumentError, "no widget named :#{parent_name} in this build"
  raise NotRealizedError, "##{parent_name} is not realized yet" unless parent_node.realized

  before = parent_node.children.length
  vars_before = @vars.length
  images_before = @images.length
  push_stack(parent_node)
  @in_add = true
  begin
    yield self if block_given?
  ensure
    @in_add = false
    pop_stack
  end

  # A var/image declared inside this block needs to be real before
  # the new widget subtree realizes, exactly like the initial
  # #realize orders them - a widget referencing one via
  # bind:/image: assumes it's already backed by the time IT gets
  # created (see Var#realize/Image#realize).
  @vars[vars_before..].each { |v| v.realize(@app) }
  @images[images_before..].each { |img| img.realize(@app) }

  realizer = Realizer.new(@app, @document, default_scroll: @scroll)
  # A lazy: true child built in this block (see WidgetDSL#append_container)
  # stays unrealized here too, exactly like one built during the
  # initial realize - it's realized later, on demand (see Handle#realize!).
  parent_node.children[before..].each { |child| realizer.realize_subtree(child, parent_node) unless child.lazy? }

  nil
end

#after(ms, on_error: :raise, &block) ⇒ Object?

Returns see #every.

Returns:

  • (Object, nil)

    see #every

See Also:



213
214
215
216
217
218
219
220
# File 'lib/teek/ui/session.rb', line 213

def after(ms, on_error: :raise, &block)
  if @app
    @app.after(ms, on_error: on_error, &block)
  else
    @timers << Timer.new(kind: :after, ms: ms, on_error: on_error, block: block)
    nil
  end
end

#appTeek::App

Returns the underlying app - the DSL's escape hatch. Anything the DSL doesn't wrap yet is one call away: ui.app.command(...).

Returns:

  • (Teek::App)

    the underlying app - the DSL's escape hatch. Anything the DSL doesn't wrap yet is one call away: ui.app.command(...).

Raises:



62
63
64
65
# File 'lib/teek/ui/session.rb', line 62

def app
  raise_unless_realized!
  @app
end

#busy(window: '.') { ... } ⇒ Object

Show a busy cursor over window for the duration of the block - App#busy already restores it even if the block raises, nothing extra to do here for that.

Parameters:

  • window (String) (defaults to: '.')

    Tk window path

Yields:

  • the work to perform while busy

Returns:

  • the block's return value

Raises:



273
274
275
276
# File 'lib/teek/ui/session.rb', line 273

def busy(window: '.', &block)
  raise_unless_realized!
  @app.busy(window: window, &block)
end

#choose_color(initial: nil, title: nil, parent: nil) ⇒ Object

Show the native color picker dialog.

Raises:

See Also:

  • App#choose_color


253
254
255
256
# File 'lib/teek/ui/session.rb', line 253

def choose_color(initial: nil, title: nil, parent: nil)
  raise_unless_realized!
  @app.choose_color(initial: initial, title: title, parent: parent)
end

#choose_dir(initialdir: nil, mustexist: false, title: nil, parent: nil) ⇒ Object

Show the native "choose directory" dialog.

Raises:

See Also:

  • App#choose_dir


261
262
263
264
# File 'lib/teek/ui/session.rb', line 261

def choose_dir(initialdir: nil, mustexist: false, title: nil, parent: nil)
  raise_unless_realized!
  @app.choose_dir(initialdir: initialdir, mustexist: mustexist, title: title, parent: parent)
end

#clipboardTeek::Clipboard

Returns +.set(text)+/+.get+/+.clear+ - text widgets don't need this at all for their own copy/cut/paste (Tk wires that to the platform's expected keys already); this is for reading/writing the clipboard directly from app code.

Returns:

  • (Teek::Clipboard)

    +.set(text)+/+.get+/+.clear+ - text widgets don't need this at all for their own copy/cut/paste (Tk wires that to the platform's expected keys already); this is for reading/writing the clipboard directly from app code.

Raises:



283
284
285
286
# File 'lib/teek/ui/session.rb', line 283

def clipboard
  raise_unless_realized!
  @app.clipboard
end

#debug_infoHash{Symbol => Integer}

A live snapshot of currently-registered callbacks, grouped by what registered them - "is my app leaking callbacks, and where." A tag absent from the result means nothing of that kind is currently registered (not a zero entry). Safe to call any time after realize; see run/run_async's debug: for printing this automatically instead of calling it yourself.

Returns:

  • (Hash{Symbol => Integer})

    :event_bindings, :menu_entries, :canvas_item_binds, :tag_binds, :widget_option_callbacks (+-command+/+-textvariable+/etc.), :window_close_handlers

Raises:



77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/teek/ui/session.rb', line 77

def debug_info
  raise_unless_realized!
  counts = @app.callback_registry.counts_by_tag
  {
    event_bindings: counts[:bind],
    menu_entries: counts[:menu],
    canvas_item_binds: counts[:canvas_bind],
    tag_binds: counts[:tag_bind],
    widget_option_callbacks: counts[:widget_option],
    window_close_handlers: counts[:wm_protocol],
  }.reject { |_, count| count.zero? }
end

#emit(event, *args, **kwargs) ⇒ void

This method returns an undefined value.

See Also:



181
182
183
# File 'lib/teek/ui/session.rb', line 181

def emit(event, *args, **kwargs)
  @bus.emit(event, *args, **kwargs)
end

#every(ms, on_error: :raise, &block) ⇒ Object?

Same queue-then-wire shape as an on_* event binding: called inside the build block, it queues and registers once the tree realizes; called after, it registers immediately - same method, correct behavior either way, so a tick loop can be declared right alongside the UI it drives instead of being forced out to a separate post-run_async step.

Returns:

  • (Object, nil)

    the live timer object (.cancel-able) once realized; nil if queued - there's no live timer to hand back yet, since nothing has registered with Tcl at that point

See Also:

  • App#every


201
202
203
204
205
206
207
208
# File 'lib/teek/ui/session.rb', line 201

def every(ms, on_error: :raise, &block)
  if @app
    @app.every(ms, on_error: on_error, &block)
  else
    @timers << Timer.new(kind: :every, ms: ms, on_error: on_error, block: block)
    nil
  end
end

#find_by_path(path) ⇒ Handle?

Reverse lookup: given a real Tk path (from an error message, a winfo query, or poking around in a REPL), find which widget it belongs to - the counterpart to the name-based ui[:name]. See Document#find_by_path for exactly what counts as a match.

Parameters:

  • path (String)

    e.g. ".toolbar.save"

Returns:

Raises:



97
98
99
100
101
# File 'lib/teek/ui/session.rb', line 97

def find_by_path(path)
  raise_unless_realized!
  node = @document.find_by_path(path)
  node && Handle.new(node)
end

#message(message:, title: nil, detail: nil, icon: :info, type: :ok, default: nil, parent: nil) ⇒ Object

Show a message box with one or more buttons.

Raises:

See Also:

  • App#message_box


244
245
246
247
248
# File 'lib/teek/ui/session.rb', line 244

def message(message:, title: nil, detail: nil, icon: :info, type: :ok, default: nil, parent: nil)
  raise_unless_realized!
  @app.message_box(message: message, title: title, detail: detail, icon: icon,
                    type: type, default: default, parent: parent)
end

#off(event, block) ⇒ void

This method returns an undefined value.

See Also:



187
188
189
# File 'lib/teek/ui/session.rb', line 187

def off(event, block)
  @bus.off(event, block)
end

#on(event, &block) ⇒ Proc

Returns the block, to pass to a later #off.

Returns:

  • (Proc)

    the block, to pass to a later #off

See Also:



175
176
177
# File 'lib/teek/ui/session.rb', line 175

def on(event, &block)
  @bus.on(event, &block)
end

#open_file(filetypes: nil, initialdir: nil, initialfile: nil, title: nil, multiple: false, parent: nil) ⇒ Object

Show the native "choose file to open" dialog.

Raises:

See Also:

  • App#choose_open_file


225
226
227
228
229
# File 'lib/teek/ui/session.rb', line 225

def open_file(filetypes: nil, initialdir: nil, initialfile: nil, title: nil, multiple: false, parent: nil)
  raise_unless_realized!
  @app.choose_open_file(filetypes: filetypes, initialdir: initialdir, initialfile: initialfile,
                         title: title, multiple: multiple, parent: parent)
end

#realize(strict: false) ⇒ Teek::App

Validate the build tree, then create the underlying App and realize the tree into it, if that hasn't happened yet. Idempotent - calling it again after the first time just returns the same app.

Atomic in two senses: a validation failure means no interpreter is ever constructed at all, and even once realizing starts, the app's root window stays withdrawn until the whole tree is realized, so a mid-realize error never leaves a half-built window visible either way. On failure the session is left exactly as if #realize had never been called - it isn't left half-realized (or half-validated).

Parameters:

Returns:

  • (Teek::App)

Raises:



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/teek/ui/session.rb', line 116

def realize(strict: false)
  return @app if @app

  Validator.validate!(@document, strict: strict)

  app = Teek::App.new(title: @title, **@app_opts)
  begin
    # vars/images realize first, so a widget bound/pointed to one
    # displays correctly (a value, a loaded image) from the moment
    # it's created instead of starting blank/broken.
    @vars.each { |v| v.realize(app) }
    @images.each { |img| img.realize(app) }
    Realizer.new(app, @document, default_scroll: @scroll).realize
    flush_timers!(app)
  rescue
    app.destroy
    raise
  end
  @app = app
end

#run(strict: false, debug: false) ⇒ void

This method returns an undefined value.

Realize, show the window, and enter the Tk event loop. Blocks until the app exits.

Parameters:

  • strict (Boolean) (defaults to: false)
  • debug (Boolean) (defaults to: false)

    print #debug_info's summary to stderr, once right before entering the event loop and once after it returns - eyeball whether the app leaked any callbacks over its run without writing any diagnostic code yourself.



145
146
147
148
149
150
151
# File 'lib/teek/ui/session.rb', line 145

def run(strict: false, debug: false)
  realize(strict: strict)
  @app.show
  print_debug_info if debug
  @app.mainloop
  print_debug_info if debug
end

#run_async(strict: false, debug: false) ⇒ self

Note:

this does not (yet) service the event loop automatically between REPL prompts - call ui.app.update yourself to process pending events while exploring interactively, the same manual-pump workaround App#mainloop's own REPL warning documents. A REPL session helper that services the loop for you on its own is future work, not built yet.

Realize and show the window without entering the event loop, for interactive/REPL use. Returns immediately.

Parameters:

  • strict (Boolean) (defaults to: false)
  • debug (Boolean) (defaults to: false)

    print #debug_info's summary to stderr right after realize

Returns:

  • (self)


166
167
168
169
170
171
# File 'lib/teek/ui/session.rb', line 166

def run_async(strict: false, debug: false)
  realize(strict: strict)
  @app.show
  print_debug_info if debug
  self
end

#save_file(filetypes: nil, initialdir: nil, initialfile: nil, title: nil, defaultextension: nil, confirmoverwrite: true, parent: nil) ⇒ Object

Show the native "choose file to save" dialog.

Raises:

See Also:

  • App#choose_save_file


234
235
236
237
238
239
# File 'lib/teek/ui/session.rb', line 234

def save_file(filetypes: nil, initialdir: nil, initialfile: nil, title: nil,
               defaultextension: nil, confirmoverwrite: true, parent: nil)
  raise_unless_realized!
  @app.choose_save_file(filetypes: filetypes, initialdir: initialdir, initialfile: initialfile, title: title,
                         defaultextension: defaultextension, confirmoverwrite: confirmoverwrite, parent: parent)
end

#toast(message, duration: DEFAULT_TOAST_DURATION_MS) ⇒ void

This method returns an undefined value.

Briefly flash a message near the bottom of the window - "Saved" after a save action, "Settings" when a modal gains focus, that kind of transient feedback, not a persistent status. Reuses one widget across every call rather than building a new one each time: calling this again while a toast is already showing replaces it (new text, restarted timer) instead of stacking a second one, and the earlier toast's own pending auto-dismiss is cancelled so it can never fire late and hide the replacement.

Parameters:

  • message (String)
  • duration (Integer) (defaults to: DEFAULT_TOAST_DURATION_MS)

    milliseconds before it auto-dismisses

Raises:



303
304
305
306
307
308
309
310
# File 'lib/teek/ui/session.rb', line 303

def toast(message, duration: DEFAULT_TOAST_DURATION_MS)
  raise_unless_realized!
  ensure_toast_widget
  @app.command(@toast_path, :configure, text: message)
  @app.command(:place, @toast_path, in: '.', relx: 0.5, rely: 1.0, anchor: 's', y: -12)
  @app.after_cancel(@toast_timer_id) if @toast_timer_id
  @toast_timer_id = @app.after(duration) { @app.command(:place, :forget, @toast_path) }
end