teek 0.1.5 → 0.2.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.
data/lib/teek.rb CHANGED
@@ -5,7 +5,15 @@ require_relative 'teek/version'
5
5
  require_relative 'teek/platform'
6
6
  require_relative 'teek/ractor_support'
7
7
  require_relative 'teek/widget'
8
+ require_relative 'teek/callback_registry'
9
+ require_relative 'teek/command_interceptors'
10
+ require_relative 'teek/menu_interceptor'
11
+ require_relative 'teek/tag_bind_interceptor'
12
+ require_relative 'teek/canvas_bind_interceptor'
8
13
  require_relative 'teek/photo'
14
+ require_relative 'teek/dialogs'
15
+ require_relative 'teek/winfo'
16
+ require_relative 'teek/wm'
9
17
 
10
18
  # Ruby interface to Tcl/Tk. Provides a thin wrapper around a Tcl interpreter
11
19
  # with Ruby callbacks, event bindings, and background work support.
@@ -30,6 +38,40 @@ require_relative 'teek/photo'
30
38
  # @see Teek::BackgroundWork
31
39
  module Teek
32
40
 
41
+ # Raised on a Tcl error (TCL_ERROR) from #tcl_eval, #tcl_invoke, or
42
+ # anything built on them. +message+ is the same short one-line Tcl
43
+ # result this exception has always carried; {#tcl_backtrace} and
44
+ # {#tcl_error_code} additionally expose Tcl's own +errorInfo+/+errorCode+
45
+ # for the failing call - captured immediately after the failing
46
+ # Tcl_Eval/Tcl_EvalObjv via Tcl_GetReturnOptions, before anything else
47
+ # can run and disturb them. Both are +nil+ for the handful of Ruby-level
48
+ # guard errors (e.g. "interpreter has been deleted") that never actually
49
+ # reached Tcl.
50
+ class TclError < RuntimeError
51
+ # @return [String, nil] Tcl's +errorInfo+ for the failing call - a
52
+ # multi-line trace with a "while executing"/"invoked from within"
53
+ # frame for each level of Tcl proc call the error unwound through
54
+ attr_reader :tcl_backtrace
55
+
56
+ # @return [String, nil] Tcl's +errorCode+ for the failing call (a Tcl
57
+ # list, typically "NONE" unless the failing command set one explicitly)
58
+ attr_reader :tcl_error_code
59
+
60
+ # @api private
61
+ def initialize(message, tcl_backtrace = nil, tcl_error_code = nil)
62
+ super(message)
63
+ @tcl_backtrace = tcl_backtrace
64
+ @tcl_error_code = tcl_error_code
65
+ end
66
+ end
67
+
68
+ # Raised by App#command when more than one registered CommandInterceptor
69
+ # claims the same call. The message names each matching interceptor's
70
+ # label so it's clear which ones collided - built-in shape-matching bug,
71
+ # a custom interceptor overlapping a built-in one, or two custom
72
+ # interceptors overlapping each other.
73
+ class AmbiguousCommandError < RuntimeError; end
74
+
33
75
  def self.bool_to_tcl(val)
34
76
  val ? "1" : "0"
35
77
  end
@@ -48,19 +90,25 @@ module Teek
48
90
  ].freeze
49
91
 
50
92
  class App
51
- attr_reader :interp, :widgets, :debugger
93
+ attr_reader :interp, :widgets, :debugger, :callback_registry, :winfo, :wm
52
94
  attr_writer :_pending_exception # @api private
53
95
 
54
96
  def initialize(title: nil, track_widgets: true, debug: false, &block)
55
97
  @interp = Teek::Interp.new
56
98
  @interp.tcl_eval('package require Tk')
99
+ @winfo = Teek::Winfo.new(self)
100
+ @wm = Teek::Wm.new(self)
57
101
  hide
58
102
  @widgets = {}
59
103
  @widget_counters = Hash.new(0)
104
+ @callback_registry = Teek::CallbackRegistry.new(self)
105
+ @widget_types_by_path = {}
60
106
  @_pending_exception = nil
61
107
  debug ||= !!ENV['TEEK_DEBUG']
62
108
  track_widgets = true if debug
