teek 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ff08eb5884f74dc6461c5ff1163a025b44b798e2e480bd4f2855210dd0f0fdf
4
- data.tar.gz: 8e012ccf6a6fffe427630c033b288f784ed455552b064870b0bce054d11e62ee
3
+ metadata.gz: 1f75bdcd33165c47419853eabf3d2393f2b576648647f7ad17efef30df2da023
4
+ data.tar.gz: 636773f5d6a743dd2ef94a3365afd4647878cc0a4ef7842627a0d5325f6f5329
5
5
  SHA512:
6
- metadata.gz: 878e28a9a7493abdccdce1a4b28e372ade4648b12392cdb0815581b4590ab69b601eceb1da686e01719c6930fe1e88c333d4751e80f3af7875ce84a2a391957c
7
- data.tar.gz: 0cd4b0ae71353720e185455e3966b954b9b2e2237417ce0efc596ad9915234f6b79923d318a54a1f42ddf9a839e8e58857462017192f750cd1d4ef31ce92710a
6
+ metadata.gz: 892af65f50c58578001bd8a274b96ff2663630489c904218a066d049caa80a49a1bd5de6012d5a4333178e030f72fb55c7001396c39fcaa851868c6f2401b415
7
+ data.tar.gz: 661bb18457269fe174470e26f16fd070404dcb3f9c70740ec5d543b501365dd0733313bd2ff803fb7108285583f86b42da98153c65a3edf77d681ddf15e1b663
data/README.md CHANGED
@@ -1,6 +1,16 @@
1
1
  # Teek
2
2
 
3
- A Ruby interface to Tcl/Tk.
3
+ Ruby apps for the desktop, built on Tcl/Tk.
4
+
5
+ **Building an app?** Start with [teek-ui](teek-ui/README.md) — a DSL for declaring widgets, layout, and events instead of hand-writing Tk mechanics:
6
+
7
+ ```
8
+ gem install teek-ui
9
+ ```
10
+
11
+ That pulls in `teek` (below) transitively. Think Rails and Rack: you write against teek-ui, and teek is the engine underneath it.
12
+
13
+ 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.
4
14
 
