teek 0.1.4 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0f6bffdcb09e1d16bac89ae6d8a5595b72e33b0ae1e1d8b3a69bbad54e1cadc
4
- data.tar.gz: 3d7ef3fe7168abd1f618577e01d02dfd9a43a91f2fe2a1fd27bff05c9cb73377
3
+ metadata.gz: 3ff08eb5884f74dc6461c5ff1163a025b44b798e2e480bd4f2855210dd0f0fdf
4
+ data.tar.gz: 8e012ccf6a6fffe427630c033b288f784ed455552b064870b0bce054d11e62ee
5
5
  SHA512:
6
- metadata.gz: 6604babdbafe5641544ea288c017722a5c08660445945e715dac47335849165bb3245b2b024c5067fd8013f14cc9aba43965fea5703dc9ff91f71e8e438ab518
7
- data.tar.gz: 8e146399dbc27889ed3284e1de07bfffcc6e83637afb6bc69ab22120410e18e23fcf35f5b0d5e6670dc0ca5062d0ecc6f505fac26cea0c610f628103d065b07e
6
+ metadata.gz: 878e28a9a7493abdccdce1a4b28e372ade4648b12392cdb0815581b4590ab69b601eceb1da686e01719c6930fe1e88c333d4751e80f3af7875ce84a2a391957c
7
+ data.tar.gz: 0cd4b0ae71353720e185455e3966b954b9b2e2237417ce0efc596ad9915234f6b79923d318a54a1f42ddf9a839e8e58857462017192f750cd1d4ef31ce92710a
data/Gemfile CHANGED
@@ -2,3 +2,5 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in teek.gemspec
4
4
  gemspec
5
+
6
+ gem 'rubyzip', '>= 2.4', '< 4'
data/README.md CHANGED
@@ -104,25 +104,54 @@ If a callback raises a Ruby exception, it becomes a Tcl error. The exception mes
104
104
 
105
105
  ## Menus
106
106
 
107
- Build a menu bar with standard `menu` widgets:
107
+ Build a menu bar with `App#menu`:
108
108
 
109
109
  ```ruby
110
110
  app = Teek::App.new(title: 'My App')
111
111
 
112
- app.command(:menu, '.menubar')
113
- app.command('.', :configure, menu: '.menubar')
112
+ menubar = app.menu('.menubar')
113
+ app.command('.', :configure, menu: menubar)
114
114
 
115
- app.command(:menu, '.menubar.file', tearoff: 0)
116
- app.command('.menubar', :add, :cascade, label: 'File', menu: '.menubar.file')
117
- app.command('.menubar.file', :add, :command,
118
- label: 'Quit', command: proc { app.command(:destroy, '.') })
115
+ file_menu = app.menu('.menubar.file')
116
+ menubar.command(:add, :cascade, label: 'File', menu: file_menu)
117
+ file_menu.command(:add, :command, label: 'Quit', command: proc { app.command(:destroy, '.') })
119
118
 
120
119
  app.show
121
120
  app.mainloop
122
121
  ```
123
122
 
123
+ `app.menu(path)` creates the underlying Tk menu the first time it's called for a given path (tearoff disabled) and just returns a handle to it on later calls, so it's safe to call again whenever you're about to rebuild a menu's entries (e.g. on every right-click). Every entry-mutating call — `add`, `insert`, `entryconfigure`, `delete` — tracks any `command:` callback and releases it when the entry is replaced, deleted, or the menu itself is destroyed. That's true whether you call it through the `Widget` handle (`menubar.command(:add, ...)`) or as a raw `app.command(path, :add, ...)`; both go through the same tracking, so there's no less-safe way to build a menu.
124
+
124
125
  > **macOS note:** On macOS, Tk always displays a menu bar. If you don't configure one, Tk shows a default menu with items like "Run Widget Demo" that are meant for the Tcl interpreter shell. Attach a custom menu bar (even an empty one) to suppress it. See the [TkDocs menu tutorial](https://tkdocs.com/tutorial/menus.html) for details.
125
126
 
