github.com/jamescook/tryst
0.1.0 / published Sep 1, 2026 / repository
Tcl/Tk bindings for Crystal, with a declarative UI DSL on top.
tryst
Tcl/Tk bindings for Crystal, with a declarative UI DSL on top.
Tryst builds real desktop apps — native widgets, menus, dialogs, canvases — from plain Crystal, compiled to a single binary, with Tcl/Tk as the only system dependency.
[!WARNING] Early development. Alpha quality. Expect breaking changes. This is under active, early development. Things may change — including public APIs — commit-to-commit without notice. No tagged version yet. It's developed and tested on macOS (Aqua) and Linux (X11), with a full spec suite on Tcl/Tk 8.6 and 9.x, but nothing has shipped as a release. Pin a commit if you need stability. Built with AI assistance.
require "tryst/ui"
clicks = 0
Tryst::UI.app(title: "Counter") do |ui|
message = ui.var("no clicks yet")
ui.column do
ui.label(bind: message)
ui.button(text: "Click me").on_action do
clicks += 1
message.value = "clicked #{clicks} time#{clicks == 1 ? "" : "s"}"
end
end
end.run
Requirements
- Crystal >= 1.21.0
- Tcl/Tk 8.6 minimum (9.x also supported, see Tests below)
- Windows: Crystal and Tcl/Tk both come from MSYS2, not their usual installers
See INSTALL.md for per-platform setup.
Installation
dependencies:
tryst:
github: jamescook/tryst
The UI DSL
Tryst::UI.app yields a builder. Everything declared in the block is a plain
tree, and the window is created and shown in one step by run. Because
building the tree never touches Tcl/Tk, your UI structure can be constructed
and inspected in specs without a display.
Widgets are the ones you'd expect: button, label, text_box,
text_area, checkbox, radio, dropdown, slider, number_box,
progress, list, tree, table, canvas. Containers arrange them:
column, row, grid, panel, group, tabs, split, scrollable,
and window for additional toplevels. Lists, tables, text areas and
trees attach their own scrollbars automatically.
A grid places widgets by cell, and stretch names which rows or columns
absorb resize:
Tryst::UI.app(title: "Login") do |ui|
user = ui.var("")
pass = ui.var("")
ui.grid(gap: 4) do
ui.cell(row: 0, col: 0) { ui.label(text: "User") }
ui.cell(row: 0, col: 1, sticky: :ew) { ui.text_box(bind: user) }
ui.cell(row: 1, col: 0) { ui.label(text: "Password") }
ui.cell(row: 1, col: 1, sticky: :ew) { ui.text_box(bind: pass, show: "*") }
ui.cell(row: 2, col: 1, sticky: :e) do
ui.button(text: "Sign in").on_action { authenticate(user.value, pass.value) }
end
ui.stretch(columns: [1])
end
end.run
Reactive variables
ui.var declares a value that widgets bind to with bind:. Set
var.value from code and every bound widget updates; type into a bound
text_box and var.value reflects it. This is the seam that keeps
application logic out of the UI: a service publishes changes, the UI
subscribes and pushes them into a var
(examples/calculator_ui/ is a complete worked
example of the split — its service runs in specs with no interpreter at
all).
Names and handles
Every widget method returns a handle, and widgets can be named:
ui.button(:save, text: "Save")
# later, from anywhere with the session in scope:
ui[:save].configure(state: :disabled)
Handles configure, show/hide, destroy, and wire events after the fact
(on_action, on_close, key and mouse bindings). For anything else,
on binds an arbitrary event:
ui[:save].on(:enter) { ui[:save].configure(cursor: "hand2") }
ui[:save].on([:control, :s]) { save }
Names live in one flat namespace for the whole build by default, and
declaring the same name twice is an ArgumentError the moment it
happens — nesting a widget inside a row or a tab doesn't make its
name any more local. That's the right default for an app that names a
handful of load-bearing widgets, and the wrong one for a reusable piece
(a card, a settings tab) that wants to name its own parts and be
mounted more than once. component opens an isolated namespace for
exactly that:
ui.tabs do
ui.component(:gamepad) do |c|
c.tab("Gamepad") { c.button(:reset, text: "Reset") }
end
ui.component(:achievements) do |c|
c.tab("Achievements") { c.button(:reset, text: "Reset") }
end
end
Both :reset buttons coexist. A component is a naming boundary and
nothing more — no widget of its own, and its children land in the
enclosing container as if the wrapper weren't there. Names are exactly
as visible as the scope they were declared in: ui[:reset] outside
either component finds nothing, c[:reset] inside one finds its own,
and neither can see the other's or the top level's — there's no fallback
up the chain, for [] and for an event's target: alike. A message for
a name that exists in some other scope says so. Handles are how a
component hands its widgets to whoever mounted it: every widget method
still returns one, so keep the handles you need and pass them out.
Timers, dialogs, and the rest
The session carries the app-level conveniences: every(ms) and
after(ms) timers (declarable right inside the build block), native file
open/save dialogs, message, choose_color, choose_dir, a toast for
transient feedback, a busy cursor block, and clipboard access. UIs that
grow at runtime append validated subtrees with add(:name) — or
add(handle), which is the way into a container that lives inside a
component (its name isn't visible from outside) and builds in that
component's own scope.
Concurrency
Three lanes, picked by what the work is waiting on: a plain spawn
fiber for socket IO, App#off_thread for File/DNS/TLS calls, and
Tryst::BackgroundWork for CPU-bound work. See
CONCURRENCY.md for which lane to pick, the one rule
that holds across all of them, and a macOS-specific gotcha around
File/DNS/TLS calls.
Validation
The tree is checked before anything reaches Tk: a cell outside its
grid, two widgets in one cell, a tab outside tabs — these raise a
ValidationError naming the offending widgets at run, rather than
surfacing later as a cryptic Tcl error.
The escape hatch
The DSL is sugar over tryst, not a wall around it. Anything it doesn't
spell yet is one call away: session.app returns the underlying
Tryst::App (structured command calls, widget creation, event binding,
window management), and below that Tryst::Interp is the raw
interpreter bridge. builder.raw { |app| ... } runs against the live
app during window creation for one-off setup like ttk style tweaks.
Custom widgets
Widget types aren't a closed set — registering one from your own code
makes it a first-class ui.<type> citizen, the same as any built-in
type. See CUSTOM_WIDGETS.md for the guide.
Examples
examples/button_label_demo.cr— hello world, no DSLexamples/calculator.cr— calculator against the App layerexamples/calculator_ui/app.cr— same calculator on the UI DSLexamples/paint/paint_demo.cr— layers, canvas, pixel buffersexamples/custom_widget_demo.cr— a widget type registered outside Tryst::UI
Run any of them with crystal run <path>.
Related shards
Everything below is its own shard and repo, depending on tryst via
github: rather than living here — so tryst itself never grows an SDL,
ThorVG or platform-specific dependency, and each one carries its own
examples and test suite.
- tryst-sdl — SDL3 rendering
surface inside a Tk window, audio, and gamepad input. Its
examples/yam/(minesweeper, with sound effects) is the one example that needs it, so it lives there. - tryst-vector — CPU vector rasterization via ThorVG: antialiased curves, gradients and shadows blitted into a Tk Photo. Tk's own canvas has no gradient fill and, on X11, no antialiasing.
- tryst-dnd — native OS file
drag-and-drop: a real drag from Finder or a file manager onto a widget
fires the
<<DropFile>>event core tryst'sApp#register_drop_targetdocuments. - tryst-switch — an animated iOS/Bootstrap-style on/off switch.
- tryst-segmented — a segmented control: a rounded pill of mutually exclusive options with a sliding highlight, in place of a row of radio buttons.
- tryst-value-slider — a single-thumb slider with a filled track, optional tick marks and a value bubble that follows the thumb.
- tryst-range-slider
— a dual-thumb slider bounding a
[low, high]range on one track. - tryst-spinner — an
antialiased indeterminate activity ring and determinate progress ring;
Tk's own indeterminate
ttk::progressbarhas neither.
The widgets are all built on Tryst::OwnerDrawnWidget and rendered
through tryst-vector — see CUSTOM_WIDGETS.md for
how a shard registers a type of its own.
Tests
shards install
crystal spec # host, auto-detects installed Tcl/Tk (prefers 9.x)
scripts/docker-test.sh # Ubuntu + Xvfb, same suite headless, 8.6 image
TCL_VERSION=8 crystal spec # host, forces 8.6
TCL_VERSION=9 crystal spec # host, forces 9.x
scripts/docker-test-tcl9.sh # Debian trixie + Xvfb, same suite headless, 9.x image
Developed and tested on macOS (Aqua) and Linux (X11).
How this was built
This codebase was written with heavy use of Claude Code, directed and
reviewed by a human. Every change passes the full spec suite on Tcl/Tk
8.6 and 9.x plus ameba lint before merge (see .githooks/). Judge it
on the code.
License
MIT — see LICENSE.
API
- Fiber
Pins Tk's thread against the one scheduler mechanism that can move a fiber off it, which this project's own investigation traced precisely: Fiber.syscall (fiber.cr, delegating to Fiber::ExecutionContext:: Scheduler#syscall) is the one choke point Crystal routes File.open (and anything that opens a file internally, like File.read(path)), DNS resolution, and TLS/OpenSSL operations through - the only operations visible to SYSMON's transfer_schedulers_blocked_on_syscall (ordinary read/write on an already-open fd never call this at all, confirmed by tracing IO::FileDescriptor#unbuffered_read all the way to the raw LibC.read - no wrapper).
- Tryst
The Tcl-major-version auto-detection probe (TCL_VERSION=8/9 forces a choice; anything else auto-detects - see interp.cr's header comment for the full policy and why this needs to be a shell probe at all) used to be hand-copied verbatim at every {% if %}/{% unless %} site that needs it: twice in interp.cr, once in event_source.cr.