109
+ @track_widgets = track_widgets
63
110
  setup_widget_tracking if track_widgets
111
+ setup_destroy_cleanup
64
112
  if debug
65
113
  require_relative 'teek/debugger'
66
114
  @debugger = Teek::Debugger.new(self)
@@ -73,6 +121,14 @@ module Teek
73
121
  # Prefer {#command} for building commands from Ruby values; use this
74
122
  # when you need Tcl-level features like variable substitution or
75
123
  # inline expressions that {#command} can't express.
124
+ # @note Any callback embedded in +script+ (e.g. a hand-built
125
+ # +ruby_callback <id>+) is on you to register and release - none of
126
+ # {#command}'s tracking applies here. Creating a widget this way
127
+ # (instead of via {#command}/{#create_widget}/{#menu}) also means its
128
+ # type is never recorded, so a registered {CommandInterceptors} entry
129
+ # won't engage for it even on later, ordinary {#command} calls -
130
+ # create widgets through those methods and reach for +tcl_eval+ for
131
+ # everything else.
76
132
  # @param script [String] Tcl code to evaluate
77
133
  # @return [String] the Tcl result
78
134
  def tcl_eval(script)
@@ -92,10 +148,25 @@ module Teek
92
148
  # throw :teek_break - stop event propagation (like Tcl "break")
93
149
  # throw :teek_continue - Tcl TCL_CONTINUE
94
150
  # throw :teek_return - Tcl TCL_RETURN
151
+ #
152
+ # +:teek_break+/+:teek_continue+ only mean something when Tcl actually
153
+ # dispatches the result through a context that knows how to handle
154
+ # TCL_BREAK/TCL_CONTINUE - Tk's bind mechanism does; a plain script
155
+ # invocation (a menu entry's or widget's `-command`) does not, and
156
+ # returning either code there is a Tcl error ("invoked break/continue
157
+ # outside of a loop"), not a no-op. +relay_break_continue: false+ is
158
+ # for exactly those non-bind callers: +throw+ is still caught (so it
159
+ # can't crash as an uncaught throw), but is treated as the callback
160
+ # simply finishing, instead of being relayed to Tcl. +:teek_return+ is
161
+ # always relayed either way - TCL_RETURN is safe in any context.
95
162
  # @param callable [#call] a Proc or lambda to invoke from Tcl
163
+ # @param relay_break_continue [Boolean] whether a caught :teek_break/
164
+ # :teek_continue is relayed to Tcl as TCL_BREAK/TCL_CONTINUE (true,
165
+ # for bind-dispatched callbacks) or silently absorbed (false, for
166
+ # callbacks invoked as a plain script - menu/widget -command options)
96
167
  # @return [Integer] callback ID, usable as +ruby_callback <id>+ in Tcl
97
168
  # @see #unregister_callback
98
- def register_callback(callable)
169
+ def register_callback(callable, relay_break_continue: true)
99
170
  wrapped = proc { |*args|
100
171
  caught = nil
101
172
  catch(:teek_break) do
@@ -109,7 +180,9 @@ module Teek
109
180
  caught ||= :continue
110
181
  end
111
182
  caught ||= :break
112
- caught == :_none ? nil : caught
183
+ next nil if caught == :_none
184
+ next caught if caught == :return
185
+ relay_break_continue ? caught : nil
113
186
  }
114
187
  @interp.register_callback(wrapped)
115
188
  end
@@ -121,6 +194,20 @@ module Teek
121
194
  @interp.unregister_callback(id)
122
195
  end
123
196
 
197
+ # Evaluate +script+ once per App instance under +name+, skipping it on
198
+ # later calls. Meant for widget-behavior modules that need to define a
199
+ # Tcl-side helper proc (e.g. a scan routine) without re-sending and
200
+ # re-parsing that definition on every call.
201
+ # @param name [Symbol] unique name for this helper
202
+ # @yieldreturn [String] the Tcl script to evaluate the first time
203
+ # @return [void]
204
+ def ensure_tcl_helper(name)
205
+ @installed_tcl_helpers ||= {}
206
+ return if @installed_tcl_helpers[name]
207
+ @interp.tcl_eval(yield)
208
+ @installed_tcl_helpers[name] = true
209
+ end
210
+
124
211
  # Schedule a one-shot timer. Calls the block after +ms+ milliseconds.