127
+ ## Dialogs
128
+
129
+ `App` has safe wrappers for the standard Tk dialogs — `choose_open_file`, `choose_save_file`, `message_box`, `choose_color`, and `popup_menu` — built without any string interpolation, so titles, paths, and messages containing spaces or braces are passed through correctly:
130
+
131
+ ```ruby
132
+ path = app.choose_open_file(
133
+ title: 'Open Image',
134
+ filetypes: [['Images', ['.png', '.jpg']], ['All Files', '*']]
135
+ )
136
+
137
+ app.message_box(message: 'Really delete this?', type: :yesno, icon: :warning) # => :yes / :no
138
+ ```
139
+
140
+ File/color pickers return `nil` when cancelled (an array of paths if `multiple: true`); `message_box` returns the pressed button as a symbol. See `sample/dialogs/dialogs_demo.rb` for a runnable demo of all five.
141
+
142
+ ## Window info
143
+
144
+ `app.winfo` groups typed wrappers for Tk's `winfo` command family — one method per query, coerced to the right Ruby type:
145
+
146
+ ```ruby
147
+ app.winfo.width(btn) # => 90 (Integer)
148
+ app.winfo.exists?(btn) # => true / false
149
+ app.winfo.class_name(btn) # => "TButton"
150
+ app.winfo.pointerx # => current mouse x, screen pixels
151
+ ```
152
+
153
+ Every method accepts a path string or anything with a matching `to_s` (a `Widget`, for instance). `Widget` also has `#width`, `#height`, and `#exist?` convenience methods that delegate here for its own path.
154
+
126
155
  ## List operations
127
156
 
128
157
  Convert between Ruby arrays and Tcl list strings:
@@ -242,3 +271,29 @@ task.stop # Stop completely
242
271
  The work block runs in a background Ractor and cannot access Tk directly. Use `t.yield()` to send results to `on_progress`, which runs on the main thread where Tk is available. Callbacks (`on_progress`, `on_done`) can be chained in any order.
243
272
 
244
273
  See [`sample/threading_demo.rb`](sample/threading_demo.rb) for a complete file hasher example.
274
+
275
+ ## File Drop Target
276
+
277
+ Register any widget as a file drop target to receive OS-native drag-and-drop:
278
+
279
+ ```ruby
280
+ app = Teek::App.new(title: "Drop Demo")
281
+ app.show
282
+
283
+ app.register_drop_target('.')
284
+
285
+ app.bind('.', '<<DropFile>>', :data) do |data|
286
+ paths = app.split_list(data)
287
+ puts "Dropped: #{paths.inspect}"
288
+ end
289
+
290
+ app.mainloop
291
+ ```
292
+
293
+ Dropped files arrive as a Tcl list in the `:data` substitution. Use `split_list` to get a Ruby array of paths. Works on macOS (Cocoa), Windows (OLE IDropTarget), and Linux (X11 XDND).
294
+
295
+ See [`sample/drop_demo.rb`](sample/drop_demo.rb) for a complete example.
296
+
297
+ ## Known Issues
298
+
299
+ - **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
@@ -2,9 +2,84 @@ require "bundler/gem_tasks"
2
2
  require 'rake/testtask'
3
3
  require 'rake/clean'
4
4
 
5
- # Sub-project Rakefiles (define sdl2:compile, mgba:compile)
5
+ # Sub-project Rakefiles (define sdl2:compile)
6
6
  import 'teek-sdl2/Rakefile'
