class

Tryst::Interp

Inherits Reference < Object

Bootstraps a Tcl interpreter with Tk loaded. Tk_Init always creates an implicit root window ("."), so #main_windows reads 1 right after #initialize - "no windows" isn't a reachable state once Tk is loaded, only "no additional windows beyond the implicit root".

Constants

BUTTON_EVENT_TYPES = {"Button", "ButtonPress", "ButtonRelease"}
CLICK_INTERVAL_MS = 100

Spacing of a synthesized repeat burst, in the milliseconds event generate -time speaks. Tk counts two clicks as a double when they land within 500ms of each other (NEARBY_MS in its own tkBind.c), so CLICK_INTERVAL_MS has to sit comfortably under that and REPEAT_GAP_MS comfortably over it.

DEFAULT_TIMER_INTERVAL_MS = 16
FINALIZER_QUEUE_CAPACITY = 4096

Fixed capacity for @finalizer_queue - see #queue_for_main_from_finalizer. Generous enough that a realistic burst of simultaneously-collected Photos (or anything else routed through it) never gets near it; a queue this size costs a preallocated array of 4096 Proc references (two pointers each), a trivial, one-time cost.

KEY_EVENT_TYPES = {"Key", "KeyPress", "KeyRelease"}
PRESS_HOLD_MS = 10
REPEAT_GAP_MS = 1000
REPEAT_MODIFIERS = {"Double" => 2, "Triple" => 3, "Quadruple" => 4}

Repeat modifiers Tk counts consecutive clicks for, and the event type tokens whose press/release pair those counts are derived from - see #simulate_event's #generate_repeated_event.

SYNTHETIC_TIME_LIMIT = 1000000000