5
15
  [API Documentation](https://jamescook.github.io/teek/)
6
16
 
@@ -294,6 +304,22 @@ Dropped files arrive as a Tcl list in the `:data` substitution. Use `split_list`
294
304
 
295
305
  See [`sample/drop_demo.rb`](sample/drop_demo.rb) for a complete example.
296
306
 
307
+ ## Multiple Interpreters
308
+
309
+ 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:
310
+
311
+ - **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.
312
+ - **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.)
313
+
314
+ So teek's stance is a hard lean **against** multiple interpreters. Reach for what you actually need instead:
315
+
316
+ - **More windows** → use `toplevel`, not more interpreters. One interpreter manages any number of top-level windows.
317
+ - **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](#background-work).
318
+ - **Isolating state or widget names** → use Tcl namespaces and a widget-path naming convention within a single interpreter.
319
+ - **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.
320
+
321
+ If you do run multiple `Teek::App`s, keep them all on the main thread and let a single one own the `mainloop`.
322
+
297
323
  ## Known Issues
298
324
 
299
325
  - **File drop on Linux/Wayland** — `register_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.
data/Rakefile CHANGED
@@ -293,6 +293,15 @@ namespace :sdl2 do
293
293
  task test: 'sdl2:compile'
294
294
  end
295
295
 
296
+ namespace :ui do
297
+ Rake::TestTask.new(:test) do |t|
298
+ t.libs << 'teek-ui/test' << 'teek-ui/lib'
299
+ t.test_files = FileList['teek-ui/test/**/test_*.rb'] - FileList['teek-ui/test/test_helper.rb']
300
+ t.ruby_opts << '-r test_helper'
301
+ t.verbose = true
302
+ end
303
+ end
304
+
296
305
  task :default => :compile
297
306
 
298
307
  namespace :release do
@@ -510,6 +519,31 @@ namespace :docker do
510
519
  sh cmd
511
520
  end
512
521
 
522
+ desc "Run teek-ui tests in Docker"
523
+ task ui: :build do
524
+ tcl_version = tcl_version_from_env
525
+ ruby_version = ruby_version_from_env
526
+ image_name = docker_image_name(tcl_version, ruby_version)
527
+
528
+ require 'fileutils'
529
+ FileUtils.mkdir_p('coverage')
530
+
531
+ warn_if_containers_running(image_name)
532
+
533
+ puts "Running teek-ui tests in Docker (Ruby #{ruby_version}, Tcl #{tcl_version})..."
534
+ cmd = "docker run --rm --init"
535
+ cmd += " -v #{Dir.pwd}/coverage:/app/coverage"
536
+ cmd += " -e TCL_VERSION=#{tcl_version}"
537
+ if ENV['COVERAGE'] == '1'
538
+ cmd += " -e COVERAGE=1"
539
+ cmd += " -e COVERAGE_NAME=#{ENV['COVERAGE_NAME'] || 'ui'}"
540
+ end
541
+ cmd += " #{image_name}"
542
+ cmd += " xvfb-run -a bundle exec rake ui:test"
543
+
544
+ sh cmd
545
+ end
546
+
513
547
  desc "Run all tests (teek + teek-sdl2) with coverage and generate report"
514
548
  task all: 'docker:build' do
515
549
  tcl_version = tcl_version_from_env
@@ -520,7 +554,7 @@ namespace :docker do
520
554
  FileUtils.rm_rf('coverage')
521
555
  FileUtils.mkdir_p('coverage/results')
522
556
 
523
- # Run all three test suites with coverage enabled and distinct COVERAGE_NAMEs
557
+ # Run all test suites with coverage enabled and distinct COVERAGE_NAMEs
524
558
  ENV['COVERAGE'] = '1'
525
559
 
526
560
  ENV['COVERAGE_NAME'] = 'main'
@@ -531,6 +565,11 @@ namespace :docker do
531
565
  Rake::Task['docker:build'].reenable
532
566
  Rake::Task['docker:test:sdl2'].invoke
533
567
 
568
+ ENV['COVERAGE_NAME'] = 'ui'
569
+ Rake::Task['docker:test:ui'].reenable
570
+ Rake::Task['docker:build'].reenable
571
+ Rake::Task['docker:test:ui'].invoke
572
+
534
573
  # Collate inside Docker (paths match /app/lib/...)
535
574
  puts "Collating coverage results..."
536
575
  cmd = "docker run --rm --init"
@@ -70,6 +70,23 @@ module Teek
70
70
  end
71
71
  end
72
72
 
73
+ # Diagnostic aggregate, not itself load-bearing for cleanup - a live
74
+ # snapshot of how many tracked callback ids currently exist, grouped
75
+ # by the tag every {#reconcile} container is already keyed on
76
+ # (+:bind+, +:menu+, +:canvas_bind+, +:tag_bind+, +:widget_option+,
77
+ # +:wm_protocol+, ...). Counts individual ids, not containers - a
78
+ # single container can hold several (e.g. one widget bound to
79
+ # several events). A tag with nothing currently tracked under it is
80
+ # simply absent from the result, not present with a zero count.
81
+ # @return [Hash{Object => Integer}]
82
+ def counts_by_tag
83
+ counts = Hash.new(0)
84
+ @entries.each do |(tag, _path), ids|
85
+ counts[tag] += ids.size unless ids.empty?
86
+ end
87
+ counts
88
+ end
89
+
73
90
  private
74
91
 
75
92
  # Convention, not a type check: every container is [feature_tag, path].
@@ -51,15 +51,15 @@ module Teek
51
51
  rescue Teek::TclError
52
52
  ''
53
53
  end
54
- # \z anchors this to a BARE `ruby_callback <id>` with nothing
55
- # after. raw_command's generic positional-Proc handling
56
- # technically allows a caller to pass %-substitutions to a
57
- # canvas-bind-shaped app.command call (the same mechanism
58
- # App#bind uses), but nothing in teek does that today. If a
59
- # caller ever does, this regex silently stops matching and that
60
- # id leaks on rebuild/delete - drop the \z anchor (match just
61
- # the leading `ruby_callback <id>`) if that changes.
62
- after[[tag_or_id, seq]] = Regexp.last_match(1) if current =~ /\Aruby_callback (\S+)\z/
54
+ # Matches a bare `ruby_callback <id>` or one followed by
55
+ # %-substitution codes (`ruby_callback <id> %x %y`) -
56
+ # raw_command's generic positional-Proc handling allows a caller
57
+ # to pass %-substitutions to a canvas-bind-shaped app.command
58
+ # call (the same mechanism App#bind uses; CanvasItem#on_drag and
59
+ # #on_right_click's menu form both do). `\S+` is greedy and stops
60
+ # at the first space, so it can't overrun into the substitution
61
+ # codes themselves.
62
+ after[[tag_or_id, seq]] = Regexp.last_match(1) if current =~ /\Aruby_callback (\S+)(?:\s|\z)/
63
63
  end
64
64
  end
65
65
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ # Thin, typed wrapper around Tk's `clipboard` command family, reached
5
+ # via {App#clipboard}.
6
+ #
7
+ # Doesn't touch text widgets' own copy/cut/paste at all - `ttk::entry`/
8
+ # `text` already bind `<<Copy>>`/`<<Cut>>`/`<<Paste>>` to the expected
9
+ # platform keys (Control-c/x/v, plus their Command-key equivalents on
10
+ # macOS) via Tk's own built-in class bindings, with nothing for teek to
11
+ # wire up. This class is purely for reading/writing the clipboard
12
+ # directly from app code (e.g. a "Copy to Clipboard" button that isn't
13
+ # itself a text widget's own selection).
14
+ #
15
+ # @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/clipboard.htm clipboard
16
+ class Clipboard
17
+ # @api private
18
+ def initialize(app)
19
+ @app = app
20
+ end
21
+
22
+ # Replace the clipboard's contents outright - Tk's own `clipboard
23
+ # clear` followed by `clipboard append` two-step, done as one call.
24
+ # @param text [String]
25
+ # @return [void]
26
+ def set(text)
27
+ @app.tcl_invoke('clipboard', 'clear')
28
+ @app.tcl_invoke('clipboard', 'append', '--', text.to_s)
29
+ nil
30
+ end
31
+
32
+ # @return [String, nil] the clipboard's current text, or +nil+ if
33
+ # it's empty/has no owner (Tk raises a TclError for this rather
34
+ # than returning an empty string)
35
+ def get
36
+ @app.tcl_invoke('clipboard', 'get')
37
+ rescue Teek::TclError
38
+ nil
39
+ end
40
+
41
+ # Clear the clipboard without setting new contents.
42
+ # @return [void]
43
+ def clear
44
+ @app.tcl_invoke('clipboard', 'clear')
45
+ nil
46
+ end
47
+ end
48
+ end
data/lib/teek/dialogs.rb CHANGED
@@ -106,6 +106,26 @@ module Teek
106
106
  result.empty? ? nil : result
107
107
  end
108
108
 
109
+ # Show the native "choose directory" dialog.
110
+ #
111
+ # @param initialdir [String, nil] directory the dialog starts in
112
+ # @param mustexist [Boolean] restrict the choice to an already-existing
113
+ # directory (Tk's own default is false, allowing a not-yet-created one)
114
+ # @param title [String, nil] dialog window title
115
+ # @param parent [String, nil] parent window (defaults to the root window)
116
+ # @return [String, nil] the chosen directory path, or +nil+ if cancelled
117
+ # @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/chooseDirectory.htm tk_chooseDirectory
118
+ def choose_dir(initialdir: nil, mustexist: false, title: nil, parent: nil)
119
+ args = ['tk_chooseDirectory']
120
+ args.push('-initialdir', initialdir) if initialdir
121
+ args.push('-mustexist', bool_to_tcl(true)) if mustexist
122
+ args.push('-title', title) if title
123
+ args.push('-parent', parent) if parent
124
+
125
+ result = tcl_invoke(*args)
126
+ result.empty? ? nil : result
127
+ end
128
+
109
129
  # Pop up a menu at the given screen coordinates.
110
130
  #
111
131
  # @param menu [Widget, String] the menu to pop up
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ # A cancellable repeating timer that fires on the main thread.
5
+ #
6
+ # Created via {App#every}. Reschedules itself after each tick using
7
+ # Tcl's +after+ command. The block runs in the event loop, so it
8
+ # must complete quickly to avoid blocking the UI.
9
+ #
10
+ # Tracks timing drift: if a tick fires significantly late (more than
11
+ # 2x the interval), a warning is printed to stderr. This helps catch
12
+ # blocks that are too slow for the requested interval.
13
+ #
14
+ # @see App#every
15
+ class RepeatingTimer
16
+ # @return [Integer] interval in milliseconds
17
+ attr_reader :interval
18
+
19
+ # @return [Exception, nil] the last error if the timer stopped due to an
20
+ # unhandled exception, nil otherwise
21
+ attr_reader :last_error
22
+
23
+ # @return [Integer] number of ticks that fired late (> 2x interval)
24
+ attr_reader :late_ticks
25
+
26
+ # @api private
27
+ def initialize(app, ms, on_error: nil, &block)
28
+ raise ArgumentError, "interval must be positive, got #{ms}" if ms <= 0
29
+
30
+ @app = app
31
+ @interval = ms
32
+ @block = block
33
+ @on_error = on_error
34
+ @cancelled = false
35
+ @after_id = nil
36
+ @last_error = nil
37
+ @late_ticks = 0
38
+ @next_expected = nil
39
+ schedule
40
+ end
41
+
42
+ # Stop the timer. Safe to call multiple times.
43
+ # @return [void]
44
+ def cancel
45
+ return if @cancelled
46
+ @cancelled = true
47
+ @app.after_cancel(@after_id) if @after_id
48
+ @after_id = nil
49
+ end
50
+
51
+ # @return [Boolean] true if the timer has been cancelled
52
+ def cancelled?
53
+ @cancelled
54
+ end
55
+
56
+ # Change the interval. Takes effect on the next tick.
57
+ # @param ms [Integer] new interval in milliseconds
58
+ def interval=(ms)
59
+ raise ArgumentError, "interval must be positive, got #{ms}" if ms <= 0
60
+ @interval = ms
61
+ end
62
+
63
+ private
64
+
65
+ def schedule
66
+ return if @cancelled
67
+ @next_expected = now_ms + @interval
68
+ @after_id = @app.after(@interval) { tick }
69
+ end
70
+
71
+ def tick
72
+ return if @cancelled
73
+ check_drift
74
+ @block.call
75
+ schedule
76
+ rescue => e
77
+ @last_error = e
78
+ case @on_error
79
+ when :raise
80
+ @cancelled = true
81
+ # Store on App so it raises from the next app.update call.
82
+ # Don't re-raise here — that would go through rb_protect → bgerror.
83
+ @app._pending_exception = e
84
+ when Proc
85
+ begin
86
+ @on_error.call(e)
87
+ rescue => handler_err
88
+ @last_error = handler_err
89
+ @cancelled = true
90
+ @app._pending_exception = handler_err
91
+ return
92
+ end
93
+ schedule
94
+ when nil
95
+ @cancelled = true
96
+ end
97
+ end
98
+
99
+ def check_drift
100
+ return unless @next_expected
101
+ actual = now_ms
102
+ drift = actual - @next_expected
103
+ if drift > @interval
104
+ @late_ticks += 1
105
+ warn "Teek::RepeatingTimer: tick #{@late_ticks} fired #{drift.round}ms late " \
106
+ "(interval=#{@interval}ms)"
107
+ end
108
+ end
109
+
110
+ def now_ms
111
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
112
+ end
113
+ end
114
+ end
data/lib/teek/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Teek
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/teek/widget.rb CHANGED
@@ -102,13 +102,42 @@ module Teek
102
102
  @app.unbind(@path, event)
103
103
  end
104
104
 
105
+ # This widget as a {Window} - the window-scoped counterpart covering
106
+ # `wm` subcommands and composite behaviors (on_close, grab_set/
107
+ # grab_release, modal). Meant for toplevels.
108
+ # @return [Window]
109
+ def window
110
+ @app.window(@path)
111
+ end
112
+
105
113
  # Register a handler for this window's close button (WM_DELETE_WINDOW).
106
- # Meant for toplevels; see {App#on_close} for the full behavior.
114
+ # Meant for toplevels; see {Window#on_close} for the full behavior.
107
115
  # @yield called when the window's close button is pressed
108
116
  # @return [void]
109
- # @see App#on_close
117
+ # @see Window#on_close
110
118
  def on_close(&block)
111
- @app.on_close(window: @path, &block)
119
+ window.on_close(&block)
120
+ end
121
+
122
+ # Grab input on this window. See {Window#grab_set}.
123
+ # @param global [Boolean]
124
+ # @return [void]
125
+ def grab_set(global: false)
126
+ window.grab_set(global: global)
127
+ end
128
+
129
+ # Release a grab previously set on this window. See {Window#grab_release}.
130
+ # @return [void]
131
+ def grab_release
132
+ window.grab_release
133
+ end
134
+
135
+ # Make this window modal. See {Window#modal}.
136
+ # @param global [Boolean]
137
+ # @yield optional - runs with the grab and focus already set
138
+ # @return [void]
139
+ def modal(global: false, &block)
140
+ window.modal(global: global, &block)
112
141
  end
113
142
 
114
143
  def inspect
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ # A single toplevel window, addressed by its Tk path - the window-scoped
5
+ # counterpart to {Widget} (which covers any widget of any type). Groups
6
+ # every `wm` subcommand alongside composite window-lifecycle behaviors
7
+ # (on_close, grab_set/grab_release, modal) that would otherwise keep
8
+ # growing as more and more +window:+-kwarg methods flattened onto {App}
9
+ # - `app.window(path).thing` reads better than threading `window:`
10
+ # through a dozen unrelated top-level methods, and gives callers (like
11
+ # teek-ui's DSL) one coherent object per window instead of loose parts.
12
+ #
13
+ # `App#wm` ({Wm}) and App's own `window_title`/`set_window_title`/etc.
14
+ # convenience methods, plus `App#on_close`/`#grab_set`/`#grab_release`/
15
+ # `#modal`, all delegate here internally - nothing about those public
16
+ # methods changed, this is where their actual work happens now.
17
+ #
18
+ # @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/wm.htm wm
19
+ # @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/grab.htm grab
20
+ class Window
21
+ attr_reader :path
22
+
23
+ # @api private
24
+ def initialize(app, path)
25
+ @app = app
26
+ @path = path.to_s
27
+ end
28
+
29
+ # @return [String] the Tk window path
30
+ def to_s
31
+ @path
32
+ end
33
+
34
+ # -- wm subcommands --
35
+
36
+ # @return [String] the window's current title
37
+ def title
38
+ @app.tcl_invoke('wm', 'title', @path)
39
+ end
40
+
41
+ # @param value [String] new title
42
+ # @return [String] the title
43
+ def set_title(value)
44
+ @app.tcl_invoke('wm', 'title', @path, value.to_s)
45
+ end
46
+
47
+ # @return [String] geometry string (e.g. +"400x300+0+0"+)
48
+ def geometry
49
+ @app.tcl_invoke('wm', 'geometry', @path)
50
+ end
51
+
52
+ # @param value [String] new geometry (e.g. +"400x300"+, +"400x300+100+50"+)
53
+ # @return [String] the geometry
54
+ def set_geometry(value)
55
+ @app.tcl_invoke('wm', 'geometry', @path, value.to_s)
56
+ end
57
+
58
+ # @return [Array(Boolean, Boolean)] [width_resizable, height_resizable]
59
+ def resizable
60
+ parts = @app.tcl_invoke('wm', 'resizable', @path).split
61
+ [@app.tcl_to_bool(parts[0]), @app.tcl_to_bool(parts[1])]
62
+ end
63
+
64
+ # @param width [Boolean] allow horizontal resize
65
+ # @param height [Boolean] allow vertical resize
66
+ # @return [void]
67
+ def set_resizable(width, height)
68
+ @app.tcl_invoke('wm', 'resizable', @path, @app.bool_to_tcl(width), @app.bool_to_tcl(height))
69
+ end
70
+
71
+ # Show the window (map it if withdrawn/iconified).
72
+ # @return [void]
73
+ def deiconify
74
+ @app.tcl_invoke('wm', 'deiconify', @path)
75
+ end
76
+
77
+ # Hide the window without destroying it.
78
+ # @return [void]
79
+ def withdraw
80
+ @app.tcl_invoke('wm', 'withdraw', @path)
81
+ end
82
+
83
+ # -- composite behaviors --
84
+
85
+ # Register a handler for the window manager's close button
86
+ # (WM_DELETE_WINDOW - the titlebar close box, Cmd-W, Alt-F4, etc.,
87
+ # depending on platform).
88
+ #
89
+ # Tk's own default behavior (destroy the window) only applies when
90
+ # nothing else has claimed this protocol - setting a handler here
91
+ # replaces it, so the block is entirely responsible for deciding
92
+ # whether the window actually closes. Call {App#destroy} yourself if
93
+ # you want it to; do nothing (or show a confirmation first) if you don't.
94
+ #
95
+ # @example Confirm before quitting
96
+ # app.window.on_close { app.destroy('.') if app.message_box(message: 'Quit?', type: :yesno) == :yes }
97
+ # @example A toplevel that just hides instead of closing
98
+ # app.window(settings_window).on_close { app.window(settings_window).withdraw }
99
+ #
100
+ # @yield called when the window's close button is pressed
101
+ # @return [void]
102
+ # @see App#bind
103
+ # @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/wm.htm#M46 wm protocol
104
+ def on_close(&block)
105
+ cb = @app.register_callback(block, relay_break_continue: false)
106
+ @app.callback_registry.reconcile([:wm_protocol, @path]) { |before| before.merge('WM_DELETE_WINDOW' => cb) }
107
+ @app.tcl_eval("wm protocol #{@path} WM_DELETE_WINDOW {ruby_callback #{cb}}")
108
+ end
109
+
110
+ # Set the input grab on the window - while held, mouse and keyboard
111
+ # events outside it (and its descendants) are redirected to it, the
112
+ # building block {#modal} uses. `grab` is its own Tcl command family,
113
+ # separate from `wm`.
114
+ # @param global [Boolean] a global grab blocks input to every other
115
+ # application too, not just this one - almost never what you want;
116
+ # local (the default) is scoped to this application.
117
+ # @return [void]
118
+ # @see #grab_release
119
+ # @see #modal
120
+ def grab_set(global: false)
121
+ args = ['grab', 'set']
122
+ args << '-global' if global
123
+ args << @path
124
+ @app.tcl_invoke(*args)
125
+ nil
126
+ end
127
+
128
+ # Release a grab previously set with {#grab_set}. Safe to call even if
129
+ # the window never held the grab - Tk itself treats that as a no-op.
130
+ # @return [void]
131
+ def grab_release
132
+ @app.tcl_invoke('grab', 'release', @path)
133
+ nil
134
+ end
135
+
136
+ # Make the window modal: grabs input and sets focus on it immediately.
137
+ # Release it explicitly with {#grab_release} (typically from the
138
+ # window's own dismiss/close handling) when the dialog is done - the
139
+ # grab is NOT released automatically just because this method returns,
140
+ # since a modal dialog is meant to stay grabbed for its whole visible
141
+ # lifetime, not just its setup.
142
+ #
143
+ # Two safety nets guard against a stuck grab locking out the rest of
144
+ # the display: if the window is destroyed while still grabbed (a crash
145
+ # mid-modal, or just forgetting to call {#grab_release} first), a
146
+ # <Destroy> binding releases it; if the optional setup block itself
147
+ # raises, the grab is released immediately rather than left dangling
148
+ # on a half-shown dialog.
149
+ #
150
+ # @example
151
+ # settings = app.window(settings_path)
152
+ # settings.modal { settings.deiconify }
153
+ # # ... later, from the window's own on_close/dismiss handler ...
154
+ # settings.grab_release
155
+ #
156
+ # @param global [Boolean] see {#grab_set}
157
+ # @yield optional - runs with the grab and focus already set
158
+ # @return [void]
159
+ # @see #grab_set
160
+ # @see #grab_release
161
+ def modal(global: false)
162
+ grab_set(global: global)
163
+ # -force: a modal dialog should own keyboard focus immediately, not
164
+ # merely be first in line whenever the app next happens to get it
165
+ # (plain `focus` only takes effect once the app already has input
166
+ # focus at the OS/WM level).
167
+ @app.tcl_invoke('focus', '-force', @path)
168
+ @app.bind(@path, 'Destroy') { grab_release }
169
+ yield if block_given?
170
+ nil
171
+ rescue
172
+ grab_release
173
+ raise
174
+ end
175
+ end
176
+ end
data/lib/teek/wm.rb CHANGED
@@ -3,20 +3,15 @@
3
3
  module Teek
4
4
  # Thin, typed wrapper around Tk's `wm` (window manager) command family -
5
5
  # one method per subcommand, coerced to the right Ruby type, reached via
6
- # {App#wm}.
7
- #
8
- # Grouped behind a single accessor instead of more flat App methods, for
9
- # the same reason {Winfo} is: `wm` is itself one big, well-known Tcl
10
- # command namespace, so knowing Tcl's `wm title` gets you to {#title}
11
- # directly. App's own +set_window_title+/+window_geometry+/etc. are kept
12
- # as thin delegates to this - use whichever reads better to you, they're
13
- # the same underlying call.
14
- #
15
- # Composite behaviors that orchestrate more than a single +wm+ subcommand
16
- # (App#on_close's callback tracking, for instance) stay top-level App/
17
- # Widget methods rather than living here - this class is only 1:1 Tcl
18
- # command wrappers, nothing with Ruby-side state of its own.
6
+ # {App#wm}. Kept for callers already using +app.wm.title(window: ...)+ -
7
+ # every method here is a one-line delegate to {Window}, which is where
8
+ # the actual `wm`/`grab` work now lives (see {Window}'s own doc comment
9
+ # for why - the same window-scoped operations kept growing as more and
10
+ # more +window:+-kwarg methods flattened onto {App}).
19
11
  #
12
+ # @note Prefer +app.window(path)+ for new code - the same calls, without
13
+ # repeating +window:+ on every one when you're working with one window
14
+ # repeatedly.
20
15
  # @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/wm.htm wm
21
16
  class Wm
22
17
  # @api private
@@ -27,34 +22,33 @@ module Teek
27
22
  # @param window [String, Widget] (default: the root window)
28
23
  # @return [String] the window's current title
29
24
  def title(window: '.')
30
- @app.tcl_invoke('wm', 'title', window.to_s)
25
+ @app.window(window).title
31
26
  end
32
27
 
33
28
  # @param value [String] new title
34
29
  # @param window [String, Widget] (default: the root window)
35
30
  # @return [String] the title
36
31
  def set_title(value, window: '.')
37
- @app.tcl_invoke('wm', 'title', window.to_s, value.to_s)
32
+ @app.window(window).set_title(value)
38
33
  end
39
34
 
40
35
  # @param window [String, Widget] (default: the root window)
41
36
  # @return [String] geometry string (e.g. +"400x300+0+0"+)
42
37
  def geometry(window: '.')
43
- @app.tcl_invoke('wm', 'geometry', window.to_s)
38
+ @app.window(window).geometry
44
39
  end
45
40
 
46
41
  # @param value [String] new geometry (e.g. +"400x300"+, +"400x300+100+50"+)
47
42
  # @param window [String, Widget] (default: the root window)
48
43
  # @return [String] the geometry
49
44
  def set_geometry(value, window: '.')
50
- @app.tcl_invoke('wm', 'geometry', window.to_s, value.to_s)
45
+ @app.window(window).set_geometry(value)
51
46
  end
52
47
 
53
48
  # @param window [String, Widget] (default: the root window)
54
49
  # @return [Array(Boolean, Boolean)] [width_resizable, height_resizable]
55
50
  def resizable(window: '.')
56
- parts = @app.tcl_invoke('wm', 'resizable', window.to_s).split
57
- [@app.tcl_to_bool(parts[0]), @app.tcl_to_bool(parts[1])]
51
+ @app.window(window).resizable
58
52
  end
59
53
 
60
54
  # @param width [Boolean] allow horizontal resize
@@ -62,21 +56,21 @@ module Teek
62
56
  # @param window [String, Widget] (default: the root window)
63
57
  # @return [void]
64
58
  def set_resizable(width, height, window: '.')
65
- @app.tcl_invoke('wm', 'resizable', window.to_s, @app.bool_to_tcl(width), @app.bool_to_tcl(height))
59
+ @app.window(window).set_resizable(width, height)
66
60
  end
67
61
 
68
62
  # Show a window (map it if withdrawn/iconified).
69
63
  # @param window [String, Widget] (default: the root window)
70
64
  # @return [void]
71
65
  def deiconify(window: '.')
72
- @app.tcl_invoke('wm', 'deiconify', window.to_s)
66
+ @app.window(window).deiconify
73
67
  end
74
68
 
75
69
  # Hide a window without destroying it.
76
70
  # @param window [String, Widget] (default: the root window)
77
71
  # @return [void]
78
72
  def withdraw(window: '.')
79
- @app.tcl_invoke('wm', 'withdraw', window.to_s)
73
+ @app.window(window).withdraw
80
74
  end
81
75
  end
82
76
  end
data/lib/teek.rb CHANGED
@@ -14,6 +14,9 @@ require_relative 'teek/photo'
14
14
  require_relative 'teek/dialogs'
15
15
  require_relative 'teek/winfo'
16
16
  require_relative 'teek/wm'
17
+ require_relative 'teek/window'
18
+ require_relative 'teek/clipboard'
19
+ require_relative 'teek/repeating_timer'
17
20
 
18
21
  # Ruby interface to Tcl/Tk. Provides a thin wrapper around a Tcl interpreter
19
22
  # with Ruby callbacks, event bindings, and background work support.
@@ -90,7 +93,7 @@ module Teek
90
93
  ].freeze
