teek 0.1.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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +4 -0
  3. data/LICENSE +21 -0
  4. data/README.md +139 -0
  5. data/Rakefile +316 -0
  6. data/ext/teek/extconf.rb +79 -0
  7. data/ext/teek/stubs.h +33 -0
  8. data/ext/teek/tcl9compat.h +211 -0
  9. data/ext/teek/tcltkbridge.c +1597 -0
  10. data/ext/teek/tcltkbridge.h +42 -0
  11. data/ext/teek/tkfont.c +218 -0
  12. data/ext/teek/tkphoto.c +477 -0
  13. data/ext/teek/tkwin.c +144 -0
  14. data/lib/teek/background_none.rb +158 -0
  15. data/lib/teek/background_ractor4x.rb +410 -0
  16. data/lib/teek/background_thread.rb +272 -0
  17. data/lib/teek/debugger.rb +742 -0
  18. data/lib/teek/demo_support.rb +150 -0
  19. data/lib/teek/ractor_support.rb +246 -0
  20. data/lib/teek/version.rb +5 -0
  21. data/lib/teek.rb +540 -0
  22. data/sample/calculator.rb +260 -0
  23. data/sample/debug_demo.rb +45 -0
  24. data/sample/goldberg.rb +1803 -0
  25. data/sample/goldberg_helpers.rb +170 -0
  26. data/sample/minesweeper/assets/MINESWEEPER_0.png +0 -0
  27. data/sample/minesweeper/assets/MINESWEEPER_1.png +0 -0
  28. data/sample/minesweeper/assets/MINESWEEPER_2.png +0 -0
  29. data/sample/minesweeper/assets/MINESWEEPER_3.png +0 -0
  30. data/sample/minesweeper/assets/MINESWEEPER_4.png +0 -0
  31. data/sample/minesweeper/assets/MINESWEEPER_5.png +0 -0
  32. data/sample/minesweeper/assets/MINESWEEPER_6.png +0 -0
  33. data/sample/minesweeper/assets/MINESWEEPER_7.png +0 -0
  34. data/sample/minesweeper/assets/MINESWEEPER_8.png +0 -0
  35. data/sample/minesweeper/assets/MINESWEEPER_F.png +0 -0
  36. data/sample/minesweeper/assets/MINESWEEPER_M.png +0 -0
  37. data/sample/minesweeper/assets/MINESWEEPER_X.png +0 -0
  38. data/sample/minesweeper/minesweeper.rb +452 -0
  39. data/sample/threading_demo.rb +499 -0
  40. data/teek.gemspec +32 -0
  41. metadata +179 -0