125
212
  # @param ms [Integer] delay in milliseconds
126
213
  # @param on_error [:raise, Proc, nil] error handling strategy:
@@ -230,10 +317,18 @@ module Teek
230
317
  Teek.bool_to_tcl(val)
231
318
  end
232
319
 
233
- # Build and evaluate a Tcl command from Ruby values.
234
- # Positional args are converted: Symbols pass bare, Procs become
235
- # callbacks, everything else is brace-quoted. Keyword args become
236
- # +-key value+ option pairs.
320
+ # Build and evaluate a Tcl command from Ruby values. Positional args
321
+ # are converted: Symbols pass bare, Procs become callbacks, everything
322
+ # else is brace-quoted. Keyword args become +-key value+ option pairs.
323
+ #
324
+ # Any Proc-valued arg or kwarg is tracked and released on overwrite,
325
+ # explicit removal, or the owning widget's destruction - there is no
326
+ # unsafe way to attach a callback through this method, regardless of
327
+ # whether the call happens to match a registered per-widget-type
328
+ # interceptor (see {CommandInterceptors}) or falls through to the
329
+ # generic default. Widget type is inferred automatically from calls
330
+ # shaped like widget creation (a {WIDGET_COMMANDS} name as +cmd+, the
331
+ # new path as the first positional arg) - not tied to +track_widgets+.
237
332
  # @example
238
333
  # app.command(:pack, '.btn', side: :left, padx: 10)
239
334
  # # evaluates: pack .btn -side left -padx {10}
@@ -241,33 +336,126 @@ module Teek
241
336
  # @param args positional arguments
242
337
  # @param kwargs keyword arguments mapped to +-key value+ pairs
243
338
  # @return [String] the Tcl result
339
+ # @raise [AmbiguousCommandError] if more than one registered
340
+ # interceptor claims the same call - see {CommandInterceptors}
244
341
  def command(cmd, *args, **kwargs)
245
- parts = [cmd.to_s]
342
+ record_widget_type(cmd, args)
343
+
344
+ type = @widget_types_by_path[cmd.to_s]
345
+ entries = type ? CommandInterceptors.for_type(type) : []
346
+ matches = entries.map { |entry| [entry.label, entry.block.call(self, cmd, args, kwargs)] }
347
+ .reject { |_label, result| result.nil? }
348
+
349
+ case matches.size
350
+ when 0 then raw_command(cmd, *args, **track_widget_option_callbacks(cmd, args, kwargs))
351
+ when 1 then matches.first.last
352
+ else
353
+ labels = matches.map(&:first).join(', ')
354
+ raise AmbiguousCommandError,
355
+ "#{matches.size} command interceptors (#{labels}) matched #{cmd.inspect} #{args.inspect} " \
356
+ "for widget type #{type.inspect}"
357
+ end
358
+ end
359
+
360
+ # The dumb Tcl builder underneath {#command} - no interceptor lookup,
361
+ # no per-widget-type awareness. Used internally by interceptors (to
362
+ # actually perform their Tcl work without re-entering dispatch) and by
363
+ # {#command}'s own generic fallback. Any Proc here still gets
364
+ # registered as a real, working callback (positional: bind-shaped,
365
+ # relay_break_continue: true; kwarg, via {#tcl_arg_value}: option-shaped,
366
+ # relay_break_continue: false) - it just isn't tracked for release.
367
+ # Prefer {#command}; call this directly only from within an
368
+ # interceptor.
369
+ #
370
+ # Built as a plain argv array passed to {Interp#tcl_invoke}
371
+ # (Tcl_EvalObjv) rather than a joined string handed to +tcl_eval+, so
372
+ # no value needs escaping - unbalanced braces, +$+, +[+, newlines,
373
+ # whatever, all pass through verbatim. There is nothing here for a
374
+ # value to "break out" of.
375
+ # @return [String] the Tcl result
376
+ def raw_command(cmd, *args, **kwargs)
377
+ argv = [cmd.to_s]
246
378
  i = 0
247
379
  while i < args.length
248
380
  arg = args[i]
