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.

API Documentation

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.create_widget('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.create_widget('ttk::frame')
frame.pack(fill: :both, expand: 1)

label = app.create_widget('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 (Tcl continue)
  • throw :teek_return - return from the current Tcl proc (Tcl return)

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.

Build a menu bar with App#menu:

app = Teek::App.new(title: 'My App')

menubar = app.menu('.menubar')
app.command('.', :configure, menu: menubar)

file_menu = app.menu('.menubar.file')
menubar.command(:add, :cascade, label: 'File', menu: file_menu)
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_box(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.create_widget(:text, width: 60, height: 10)
log.pack(fill: :both, expand: 1, padx: 10, pady: 5)

app.create_widget('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 mainloop drives every interpreter on that thread and exits only when the last window anywhere closes; multiple mainloop calls 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_slave primitive exists, but a safe slave has no Tk by default and isn't wired into the App/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/Waylandregister_drop_target does 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 via wl_data_device is planned.