7
- import 'teek-mgba/Rakefile'
7
+
8
+ desc 'Run clang-tidy on C code'
9
+ task :lint do
10
+ # Find clang-tidy binary
11
+ clang_tidy = nil
12
+
13
+ # Try system PATH first (Linux, or if user has llvm in PATH)
14
+ if system('which clang-tidy > /dev/null 2>&1')
15
+ clang_tidy = 'clang-tidy'
16
+ # On macOS, check Homebrew LLVM (keg-only, not in PATH by default)
17
+ elsif system('which brew > /dev/null 2>&1')
18
+ llvm_prefix = `brew --prefix llvm 2>/dev/null`.strip
19
+ clang_tidy = "#{llvm_prefix}/bin/clang-tidy" if !llvm_prefix.empty? && File.exist?("#{llvm_prefix}/bin/clang-tidy")
20
+ end
21
+
22
+ unless clang_tidy
23
+ abort("clang-tidy not installed.\n " \
24
+ "macOS: brew install llvm\n " \
25
+ "Ubuntu/Debian: apt-get install clang-tidy\n " \
26
+ 'Fedora/RHEL: dnf install clang-tools-extra')
27
+ end
28
+
29
+ puts 'Running clang-tidy on C code...'
30
+
31
+ # Find all .c files in ext/cataract/ and ext/cataract_color/
32
+ c_files = Dir.glob('ext/teek/**/*.c') + Dir.glob('teek-sdl2/ext/**/*.c')
33
+
34
+ # Run clang-tidy on each file
35
+ # Note: clang-tidy uses the .clang-tidy config file automatically
36
+ # We pass Ruby include path so it can find ruby.h
37
+ ruby_include = RbConfig::CONFIG['rubyhdrdir']
38
+ ruby_arch_include = RbConfig::CONFIG['rubyarchhdrdir']
39
+
40
+ # Without an explicit -I, clang-tidy falls back to whatever tcl.h/tk.h it
41
+ # finds on the default system search path - on macOS that's Apple's own
42
+ # ancient bundled Tcl/Tk 8.5 (symlinked into the Command Line Tools SDK),
43
+ # not the Homebrew tcl-tk this project actually builds against. That
44
+ # header unconditionally needs X11/Xlib.h, which isn't in the SDK -
45
+ # hence "file not found" even though the real build works fine.
46
+ # pkg-config points at the same tcl-tk include dir extconf.rb uses,
47
+ # which also bundles its own vendored X11 headers alongside tk.h, so no
48
+ # separate X11 lookup is needed.
49
+ tcltk_cflags = `pkg-config --cflags tcl tk 2>/dev/null`.strip.split
50
+ if tcltk_cflags.empty?
51
+ warn "Warning: pkg-config couldn't find tcl/tk - clang-tidy may pick up " \
52
+ "the wrong (or no) tcl.h/tk.h. Install tcl-tk via Homebrew/apt, or " \
53
+ "ensure pkg-config can see it."
54
+ end
55
+
56
+ # Same story for teek-sdl2: no explicit -I means clang-tidy won't find
57
+ # SDL2/SDL.h (and friends) at all, since SDL2 has no system-bundled
58
+ # fallback to silently misresolve to - it just fails outright.
59
+ sdl2_cflags = `pkg-config --cflags sdl2 SDL2_ttf SDL2_image SDL2_mixer 2>/dev/null`.strip.split
60
+ if sdl2_cflags.empty?
61
+ warn "Warning: pkg-config couldn't find SDL2 - clang-tidy will fail on " \
62
+ "teek-sdl2/ext/**/*.c. Install SDL2 (+ ttf/image/mixer) via " \
63
+ "Homebrew/apt, or ensure pkg-config can see it."
64
+ end
65
+
66
+ success = c_files.all? do |file|
67
+ puts " Checking #{file}..."
68
+ system(clang_tidy, '--quiet', file, '--',
69
+ "-I#{ruby_include}",
70
+ "-I#{ruby_arch_include}",
71
+ '-Iext/teek',
72
+ '-Iteek-sdl2/ext',
73
+ *tcltk_cflags,
74
+ *sdl2_cflags)
75
+ end
76
+
77
+ if success
78
+ puts '✓ clang-tidy passed'
79
+ else
80
+ abort('clang-tidy found issues!')
81
+ end
82
+ end
8
83
 
9
84
  # Documentation tasks - all doc gems are in docs_site/Gemfile
10
85
  namespace :docs do
@@ -112,7 +187,6 @@ task yard: 'docs:yard'
112
187
  CLEAN.include('ext/teek/config_list')
113
188
  CLOBBER.include('tmp', 'lib/*.bundle', 'lib/*.so', 'ext/**/*.o', 'ext/**/*.bundle', 'ext/**/*.bundle.dSYM')
114
189
  CLOBBER.include('teek-sdl2/lib/*.bundle', 'teek-sdl2/lib/*.so', 'teek-sdl2/ext/**/*.o', 'teek-sdl2/ext/**/*.bundle')
115
- CLOBBER.include('teek-mgba/lib/*.bundle', 'teek-mgba/lib/*.so', 'teek-mgba/ext/**/*.o', 'teek-mgba/ext/**/*.bundle')
116
190
 
117
191
  # Clean coverage artifacts before test runs to prevent accumulation
118
192
  CLEAN.include('coverage/.resultset.json', 'coverage/results')
@@ -219,84 +293,45 @@ namespace :sdl2 do
219
293
  task test: 'sdl2:compile'
220
294
  end
221
295
 
