Tryst::App
Inherits Tryst::UI::AppContract < Reference < Object
Ruby interface to Tcl/Tk (mirrors ruby-tryst's lib/tryst.rb). App wraps a Tryst::Interp - the low-level bridge - with the ergonomic API real applications use: creating widgets, evaluating Tcl code, running the event loop.
Constants
Symbol shorthands for #bind's substitution codes - Tk's own %-codes can always be passed directly instead (e.g. "%K") for anything not listed here. Mirrors ruby-tryst's App::BIND_SUBS (lib/tryst.rb).
Every raw %-code #bind accepts is exactly this shape - Tk's own bind(n) documents %% and %<one letter> as the complete set (%a, %b, ..., %A, %B, ..., plus the literal %% and %#). A raw sub that doesn't match this is rejected outright rather than spliced into the bound script: #bind builds that script by interpolation (the id/sub codes are meant to be the only moving parts, everything else is a fixed template), and Tk re-parses the WHOLE script as Tcl - after its own %-substitution - every time the event fires. A caller-controlled string here would therefore run as arbitrary Tcl on every firing, not just at bind time.
Constructors
Bootstraps a new App, running block with self rebound to the new instance (mirrors ruby-tryst's App.new { ... } via instance_eval, see epic notes: 'with instance yield' only works spliced in here, never inside #initialize itself - confirmed by direct test).
track_widgets: whether to populate #widgets as widgets are created (via a Tcl execution trace on every WIDGET_COMMANDS entry) - see #setup_widget_tracking. Callback cleanup on widget destruction (see #setup_destroy_cleanup) happens either way; ruby-tryst's debug-mode forcing of track_widgets on, and its Debugger integration, are out of scope for this port (see project notes on Debugger).
Instance methods
@api private - set by RepeatingTimer when a tick's on_error: :raise strategy fires, so the error surfaces from the next #update call instead of going through tryst_crystal_callback_dispatch's own rescue (which would just report it as a generic Tcl error).
Enable the Tk debug console, toggled with the given keyboard shortcut (default: F12). The console is a built-in interactive Tcl shell, useful for inspecting variables and running Tcl commands at runtime - available on macOS and Windows only; on Linux this is a no-op (Linux has a real terminal instead). @return true if the console was created, false if unavailable on this platform.
Add a directory to Tcl's package search path (::auto_path). Goes
through #tcl_invoke, not the string-interpolated lappend ::auto_path {#{path}} ruby-tryst builds - path is exactly the kind
of externally-supplied value #tcl_invoke exists to pass safely.
Schedule a one-shot timer. Calls the block after ms milliseconds. on_error: :raise (default) propagates to Tcl's background error handler, :ignore swallows it. Returns an AfterHandle - pass to #after_cancel to cancel.
As above, but hands the exception to on_error instead of applying a policy - see ErrorHandler.
Cancel a pending #after or #after_idle timer.
Schedule a block to run once when the event loop is idle. Returns an AfterHandle - pass to #after_cancel to cancel.
A window's macOS appearance: "aqua", "darkaqua", or "auto". Returns nil on every other platform.
On Tk 8.6 - all this port targets - the only way in is Tk's private
tk::unsupported namespace; the supported wm attributes -appearance
spelling arrived in Tk 9. Tk answers with the appearance a window had
before the call, and with an empty string if the window has no
NSWindow yet, so show and settle a window before trusting this.
Whether Tk is running on macOS's Aqua windowing system. Memoized -
the windowing system can't change for the life of the process, and
the block form caches false as readily as true (a plain ||= would
re-run the Tcl call every time on non-macOS).
Bind a Tk event on a widget, with optional substitutions forwarded as the block's Array(String) argument, in order requested. widget accepts a Widget, a path String, or a class tag (e.g. "Entry").
event is never Tk's own event-sequence syntax spelled by hand - see EventSpec.resolve for the full rules. In short: a bare Symbol resolves to whichever of Tk's own native events/keysyms it names (:return, :configure, :click), or a virtual event otherwise (:drop_file -> "<<DropFile>>"); an Array is always a native modifier combo ([:control, :s]). A raw Tk sequence String ("<<DropFile>>", "<Control-s>") remains a fully-supported escape hatch for anything the Symbol vocabulary doesn't cover yet.
subs: can be a single Symbol/String or an Array of them - Symbols map through BIND_SUBS, a String must be a raw Tk %-code.
@example Mouse click with window coordinates app.bind(".c", :click, subs: [:x, :y]) { |values, _signal| puts values.join(",") } @example No substitutions app.bind(".btn", :enter) { |_values, _signal| highlight } @example A custom virtual event with its own data app.bind(".", :drop_file, subs: :data) { |values, _signal| puts values[0] } @example A modifier combo app.bind(".", [:control, :s]) { save } @example Raw Tcl expression (for codes not in BIND_SUBS) app.bind(".c", :click, subs: "%T") { |values, _signal| ... }
owner: names the widget whose destruction releases this binding's callback, and defaults to widget itself - always right when widget IS a widget path. It matters only when binding to a BINDTAG, which is not a window and so never fires the <Destroy> that cleanup hangs off (see CallbackRegistry#forget_all_for_path): left alone, such a callback is tracked under a key nothing ever sweeps and lives as long as the process. Name the widget whose lifetime the tag actually follows - for a scroll region's shared wheel tag, the canvas being scrolled. This mirrors how every other non-window callback holder is already tracked (a menu entry under its menu, a text tag under its text widget, a canvas item under its canvas).
Leave it nil for a genuine CLASS tag ("Entry", "Button"): those outlive every individual widget on purpose, so there is no owner to name and nothing to release.
Converts a Crystal truthy/falsy value to a Tcl boolean string. See Tryst.bool_to_tcl.
Show a window AND put it in front with the keyboard focus - what launching an app should do, where #show alone only deiconifies. See Interp#bring_to_front for what the sequence is and why the -topmost pin has to be released again; the release needs one turn of the event loop, so call this before entering #mainloop (Session#run already does).
Only the Widget-or-path coercion lives here - window accepts a Widget, a path String, or :root, the same as #show/#hide.
Show the busy cursor on a window while the block runs, and return whatever the block returned. Defaults to the root window (:root). window accepts a Widget, a path String, or :root.
Tk's busy cursor also swallows mouse events for the window and its children while held, which is the point - it's how you stop a user clicking into a half-finished operation. The cursor is forgotten again even if the block raises, so a failure can't leave the window wedged looking busy forever.
Wraps a block as a Proc(Array(String), CallbackSignal, Nil), for
passing as a Proc-valued option to #command/#create_widget (e.g.
command:, validatecommand:) - app.command(:button, ".b", command: app.callback { ... }). A block passed as a **kwargs value (unlike
one passed directly to a method expecting &block) gets no help
inferring its parameter types from context, so it can't just declare
fewer params and have Crystal fill in the rest the way
#register_callback/#bind's blocks can - wrapping it here, where the
block IS passed directly to a &block parameter, is what gives it
that flexibility (0, 1, or 2 params, extras ignored) before handing
the correctly-typed result back as a plain value.
Built on first use. Constructing it takes self, so building it lazily keeps self from escaping mid-construction - which would mark every ivar not yet assigned at that point as nilable for good.
Show the native color picker dialog. initial: e.g. "#ff0000". Returns the chosen color as "#rrggbb", or nil if cancelled.
Show the native "choose directory" dialog. mustexist: restrict the choice to an already-existing directory (Tk's own default is false, allowing a not-yet-created one). Returns the chosen directory path, or nil if cancelled.
Show the native "choose file to open" dialog. filetypes: e.g. [{"PNG Images", ".png"}, {"All Files", "*"}] - the second element of each pair can also be an array of extensions ({"Images", [".png", ".jpg"]}). multiple: allow selecting more than one file. Returns the chosen path (an array of paths if multiple:), or nil if the dialog was cancelled.
Show the native "choose file to save" dialog. filetypes: see #choose_open_file. defaultextension: appended if the typed filename doesn't already have one. confirmoverwrite: ask before overwriting an existing file (Tk's own default is true; pass false to skip the confirmation). Returns the chosen path, or nil if cancelled.
Typed wrapper around Tk's clipboard command family - see
Clipboard. Built on first use, same reasoning as #winfo.
Same as the splat overload above, for callers that already have a built Array(TclArgValue)/Hash(String, TclArgValue) in hand (e.g. Realizer forwarding a Node's own opts, only known at runtime) - a runtime Array/Hash can't be re-splatted into another method's own *args/**kwargs (same reason #raw_command needed this exact overload
- see its own comment).
Build and evaluate a Tcl command from Crystal values. Positional args are converted: Symbols pass bare, Procs become callbacks (bind-shaped
- see #callback), everything else is brace-quoted via #tcl_arg_value. Keyword args become "-key value" option pairs.
Any Proc-valued arg or kwarg is tracked and released on overwrite, explicit removal, or the owning widget's destruction. Widget type is inferred automatically from calls shaped like widget creation (a WIDGET_COMMANDS name as cmd, the new path as the first positional arg).
Consults CommandInterceptors.for_type(type) for the widget type recorded at cmd's path (see #record_widget_type) before falling through to #raw_command's generic handling. Raises AmbiguousCommandError if more than one registered interceptor claims the same call. @example app.command(:pack, ".btn", side: :left, padx: 10)
evaluates: pack .btn -side left -padx {10}
Create a Tk widget and return a Widget wrapper.
Auto-generates a unique path if none is given, derived from the widget type and a monotonic counter. parent accepts a Widget, a path String, or nil. idempotent: skip the creation command if a widget already exists at path (see #menu) - for widgets meant to be fetched by a stable, caller-chosen path and reused across many calls, rather than freshly created each time.
@example Auto-named btn = app.create_widget("ttk::button", text: "Click")
btn.path => ".ttkbtn1"
@example Nested under a parent frm = app.create_widget("ttk::frame") btn = app.create_widget("ttk::button", parent: frm, text: "Click")
btn.path => ".ttkfrm1.ttkbtn1"
Whether a window is currently being displayed in dark mode. Always false off macOS. Reflects the window's own forced appearance when #set_appearance pinned one, and the system preference otherwise.
A live snapshot of App's own per-path bookkeeping that #widgets doesn't cover - "is something still tracking a destroyed widget's path." Currently just @widget_types_by_path (#record_widget_type, read by #command on every call): unlike #widgets, it's written unconditionally regardless of track_widgets:, so it needs its own way to confirm #setup_destroy_cleanup actually keeps it bounded rather than growing across a create/destroy loop. A key absent from the result means empty, not present-as-zero.
Destroy a widget and all its children. widget accepts a Widget, a path String, :root, or the default (the root window).
Destroying the root also deletes the interpreter (see #setup_destroy_cleanup) - once this returns, or once the callback it was called from has returned, every further Tcl call on this App raises TclError as already-deleted. There is nothing useful left to call by then anyway: Tk itself refuses every widget command after the root is gone.
Evaluate script once per App instance under name, skipping it on later calls. Meant for widget-behavior code that needs to define a Tcl-side helper proc without re-sending and re-parsing that definition on every call.
True while any event loop is actively servicing events - see Interp#event_loop_running?. @api private (RepeatingTimer).
Schedule a repeating timer. Calls the block every ms milliseconds until cancelled. The block runs on the main thread in the event loop, so it must be fast (don't block the UI). Returns a RepeatingTimer - call #cancel on it later.
@example Basic polling loop timer = app.every(50) { update_display } timer.cancel # stop later
As above, but hands each tick's exception to on_error instead of applying a policy. Only this form keeps the timer running after an error - see ErrorHandler.
A font's :ascent, :descent and :linespace in pixels. See Interp#font_metrics.
Get a Tcl variable's value. name accepts array-element and namespaced forms.
Release a grab previously set with #grab_set. Defaults to the root window ("."). window accepts a Widget or a path String. Prefer app.window(window).grab_release for new code - this flat method is kept for parity with ruby-tryst and just delegates there.
Set the input grab on a window. Defaults to the root window ("."). window accepts a Widget or a path String. Prefer app.window(window).grab_set for new code - this flat method is kept for parity with ruby-tryst and just delegates there.
Hide a window without destroying it. Defaults to the root window (:root). window accepts a Widget, a path String, or :root.
Enter the Tk event loop. Blocks until the application exits.
ruby-tryst warns here if running under IRB/Pry (mainloop would make the REPL unresponsive) - Crystal has no equivalent REPL culture to detect the same way, so that warning is skipped rather than forced into a shape that doesn't really fit. Raises a RepeatingTimer's on_error: :raise exception the same way #update does (see there), checked once per loop iteration so a timer error surfaces here too rather than sitting unread for the rest of the run - #update is never called while #mainloop owns the event loop.
Builds a properly-escaped Tcl list from Crystal strings. See Tryst.make_list.
How much of text fits within max_pixels (-1 for unlimited), as :bytes and their :width in pixels - for truncation, ellipsis and line wrapping. See Interp#measure_chars for the flags.
Show a message box with one or more buttons. icon: :error/:info/:question/:warning. type: :ok/:okcancel/:abortretryignore/:yesno/:yesnocancel/:retrycancel - which button(s) to show. default: which button is focused by default (e.g. :cancel); Tk's own choice if omitted. Returns the pressed button as a Symbol - :ok, :cancel, :yes, :no, :abort, :retry, or :ignore.
Make a window modal. Defaults to the root window ("."). window accepts a Widget or a path String. Prefer app.window(window).modal for new code - this flat method is kept for parity with ruby-tryst and just delegates there.
Make a window modal without a setup block. Defaults to the root window ("."). window accepts a Widget or a path String.
The platform window identifier behind a widget, for handing to something that draws into a window Tk owns. window accepts a Widget, a path String, or :root. Raises unless the widget is mapped - see Interp#native_window_handle.
Runs block off Tk's own thread and returns its result, raising whatever exception block raised instead - same contract as calling block synchronously would have, just relocated.
Route slow File I/O, DNS, and TLS calls through this rather than calling them directly: on Tk's thread those calls run bare, with the thread pinned (see syscall_guard.cr - Crystal's SYSMON would otherwise migrate the fiber onto a different OS thread mid-call, and Tcl requires every call into a given interpreter to come from the one OS thread that created it, forever), so Tk's event loop and every other fiber on it stall for as long as the call takes. A sub-millisecond settings read is harmless there; a network fetch is a frozen UI. Interp#check_thread_affinity! is the hard-error backstop should anything still reach Tk from the wrong thread.
new_thread: false (default) dispatches to Tryst::OffThreadWorker's single persistent, lazily-started thread - shared across every default-mode call in the process, so concurrent calls queue behind each other rather than running in parallel. Pass new_thread: true for a one-shot dedicated Isolated thread instead (same shape Tryst::BackgroundWork uses per task) when a call is slow/heavy enough that it shouldn't make other off_thread calls wait behind it.
@example content = app.off_thread { File.read(path) }
Register a handler for the window manager's close button. Defaults to the root window ("."). window accepts a Widget or a path String. Prefer app.window(window).on_close { } for new code - this flat method is kept for parity with ruby-tryst and just delegates there.
Same hook, scoped to owner's own lifetime via a WeakRef: the subscription is swept once owner is unreachable, rather than staying registered (and permanently unregisterable) for the life of the App the way the plain block form above is - what makes this one safe for a per-widget subscription instead of just a once-per-App one like Session's own node_destroyed hook.
block receives owner as an explicit argument for exactly this reason - write against that, not against self/an outer local, or the block itself pins owner reachable and defeats the WeakRef.
Register a callback fired with a widget's own Tk path immediately after Tk destroys it (see #setup_destroy_cleanup) - regardless of whether the destroy was explicit (an app-level #destroy call, or a tryst-ui Handle#destroy!) or implicit (the window manager's own close button, or Tk recursively destroying a descendant along with its parent). Core App has no opinion on what a "widget path" ultimately represents to a caller - tryst-ui's Session hangs its own Document-cleanup off this (see Document#node_destroyed), so an implicit destroy's bookkeeping converges on the exact same path an explicit one already used.
Every package this interpreter currently knows about - scans ::auto_path for package indexes first (see #scan_packages), so a package nothing has required yet still shows up.
Whether a package is already loaded (package required, not just
discoverable on ::auto_path) in this interpreter.
Every version of a package available on ::auto_path - scans first, same as #package_names.
Same as the splat overload above, for callers that already have a built Array(TclArgValue)/Hash(String, TclArgValue) in hand - a CommandInterceptors block, for instance, which receives exactly this shape and can't re-splat a runtime Array into another method's own *args (verified directly, same reason Tryst.make_list needed its own Enumerable overload: "argument to splat must be a tuple"). Mirrors ruby-tryst's raw_command being directly callable from within an interceptor.
The dumb Tcl builder underneath #command - no interceptor lookup, no per-widget-type awareness. Used by #command's own generic fallback; prefer #command, call this directly only from within a future interceptor. Any Proc here still gets registered as a real, working callback - it just isn't tracked for release the way #command's own kwargs are (see #track_widget_option_callbacks).
Built as a plain argv array passed to Interp#tcl_invoke (Tcl_EvalObjv) rather than a joined string handed to #tcl_eval, so no value needs escaping - unbalanced braces, $, [, newlines, whatever, all pass through verbatim.
Register a Crystal callable as a Tcl callback. The block's second argument is a Tryst::CallbackSignal - ignore it unless the callback needs to signal Tcl control flow (see Interp#register_callback).
Register widget as a native OS file-drop target. Once a real drag
actually lands on it, a <<DropFile>> virtual event fires with the
dropped path(s) as a Tcl list in its -data field:
app.register_drop_target(:root)
app.bind(:root, :drop_file, subs: :data) do |values, _signal|
paths = app.split_list(values[0])
puts "Dropped #{paths.size} file(s): #{paths.inspect}"
end
Currently a documented no-op: the native platform layer that
detects a real OS-level drag and fires the event (X11/XDND on
Linux, NSDraggingDestination on macOS, WM_DROPFILES on Windows) -
a genuine per-platform native-code undertaking, not something FFI
against an already-built system library covers - hasn't shipped
yet. Calling this today does nothing harmful and nothing useful;
<<DropFile>> can still be exercised directly for testing via
event generate widget <<DropFile>> -data {...} (see this
method's own spec cases), and any caller written against this API
today starts working for real the moment the native layer lands,
with no code change needed here.
Load a Tcl package into this interpreter. Raises TclError (with a clearer message naming the package) if it isn't found on ::auto_path - see #add_package_path.
Resets #create_widget's auto-naming counters back to zero, without touching any already-created widget. Test-only in practice (mirrors ruby-tryst's Tryst::TestWorker reset_widget_counters!, test/tryst_test_worker.rb) - the persistent Tk test worker calls this between tests so auto-named paths (".ttkbtn1", ...) don't keep incrementing across tests that never destroyed their own widgets.
A widget's on-screen content rectangle, formatted for macOS
screencapture -R: "x,y,w,h". Chrome-free by construction - a
window manager's title bar/shadow is a separate frame that winfo's
rootx/rooty/width/height never include. See scripts/screenshot.sh.
Force a window's macOS appearance, opting it out of the system light/dark preference; :auto hands it back. A no-op on every other platform. Not appearance=, since it takes the window to act on as well as the mode - the same reason #set_window_title isn't a setter.
Raw-value overload, for an appearance name Appearance doesn't cover.
Set a Tcl variable. Useful for widget textvariable and variable options. Goes through Tcl_SetVar directly (no re-parsing), so the value never needs escaping - braces, backslashes, $, [, whatever, all safe. name accepts array-element and namespaced forms.
Set a window's geometry (e.g. "400x300", "400x300+100+50"). Defaults to the root window ("."). window accepts a Widget or a path String.
Set a window's maximum size, as {width, height} in pixels. Defaults to the root window ("."). window accepts a Widget or a path String.
Set a window's minimum size, as {width, height} in pixels. Defaults to the root window ("."). window accepts a Widget or a path String.
Set whether a window is resizable. Defaults to the root window ("."). window accepts a Widget or a path String.
Set a window's title. Defaults to the root window ("."). window accepts a Widget or a path String.
Show a window. Defaults to the root window (:root). window accepts a Widget, a path String, or :root.
Splits a Tcl list string into a Ruby array of strings. See Tryst.split_list.
Typed wrapper around Tk's ttk::style command family - see Style.
Built on first use, same reasoning as #winfo.
Resolves a Crystal value to the plain string #raw_command passes as one tcl_invoke argv element - no Tcl quoting of any kind, since tcl_invoke (Tcl_EvalObjv) never re-parses its arguments.
Evaluate a raw Tcl script string and return the result. Prefer #tcl_invoke for building commands from Crystal values.
Invoke a Tcl command with pre-split arguments (no Tcl parsing).
The Tcl/Tk version actually loaded at runtime (e.g. "9.0.3") - see Interp#tcl_patch_level's own doc comment for why this, not the compile-time TCL_MAJOR_VERSION constant, is what a runtime feature-availability check should read.
Converts a Tcl boolean string to a Crystal Bool. See Tryst.tcl_to_bool.
Pixel width of text in a given font. See Interp#text_width - this
goes through Tk's C font API, not the slower Tcl font measure.
Remove an event binding previously set with #bind. Pass the same owner: #bind was given, or this reconciles a different container and the callback is never released.
See Interp#unsafe_ptr's own doc comment - the one deliberate escape hatch for a satellite shard's FFI that needs the real Tcl_Interp* (erased to Void*).
Process all pending events and idle callbacks, then return. Raises an exception a RepeatingTimer's on_error: :raise tick handling stashed via #_pending_exception= (see there for why it can't just raise directly from the tick).
A single toplevel window, addressed by path - groups wm
subcommands and composite window-lifecycle behaviors (#on_close,
#grab_set/#grab_release, #modal) into one object. Defaults to the
root window ("."). path accepts a Widget or a path String.
Get a window's current geometry. Defaults to the root window ("."). window accepts a Widget or a path String.
Get a window's maximum size. Defaults to the root window ("."). window accepts a Widget or a path String.
Get a window's minimum size. Defaults to the root window ("."). window accepts a Widget or a path String.
Get whether a window is resizable ({width_resizable, height_resizable}). Defaults to the root window ("."). window accepts a Widget or a path String.
Get a window's current title. Defaults to the root window ("."). window accepts a Widget or a path String.
Typed wrapper around Tk's winfo command family (width, exists?,
...) - see Winfo. Built on first use: constructing it takes self,
and doing that inside #initialize would mark every ivar not yet
assigned at that point as nilable for good.
Resolves font once and yields a FontHandle to measure many strings against it - for a per-glyph layout pass that would otherwise pay #text_width/#font_metrics/#measure_chars's own Tk_GetFont/Tk_FreeFont cost per glyph. See Interp#with_font.