91
94
 
92
95
  class App
93
- attr_reader :interp, :widgets, :debugger, :callback_registry, :winfo, :wm
96
+ attr_reader :interp, :widgets, :debugger, :callback_registry, :winfo, :wm, :clipboard
94
97
  attr_writer :_pending_exception # @api private
95
98
 
96
99
  def initialize(title: nil, track_widgets: true, debug: false, &block)
@@ -98,6 +101,7 @@ module Teek
98
101
  @interp.tcl_eval('package require Tk')
99
102
  @winfo = Teek::Winfo.new(self)
100
103
  @wm = Teek::Wm.new(self)
104
+ @clipboard = Teek::Clipboard.new(self)
101
105
  hide
102
106
  @widgets = {}
103
107
  @widget_counters = Hash.new(0)
@@ -680,19 +684,19 @@ module Teek
680
684
  # Show a window. Defaults to the root window (".").
681
685
  # @param window [String] Tk window path
682
686
  # @return [void]
683
- # @see Wm#deiconify
687
+ # @see Window#deiconify
684
688
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M38 wm deiconify
685
689
  def show(window = '.')
686
- @wm.deiconify(window: window)
690
+ self.window(window).deiconify
687
691
  end
688
692
 
689
693
  # Hide a window without destroying it. Defaults to the root window (".").