249
381
  if arg.is_a?(Proc)
250
- id = @interp.register_callback(arg)
382
+ id = register_callback(arg)
251
383
  subs = []
252
384
  while i + 1 < args.length && args[i + 1].is_a?(String) && args[i + 1].start_with?('%')
253
385
  subs << args[i + 1]
254
386
  i += 1
255
387
  end
256
- parts << if subs.empty?
257
- "{ruby_callback #{id}}"
258
- else
259
- "{ruby_callback #{id} #{subs.join(' ')}}"
260
- end
388
+ argv << if subs.empty?
389
+ "ruby_callback #{id}"
390
+ else
391
+ "ruby_callback #{id} #{subs.join(' ')}"
392
+ end
261
393
  else
262
- parts << tcl_value(arg)
394
+ argv << tcl_arg_value(arg)
263
395
  end
264
396
  i += 1
265
397
  end
266
398
  kwargs.each do |key, value|
267
- parts << "-#{key}"
268
- parts << tcl_value(value)
399
+ argv << "-#{key}"
400
+ argv << tcl_arg_value(value)
401
+ end
402
+ @interp.tcl_invoke(*argv)
403
+ end
404
+
405
+ # {#command}'s fallback for any call that no registered interceptor
406
+ # claimed: registers any Proc-valued kwarg (e.g. command:,
407
+ # validatecommand:) as a callback tracked under +cmd+, releasing it if
408
+ # reconfigured or when the widget is destroyed. A widget's own options
409
+ # are never silently renumbered or invalidated out from under us the
410
+ # way menu entries are, so this uses a cheap in-memory
411
+ # {CallbackRegistry#reconcile} rather than a live-scan one.
412
+ #
413
+ # Tracked under the widget's own path, by +[*context, key]+, where
414
+ # +context+ is +args+ normalized to strings - except a bare +configure+
415
+ # (or widget creation - the container is the new widget's path, not the
416
+ # +cmd+ used to create it) normalizes to an empty context, since all
417
+ # three address the same underlying option namespace and must replace
418
+ # each other. Any other subcommand (e.g. a treeview's +heading col+)
419
+ # keeps its own args as part of the key, so two different targets
420
+ # sharing an option name (two columns both using +command:+) don't
421
+ # collide.
422
+ # @return [Hash] +kwargs+ with any Proc values swapped for the Tcl
423
+ # script {#raw_command} embeds
424
+ def track_widget_option_callbacks(cmd, args, kwargs)
425
+ proc_kwargs = kwargs.select { |_, value| value.is_a?(Proc) }
426
+ return kwargs if proc_kwargs.empty?
427
+
428
+ if WIDGET_COMMANDS.include?(cmd.to_s) && args[0].is_a?(String)
429
+ widget_path = args[0]
430
+ context = []
431
+ else
432
+ widget_path = cmd.to_s
433
+ context = (args.empty? || args[0].to_s == 'configure') ? [] : args.map(&:to_s)
434
+ end
435
+
436
+ ids = {}
437
+ replacements = {}
438
+ proc_kwargs.each do |key, value|
439
+ id = register_callback(value, relay_break_continue: false)
440
+ ids[context + [key.to_s]] = id
441
+ replacements[key] = "ruby_callback #{id}"
269
442
  end
270
- @interp.tcl_eval(parts.join(' '))
443
+ @callback_registry.reconcile([:widget_option, widget_path]) { |before| before.merge(ids) }
444
+ kwargs.merge(replacements)
445
+ end
446
+
447
+ # Records that +args[0]+ is a widget of type +cmd+, if this call looks
448
+ # like widget creation (+cmd+ is a known {WIDGET_COMMANDS} entry). Not
449
+ # tied to +track_widgets+ - this is what lets {#command} look up a
450
+ # registered interceptor for a bare path string on any later call,
451
+ # regardless of how the widget was created.
452
+ # @return [void]
453
+ def record_widget_type(cmd, args)
454
+ return unless WIDGET_COMMANDS.include?(cmd.to_s)
455
+ path = args[0]
456
+ return unless path.is_a?(String)
457
+
458
+ @widget_types_by_path[path] = cmd.to_s
271
459
  end
272
460
 
