Teek
Ruby apps for the desktop, built on Tcl/Tk.
Building an app? Start with teek-ui — a DSL for declaring widgets, layout, and events instead of hand-writing Tk mechanics:
gem install teek-ui
That pulls in teek (below) transitively. Think Rails and Rack: you write against teek-ui, and teek is the engine underneath it.
This repo/gem is teek itself — the low-level Ruby↔Tcl/Tk binding teek-ui compiles down to. It's the right layer to reach for directly if you're embedding Tk in an existing app, building your own abstraction on top, or want direct control over widget mechanics — but most app authors want teek-ui instead.
Quick Start
require 'teek'
app = Teek::App.new
app.show
app.set_window_title('Hello Teek')
# Create widgets with the command helper — Ruby values are auto-quoted,
# symbols pass through bare, and procs become callbacks
app.command('ttk::label', '.lbl', text: 'Hello, world!')
app.command(:pack, '.lbl', pady: 10)
app.command('ttk::button', '.btn', text: 'Click me', command: proc {
app.command('.lbl', :configure, text: 'Clicked!')
})
app.command(:pack, '.btn', pady: 10)
app.mainloop
Widgets
create_widget returns a Teek::Widget — a thin wrapper that holds the widget path and provides convenience methods. Paths are auto-generated from the widget type.
btn = app.('ttk::button', text: 'Click me')
btn.pack(pady: 10)
btn.command(:configure, text: 'Updated') # widget subcommand
btn.destroy
Nest widgets under a parent:
frame = app.('ttk::frame')
frame.pack(fill: :both, expand: 1)
label = app.('ttk::label', parent: frame, text: 'Hello')
label.pack(pady: 5)
Widgets work anywhere a path string is expected (via to_s):
app.command(:pack, btn, pady: 10) # equivalent to btn.pack(pady: 10)
app.tcl_eval("#{btn} configure -text New") # string interpolation works
The raw app.command approach still works for cases where you don't need a wrapper:
app.command('ttk::label', '.mylabel', text: 'Direct')
app.command(:pack, '.mylabel')
Callbacks
Pass a proc to command and it becomes a Tcl callback automatically:
app = Teek::App.new
app.command(:button, '.b', text: 'Click', command: proc { puts "clicked!" })
Use bind for event bindings with optional substitutions:
app.bind('.b', 'Enter') { puts "hovered" }
app.bind('.c', 'Button-1', :x, :y) { |x, y| puts "#{x},#{y}" }
Stopping event propagation
In bind handlers, you can stop an event from propagating to subsequent binding tags by throwing :teek_break:
app.bind('.entry', 'KeyPress', :keysym) { |key|
puts "handled #{key} - stop here"
throw :teek_break
}
This is equivalent to Tcl's break command in a bind script.
Two other control flow signals are available for advanced use:
throw :teek_continue- skip remaining bind scripts for this event (Tclcontinue)throw :teek_return- return from the current Tcl proc (Tclreturn)
Errors in callbacks
If a callback raises a Ruby exception, it becomes a Tcl error. The exception message is preserved and can be caught on the Tcl side with catch.
Menus
Build a menu bar with App#menu:
app = Teek::App.new(title: 'My App')
= app.('.menubar')
app.command('.', :configure, menu: )
= app.('.menubar.file')
.command(:add, :cascade, label: 'File', menu: )
.command(:add, :command, label: 'Quit', command: proc { app.command(:destroy, '.') })
app.show
app.mainloop
app.menu(path) creates the underlying Tk menu the first time it's called for a given path (tearoff disabled) and just returns a handle to it on later calls, so it's safe to call again whenever you're about to rebuild a menu's entries (e.g. on every right-click). Every entry-mutating call — add, insert, entryconfigure, delete — tracks any command: callback and releases it when the entry is replaced, deleted, or the menu itself is destroyed. That's true whether you call it through the Widget handle (menubar.command(:add, ...)) or as a raw app.command(path, :add, ...); both go through the same tracking, so there's no less-safe way to build a menu.
macOS note: On macOS, Tk always displays a menu bar. If you don't configure one, Tk shows a default menu with items like "Run Widget Demo" that are meant for the Tcl interpreter shell. Attach a custom menu bar (even an empty one) to suppress it. See the TkDocs menu tutorial for details.
Dialogs
App has safe wrappers for the standard Tk dialogs — choose_open_file, choose_save_file, message_box, choose_color, and popup_menu — built without any string interpolation, so titles, paths, and messages containing spaces or braces are passed through correctly:
path = app.choose_open_file(
title: 'Open Image',
filetypes: [['Images', ['.png', '.jpg']], ['All Files', '*']]
)
app.(message: 'Really delete this?', type: :yesno, icon: :warning) # => :yes / :no
File/color pickers return nil when cancelled (an array of paths if multiple: true); message_box returns the pressed button as a symbol. See sample/dialogs/dialogs_demo.rb for a runnable demo of all five.
Window info
app.winfo groups typed wrappers for Tk's winfo command family — one method per query, coerced to the right Ruby type:
app.winfo.width(btn) # => 90 (Integer)
app.winfo.exists?(btn) # => true / false
app.winfo.class_name(btn) # => "TButton"
app.winfo.pointerx # => current mouse x, screen pixels
Every method accepts a path string or anything with a matching to_s (a Widget, for instance). Widget also has #width, #height, and #exist? convenience methods that delegate here for its own path.
List operations
Convert between Ruby arrays and Tcl list strings:
# Ruby array → Tcl list string (properly quoted)
Teek.make_list("hello world", "foo", "bar baz")
# => "{hello world} foo {bar baz}"
# Tcl list string → Ruby array
Teek.split_list("{hello world} foo {bar baz}")
# => ["hello world", "foo", "bar baz"]
Also available as app.make_list and app.split_list on an interpreter instance.
Boolean conversion
Convert between Tcl boolean strings and Ruby booleans:
# Tcl boolean string → Ruby bool
Teek.tcl_to_bool("yes") # => true
Teek.tcl_to_bool("0") # => false
# Ruby bool → Tcl boolean string
Teek.bool_to_tcl(true) # => "1"
Teek.bool_to_tcl(nil) # => "0"
tcl_to_bool recognizes all Tcl boolean forms: true/false, yes/no, on/off, 1/0, and numeric values (case-insensitive).
Tcl Packages
Load external Tcl packages (BWidget, tkimg, etc.):
app.require_package('BWidget')
app.require_package('BWidget', '1.9') # with version constraint
For packages in non-standard locations:
app.add_package_path('/path/to/packages')
app.require_package('mypackage')
Query what's available:
app.package_names # => ["Tk", "BWidget", ...]
app.package_present?('Tk') # => true
app.package_versions('Tk') # => ["9.0.1"]
Debugger
Pass debug: true to open a debugger window alongside your app:
app = Teek::App.new(debug: true)
Or set the TEEK_DEBUG environment variable to enable it without changing code.
The debugger provides three tabs:
- Widgets — live tree of all widgets with a detail panel showing configuration
- Variables — all global Tcl variables with search/filter, auto-refreshes every second
- Watches — right-click or double-click a variable to watch it; tracks last 50 values with timestamps
The debugger runs in the same interpreter as your app (as a Toplevel window) and filters its own widgets from app.widgets.
Background Work
Tk applications need to keep the UI responsive while doing CPU-intensive work. The Teek.background_work API runs work in a background Ractor with automatic UI integration.
This API is designed for Ruby 4.x. Ractors on Ruby 3.x lack shareable procs, making them impractical for our use case. A :thread mode exists but is rarely beneficial — Ruby threads share the GVL, so thread-based background work often performs worse than running inline unless the work involves non-blocking I/O.
app = Teek::App.new
app.show
app.set_variable('::progress', 0)
log = app.(:text, width: 60, height: 10)
log.pack(fill: :both, expand: 1, padx: 10, pady: 5)
app.('ttk::progressbar', variable: '::progress', maximum: 100)
.pack(fill: :x, padx: 10, pady: 5)
files = Dir.glob('**/*').select { |f| File.file?(f) }
task = Teek::BackgroundWork.new(app, files) do |t, data|
# Background work goes here - this block cannot access Tk
data.each_with_index do |file, i|
t.check_pause
hash = Digest::SHA256.file(file).hexdigest
t.yield({ file: file, hash: hash, pct: (i + 1) * 100 / data.size })
end
end.on_progress do |msg|
# This block can access Tk
log.command(:insert, :end, "#{msg[:file]}: #{msg[:hash]}\n")
log.command(:see, :end)
app.set_variable('::progress', msg[:pct])
end.on_done do
# This block can also access Tk
log.command(:insert, :end, "Done!\n")
end
# Control the task
task.pause # Pause work (resumes at next t.check_pause)
task.resume # Resume paused work
task.stop # Stop completely
The work block runs in a background Ractor and cannot access Tk directly. Use t.yield() to send results to on_progress, which runs on the main thread where Tk is available. Callbacks (on_progress, on_done) can be chained in any order.
See sample/threading_demo.rb for a complete file hasher example.
File Drop Target
Register any widget as a file drop target to receive OS-native drag-and-drop:
app = Teek::App.new(title: "Drop Demo")
app.show
app.register_drop_target('.')
app.bind('.', '<<DropFile>>', :data) do |data|
paths = app.split_list(data)
puts "Dropped: #{paths.inspect}"
end
app.mainloop
Dropped files arrive as a Tcl list in the :data substitution. Use split_list to get a Ruby array of paths. Works on macOS (Cocoa), Windows (OLE IDropTarget), and Linux (X11 XDND).
See sample/drop_demo.rb for a complete example.
Multiple Interpreters
Teek can create more than one Tcl/Tk interpreter in a process — each Teek::App.new is a fully independent interpreter with its own widget tree, callbacks, and . main window — and the low-level Interp#create_slave(name, safe) primitive wraps Tcl's master/slave (child) interpreters. In practice you almost never want either. Two hard constraints make multi-interpreter setups more trouble than they solve:
- The event loop is singular. Tcl's notifier and event queue are per thread, not per interpreter. One
mainloopdrives every interpreter on that thread and exits only when the last window anywhere closes; multiplemainloopcalls do not compose. Multi-interpreter buys you no concurrency — the GUI still runs on one thread. - GUI is main-thread-only on macOS. Tk on Aqua sits on Cocoa, which requires the main thread. You can run several interpreters on the main thread, but you cannot own Tk windows or run a Tk event loop on a background thread on macOS. (X11 is more permissive, but code that leans on that won't port.)
So teek's stance is a hard lean against multiple interpreters. Reach for what you actually need instead:
- More windows → use
toplevel, not more interpreters. One interpreter manages any number of top-level windows. - Concurrency / a responsive UI during heavy work → use Background Work (Ractors) plus
after/after_idle, which marshal results back to the main thread. See Background Work. - Isolating state or widget names → use Tcl namespaces and a widget-path naming convention within a single interpreter.
- Running untrusted Tcl → this is the one legitimate reason for a separate (safe) interpreter. The
Interp#create_slaveprimitive exists, but a safe slave has no Tk by default and isn't wired into theApp/widget layer — treat GUI-in-a-sandbox as advanced, unsupported territory for now.
If you do run multiple Teek::Apps, keep them all on the main thread and let a single one own the mainloop.
Known Issues
- File drop on Linux/Wayland —
register_drop_targetdoes not yet work under Wayland. The current implementation uses the X11 XDND protocol, which is not compatible with Wayland's native drag-and-drop. Workaround: select an Xorg/X11 session at the login screen (e.g., "GNOME on Xorg"). Native Wayland support viawl_data_deviceis planned.