690
694
  # @param window [String] Tk window path
691
695
  # @return [void]
692
- # @see Wm#withdraw
696
+ # @see Window#withdraw
693
697
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M65 wm withdraw
694
698
  def hide(window = '.')
695
- @wm.withdraw(window: window)
699
+ self.window(window).withdraw
696
700
  end
697
701
 
698
702
  # Enable the Tk debug console. The console starts hidden and can be
@@ -737,38 +741,38 @@ module Teek
737
741
  # @param title [String] new title
738
742
  # @param window [String] Tk window path
739
743
  # @return [String] the title
740
- # @see Wm#set_title
744
+ # @see Window#set_title
741
745
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M63 wm title
742
746
  def set_window_title(title, window: '.')
743
- @wm.set_title(title, window: window)
747
+ self.window(window).set_title(title)
744
748
  end
745
749
 
746
750
  # Get a window's current title.
747
751
  # @param window [String] Tk window path
748
752
  # @return [String] current title
749
- # @see Wm#title
753
+ # @see Window#title
750
754
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M63 wm title
751
755
  def window_title(window: '.')
752
- @wm.title(window: window)
756
+ self.window(window).title
753
757
  end
754
758
 
755
759
  # Set a window's geometry (e.g. "400x300", "400x300+100+50").
756
760
  # @param geometry [String] geometry string