273
461
  # Create a Tk widget and return a {Widget} wrapper.
@@ -278,6 +466,10 @@ module Teek
278
466
  # @param type [String, Symbol] Tk widget command (e.g. 'ttk::button', :canvas)
279
467
  # @param path [String, nil] explicit Tk path, or nil for auto-naming
280
468
  # @param parent [Widget, String, nil] parent widget for path nesting
469
+ # @param idempotent [Boolean] skip the creation command if a widget
470
+ # already exists at +path+ - for widgets meant to be fetched by a
471
+ # stable, caller-chosen path and reused across many calls (see {#menu})
472
+ # rather than freshly created each time
281
473
  # @param kwargs keyword arguments passed to the Tk widget command
282
474
  # @return [Widget] the created widget
283
475
  #
@@ -293,13 +485,33 @@ module Teek
293
485
  # btn = app.create_widget('ttk::button', parent: frm, text: 'Click')
294
486
  # # btn.path => ".ttkfrm1.ttkbtn1"
295
487
  #
296
- def create_widget(type, path = nil, parent: nil, **kwargs)
488
+ def create_widget(type, path = nil, parent: nil, idempotent: false, **kwargs)
297
489
  type_s = type.to_s
298
490
  path ||= next_widget_path(type_s, parent)
299
- command(type_s, path, **kwargs)
491
+ if idempotent && @winfo.exists?(path)
492
+ # Still record the type even though creation itself is skipped - a
493
+ # path that already existed (e.g. built with a raw tcl_eval) is
494
+ # otherwise never seen by #command, so no interceptor could ever
495
+ # engage for it.
496
+ record_widget_type(type_s, [path])
497
+ else
498
+ command(type_s, path, **kwargs)
499
+ end
300
500
  Widget.new(self, path)
301
501
  end
302
502
 
503
+ # Wrap a Tk menu at the given path, creating it (tearoff disabled) if
504
+ # it doesn't exist yet. Safe to call repeatedly with the same path -
505
+ # it's a flyweight, not a handle you need to hold onto: call this again
506
+ # any time you're about to rebuild the menu (e.g. on every right-click).
507
+ # @param path [String] Tk menu path (e.g. ".card.ctx")
508
+ # @param kwargs extra options for the underlying `menu` command, used
509
+ # only the first time this path is created
510
+ # @return [Widget]
511
+ def menu(path, **kwargs)
512
+ create_widget(:menu, path, idempotent: true, tearoff: 0, **kwargs)
513
+ end
514
+
303
515
  # Add a directory to Tcl's package search path.
304
516
  # @param path [String] directory containing Tcl packages
305
517
  # @return [void]
@@ -351,21 +563,25 @@ module Teek
351
563
  end
352
564
 
353
565
  # Set a Tcl variable. Useful for widget +textvariable+ and +variable+ options.
354
- # @param name [String] variable name
566
+ # Goes through Tcl_SetVar directly (no re-parsing), so the value never
567
+ # needs escaping - braces, backslashes, +$+, +[+, whatever, all safe.
568
+ # @param name [String] variable name (array-element and namespaced forms work)
355
569
  # @param value [String] value to set
356
570
  # @return [String] the value
357
571
  # @see https://www.tcl-lang.org/man/tcl8.6/TclCmd/set.htm set
358
572
  def set_variable(name, value)
359
- tcl_eval("set #{name} {#{value}}")
573
+ @interp.tcl_set_var(name.to_s, value.to_s)
360
574
  end
361
575
 
362
576
  # Get a Tcl variable's value.
363
- # @param name [String] variable name
577
+ # @param name [String] variable name (array-element and namespaced forms work)
364
578
  # @return [String] the value
365
579
  # @raise [Teek::TclError] if the variable doesn't exist
366
580
  # @see https://www.tcl-lang.org/man/tcl8.6/TclCmd/set.htm set
367
581
  def get_variable(name)
368
- tcl_eval("set #{name}")
582
+ value = @interp.tcl_get_var(name.to_s)
583
+ return value unless value.nil?
584
+ raise Teek::TclError, "can't read \"#{name}\": no such variable"
369
585
  end
370
586
 
371
587
  # Destroy a widget and all its children.