222
- namespace :mgba do
223
- Rake::TestTask.new(:test) do |t|
224
- t.libs << 'teek-mgba/test' << 'teek-mgba/lib' << 'teek-sdl2/lib'
225
- t.test_files = FileList['teek-mgba/test/**/test_*.rb'] - FileList['teek-mgba/test/test_helper.rb']
226
- t.ruby_opts << '-r test_helper'
227
- t.verbose = true
228
- end
229
- task test: ['mgba:compile', 'sdl2:compile']
230
-
231
- desc "Download and build libmgba from source (for macOS / platforms without libmgba-dev)"
232
- task :deps do
233
- require 'fileutils'
234
- require 'etc'
235
-
236
- vendor_dir = File.expand_path('teek-mgba/vendor')
237
- mgba_src = File.join(vendor_dir, 'mgba')
238
- build_dir = File.join(vendor_dir, 'build')
239
- install_dir = File.join(vendor_dir, 'install')
240
-
241
- unless File.directory?(mgba_src)
242
- FileUtils.mkdir_p(vendor_dir)
243
- sh "git clone --depth 1 --branch 0.10.3 https://github.com/mgba-emu/mgba.git #{mgba_src}"
244
- end
245
-
246
- FileUtils.mkdir_p(build_dir)
247
- cmake_flags = %W[
248
- -DBUILD_SHARED=OFF
249
- -DBUILD_STATIC=ON
250
- -DBUILD_QT=OFF
251
- -DBUILD_SDL=OFF
252
- -DBUILD_GL=OFF
253
- -DBUILD_GLES2=OFF
254
- -DBUILD_GLES3=OFF
255
- -DBUILD_LIBRETRO=OFF
256
- -DSKIP_FRONTEND=ON
257
- -DUSE_SQLITE3=OFF
258
- -DUSE_ELF=OFF
259
- -DUSE_LZMA=OFF
260
- -DUSE_EDITLINE=OFF
261
- -DCMAKE_INSTALL_PREFIX=#{install_dir}
262
- -DCMAKE_POLICY_VERSION_MINIMUM=3.5
263
- ].join(' ')
264
-
265
- sh "cmake -S #{mgba_src} -B #{build_dir} #{cmake_flags}"
266
- sh "cmake --build #{build_dir} -j #{Etc.nprocessors}"
267
- sh "cmake --install #{build_dir}"
268
-
269
- puts "libmgba built and installed to #{install_dir}"
270
- end
271
- end
272
-
273
296
  task :default => :compile
274
297
 
275
298
  namespace :release do
276
- desc "Build gems, install to temp dir, run smoke test"
299
+ desc "Clean-slate smoke test: clobber, build gems, install fresh, verify"
277
300
  task :smoke do
278
301
  require 'tmpdir'
279
302
  require 'fileutils'
280
303
 
304
+ # Clean slate — nuke compiled extensions so nothing local leaks in
305
+ puts "Clobbering local build artifacts..."
306
+ Rake::Task['clobber'].invoke
307
+ Rake::Task['sdl2:clobber'].invoke
308
+
281
309
  Dir.mktmpdir('teek-smoke') do |tmpdir|
282
310
  gem_home = File.join(tmpdir, 'gems')
283
311
 
284
312
  # Build both gems
285
- puts "Building gems..."
313
+ puts "\nBuilding gems..."
286
314
  sh "gem build teek.gemspec -o #{tmpdir}/teek.gem 2>&1"
287
315
  Dir.chdir('teek-sdl2') { sh "gem build teek-sdl2.gemspec -o #{tmpdir}/teek-sdl2.gem 2>&1" }
288
316
 
289
- # Install into isolated GEM_HOME
290
- puts "\nInstalling gems..."
317
+ # Install into isolated GEM_HOME (no system gems, no stale versions)
318
+ puts "\nInstalling gems into #{gem_home}..."
291
319
  sh "GEM_HOME=#{gem_home} gem install #{tmpdir}/teek.gem --no-document 2>&1"
292
320
  sh "GEM_HOME=#{gem_home} gem install #{tmpdir}/teek-sdl2.gem --no-document 2>&1"
293
321
 
294
322
  # Run smoke test using only the installed gems (no -I, no bundle)
295
- puts "\nRunning SDL2 smoke test..."
323
+ puts "\nRunning smoke test..."
296
324
  smoke = <<~'RUBY'
297
325
  require "teek"
298
326
  require "teek/sdl2"
299
327
 
328
+ # Verify native extensions loaded from gem path, not local source
329
+ %w[tcltklib teek_sdl2].each do |ext|
330
+ path = $LOADED_FEATURES.find { |f| f.include?(ext) && f.end_with?(".bundle", ".so", ".dll") }
331
+ abort "#{ext}: native extension not found in $LOADED_FEATURES" unless path
332
+ abort "#{ext}: loaded from local source (#{path}), not installed gem" if path.include?("/ext/")
333
+ end
334
+
300
335
  app = Teek::App.new