757
761
  # @param window [String] Tk window path
758
762
  # @return [String] the geometry
759
- # @see Wm#set_geometry
763
+ # @see Window#set_geometry
760
764
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M42 wm geometry
761
765
  def set_window_geometry(geometry, window: '.')
762
- @wm.set_geometry(geometry, window: window)
766
+ self.window(window).set_geometry(geometry)
763
767
  end
764
768
 
765
769
  # Get a window's current geometry.
766
770
  # @param window [String] Tk window path
767
771
  # @return [String] geometry string (e.g. "400x300+0+0")
768
- # @see Wm#geometry
772
+ # @see Window#geometry
769
773
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M42 wm geometry
770
774
  def window_geometry(window: '.')
771
- @wm.geometry(window: window)
775
+ self.window(window).geometry
772
776
  end
773
777
 
774
778
  # Set whether a window is resizable.
@@ -776,19 +780,19 @@ module Teek
776
780
  # @param height [Boolean] allow vertical resize
777
781
  # @param window [String] Tk window path
778
782
  # @return [void]
779
- # @see Wm#set_resizable
783
+ # @see Window#set_resizable
780
784
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M59 wm resizable
781
785
  def set_window_resizable(width, height, window: '.')
782
- @wm.set_resizable(width, height, window: window)
786
+ self.window(window).set_resizable(width, height)
783
787
  end