@@ -464,17 +680,19 @@ module Teek
464
680
  # Show a window. Defaults to the root window (".").
465
681
  # @param window [String] Tk window path
466
682
  # @return [void]
683
+ # @see Wm#deiconify
467
684
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M38 wm deiconify
468
685
  def show(window = '.')
469
- @interp.tcl_eval("wm deiconify #{window}")
686
+ @wm.deiconify(window: window)
470
687
  end
471
688
 
472
689
  # Hide a window without destroying it. Defaults to the root window (".").
473
690
  # @param window [String] Tk window path
474
691
  # @return [void]
692
+ # @see Wm#withdraw
475
693
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M65 wm withdraw
476
694
  def hide(window = '.')
477
- @interp.tcl_eval("wm withdraw #{window}")
695
+ @wm.withdraw(window: window)
478
696
  end
479
697
 
480
698
  # Enable the Tk debug console. The console starts hidden and can be
@@ -519,34 +737,38 @@ module Teek
519
737
  # @param title [String] new title
520
738
  # @param window [String] Tk window path
521
739
  # @return [String] the title
740
+ # @see Wm#set_title
522
741
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M63 wm title
523
742
  def set_window_title(title, window: '.')
524
- tcl_eval("wm title #{window} {#{title}}")
743
+ @wm.set_title(title, window: window)
525
744
  end
526
745
 
527
746
  # Get a window's current title.
528
747
  # @param window [String] Tk window path
529
748
  # @return [String] current title
749
+ # @see Wm#title
530
750
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M63 wm title
531
751
  def window_title(window: '.')
532
- tcl_eval("wm title #{window}")
752
+ @wm.title(window: window)
533
753
  end
534
754
 
535
755
  # Set a window's geometry (e.g. "400x300", "400x300+100+50").
536
756
  # @param geometry [String] geometry string
537
757
  # @param window [String] Tk window path
538
758
  # @return [String] the geometry
759
+ # @see Wm#set_geometry
539
760
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M42 wm geometry
540
761
  def set_window_geometry(geometry, window: '.')
541
- tcl_eval("wm geometry #{window} #{geometry}")
762
+ @wm.set_geometry(geometry, window: window)
542
763
  end
543
764
 
544
765
  # Get a window's current geometry.
545
766
  # @param window [String] Tk window path
546
767
  # @return [String] geometry string (e.g. "400x300+0+0")
768
+ # @see Wm#geometry
547
769
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M42 wm geometry
548
770
  def window_geometry(window: '.')
549
- tcl_eval("wm geometry #{window}")
771
+ @wm.geometry(window: window)
550
772
  end
551
773
 
552
774
  # Set whether a window is resizable.
@@ -554,18 +776,19 @@ module Teek
554
776
  # @param height [Boolean] allow vertical resize
555
777
  # @param window [String] Tk window path
556
778
  # @return [void]
779
+ # @see Wm#set_resizable
557
780
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M59 wm resizable
558
781
  def set_window_resizable(width, height, window: '.')
559
- tcl_eval("wm resizable #{window} #{width ? 1 : 0} #{height ? 1 : 0}")
782
+ @wm.set_resizable(width, height, window: window)
560
783
  end
561
784
 
562
785
  # Get whether a window is resizable.
563
786
  # @param window [String] Tk window path
564
787
  # @return [Array(Boolean, Boolean)] [width_resizable, height_resizable]
788
+ # @see Wm#resizable
565
789
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/wm.htm#M59 wm resizable
566
790
  def window_resizable(window: '.')
567
- parts = tcl_eval("wm resizable #{window}").split
568
- [parts[0] == '1', parts[1] == '1']
791
+ @wm.resizable(window: window)
569
792
  end
570
793
 
571
794
  # Bind a Tk event on a widget, with optional substitutions forwarded
@@ -616,6 +839,7 @@ module Teek
616
839
  def bind(widget, event, *subs, &block)
617
840
  event_str = event.start_with?('<') ? event : "<#{event}>"
618
841
  cb = register_callback(proc { |*args| block.call(*args) })
842
+ @callback_registry.reconcile([:bind, widget]) { |before| before.merge(event_str => cb) }
619
843
  tcl_subs = subs.map { |s| s.is_a?(Symbol) ? BIND_SUBS.fetch(s) : s.to_s }