301
336
  app.set_window_title("Release Smoke Test")
302
337
  app.set_window_geometry("320x240")
@@ -305,6 +340,7 @@ namespace :release do
305
340
 
306
341
  vp = Teek::SDL2::Viewport.new(app, width: 300, height: 200)
307
342
  vp.pack
343
+ app.update
308
344
 
309
345
  vp.render do |r|
310
346
  r.clear(30, 30, 30)
@@ -319,7 +355,7 @@ namespace :release do
319
355
 
320
356
  app.after(500) { vp.destroy; app.destroy }
321
357
  app.mainloop
322
- puts "Release smoke test passed (teek #{Teek::VERSION}, teek-sdl2 #{Teek::SDL2::VERSION})"
358
+ puts "release:smoke OK teek #{Teek::VERSION}, teek-sdl2 #{Teek::SDL2::VERSION}"
323
359
  RUBY
324
360
 
325
361
  smoke_file = File.join(tmpdir, 'smoke.rb')
@@ -474,32 +510,7 @@ namespace :docker do
474
510
  sh cmd
475
511
  end
476
512
 
477
- desc "Run teek-mgba tests in Docker"
478
- task mgba: :build do
479
- tcl_version = tcl_version_from_env
480
- ruby_version = ruby_version_from_env
481
- image_name = docker_image_name(tcl_version, ruby_version)
482
-
483
- require 'fileutils'
484
- FileUtils.mkdir_p('coverage')
485
-
486
- warn_if_containers_running(image_name)
487
-
488
- puts "Running teek-mgba tests in Docker (Ruby #{ruby_version}, Tcl #{tcl_version})..."
489
- cmd = "docker run --rm --init"
490
- cmd += " -v #{Dir.pwd}/coverage:/app/coverage"
491
- cmd += " -e TCL_VERSION=#{tcl_version}"
492
- if ENV['COVERAGE'] == '1'
493
- cmd += " -e COVERAGE=1"
494
- cmd += " -e COVERAGE_NAME=#{ENV['COVERAGE_NAME'] || 'mgba'}"
495
- end
496
- cmd += " #{image_name}"
497
- cmd += " xvfb-run -a bundle exec rake mgba:test"
498
-
499
- sh cmd
500
- end
501
-
502
- desc "Run all tests (teek + teek-sdl2 + teek-mgba) with coverage and generate report"
513
+ desc "Run all tests (teek + teek-sdl2) with coverage and generate report"
503
514
  task all: 'docker:build' do
504
515
  tcl_version = tcl_version_from_env
505
516
  ruby_version = ruby_version_from_env
@@ -520,11 +531,6 @@ namespace :docker do
520
531
  Rake::Task['docker:build'].reenable
521
532
  Rake::Task['docker:test:sdl2'].invoke
522
533
 
523
- ENV['COVERAGE_NAME'] = 'mgba'
524
- Rake::Task['docker:test:mgba'].reenable
525
- Rake::Task['docker:build'].reenable
526
- Rake::Task['docker:test:mgba'].invoke
527
-
528
534
  # Collate inside Docker (paths match /app/lib/...)
529
535
  puts "Collating coverage results..."
530
536
  cmd = "docker run --rm --init"
data/ext/teek/extconf.rb CHANGED
@@ -26,6 +26,21 @@ def find_tcltk
26
26
  if File.exist?("#{inc}/tcl-tk/tcl.h")
27
27
  inc = "#{inc}/tcl-tk"
28
28
  end
29
+
30
+ # Check for versioned subdirectories (Debian/Ubuntu layout:
31
+ # /usr/include/tcl9.0/tcl.h, /usr/include/tcl8.6/tcl.h)
32
+ unless File.exist?("#{inc}/tcl.h")
33
+ versioned = Dir.glob("#{inc}/tcl*/tcl.h").max
34
+ if versioned
35
+ tcl_ver_dir = File.dirname(versioned)
36
+ tk_ver_dir = tcl_ver_dir.sub(/tcl/, 'tk')
37
+ $INCFLAGS << " -I#{tcl_ver_dir}"
38
+ $INCFLAGS << " -I#{tk_ver_dir}" if File.directory?(tk_ver_dir)
39
+ $LDFLAGS << " -L#{lib}"
40
+ break
41
+ end
42
+ end
43
+
29
44
  if File.exist?("#{inc}/tcl.h") && File.exist?("#{inc}/tk.h")