784
788
 
785
789
  # Get whether a window is resizable.
786
790
  # @param window [String] Tk window path
787
791
  # @return [Array(Boolean, Boolean)] [width_resizable, height_resizable]
788
- # @see Wm#resizable
792
+ # @see Window#resizable
789
793
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M59 wm resizable
790
794
  def window_resizable(window: '.')
791
- @wm.resizable(window: window)
795
+ self.window(window).resizable
792
796
  end
793
797
 
794
798
  # Bind a Tk event on a widget, with optional substitutions forwarded
@@ -857,6 +861,20 @@ module Teek
857
861
  @interp.tcl_eval("bind #{widget} #{event_str} {}")
858
862
  end
859
863
 
864
+ # A single toplevel window, addressed by path - groups `wm` subcommands
865
+ # and composite window-lifecycle behaviors ({#on_close}, {#grab_set}/
866
+ # {#grab_release}, {#modal}) into one object instead of threading
867
+ # +window:+ through a pile of unrelated flat methods. This app's own
868
+ # +window_title+/+set_window_title+/etc., {#wm}, {#on_close},
869
+ # {#grab_set}, {#grab_release}, and {#modal} all delegate here
870
+ # internally - use whichever reads better to you, they're the same
871
+ # underlying calls.
872
+ # @param path [String, Widget] (default: the root window)
873
+ # @return [Window]
874
+ def window(path = '.')
875
+ Window.new(self, path)
876
+ end
877
+
860
878
  # Register a handler for the window manager's close button