@@ -0,0 +1,742 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ # Live inspector for Teek applications. Opens a Toplevel window with three
5
+ # tabs: Widgets (tree + config), Variables (searchable list), and Watches
6
+ # (tracked variable history).
7
+ #
8
+ # @example
9
+ # app = Teek::App.new(debug: true)
10
+ #
11
+ # Can also be enabled via the +TEEK_DEBUG+ environment variable.
12
+ class Debugger
13
+ # Prefix for all debugger widget paths
14
+ TOP = ".teek_debug"
15
+ NB = "#{TOP}.nb"
16
+ WATCH_HISTORY_SIZE = 50
17
+
18
+ attr_reader :interp
19
+
20
+ def initialize(app)
21
+ @app = app
22
+ @interp = app.interp
23
+ @watches = {}
24
+
25
+ app.command(:toplevel, TOP)
26
+ app.command(:wm, 'title', TOP, 'Teek Debugger')
27
+ app.command(:wm, 'geometry', TOP, '400x500')
28
+
29
+ # Don't let closing the debugger kill the app
30
+ close_proc = proc { |*| app.command(:wm, 'withdraw', TOP) }
31
+ app.command(:wm, 'protocol', TOP, 'WM_DELETE_WINDOW', close_proc)
32
+
33
+ setup_ui
34
+ sync_widget_tree
35
+ start_auto_refresh
36
+
37
+ # Start behind the main app window
38
+ app.command(:lower, TOP, '.')
39
+ end
40
+
41
+ def show
42
+ @app.show(TOP)
43
+ @app.command(:raise, TOP)
44
+ end
45
+
46
+ def hide
47
+ @app.hide(TOP)
48
+ end
49
+
50
+ # Called by App when a widget is created
51
+ def on_widget_created(path, cls)
52
+ tree = "#{NB}.widgets.tree"
53
+ return unless @app.command(:winfo, 'exists', tree) == "1"
54
+ return if @app.command(tree, 'exists', path) == "1"
55
+
56
+ ensure_parent_exists(path)
57
+ parent_id = parent_tree_id(path)
58
+ name = tk_basename(path)
59
+
60
+ @app.command(tree, 'insert', parent_id, 'end',
61
+ id: path, text: name, values: Teek.make_list(path, cls))
62
+ @app.command(tree, 'item', parent_id, open: 1)
63
+ rescue Teek::TclError => e
64
+ $stderr.puts "teek debugger: on_widget_created(#{path}): #{e.message}"
65
+ end
66
+
67
+ # Called by App when a widget is destroyed
68
+ def on_widget_destroyed(path)
69
+ tree = "#{NB}.widgets.tree"
70
+ return unless @app.command(:winfo, 'exists', tree) == "1"
71
+ return unless @app.command(tree, 'exists', path) == "1"
72
+
73
+ @app.command(tree, 'delete', path)
74
+ rescue Teek::TclError => e
75
+ $stderr.puts "teek debugger: on_widget_destroyed(#{path}): #{e.message}"
76
+ end
77
+
78
+ private
79
+
80
+ def setup_ui
81
+ @app.command('ttk::notebook', NB)
82
+ @app.command(:pack, NB, fill: :both, expand: 1)
83
+
84
+ setup_widget_tree_tab
85
+ setup_variables_tab
86
+ setup_watches_tab
87
+ end
88
+
89
+ # ── Widgets tab ──────────────────────────────────────────
90
+
91
+ def setup_widget_tree_tab
92
+ tree = "#{NB}.widgets.tree"
93
+
94
+ @app.command('ttk::frame', "#{NB}.widgets")
95
+ @app.command(NB, 'add', "#{NB}.widgets", text: 'Widgets')
96
+
97
+ @app.command('ttk::treeview', tree,
98
+ columns: 'path class', show: 'tree headings', selectmode: :browse)
99
+ @app.command(tree, 'heading', '#0', text: 'Name')
100
+ @app.command(tree, 'heading', 'path', text: 'Path')
101
+ @app.command(tree, 'heading', 'class', text: 'Class')
102
+ @app.command(tree, 'column', '#0', width: 150)
103
+ @app.command(tree, 'column', 'path', width: 150)
104
+ @app.command(tree, 'column', 'class', width: 100)
105
+
106
+ @app.command('ttk::scrollbar', "#{NB}.widgets.vsb",
107
+ orient: :vertical, command: "#{tree} yview")
108
+ @app.command(tree, 'configure', yscrollcommand: "#{NB}.widgets.vsb set")
109
+
110
+ @app.command(:pack, "#{NB}.widgets.vsb", side: :right, fill: :y)
111
+ @app.command(:pack, tree, fill: :both, expand: 1)
112
+
113
+ # Root item for "."
114
+ @app.command(tree, 'insert', '', 'end',
115
+ id: '.', text: '.', values: '. Tk', open: 1)
116
+
117
+ setup_detail_panel
118
+ end
119
+
120
+ def setup_detail_panel
121
+ tree = "#{NB}.widgets.tree"
122
+ detail = "#{NB}.widgets.detail"
123
+ detail_text = "#{detail}.text"
124
+
125
+ @app.command('ttk::frame', detail)
126
+ @app.command(:pack, detail, side: :bottom, fill: :x)
127
+ @app.command(:text, detail_text,
128
+ height: 6, wrap: :word, state: :disabled, font: 'TkFixedFont')
129
+ @app.command(:pack, detail_text, fill: :x)
130
+
131
+ # Repack tree to not overlap detail
132
+ @app.command(:pack, detail, before: tree, side: :bottom, fill: :x)
133
+
134
+ select_proc = proc { |*| on_tree_select }
135
+ @app.command(tree, 'configure', cursor: 'hand2')
136
+ @app.command(:bind, tree, '<<TreeviewSelect>>', select_proc)
137
+ end
138
+
139
+ def on_tree_select
140
+ tree = "#{NB}.widgets.tree"
141
+ detail_text = "#{NB}.widgets.detail.text"
142
+
143
+ sel = @app.command(tree, 'selection')
144
+ return if sel.empty?
145
+
146
+ path = sel
147
+ begin
148
+ config = @app.command(path, 'configure')
149
+ lines = Teek.split_list(config).map { |item|
150
+ parts = Teek.split_list(item)
151
+ next if parts.size < 5
152
+ " #{parts[0]} = #{parts[4]}"
153
+ }.compact.join("\n")
154
+ detail = "#{path}\n#{lines}"
155
+ rescue Teek::TclError
156
+ detail = "#{path}\n (widget no longer exists)"
157
+ end
158
+
159
+ @app.command(detail_text, 'configure', state: :normal)
160
+ @app.command(detail_text, 'delete', '1.0', 'end')
161
+ @app.command(detail_text, 'insert', '1.0', detail)
162
+ @app.command(detail_text, 'configure', state: :disabled)
163
+ end
164
+
165
+ # ── Variables tab ────────────────────────────────────────
166
+
167
+ def setup_variables_tab
168
+ vars_tree = "#{NB}.vars.tree"
169
+
170
+ @app.command('ttk::frame', "#{NB}.vars")
171
+ @app.command(NB, 'add', "#{NB}.vars", text: 'Variables')
172
+
173
+ # Toolbar: search entry + refresh button
174
+ @app.command('ttk::frame', "#{NB}.vars.toolbar")
175
+ @app.command(:pack, "#{NB}.vars.toolbar", fill: :x, padx: 2, pady: 2)
176
+
177
+ @app.command('ttk::label', "#{NB}.vars.toolbar.lbl", text: 'Filter:')
178
+ @app.command(:pack, "#{NB}.vars.toolbar.lbl", side: :left)
179
+
180
+ @app.command('ttk::entry', "#{NB}.vars.toolbar.search")
181
+ @app.command(:pack, "#{NB}.vars.toolbar.search",
182
+ side: :left, fill: :x, expand: 1, padx: 4)
183
+
184
+ refresh_proc = proc { |*| refresh_variables }
185
+ @app.command('ttk::button', "#{NB}.vars.toolbar.refresh",
186
+ text: 'Refresh', command: refresh_proc)
187
+ @app.command(:pack, "#{NB}.vars.toolbar.refresh", side: :right)
188
+
189
+ # Filter on keypress
190
+ filter_proc = proc { |*| filter_variables }
191
+ @app.command(:bind, "#{NB}.vars.toolbar.search", '<KeyRelease>', filter_proc)
192
+
193
+ # Treeview: name, value, type
194
+ @app.command('ttk::treeview', vars_tree,
195
+ columns: 'value type', show: 'tree headings', selectmode: :browse)
196
+ @app.command(vars_tree, 'heading', '#0', text: 'Name')
197
+ @app.command(vars_tree, 'heading', 'value', text: 'Value')
198
+ @app.command(vars_tree, 'heading', 'type', text: 'Type')
199
+ @app.command(vars_tree, 'column', '#0', width: 150)
200
+ @app.command(vars_tree, 'column', 'value', width: 200)
201
+ @app.command(vars_tree, 'column', 'type', width: 50)
202
+
203
+ @app.command('ttk::scrollbar', "#{NB}.vars.vsb",
204
+ orient: :vertical, command: "#{vars_tree} yview")
205
+ @app.command(vars_tree, 'configure',
206
+ yscrollcommand: "#{NB}.vars.vsb set")
207
+
208
+ @app.command(:pack, "#{NB}.vars.vsb", side: :right, fill: :y)
209
+ @app.command(:pack, vars_tree, fill: :both, expand: 1)
210
+
211
+ # Right-click context menu
212
+ @app.command(:menu, "#{NB}.vars.ctx", tearoff: 0)
213
+ watch_proc = proc { |*| watch_selected_variable }
214
+ @app.command("#{NB}.vars.ctx", 'add', 'command',
215
+ label: 'Watch', command: watch_proc)
216
+
217
+ @app.command(:bind, vars_tree, '<Button-3>', proc { |*|
218
+ # Select the row under cursor, then show context menu
219
+ @app.tcl_eval("
220
+ set item [#{vars_tree} identify item [winfo pointerx #{vars_tree}] [winfo pointery #{vars_tree}]]
221
+ if {$item ne {}} { #{vars_tree} selection set $item }
222
+ ")
223
+ @app.tcl_eval("tk_popup #{NB}.vars.ctx [winfo pointerx .] [winfo pointery .]")
224
+ })
225
+
226
+ # Double-click to watch
227
+ @app.command(:bind, vars_tree, '<Double-1>', proc { |*| watch_selected_variable })
228
+
229
+ refresh_variables
230
+ end
231
+
232
+ def refresh_variables
233
+ new_data = fetch_variables
234
+ return if new_data == @var_data
235
+
236
+ vars_tree = "#{NB}.vars.tree"
237
+
238
+ # Preserve selection and scroll
239
+ sel = @app.command(vars_tree, 'selection') rescue ""
240
+ scroll = Teek.split_list(@app.command(vars_tree, 'yview')) rescue nil
241
+
242
+ @var_data = new_data
243
+ filter_variables
244
+
245
+ # Restore selection if item still exists
246
+ unless sel.empty?
247
+ if @app.command(vars_tree, 'exists', sel) == "1"
248
+ @app.command(vars_tree, 'selection', 'set', sel)
249
+ end
250
+ end
251
+
252
+ # Restore scroll position
253
+ if scroll && scroll.size == 2
254
+ @app.command(vars_tree, 'yview', 'moveto', scroll[0])
255
+ end
256
+ rescue Teek::TclError => e
257
+ $stderr.puts "teek debugger: refresh_variables: #{e.message}"
258
+ end
259
+
260
+ def fetch_variables
261
+ vars = {}
262
+ names = Teek.split_list(@app.command(:info, 'globals'))
263
+ names.sort.each do |name|
264
+ is_array = @app.command(:array, 'exists', name) == "1"
265
+ if is_array
266
+ begin
267
+ elements = Teek.split_list(@app.command(:array, 'get', name))
268
+ pairs = elements.each_slice(2).to_a
269
+ vars[name] = { type: "array", value: "(#{pairs.size} elements)", elements: pairs }
270
+ rescue Teek::TclError
271
+ vars[name] = { type: "array", value: "(error reading)" }
272
+ end
273
+ else
274
+ begin
275
+ val = @app.command(:set, name)
276
+ vars[name] = { type: "scalar", value: val }
277
+ rescue Teek::TclError
278
+ vars[name] = { type: "?", value: "(error reading)" }
279
+ end
280
+ end
281
+ end
282
+ vars
283
+ end
284
+
285
+ def filter_variables
286
+ return unless @var_data
287
+
288
+ vars_tree = "#{NB}.vars.tree"
289
+ pattern = @app.command("#{NB}.vars.toolbar.search", 'get').downcase
290
+
291
+ # Clear tree — uses Tcl command substitution
292
+ @app.tcl_eval("#{vars_tree} delete [#{vars_tree} children {}]")
293
+
294
+ @var_data.each do |name, info|
295
+ next unless pattern.empty? ||
296
+ name.downcase.include?(pattern) ||
297
+ info[:value].downcase.include?(pattern)
298
+
299
+ display_val = info[:value]
300
+ display_val = display_val[0, 200] + "..." if display_val.size > 200
301
+
302
+ item_id = "v:#{name}"
303
+ @app.command(vars_tree, 'insert', '', 'end',
304
+ id: item_id, text: name,
305
+ values: Teek.make_list(display_val, info[:type]))
306
+
307
+ # For arrays, add child items for each element
308
+ next unless info[:type] == "array" && info[:elements]
309
+ info[:elements].each do |key, val|
310
+ next unless pattern.empty? ||
311
+ key.downcase.include?(pattern) ||
312
+ val.downcase.include?(pattern)
313
+ el_val = val.size > 200 ? val[0, 200] + "..." : val
314
+ @app.command(vars_tree, 'insert', item_id, 'end',
315
+ id: "v:#{name}:#{key}", text: "#{name}(#{key})",
316
+ values: Teek.make_list(el_val, ''))
317
+ end
318
+ end
319
+ rescue Teek::TclError => e
320
+ $stderr.puts "teek debugger: filter_variables: #{e.message}"
321
+ end
322
+
323
+ def update_variables_incremental
324
+ return unless @var_data
325
+
326
+ vars_tree = "#{NB}.vars.tree"
327
+ pattern = @app.command("#{NB}.vars.toolbar.search", 'get').downcase
328
+
329
+ # Build set of vars that match the current filter
330
+ visible = {}
331
+ @var_data.each do |name, info|
332
+ next unless pattern.empty? ||
333
+ name.downcase.include?(pattern) ||
334
+ info[:value].downcase.include?(pattern)
335
+ visible[name] = info
336
+ end
337
+
338
+ # Walk current tree items: update existing, mark deleted
339
+ current_ids = Teek.split_list(@app.command(vars_tree, 'children', ''))
340
+ in_tree = {}
341
+
342
+ current_ids.each do |item_id|
343
+ name = item_id.sub(/\Av:/, '')
344
+ in_tree[name] = item_id
345
+
346
+ if visible.key?(name)
347
+ info = visible[name]
348
+ display_val = info[:value]
349
+ display_val = display_val[0, 200] + "..." if display_val.size > 200
350
+
351
+ @app.command(vars_tree, 'item', item_id,
352
+ text: name,
353
+ values: Teek.make_list(display_val, info[:type]))
354
+
355
+ if info[:type] == "array" && info[:elements]
356
+ update_array_children(vars_tree, item_id, name, info[:elements], pattern)
357
+ else
358
+ # Remove leftover children (e.g. was array, now scalar)
359
+ children = Teek.split_list(@app.command(vars_tree, 'children', item_id))
360
+ children.each { |c| @app.command(vars_tree, 'delete', c) } unless children.empty?
361
+ end
362
+ else
363
+ # Mark as deleted in-place
364
+ current_text = @app.command(vars_tree, 'item', item_id, '-text')
365
+ unless current_text.include?("(deleted)")
366
+ @app.command(vars_tree, 'item', item_id,
367
+ text: "(deleted) #{name}",
368
+ values: Teek.make_list("", ""))
369
+ children = Teek.split_list(@app.command(vars_tree, 'children', item_id))
370
+ children.each { |c| @app.command(vars_tree, 'delete', c) }
371
+ end
372
+ end
373
+ end
374
+
375
+ # Append new vars not yet in tree
376
+ visible.each do |name, info|
377
+ next if in_tree.key?(name)
378
+
379
+ display_val = info[:value]
380
+ display_val = display_val[0, 200] + "..." if display_val.size > 200
381
+
382
+ item_id = "v:#{name}"
383
+ @app.command(vars_tree, 'insert', '', 'end',
384
+ id: item_id, text: name,
385
+ values: Teek.make_list(display_val, info[:type]))
386
+
387
+ next unless info[:type] == "array" && info[:elements]
388
+ info[:elements].each do |key, val|
389
+ next unless pattern.empty? ||
390
+ key.downcase.include?(pattern) ||
391
+ val.downcase.include?(pattern)
392
+ el_val = val.size > 200 ? val[0, 200] + "..." : val
393
+ @app.command(vars_tree, 'insert', item_id, 'end',
394
+ id: "v:#{name}:#{key}", text: "#{name}(#{key})",
395
+ values: Teek.make_list(el_val, ''))
396
+ end
397
+ end
398
+ rescue Teek::TclError => e
399
+ $stderr.puts "teek debugger: update_variables_incremental: #{e.message}"
400
+ end
401
+
402
+ def update_array_children(vars_tree, parent_id, name, elements, pattern)
403
+ current_children = Teek.split_list(@app.command(vars_tree, 'children', parent_id))
404
+ child_set = {}
405
+ current_children.each { |c| child_set[c] = true }
406
+
407
+ seen = {}
408
+ elements.each do |key, val|
409
+ next unless pattern.empty? ||
410
+ key.downcase.include?(pattern) ||
411
+ val.downcase.include?(pattern)
412
+
413
+ el_id = "v:#{name}:#{key}"
414
+ el_val = val.size > 200 ? val[0, 200] + "..." : val
415
+ seen[el_id] = true
416
+
417
+ if child_set.key?(el_id)
418
+ @app.command(vars_tree, 'item', el_id,
419
+ text: "#{name}(#{key})",
420
+ values: Teek.make_list(el_val, ''))
421
+ else
422
+ @app.command(vars_tree, 'insert', parent_id, 'end',
423
+ id: el_id, text: "#{name}(#{key})",
424
+ values: Teek.make_list(el_val, ''))
425
+ end
426
+ end
427
+
428
+ # Remove children no longer in the array
429
+ (child_set.keys - seen.keys).each do |old_id|
430
+ @app.command(vars_tree, 'delete', old_id)
431
+ end
432
+ end
433
+
434
+ def watch_selected_variable
435
+ vars_tree = "#{NB}.vars.tree"
436
+ sel = @app.command(vars_tree, 'selection')
437
+ return if sel.empty?
438
+
439
+ name = @app.command(vars_tree, 'item', sel, '-text')
440
+ return if name.empty?
441
+
442
+ # Strip array(key) back to just the array name
443
+ name = name.sub(/\(.*\)\z/, '')
444
+ add_watch(name)
445
+ end
446
+
447
+ # ── Watches tab ──────────────────────────────────────────
448
+
449
+ def setup_watches_tab
450
+ watch_tree = "#{NB}.watches.tree"
451
+
452
+ @app.command('ttk::frame', "#{NB}.watches")
453
+ @app.command(NB, 'add', "#{NB}.watches", text: 'Watches')
454
+
455
+ # Toolbar: refresh button
456
+ @app.command('ttk::frame', "#{NB}.watches.toolbar")
457
+ @app.command(:pack, "#{NB}.watches.toolbar", fill: :x, padx: 2, pady: 2)
458
+
459
+ refresh_proc = proc { |*| refresh_watches }
460
+ @app.command('ttk::button', "#{NB}.watches.toolbar.refresh",
461
+ text: 'Refresh', command: refresh_proc)
462
+ @app.command(:pack, "#{NB}.watches.toolbar.refresh", side: :right)
463
+
464
+ # Help label — shown when no watches exist
465
+ @app.command('ttk::label', "#{NB}.watches.help",
466
+ text: "Right-click a variable in the Variables tab to watch it.",
467
+ foreground: 'gray50')
468
+ @app.command(:pack, "#{NB}.watches.help", expand: 1)
469
+
470
+ # Treeview: name, current value, changes count
471
+ @app.command('ttk::treeview', watch_tree,
472
+ columns: 'value changes', show: 'tree headings', selectmode: :browse)
473
+ @app.command(watch_tree, 'heading', '#0', text: 'Name')
474
+ @app.command(watch_tree, 'heading', 'value', text: 'Value')
475
+ @app.command(watch_tree, 'heading', 'changes', text: 'Changes')
476
+ @app.command(watch_tree, 'column', '#0', width: 120)
477
+ @app.command(watch_tree, 'column', 'value', width: 200)
478
+ @app.command(watch_tree, 'column', 'changes', width: 60)
479
+
480
+ @app.command('ttk::scrollbar', "#{NB}.watches.vsb",
481
+ orient: :vertical, command: "#{watch_tree} yview")
482
+ @app.command(watch_tree, 'configure',
483
+ yscrollcommand: "#{NB}.watches.vsb set")
484
+
485
+ # Don't pack tree yet — help label is shown first
486
+
487
+ # Detail panel for history
488
+ @app.command(:text, "#{NB}.watches.history",
489
+ height: 8, wrap: :word, state: :disabled, font: 'TkFixedFont')
490
+
491
+ # Selection shows history
492
+ select_proc = proc { |*| on_watch_select }
493
+ @app.command(:bind, watch_tree, '<<TreeviewSelect>>', select_proc)
494
+
495
+ # Right-click to unwatch
496
+ @app.command(:menu, "#{NB}.watches.ctx", tearoff: 0)
497
+ unwatch_proc = proc { |*| unwatch_selected }
498
+ @app.command("#{NB}.watches.ctx", 'add', 'command',
499
+ label: 'Unwatch', command: unwatch_proc)
500
+
501
+ @app.command(:bind, watch_tree, '<Button-3>', proc { |*|
502
+ @app.tcl_eval("
503
+ set item [#{watch_tree} identify item [winfo pointerx #{watch_tree}] [winfo pointery #{watch_tree}]]
504
+ if {$item ne {}} { #{watch_tree} selection set $item }
505
+ ")
506
+ @app.tcl_eval("tk_popup #{NB}.watches.ctx [winfo pointerx .] [winfo pointery .]")
507
+ })
508
+ end
509
+
510
+ def add_watch(name)
511
+ return if @watches.key?(name)
512
+
513
+ # Register Tcl trace on the variable
514
+ cb_id = @app.register_callback(proc { |var_name, index, *|
515
+ record_watch(var_name, index)
516
+ })
517
+
518
+ @app.tcl_eval("trace add variable #{Teek.make_list(name)} write {ruby_callback #{cb_id}}")
519
+
520
+ @watches[name] = { cb_id: cb_id, values: [] }
521
+
522
+ # Capture current value
523
+ record_watch(name, nil)
524
+
525
+ update_watches_ui
526
+ end
527
+
528
+ def remove_watch(name)
529
+ info = @watches.delete(name)
530
+ return unless info
531
+
532
+ # Remove Tcl trace
533
+ @app.tcl_eval(
534
+ "trace remove variable #{Teek.make_list(name)} write {ruby_callback #{info[:cb_id]}}"
535
+ )
536
+ @app.unregister_callback(info[:cb_id])
537
+
538
+ update_watches_ui
539
+ rescue Teek::TclError => e
540
+ $stderr.puts "teek debugger: remove_watch(#{name}): #{e.message}"
541
+ end
542
+
543
+ def record_watch(name, index)
544
+ watch = @watches[name]
545
+ return unless watch
546
+
547
+ val = begin
548
+ if index && !index.empty?
549
+ @app.tcl_eval("set #{Teek.make_list(name)}(#{index})")
550
+ else
551
+ @app.command(:set, name)
552
+ end
553
+ rescue Teek::TclError
554
+ "(undefined)"
555
+ end
556
+
557
+ entry = { value: val, time: Time.now }
558
+ watch[:values] << entry
559
+ watch[:values].shift if watch[:values].size > WATCH_HISTORY_SIZE
560
+
561
+ # Update this row in-place
562
+ update_watch_row(name, watch)
563
+ rescue Teek::TclError => e
564
+ $stderr.puts "teek debugger: record_watch(#{name}): #{e.message}"
565
+ end
566
+
567
+ def update_watch_row(name, info)
568
+ watch_tree = "#{NB}.watches.tree"
569
+ item_id = "watch_#{name}"
570
+
571
+ current = info[:values].last
572
+ display_val = current ? current[:value] : ""
573
+ display_val = display_val[0, 200] + "..." if display_val.size > 200
574
+
575
+ if @app.command(watch_tree, 'exists', item_id) == "1"
576
+ @app.command(watch_tree, 'item', item_id,
577
+ values: Teek.make_list(display_val, info[:values].size.to_s))
578
+ else
579
+ @app.command(watch_tree, 'insert', '', 'end',
580
+ id: item_id, text: name,
581
+ values: Teek.make_list(display_val, info[:values].size.to_s))
582
+ end
583
+ rescue Teek::TclError => e
584
+ $stderr.puts "teek debugger: update_watch_row(#{name}): #{e.message}"
585
+ end
586
+
587
+ def refresh_watches
588
+ @watches.each do |name, info|
589
+ # Re-read current value
590
+ val = begin
591
+ @app.command(:set, name)
592
+ rescue Teek::TclError
593
+ "(undefined)"
594
+ end
595
+
596
+ current = info[:values].last
597
+ if current.nil? || current[:value] != val
598
+ info[:values] << { value: val, time: Time.now }
599
+ info[:values].shift if info[:values].size > WATCH_HISTORY_SIZE
600
+ end
601
+
602
+ update_watch_row(name, info)
603
+ end
604
+ rescue Teek::TclError => e
605
+ $stderr.puts "teek debugger: refresh_watches: #{e.message}"
606
+ end
607
+
608
+ def update_watches_ui
609
+ watch_tree = "#{NB}.watches.tree"
610
+ help = "#{NB}.watches.help"
611
+ history = "#{NB}.watches.history"
612
+
613
+ # Update tab label with count
614
+ label = @watches.empty? ? "Watches" : "Watches (#{@watches.size})"
615
+ @app.command(NB, 'tab', "#{NB}.watches", text: label)
616
+
617
+ if @watches.empty?
618
+ @app.command(:pack, 'forget', watch_tree) rescue nil
619
+ @app.command(:pack, 'forget', "#{NB}.watches.vsb") rescue nil
620
+ @app.command(:pack, 'forget', history) rescue nil
621
+ @app.command(:pack, help, expand: 1)
622
+ else
623
+ @app.command(:pack, 'forget', help) rescue nil
624
+ @app.command(:pack, "#{NB}.watches.vsb", side: :right, fill: :y)
625
+ @app.command(:pack, watch_tree, fill: :both, expand: 1)
626
+ @app.command(:pack, history, side: :bottom, fill: :x, before: watch_tree)
627
+ end
628
+
629
+ refresh_watches
630
+ end
631
+
632
+ def on_watch_select
633
+ watch_tree = "#{NB}.watches.tree"
634
+ history_w = "#{NB}.watches.history"
635
+
636
+ sel = @app.command(watch_tree, 'selection')
637
+ return if sel.empty?
638
+
639
+ name = @app.command(watch_tree, 'item', sel, '-text')
640
+ info = @watches[name]
641
+ return unless info
642
+
643
+ lines = info[:values].reverse.map { |e|
644
+ ts = e[:time].strftime("%H:%M:%S.%L")
645
+ val = e[:value]
646
+ val = val[0, 100] + "..." if val.size > 100
647
+ " [#{ts}] #{val}"
648
+ }.join("\n")
649
+ text = "#{name} (#{info[:values].size} changes)\n#{lines}"
650
+
651
+ @app.command(history_w, 'configure', state: :normal)
652
+ @app.command(history_w, 'delete', '1.0', 'end')
653
+ @app.command(history_w, 'insert', '1.0', text)
654
+ @app.command(history_w, 'configure', state: :disabled)
655
+ end
656
+
657
+ def unwatch_selected
658
+ watch_tree = "#{NB}.watches.tree"
659
+ sel = @app.command(watch_tree, 'selection')
660
+ return if sel.empty?
661
+
662
+ name = @app.command(watch_tree, 'item', sel, '-text')
663
+ remove_watch(name)
664
+ end
665
+
666
+ # ── Auto-refresh ─────────────────────────────────────────
667
+
668
+ def start_auto_refresh
669
+ @auto_refresh_id = @app.after(1000) do
670
+ auto_refresh_tick
671
+ start_auto_refresh
672
+ end
673
+ end
674
+
675
+ def auto_refresh_tick
676
+ vars_tree = "#{NB}.vars.tree"
677
+
678
+ new_data = fetch_variables
679
+ unless new_data == @var_data
680
+ # Preserve selection and scroll
681
+ sel = @app.command(vars_tree, 'selection') rescue ""
682
+ scroll = Teek.split_list(@app.command(vars_tree, 'yview')) rescue nil
683
+
684
+ @var_data = new_data
685
+ update_variables_incremental
686
+
687
+ # Restore selection if item still exists
688
+ unless sel.empty?
689
+ if @app.command(vars_tree, 'exists', sel) == "1"
690
+ @app.command(vars_tree, 'selection', 'set', sel)
691
+ end
692
+ end
693
+
694
+ # Restore scroll position
695
+ if scroll && scroll.size == 2
696
+ @app.command(vars_tree, 'yview', 'moveto', scroll[0])
697
+ end
698
+ end
699
+
700
+ refresh_watches
701
+ rescue Teek::TclError => e
702
+ $stderr.puts "teek debugger: auto-refresh error: #{e.message}"
703
+ end
704
+
705
+ # ── Widget tree helpers ──────────────────────────────────
706
+
707
+ def sync_widget_tree
708
+ @app.widgets.sort_by { |path, _| path.count('.') }.each do |path, info|
709
+ on_widget_created(path, info[:class])
710
+ end
711
+ end
712
+
713
+ def parent_tree_id(path)
714
+ last_dot = path.rindex('.')
715
+ return '.' if last_dot.nil? || last_dot == 0
716
+ path[0...last_dot]
717
+ end
718
+
719
+ def tk_basename(path)
720
+ last_dot = path.rindex('.')
721
+ return path if last_dot.nil?
722
+ path[(last_dot + 1)..]
723
+ end
724
+
725
+ def ensure_parent_exists(path)
726
+ tree = "#{NB}.widgets.tree"
727
+ parent = parent_tree_id(path)
728
+ return if parent == '.'
729
+ return if @app.command(tree, 'exists', parent) == "1"
730
+
731
+ ensure_parent_exists(parent)
732
+ name = tk_basename(parent)
733
+ cls = begin
734
+ @app.command(:winfo, 'class', parent)
735
+ rescue Teek::TclError
736
+ "?"
737
+ end
738
+ @app.command(tree, 'insert', parent_tree_id(parent), 'end',
739
+ id: parent, text: name, values: Teek.make_list(parent, cls), open: 1)
740
+ end
741
+ end
742
+ end