30
45
  $INCFLAGS << " -I#{inc}"
31
46
  $LDFLAGS << " -L#{lib}"
@@ -584,6 +584,70 @@ interp_unregister_callback(VALUE self, VALUE id)
584
584
  return Qnil;
585
585
  }
586
586
 
587
+ /* ---------------------------------------------------------
588
+ * Interp#callback_ids - Currently registered callback id strings
589
+ * (test/introspection use: asserting exactly which ids survive a
590
+ * release, not just how many)
591
+ * --------------------------------------------------------- */
592
+
593
+ static VALUE
594
+ interp_callback_ids(VALUE self)
595
+ {
596
+ struct tcltk_interp *tip = get_interp(self);
597
+ return rb_funcall(tip->callbacks, rb_intern("keys"), 0);
598
+ }
599
+
600
+ /* ---------------------------------------------------------
601
+ * Raise Teek::TclError for a Tcl_Eval/Tcl_EvalObjv failure, attaching
602
+ * errorInfo/errorCode via Tcl_GetReturnOptions - the return options are
603
+ * tied to the specific result code just returned, not the mutable
604
+ * ::errorInfo/::errorCode globals, so this is safe even though it runs
605
+ * after control has already left Tcl_Eval/Tcl_EvalObjv.
606
+ * --------------------------------------------------------- */
607
+
608
+ static VALUE
609
+ tclobj_to_rb_str_or_nil(Tcl_Obj *obj)
610
+ {
611
+ Tcl_Size len;
612
+ const char *str;
613
+
614
+ if (obj == NULL) return Qnil;
615
+ str = Tcl_GetStringFromObj(obj, &len);
616
+ return rb_utf8_str_new(str, len);
617
+ }
618
+
619
+ NORETURN(static void raise_tcl_error(Tcl_Interp *interp, int code));
620
+
621
+ static void
622
+ raise_tcl_error(Tcl_Interp *interp, int code)
623
+ {
624
+ Tcl_Obj *options, *einfo_key, *ecode_key;
625
+ Tcl_Obj *einfo_val = NULL, *ecode_val = NULL;
626
+ VALUE msg, backtrace, error_code, exc;
627
+
628
+ options = Tcl_GetReturnOptions(interp, code);
629
+ Tcl_IncrRefCount(options);
630
+
631
+ einfo_key = Tcl_NewStringObj("-errorinfo", -1);
632
+ ecode_key = Tcl_NewStringObj("-errorcode", -1);
633
+ Tcl_IncrRefCount(einfo_key);
634
+ Tcl_IncrRefCount(ecode_key);
635
+
636
+ Tcl_DictObjGet(interp, options, einfo_key, &einfo_val);
637
+ Tcl_DictObjGet(interp, options, ecode_key, &ecode_val);
638
+
639
+ msg = rb_utf8_str_new_cstr(Tcl_GetStringResult(interp));
640
+ backtrace = tclobj_to_rb_str_or_nil(einfo_val);
641
+ error_code = tclobj_to_rb_str_or_nil(ecode_val);
642
+
643
+ Tcl_DecrRefCount(einfo_key);
644
+ Tcl_DecrRefCount(ecode_key);
645
+ Tcl_DecrRefCount(options);
646
+
647
+ exc = rb_funcall(eTclError, rb_intern("new"), 3, msg, backtrace, error_code);
648
+ rb_exc_raise(exc);
649
+ }
650
+
587
651
  /* ---------------------------------------------------------
588
652
  * Thread-safe event queue: run Ruby proc on main Tcl thread
589
653
  *
@@ -606,7 +670,7 @@ execute_queued_eval(VALUE arg)
606
670
  int result = Tcl_Eval(tip->interp, script_cstr);
607
671
 
608
672
  if (result != TCL_OK) {
609
- rb_raise(eTclError, "%s", Tcl_GetStringResult(tip->interp));
673
+ raise_tcl_error(tip->interp, result);
610
674
  }
611
675
  return rb_utf8_str_new_cstr(Tcl_GetStringResult(tip->interp));
612
676
  }
@@ -647,7 +711,7 @@ execute_queued_invoke(VALUE arg)
647
711
  }
648
712
 
649
713
  if (result != TCL_OK) {
650
- rb_raise(eTclError, "%s", Tcl_GetStringResult(tip->interp));
714
+ raise_tcl_error(tip->interp, result);
651
715
  }
652
716
  return rb_utf8_str_new_cstr(Tcl_GetStringResult(tip->interp));
653
717
  }
@@ -818,7 +882,7 @@ interp_tcl_eval(VALUE self, VALUE script)
818
882
  result = Tcl_Eval(tip->interp, script_cstr);
819
883
 
820
884
  if (result != TCL_OK) {
821
- rb_raise(eTclError, "%s", Tcl_GetStringResult(tip->interp));
885
+ raise_tcl_error(tip->interp, result);
822
886
  }
823
887
 
824
888
  return rb_utf8_str_new_cstr(Tcl_GetStringResult(tip->interp));
@@ -883,7 +947,7 @@ interp_tcl_invoke(int argc, VALUE *argv, VALUE self)
883
947
  }
884
948
 
885
949
  if (result != TCL_OK) {
886
- rb_raise(eTclError, "%s", Tcl_GetStringResult(tip->interp));
950
+ raise_tcl_error(tip->interp, result);
887
951
  }
888
952
 
889
953
  ret = rb_utf8_str_new_cstr(Tcl_GetStringResult(tip->interp));
@@ -1504,6 +1568,7 @@ Init_tcltklib(void)
1504
1568
  rb_define_method(cInterp, "mainloop", interp_mainloop, 0);
1505
1569
  rb_define_method(cInterp, "register_callback", interp_register_callback, 1);
1506
1570
  rb_define_method(cInterp, "unregister_callback", interp_unregister_callback, 1);
1571
+ rb_define_method(cInterp, "callback_ids", interp_callback_ids, 0);
1507
1572
  rb_define_method(cInterp, "create_slave", interp_create_slave, -1);
1508
1573
  rb_define_method(cInterp, "thread_timer_ms", interp_get_thread_timer_ms, 0);
1509
1574
  rb_define_method(cInterp, "thread_timer_ms=", interp_set_thread_timer_ms, 1);
@@ -48,8 +48,8 @@ static int
48
48
  hex_decode(const char *s)
49
49
  {
50
50
  int hi, lo;
51
- hi = s[0];
52
- lo = s[1];
51
+ hi = (unsigned char)s[0];
52
+ lo = (unsigned char)s[1];
53
53
  if (hi >= '0' && hi <= '9') hi -= '0';
54
54
  else if (hi >= 'a' && hi <= 'f') hi = hi - 'a' + 10;
55
55
  else if (hi >= 'A' && hi <= 'F') hi = hi - 'A' + 10;
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Teek
6
+ # Tracks Ruby callback ids scoped to something narrower than a whole
7
+ # widget - keyed by (container, key) pairs - so they can be released
8
+ # again without waiting for a <Destroy> that may never come for the
9
+ # thing the callback is actually attached to (an event binding, a menu
10
+ # entry, ...). A single instance is shared across every feature that
11
+ # needs this; callers namespace their own container keys (by
12
+ # convention, [feature_tag, path] tuples) so two features tracking the
13
+ # same underlying path never collide.
14
+ #
15
+ # There is exactly one way to keep the registry in sync: {#reconcile}.
16
+ # Its block is handed the {key => id} hash tracked last time and must
17
+ # return the {key => id} hash that should be tracked now; whatever id
18
+ # drops out between the two gets released. What the block *does* with
19
+ # the hash it's handed is entirely up to the caller - reuse its values
20
+ # as a starting point for a cheap in-memory update (nothing external
21
+ # can silently change an event binding), or ignore it and recompute
22
+ # the truth from scratch by asking Tk (Tk silently renumbers menu
23
+ # entries, so nothing short of asking can be trusted there). The
24
+ # registry itself never knows or cares which one a caller chose.
25
+ #
26
+ # The one hard rule either way: the returned hash must be a DIFFERENT
27
+ # object from the one the block was handed - e.g. +before.merge(...)+,
28
+ # never +before.merge!(...)+ returned as-is. Released ids are computed
29
+ # as +before.values - after.values+; if the block mutates +before+ in
30
+ # place and returns that same object, +before+ and +after+ are
31
+ # identical by the time that subtraction runs, so any id that was
32
+ # dropped or replaced is silently never released - a leak, the exact
33
+ # thing this class exists to prevent.
34
+ #
35
+ # {#forget_all_for_path} is the only thing a <Destroy> handler needs to
36
+ # call: it releases every container ever registered under a path,
37
+ # regardless of which feature created it, via a reverse index built
38
+ # automatically as a side effect of {#reconcile}.
39
+ class CallbackRegistry
40
+ def initialize(app)
41
+ @app = app
42
+ @entries = Hash.new { |h, k| h[k] = {} }
43
+ @containers_by_path = Hash.new { |h, k| h[k] = Set.new }
44
+ end
45
+
46
+ # @yieldparam before [Hash] the {key => id} hash tracked for +container+
47
+ # as of the last call (empty on the first call)
48
+ # @yieldreturn [Hash] the {key => id} hash that should be tracked now -
49
+ # must be a different object from +before+ (see class docs above);
50
+ # do not mutate +before+ in place and return it, or dropped/replaced
51
+ # ids silently never get released
52
+ # @return [void]
53
+ def reconcile(container)
54
+ track(container)
55
+ before = @entries[container]
56
+ after = yield(before)
57
+ (before.values - after.values).each { |id| @app.unregister_callback(id) }
58
+ @entries[container] = after
59
+ end
60
+
61
+ # Release every callback tracked under any container registered for
62
+ # +path+, regardless of which feature created it, and forget them.
63
+ # @return [void]
64
+ def forget_all_for_path(path)
65
+ containers = @containers_by_path.delete(path)
66
+ return unless containers
67
+ containers.each do |container|
68
+ ids = @entries.delete(container)
69
+ ids&.each_value { |id| @app.unregister_callback(id) }
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ # Convention, not a type check: every container is [feature_tag, path].
76
+ def track(container)
77
+ @containers_by_path[container.last] << container
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'command_interceptors'
4
+ require_relative 'callback_registry'
5
+
6
+ module Teek
7
+ # @api private
8
+ #
9
+ # Registered for the "canvas" {CommandInterceptors} entry below.
10
+ #
11
+ # Canvas items aren't windows (only the canvas itself is one), so a bound
12
+ # item's callback never fires <Destroy> on its own, and `canvas delete`
13
+ # is silent - the same leak shape menu entries have. Unlike menu or
14
+ # text/treeview tags, canvas has no "list every live binding" enumeration
15
+ # command (no analogue to menu's `index end` or text's `tag names`), so
16
+ # this can't do a full-scan reconcile. Instead it re-queries only the
17
+ # (tagOrId, sequence) keys it already knows about - via `canvas bind
18
+ # tagOrId sequence`, the 2-arg read form - after every bind/delete call,
19
+ # and lets whatever no longer resolves drop out.
20
+ #
21
+ # A binding on a numeric item id is released this way once that item is
22
+ # deleted (Tk's Tk_DeleteAllBindings clears its binding-table entries
23
+ # along with it). A binding on a tag is NOT released by deleting a
24
+ # tagged item - the tag itself isn't an item, so its binding-table entry
25
+ # persists independent of which (if any) items currently carry that tag.
26
+ module CanvasBindInterceptor
27
+ MUTATING_SUBCOMMANDS = %w[bind delete].freeze
28
+
29
+ def self.call(app, path, args, kwargs)
30
+ sub = args[0]&.to_s
31
+ return nil unless MUTATING_SUBCOMMANDS.include?(sub)
32
+
33
+ result = app.raw_command(path, *args, **kwargs)
34
+ app.callback_registry.reconcile([:canvas_bind, path]) { |before| requery(app, path, before, sub, args) }
35
+ result
36
+ end
37
+
38
+ def self.requery(app, path, before, sub, args)
39
+ keys = before.keys
40
+ keys += [[args[1].to_s, args[2].to_s]] if sub == 'bind' && args.length >= 4
41
+
42
+ keys.uniq.each_with_object({}) do |(tag_or_id, seq), after|
43
+ # Unlike a tag (a plain Tcl string, always a valid query target even
44
+ # if nothing currently carries it), a numeric item id is a hash key
45
+ # into the canvas's item table - querying one after its item is
46
+ # deleted raises "item \"N\" doesn't exist" rather than returning
47
+ # empty, so a deleted item's binding has to be dropped via rescue,
48
+ # not by checking the result.
49
+ current = begin
50
+ app.tcl_eval("#{path} bind #{tag_or_id} #{seq}")
51
+ rescue Teek::TclError
52
+ ''
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/
63
+ end
64
+ end
65
+ end
66
+
67
+ CommandInterceptors.register('canvas', 'canvas_bind') { |*a| CanvasBindInterceptor.call(*a) }
68
+ end