620
844
  sub_str = tcl_subs.empty? ? '' : ' ' + tcl_subs.join(' ')
621
845
  @interp.tcl_eval("bind #{widget} #{event_str} {ruby_callback #{cb}#{sub_str}}")
@@ -629,9 +853,36 @@ module Teek
629
853
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/bind.htm bind
630
854
  def unbind(widget, event)
631
855
  event_str = event.start_with?('<') ? event : "<#{event}>"
856
+ @callback_registry.reconcile([:bind, widget]) { |before| before.reject { |k, _| k == event_str } }
632
857
  @interp.tcl_eval("bind #{widget} #{event_str} {}")
633
858
  end
634
859
 
860
+ # Register a handler for the window manager's close button
861
+ # (WM_DELETE_WINDOW - the titlebar close box, Cmd-W, Alt-F4, etc.,
862
+ # depending on platform).
863
+ #
864
+ # Tk's own default behavior (destroy the window) only applies when
865
+ # nothing else has claimed this protocol - setting a handler here
866
+ # replaces it, so the block is entirely responsible for deciding
867
+ # whether the window actually closes. Call {#destroy} yourself if you
868
+ # want it to; do nothing (or show a confirmation first) if you don't.
869
+ #
870
+ # @example Confirm before quitting
871
+ # app.on_close { app.destroy('.') if app.message_box(message: 'Quit?', type: :yesno) == :yes }
872
+ # @example A toplevel that just hides instead of closing
873
+ # app.on_close(window: settings_window) { app.tcl_eval("wm withdraw #{settings_window}") }
874
+ #
875
+ # @param window [String] Tk window path (default: the root window)
876
+ # @yield called when the window's close button is pressed
877
+ # @return [void]
878
+ # @see #bind
879
+ # @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/wm.htm#M46 wm protocol
880
+ 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}}")
884
+ end
885
+
635
886
  # Register a widget as a file drop target.
636
887
  # After registration, dropping files onto the widget generates a single
637
888
  # +<<DropFile>>+ virtual event with all file paths as a Tcl list in the
@@ -761,11 +1012,6 @@ module Teek
761
1012
  @widgets[path] = { class: cls, parent: File.dirname(path).gsub(/\A$/, '.') }
762
1013
  @debugger&.on_widget_created(path, cls)
763
1014
  })
764
- @destroy_cb_id = @interp.register_callback(proc { |path|
765
- next if path.start_with?('.teek_debug')
766
- @widgets.delete(path)
767
- @debugger&.on_widget_destroyed(path)
768
- })
769
1015
 
770
1016
  # Tcl proc called on widget creation (trace leave)
771
1017
  @interp.tcl_eval("proc ::teek_track_create {cmd_string code result op} {
@@ -776,37 +1022,51 @@ module Teek
776
1022
  }
777
1023
  }")
778
1024
 
779
- # Tcl proc called on widget destruction (bind)
780
- @interp.tcl_eval("bind all <Destroy> {ruby_callback #{@destroy_cb_id} %W}")
781
-
782
1025
  # Add trace on each widget command
783
1026
  Teek::WIDGET_COMMANDS.each do |cmd|
784
1027
  @interp.tcl_eval("catch {trace add execution #{cmd} leave ::teek_track_create}")
785
1028
  end
786
1029
  end
787
1030
 
788
- def tcl_value(value)
1031
+ # Installed unconditionally (unlike widget-creation tracking, which is
1032
+ # opt-out via track_widgets: false) so that bind-callback cleanup always
1033
+ # runs. A single `bind all <Destroy>` script is used because Tcl's bind
1034
+ # command replaces rather than appends per tag+event, so widget-tracking
1035
+ # cleanup is folded into the same callback rather than installed separately.
1036
+ def setup_destroy_cleanup
1037
+ @destroy_cb_id = @interp.register_callback(proc { |path|
1038
+ @callback_registry.forget_all_for_path(path)
1039
+ next if path.start_with?('.teek_debug')
1040
+ if @track_widgets
1041
+ @widgets.delete(path)
1042
+ @debugger&.on_widget_destroyed(path)
1043
+ end
1044
+ })
1045
+ @interp.tcl_eval("bind all <Destroy> {ruby_callback #{@destroy_cb_id} %W}")
1046
+ end
1047
+
1048
+ # Resolves a Ruby value to the plain string {#raw_command} passes as
1049
+ # one +tcl_invoke+ argv element - no Tcl quoting of any kind, since
1050
+ # +tcl_invoke+ (Tcl_EvalObjv) never re-parses its arguments.
1051
+ def tcl_arg_value(value)
789
1052
  case value