861
879
  # (WM_DELETE_WINDOW - the titlebar close box, Cmd-W, Alt-F4, etc.,
862
880
  # depending on platform).
@@ -875,12 +893,46 @@ module Teek
875
893
  # @param window [String] Tk window path (default: the root window)
876
894
  # @yield called when the window's close button is pressed
877
895
  # @return [void]
878
- # @see #bind
896
+ # @note Prefer +app.window(window).on_close { }+ for new code - this
897
+ # flat method is kept for compatibility and just delegates there.
898
+ # @see Window#on_close
879
899
  # @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/wm.htm#M46 wm protocol
880
900
  def on_close(window: '.', &block)
881
- cb = register_callback(block, relay_break_continue: false)
882
- @callback_registry.reconcile([:wm_protocol, window]) { |before| before.merge('WM_DELETE_WINDOW' => cb) }
883
- @interp.tcl_eval("wm protocol #{window} WM_DELETE_WINDOW {ruby_callback #{cb}}")
901
+ self.window(window).on_close(&block)
902
+ end
903
+
904
+ # Set the input grab on window. See {Window#grab_set}.
905
+ # @param window [String, Widget] (default: the root window)
906
+ # @param global [Boolean]
907
+ # @return [void]
908
+ # @note Prefer +app.window(window).grab_set+ for new code - this flat
909
+ # method is kept for compatibility and just delegates there.
910
+ # @see #grab_release
911
+ # @see #modal
912
+ def grab_set(window: '.', global: false)
913
+ self.window(window).grab_set(global: global)
914
+ end
915
+
916
+ # Release a grab previously set with {#grab_set}. See {Window#grab_release}.
917
+ # @param window [String, Widget] (default: the root window)
918
+ # @return [void]
919
+ # @note Prefer +app.window(window).grab_release+ for new code - this
920
+ # flat method is kept for compatibility and just delegates there.
921
+ def grab_release(window: '.')
922
+ self.window(window).grab_release
923
+ end
924
+
925
+ # Make window modal. See {Window#modal}.
926
+ # @param window [String, Widget] (default: the root window)
927
+ # @param global [Boolean] see {#grab_set}
928
+ # @note Prefer +app.window(window).modal { }+ for new code - this flat
929
+ # method is kept for compatibility and just delegates there.
930
+ # @yield optional - runs with the grab and focus already set
931
+ # @return [void]
932
+ # @see #grab_set
933
+ # @see #grab_release
934
+ def modal(window: '.', global: false, &block)
935
+ self.window(window).modal(global: global, &block)
884
936
  end