event generate -time takes a 32-bit value (X's own Time), so the synthetic clock wraps well inside that rather than growing forever.

TCL_OK = 0

Constructors

Class methods

alert_thread(id : LibTcl::ThreadId) : Nil
Source
current_thread_id

Tcl_GetCurrentThread/Tcl_ThreadAlert wrapped here rather than exposed as raw LibTcl calls, so App#off_thread doesn't need its own LibTcl require - see #spin_until, the only other half of this.

Source
ensure_notifier_installed

Replaces Tcl's own default notifier with tryst's, once per process. Notifier.install_once (Linux) and NotifierMacOS.install_once (Darwin) - see notifier.cr's header comment for why on Linux, and notifier_macos.cr's for why on Darwin (Tcl's own default there spawns a raw-pthread background thread Boehm GC's stop-the-world can't safely handle). Windows still falls back to the plain poll+sleep loop in #mainloop below - neither notifier has ever been ported there (see each file's own header comment on why).

MUST run before this process's very first Tcl_FindExecutable / Tcl_CreateInterp, whichever comes first: either one runs Tcl_InitSubsystems, which initializes the calling thread's notifier exactly once, with whatever notifier is installed at that moment, and Tcl_SetNotifier afterwards swaps only the procs, never that thread's already-created notifier data. So a later install leaves the main thread on Tcl's default notifier for the life of the process, with tryst's procs being handed Tcl's own private data: WaitForEvent finds no ThreadState and returns at once (Tk's event loop busy-spins instead of waiting), and the first cross-thread Tcl_ThreadAlert - every App#off_thread completion - unboxes that data as a ThreadState and reads garbage. Seen live: gemba, where a DSL Font's #to_tcl reached Values.utility_interp before any Interp existed, then segfaulted in the alert proc once a boxart fetch finished after the spec's App was destroyed. Hence one entry point, called from both places that can be first.

Source
press_release_patterns(base_event : String) : Tuple(String, String)

The press/release patterns whose repetition Tk counts for a given base event: "<Button-1>" -> {"<ButtonPress-1>", "<ButtonRelease-1>"}. Modifiers are carried through untouched ("<Shift-Button-1>" keeps its Shift), and Tk's bare-detail button form ("<Double-1>", what a Treeview binding is usually written as) is recognized too - there's no type token to rewrite there, so the type is inserted.

A ButtonRelease/KeyRelease base yields the same pair as its press counterpart: it's the same physical repetition either way, only the member of the pair Tk matches the binding against differs.

Source
split_repeat_modifier(event : String) : Tuple(Int32, String)

Splits a leading repeat count off an event pattern: "<Double-Button-1>" becomes {2, "<Button-1>"}. Anything without one comes back as {1, event} untouched, so the common path is unaffected.

The modifier is matched anywhere in the pattern, not just first: Tk accepts modifiers in any order, so "<Shift-Double-1>" is as valid as "<Double-Shift-1>" and both have to lose the same token.

Source

Instance methods

bind(path : String, event : String, &block : Array(String), CallbackSignal -> Nil) : String

Binds a Tcl event (e.g. "<Key-a>", "<Button-1>") on a widget/path to a block, via Tcl's own bind command - reuses the same crystal_callback dispatch mechanism #register_callback already wires up for widget -command options, since a bind script is just another Tcl script Tcl runs on the event.

Source
bring_to_front(path : String = ".") : Nil

Show a toplevel and put it in front with the keyboard focus - what launching an app should do, and what a bare wm deiconify doesn't: a CLI-launched Tk process gets no foreground focus on macOS, so its window exists but sits behind the terminal that started it.

-topmost is set to jump the window forward and then RELEASED again on the next idle, which is the part worth getting right. A window left topmost floats above every later window - native modal dialogs included - so a file chooser or colour picker opens behind the window that asked for it and cannot be raised over it.

The release therefore needs one turn of the event loop: call this before #mainloop, not after. It's queued as a plain Tcl script (built by Tcl's own list, so a path is quoted rather than substituted) rather than a registered Crystal callback - there's nothing here that needs Crystal to run.

Wrapped in catch because the window may be destroyed by the time the idle script fires (e.g. specs that tear it down within one event-loop turn).

Source
callback_ids

Currently registered callback id strings - test/introspection use: asserting exactly which ids survive a release, not just how many. Mirrors ruby-tryst's Interp#callback_ids.

Source
create_console

Creates a Tk console window - a built-in interactive Tcl shell, useful for inspecting variables and running Tcl commands at runtime. Only available on macOS and Windows (Tk provides no equivalent on Linux, which has a real terminal instead); raises TclError there. Starts hidden - see App#add_debug_console for the visibility-toggle wrapper built on top of this. Mirrors ruby-tryst's Interp#create_console (ext/tryst/tcltkbridge.c).

Source
create_widget(kind : String, path : String, **options) : String

Creates a widget of the given kind (e.g. "button", "label", "frame") at the given Tk path (e.g. ".b", ".f.label1") with Tcl "-key value" options built from named args. Just a thin tcl_invoke wrapper - no widget class hierarchy yet.

Source
delete

Safe to call more than once. Tcl_DeleteInterp releases the Tcl_Interp struct outright - every method below guards against that via #ptr instead of touching @ptr directly.

Safe to call from inside a callback as well - the root window's <Destroy> is where App requests it - but only takes effect once no Tcl frame of this interpreter is left on the stack: Tcl itself would cope (Tcl_DeleteInterp on an interpreter that is mid-eval only marks it and frees it on release), but every FFI call this class makes between the request and the outermost frame's return would then raise as already-deleted, from inside code that was still legitimately running. So it's deferred to #leave_tcl_frame's last pop instead, which is a plain ordinary return from Tcl.

Source
dispatch_callback(id : String, args : Array(String)) : Tuple(LibC::Int, String | Nil)

Called by tryst_crystal_callback_dispatch (the C-callable trampoline)

  • not meant to be called directly. Returns the Tcl result code to report back to Tcl, paired with an error message (only set when the code is TCL_ERROR). A callback that calls signal.break! stops Tk from running any other bindings for this event (TCL_BREAK) instead of a plain success; an unhandled exception still becomes TCL_ERROR.

SELF-re-entrant dispatch (id already has a frame active on the stack) is queued rather than run inline - see #run_dispatch's own comment for why. A DIFFERENT id nested inside an active one still runs immediately, same as always; only a callback re-entering ITSELF is deferred.

Source
event_loop_running?

True while this interpreter is actively servicing Tk events - inside #mainloop, #pump_once, #spin_until or App#update. Anything armed while this reads false is being armed by code that isn't letting the loop run, so it can't expect to be serviced on time.

RepeatingTimer is the only caller: drift alone can't tell "this tick was late because something blocked the loop" apart from "this timer was armed during startup and nothing was pumping yet", and this is the difference. @api private

Source
event_sources

The sources registered through this interpreter and still live.

Source
font_metrics(font : String) : NamedTuple(ascent: Int32, descent: Int32, linespace: Int32)

A font's ascent and descent in pixels, plus the linespace Tk derives from them (their sum).

Source
main_windows
Source
mainloop(on_tick : -> Nil | Nil = nil) : Nil

Runs until every toplevel window has been closed. A plain blocking Tcl_DoOneEvent(TCL_ALL_EVENTS) wait - the obvious way to write this - stalls the whole OS thread it runs on: neither this loop nor #drain_main_queue calls Fiber.yield or #sleep, so nothing hands control back to Crystal's own fiber scheduler while it blocks. Any fiber spawned before #mainloop is entered (an HTTP::Client request, a socket accept loop, a sleep-driven poller) would silently never run another instruction, with no error or diagnostic.

On Linux/Windows this is fixed at the root: Tryst::Notifier (see notifier.cr) replaces Tcl's own notifier via Tcl_SetNotifier, so the blocking wait inside Tcl_DoOneEvent is what's actually made cooperative with Crystal's scheduler - Tcl_ALL_EVENTS itself is safe to call exactly as before.

macOS has no such fix available - confirmed from Tk's own real Aqua notifier source (macosx/tkMacOSXNotify.c / macosx/tclMacOSXNotify.c) that the actual wait there is CFRunLoopRunInMode, Apple's Cocoa run loop; real UI events (clicks, redraws) are delivered through that call via AppKit's own run-loop source, not via any fd a custom notifier could hand to Crystal's kqueue reactor. So on macOS: pump whatever Tk event is immediately available (non-blocking, like #pump_once), drain #queue_for_main, then #sleep briefly before the next iteration. #sleep specifically, not Fiber.yield alone - with nothing else ready, Fiber.yield just resumes this same fiber immediately, never consulting Crystal's IO event loop at all; #sleep actually suspends this fiber and hands control to the scheduler, which is what lets IO-bound fibers make progress. Trade-off there is a ~1ms floor on event latency and constant idle CPU, versus near-zero latency, in exchange for the rest of the program actually running - this is real, precedented prior art (every Python Tkinter+asyncio integration does exactly this), not a novel hack, just the fallback of last resort where no fd-level integration point exists.

Must run on the same thread that created this Interp regardless of platform (Tcl's own thread-affinity model, and on macOS Cocoa/ AppKit's main-thread requirement for Tk's Aqua backend) - see the Fiber::ExecutionContext::Isolated spike.

on_tick, if given, runs once per loop iteration after #drain_main_queue - App#mainloop's hook for raising a RepeatingTimer's stashed _pending_exception from this same blocking path App#update does, since this loop (unlike #update) never returns on its own for App#mainloop's caller to check between calls. Stops as soon as this interpreter has been deleted, too - the root window's <Destroy> requests exactly that (see App's destroy cleanup), and it takes effect the moment the Tcl_DoOneEvent it fired inside returns, so there's nothing left to drain or tick.

Source
measure_chars(font : String, text : String, max_pixels : Int32, partial_ok : Bool = false, whole_words : Bool = false, at_least_one : Bool = false) : NamedTuple(bytes: Int32, width: Int32)

How much of text fits within max_pixels, for truncation, ellipsis or line wrapping: bytes is how many bytes fit, width their actual pixel width. max_pixels of -1 means unlimited.

partial_ok stops at a character that only partly fits rather than before it; whole_words breaks on a word boundary instead of mid-word; at_least_one returns one character even when nothing fits, which is how you avoid an infinite loop in a wrapping routine.

Source
native_window_handle(path : String) : NativeWindow

The platform window identifier behind a widget path, for handing to something that draws into a window Tk owns - a GPU renderer, a video surface, a browser view.

REFUSES AN UNMAPPED WIDGET, deliberately. The identifier for one is either absent or not yet usable: on X11 the window has to process MapNotify before anything can be embedded in it, and a handle taken before that point looks perfectly valid and fails later, somewhere else. Pack or grid the widget and call #update first.

What comes back differs by platform, so the answer carries its own kind - see NativeWindow, and #covers_toplevel? in particular, which is the difference between a surface confined to one widget and one painting over the whole window.

Source
pack(*paths : String, **options) : String

Packs one or more widget paths with Tcl "-key value" geometry options built from named args (e.g. side: "left", padx: 10).

Source
photo_blank(name : String) : Nil
Source
photo_expand(name : String, width : Int32, height : Int32) : Nil
Source
photo_get_image(name : String, x : Int32 = 0, y : Int32 = 0, width : Int32 | Nil = nil, height : Int32 | Nil = nil) : NamedTuple(data: Bytes, width: Int32, height: Int32)

Read a rectangle of pixels back out as tightly packed RGBA bytes. width/height default to the rest of the image from (x, y), and a region running past the edge is clamped rather than rejected - only an origin that isn't inside the image at all is an error.

The returned data is always RGBA regardless of how Tk happens to store the image internally: the copy below reads through the block's own channel offsets, and substitutes an opaque alpha for an image with no alpha channel of its own.

Source
photo_get_pixel(name : String, x : Int32, y : Int32) : NamedTuple(r: Int32, g: Int32, b: Int32, a: Int32)

One pixel's channels, each 0-255.

Source
photo_get_size(name : String) : NamedTuple(width: Int32, height: Int32)
Source
photo_put_block(name : String, pixel_data : Bytes, width : Int32, height : Int32, x : Int32 = 0, y : Int32 = 0, format : PixelFormat = :rgba, composite : PhotoComposite = :set) : Nil

Write pixel_data (exactly widthheight4 bytes) into the named photo image, with its top-left corner at (x, y).

Source
photo_put_zoomed_block(name : String, pixel_data : Bytes, width : Int32, height : Int32, x : Int32 = 0, y : Int32 = 0, zoom_x : Int32 = 1, zoom_y : Int32 = 1, subsample_x : Int32 = 1, subsample_y : Int32 = 1, format : PixelFormat = :rgba, composite : PhotoComposite = :set) : Nil

As #photo_put_block, but scaling as it writes - one pass rather than a write followed by a copy. zoom replicates each source pixel (zoom 3 makes it 3x3); subsample skips source pixels (subsample 2 takes every other one).

Source
photo_set_size(name : String, width : Int32, height : Int32) : Nil
Source
pump_once

Non-blocking: processes whatever Tk event is immediately available (if any), then drains #queue_for_main requests - one iteration of what #mainloop's loop body does, without blocking. For tests that need to observe a #queue_for_main effect without waiting for a window to close (which is the only thing that ends #mainloop).

Source
pumping_events

Marks its block as one that services Tk events, for #event_loop_running?. A depth counter rather than a flag: nesting is normal (App#update called from a callback #mainloop dispatched, #spin_until from inside #mainloop), and the inner one returning must not report the outer loop as gone. @api private

Source
queue_for_main

The ONLY sanctioned way for code running in another execution context/thread to make this Interp do something - never call #tcl_eval/#tcl_invoke/#create_widget/etc. directly from a fiber that isn't the one that created this Interp, which is unsafe (see the Fiber::ExecutionContext::Isolated spike) and which Crystal, unlike Ruby's Ractor, does nothing to stop you from doing anyway. Queues the block onto a Channel; it runs on the main thread the next time #mainloop drains it (bounded by #mainloop's own ~1ms loop interval), not immediately.

Source
queue_for_main_from_finalizer(task : Proc(Nil)) : Nil

Like #queue_for_main, but safe to call from a GC finalizer, where #queue_for_main is not: Channel#send suspends the calling FIBER once @main_queue is full, and a GC finalizer has no guarantee that any other fiber will ever run again to drain it - an indefinite hang, not a brief wait. This queue is a plain, fixed-capacity Deque guarded by a Mutex held only across a single push/shift, so the calling fiber can never be left waiting on progress it doesn't control itself. Past FINALIZER_QUEUE_CAPACITY outstanding entries, a task is silently dropped rather than growing the Deque - see its declaration for why growth specifically isn't an option here.

Source
register_callback(relay_break : Bool = true, &block : Array(String), CallbackSignal -> Nil) : String

Registers a block to be called from Tcl and returns an id string. Pass "crystal_callback #{id}" as a widget's -command (or as part of a larger script) to wire it up. The block's second argument is a CallbackSignal - ignore it unless the callback needs to signal Tcl control flow; see relay_break above for when signal.break! actually takes effect. Mirrors ruby-tryst's Interp#register_callback.

Source
register_event_source(check : EventSource::Check, data : Pointer(Void) = Pointer(Void).null, interval : Time::Span = EventSource::DEFAULT_INTERVAL) : EventSource

Registers a callback Tcl will run on every pass of its event loop, for pumping a library that has an event queue of its own.

check must be a plain function pointer rather than a closure, and state reaches it through data. See Tryst::EventSource for why, and for what the callback may and may not do.

The source is unregistered automatically when this interpreter is deleted; #unregister on the returned object does it sooner.

Source
simulate_event(path : String, event : String, **options) : Nil

Synthesizes a real Tk event (e.g. "<Key-a>", "<Button-1>") on a widget/path via Tcl's event generate, deiconifying the path's toplevel and focusing it first - a withdrawn/unmapped window can't take real focus (focus -force silently no-ops: querying focus afterward still comes back empty) or deliver real events at all, and every toplevel ends up withdrawn between tests (see spec/support/tk_worker.cr's reset_tk_state!). This is the e2e way to exercise a #bind binding: unlike a widget's -command (which the widget's own "invoke" subcommand can trigger directly), there's no shortcut around real event delivery for a bind. Options become "-key value" pairs (e.g. x: 10, y: 10 for a mouse event's coordinates).

A "<Double-...>"/"<Triple-...>"/"<Quadruple-...>" pattern is expanded into repeated press/release pairs rather than passed through - see #generate_repeated_event for why that expansion is the only way to fire such a binding at all.

The target must be VIEWABLE, hence #ensure_viewable! - see there.

Source
spin_until

App#off_thread's in-callback path: blocks (this C stack, not the fiber) until the block returns true, servicing Tk's event loop meanwhile - the same vwait/update semantics Tk itself uses for a nested wait. Wrapped in #guarded_entry so this counts as the already-parked fiber re-entering (allowed), not a second one.

Source
tcl_eval(script : String) : String

Evaluates a full Tcl script string. Fine for static scripts, but don't build one out of untrusted/dynamic pieces via interpolation - use #tcl_invoke instead, which quotes each argument as a distinct Tcl_Obj rather than relying on Tcl's string-quoting rules.

Source
tcl_get_var(name : String) : String | Nil

Gets a Tcl variable's value (array-element and namespaced forms work), or nil if it doesn't exist. Mirrors ruby-tryst's Interp#tcl_get_var.

Source
tcl_invoke(args : Enumerable(String)) : String
Source
tcl_invoke(*args : String) : String

Invokes a single command with each argument passed as its own Tcl_Obj (via Tcl_EvalObjv) - the safe way to pass dynamic/untrusted values as arguments, since there's no string-quoting step where injection could creep in. Mirrors ruby-tryst's Interp#tcl_invoke.

Source
tcl_major_version

Just the major version number, parsed from #tcl_patch_level (9 for "9.0.3", 8 for "8.6.17").

Source
tcl_patch_level

The @[Link] lines' library lookup (top of this file) is heuristic, not a guarantee - particularly the Tcl 9.x arm, which falls back to Homebrew's unversioned tcl.pc/tk.pc that a system with both 8.6 and 9.x installed could plausibly resolve to the wrong one. tcl_patchLevel is a core interpreter global Tcl_CreateInterp itself sets up, so it's readable this early - no need to wait for Tcl_Init. A mismatch here means every version-shaped fun signature in this file (Tk_TextWidth's Tcl_Size parameter, the macOS window-handle symbol, ...) is wrong for what actually got linked, which is worse silently than loudly, so this runs before anything else touches the interpreter. The Tcl/Tk version this interpreter is ACTUALLY running - read straight off the loaded library's own tcl_patchLevel global (e.g. "9.0.3", "8.6.17"). The runtime counterpart to the compile-time TCL_MAJOR_VERSION constant: use THIS for any runtime decision about what a specific version supports (whether a Tk feature/photo format exists, say), since it reflects the library that's actually loaded rather than what the build was compiled to target - those two can disagree (see TCL_MAJOR_VERSION's own doc comment on the heuristic library lookup, and #check_tcl_major_version below, which is what catches that disagreement at startup). Reserve TCL_MAJOR_VERSION itself for what genuinely has to be resolved at COMPILE time - a raw C symbol/struct layout that differs by version

  • not as a stand-in for "what version is running" anywhere else.
Source
tcl_set_var(name : String, value : String) : String

Sets a Tcl variable (array-element and namespaced forms work). Goes through Tcl_SetVar2Ex (no re-parsing), so the value never needs escaping - braces, backslashes, $, [, whatever, all safe. Obj-based rather than the plain char*-based Tcl_SetVar2, so an embedded NUL in value survives (Tcl_SetVar2 has no length parameter - it would silently truncate at the first NUL). Mirrors ruby-tryst's Interp#tcl_set_var.

Source
text_width(font : String, text : String) : Int32

Pixel width of text rendered in font. font is any Tk font description - a named font ("TkDefaultFont") or a spec ("Helvetica 12").

Resolves and frees the font for this one call - see #with_font to measure many strings against the same font without paying that cost per call.

Source
unregister_callback(id : String) : Nil

Removes a previously registered callback by its id. Mirrors ruby-tryst's Interp#unregister_callback. Safe to call on an id that's already gone (a no-op) - callers like CallbackRegistry rely on this.

Source
unsafe_ptr

The raw Tcl_Interp* itself, erased to Void* - the ONE deliberate escape hatch past #ptr's own privacy, for a satellite shard's FFI that has to call directly into Tcl/Tk's C API with the real interpreter pointer (tryst-dnd, most notably: registering native OS drag-and-drop needs a real Tcl_Interp*/Tk_Window, which no Tcl-level command can hand back the way #native_window_handle's own winfo id escape hatch covers a platform window handle). Erased to Void* rather than LibTcl::Interp* so a caller outside this file never needs to reference this file's own LibTcl lib block at all - it's fully opaque either way (Tcl_Interp is never dereferenced, only ever handed to another Tcl/Tk C function), so nothing is lost by widening the type at this boundary. Goes through #ptr rather than @ptr directly, so this raises the same clear TclError after #delete that every other FFI call here does, instead of handing back a pointer to freed memory.

Source
wait_until(timeout : Time::Span = 1.second, & : -> Bool) : Bool

Pumps the event loop (non-blocking) until the block returns true or timeout elapses. For tests: the deterministic way to wait for an event/callback's effect to land instead of guessing a fixed sleep. Mirrors ruby-tryst's TestContext#wait_until (test/tryst_test_worker.rb), which does the same via repeated app.update calls.

Source
with_font(font : String, & : FontHandle -> T) : T forall T

Resolves a font description ONCE for the duration of the block, yielding a FontHandle to measure as many strings against it as needed - the batch counterpart to #text_width/#font_metrics/ #measure_chars, which each pay their own Tk_GetFont/Tk_FreeFont pair. A per-glyph layout loop is exactly the case this amortizes: one resolve/free pair for the whole pass instead of one per glyph.

Tk_GetFont hands back a reference into a shared, interpreter-wide font cache, so the matching Tk_FreeFont runs in an ensure - a leaked reference (e.g. the block raising) keeps that cache entry alive for the life of the process.

Source