790
1053
  when Proc
791
- id = @interp.register_callback(value)
792
- "{ruby_callback #{id}}"
1054
+ # A Proc reaching tcl_arg_value is always a kwarg/option value
1055
+ # (e.g. -command), never a bind script - see #register_callback's
1056
+ # note on why break/continue can't be relayed there.
1057
+ id = register_callback(value, relay_break_continue: false)
1058
+ "ruby_callback #{id}"
793
1059
  when Symbol
794
1060
  value.to_s
795
1061
  when Array
796
- "{#{value.map { |v| tcl_value(v) }.join(' ')}}"
1062
+ # Each element becomes its own well-formed Tcl list element via
1063
+ # make_list (which quotes only where needed), so this produces a
1064
+ # single argv value that Tk parses back out as a proper list -
1065
+ # nested arrays fall out for free, since make_list happily
1066
+ # accepts an already-list-shaped string as one of its elements.
1067
+ make_list(*value.map { |v| tcl_arg_value(v) })
797
1068
  else
798
- tcl_quote_string(value.to_s)
799
- end
800
- end
801
-
802
- # Brace-quote a string for Tcl, falling back to double-quote quoting
803
- # when the string ends with a backslash (Tcl treats \} as an escaped
804
- # brace, preventing the closing brace from terminating the group).
805
- def tcl_quote_string(s)
806
- if s.end_with?('\\')
807
- '"' + s.gsub(/[\\\[\]$"]/) { |c| "\\#{c}" } + '"'
808
- else
809
- "{#{s}}"
1069
+ value.to_s
810
1070
  end
811
1071
  end
812
1072
  end
data/teek.gemspec CHANGED
@@ -19,7 +19,7 @@ Gem::Specification.new do |spec|
19
19
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
20
  spec.require_paths = ["lib"]
21
21
  spec.extensions = ["ext/teek/extconf.rb"]
22
- spec.required_ruby_version = ">= 3.2"
22
+ spec.required_ruby_version = ">= 3.3"
23
23
 
24
24
  spec.add_development_dependency "rake", "~> 13.0"
25
25
  spec.add_development_dependency "rake-compiler", "~> 1.0"
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.1.5
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Cook
@@ -137,14 +137,22 @@ files:
137
137
  - lib/teek/background_none.rb
138
138
  - lib/teek/background_ractor4x.rb
139
139
  - lib/teek/background_thread.rb
140
+ - lib/teek/callback_registry.rb
141
+ - lib/teek/canvas_bind_interceptor.rb
142
+ - lib/teek/command_interceptors.rb
140
143
  - lib/teek/debugger.rb
141
144
  - lib/teek/demo_support.rb
145
+ - lib/teek/dialogs.rb
146
+ - lib/teek/menu_interceptor.rb
142
147
  - lib/teek/method_coverage_service.rb
143
148
  - lib/teek/photo.rb
144
149
  - lib/teek/platform.rb
145
150
  - lib/teek/ractor_support.rb
151
+ - lib/teek/tag_bind_interceptor.rb
146
152
  - lib/teek/version.rb
147
153
  - lib/teek/widget.rb
154
+ - lib/teek/winfo.rb
155
+ - lib/teek/wm.rb
148
156
  - teek.gemspec
149
157
  homepage: https://github.com/jamescook/teek
150
158
  licenses:
@@ -158,7 +166,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
158
166
  requirements:
159
167
  - - ">="
160
168
  - !ruby/object:Gem::Version
161
- version: '3.2'
169
+ version: '3.3'
162
170
  required_rubygems_version: !ruby/object:Gem::Requirement
163
171
  requirements:
164
172
  - - ">="