885
937
 
886
938
  # Register a widget as a file drop target.
@@ -1070,115 +1122,4 @@ module Teek
1070
1122
  end
1071
1123
  end
1072
1124
  end
1073
-
1074
- # A cancellable repeating timer that fires on the main thread.
1075
- #
1076
- # Created via {App#every}. Reschedules itself after each tick using
1077
- # Tcl's +after+ command. The block runs in the event loop, so it
1078
- # must complete quickly to avoid blocking the UI.
1079
- #
1080
- # Tracks timing drift: if a tick fires significantly late (more than
1081
- # 2x the interval), a warning is printed to stderr. This helps catch
1082
- # blocks that are too slow for the requested interval.
1083
- #
1084
- # @see App#every
1085
- class RepeatingTimer
1086
- # @return [Integer] interval in milliseconds
1087
- attr_reader :interval
1088
-
1089
- # @return [Exception, nil] the last error if the timer stopped due to an
1090
- # unhandled exception, nil otherwise
1091
- attr_reader :last_error
1092
-
1093
- # @return [Integer] number of ticks that fired late (> 2x interval)
1094
- attr_reader :late_ticks
1095
-
1096
- # @api private
1097
- def initialize(app, ms, on_error: nil, &block)
1098
- raise ArgumentError, "interval must be positive, got #{ms}" if ms <= 0
1099
-
1100
- @app = app
1101
- @interval = ms
1102
- @block = block
1103
- @on_error = on_error
1104
- @cancelled = false
1105
- @after_id = nil
1106
- @last_error = nil
1107
- @late_ticks = 0
1108
- @next_expected = nil
1109
- schedule
1110
- end
1111
-
1112
- # Stop the timer. Safe to call multiple times.
1113
- # @return [void]
1114
- def cancel
1115
- return if @cancelled
1116
- @cancelled = true
1117
- @app.after_cancel(@after_id) if @after_id
1118
- @after_id = nil
1119
- end
1120
-
1121
- # @return [Boolean] true if the timer has been cancelled
1122
- def cancelled?
1123
- @cancelled
1124
- end
1125
-
1126
- # Change the interval. Takes effect on the next tick.
1127
- # @param ms [Integer] new interval in milliseconds
1128
- def interval=(ms)
1129
- raise ArgumentError, "interval must be positive, got #{ms}" if ms <= 0
1130
- @interval = ms
1131
- end
1132
-
1133
- private
1134
-
1135
- def schedule
1136
- return if @cancelled
1137
- @next_expected = now_ms + @interval
1138
- @after_id = @app.after(@interval) { tick }
1139
- end
1140
-
1141
- def tick
1142
- return if @cancelled
1143
- check_drift
1144
- @block.call
1145
- schedule
1146
- rescue => e
1147
- @last_error = e
1148
- case @on_error
1149
- when :raise
1150
- @cancelled = true
1151
- # Store on App so it raises from the next app.update call.
1152
- # Don't re-raise here — that would go through rb_protect → bgerror.
1153
- @app._pending_exception = e
1154
- when Proc
1155
- begin
1156
- @on_error.call(e)
1157
- rescue => handler_err
1158
- @last_error = handler_err
1159
- @cancelled = true
1160
- @app._pending_exception = handler_err
1161
- return
1162
- end
1163
- schedule
1164
- when nil
1165
- @cancelled = true
1166
- end
1167
- end
1168
-
1169
- def check_drift
1170
- return unless @next_expected
1171
- actual = now_ms
1172
- drift = actual - @next_expected
1173
- if drift > @interval
1174
- @late_ticks += 1
1175
- warn "Teek::RepeatingTimer: tick #{@late_ticks} fired #{drift.round}ms late " \
1176
- "(interval=#{@interval}ms)"
1177
- end
1178
- end
1179
-
1180
- def now_ms
1181
- Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
1182
- end
1183
- end
1184
1125
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: teek
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Cook
@@ -139,6 +139,7 @@ files:
139
139
  - lib/teek/background_thread.rb
140
140
  - lib/teek/callback_registry.rb
141
141
  - lib/teek/canvas_bind_interceptor.rb
142
+ - lib/teek/clipboard.rb
142
143
  - lib/teek/command_interceptors.rb
143
144
  - lib/teek/debugger.rb
144
145
  - lib/teek/demo_support.rb
@@ -148,9 +149,11 @@ files:
148
149
  - lib/teek/photo.rb
149
150
  - lib/teek/platform.rb
150
151
  - lib/teek/ractor_support.rb
152
+ - lib/teek/repeating_timer.rb
151
153
  - lib/teek/tag_bind_interceptor.rb
152
154
  - lib/teek/version.rb
153
155
  - lib/teek/widget.rb
156
+ - lib/teek/window.rb
154
157
  - lib/teek/winfo.rb
155
158
  - lib/teek/wm.rb
156
159
  - teek.gemspec