teek 0.1.3 → 0.1.5

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -0
  3. data/README.md +47 -0
  4. data/Rakefile +55 -27
  5. data/ext/teek/extconf.rb +34 -1
  6. data/ext/teek/tcltkbridge.c +38 -2
  7. data/ext/teek/tcltkbridge.h +3 -0
  8. data/ext/teek/tkdrop.c +66 -0
  9. data/ext/teek/tkdrop.h +26 -0
  10. data/ext/teek/tkdrop_macos.m +141 -0
  11. data/ext/teek/tkdrop_win.c +232 -0
  12. data/ext/teek/tkdrop_x11.c +337 -0
  13. data/ext/teek/tkwin.c +42 -0
  14. data/lib/teek/platform.rb +29 -0
  15. data/lib/teek/version.rb +1 -1
  16. data/lib/teek.rb +49 -3
  17. data/teek.gemspec +4 -3
  18. metadata +8 -54
  19. data/sample/calculator.rb +0 -255
  20. data/sample/debug_demo.rb +0 -43
  21. data/sample/gamepad_viewer/assets/controller.png +0 -0
  22. data/sample/gamepad_viewer/gamepad_viewer.rb +0 -554
  23. data/sample/goldberg.rb +0 -1803
  24. data/sample/goldberg_helpers.rb +0 -170
  25. data/sample/optcarrot/thwaite.nes +0 -0
  26. data/sample/optcarrot/vendor/optcarrot/apu.rb +0 -856
  27. data/sample/optcarrot/vendor/optcarrot/config.rb +0 -257
  28. data/sample/optcarrot/vendor/optcarrot/cpu.rb +0 -1162
  29. data/sample/optcarrot/vendor/optcarrot/driver.rb +0 -144
  30. data/sample/optcarrot/vendor/optcarrot/mapper/cnrom.rb +0 -14
  31. data/sample/optcarrot/vendor/optcarrot/mapper/mmc1.rb +0 -105
  32. data/sample/optcarrot/vendor/optcarrot/mapper/mmc3.rb +0 -153
  33. data/sample/optcarrot/vendor/optcarrot/mapper/uxrom.rb +0 -14
  34. data/sample/optcarrot/vendor/optcarrot/nes.rb +0 -105
  35. data/sample/optcarrot/vendor/optcarrot/opt.rb +0 -168
  36. data/sample/optcarrot/vendor/optcarrot/pad.rb +0 -92
  37. data/sample/optcarrot/vendor/optcarrot/palette.rb +0 -65
  38. data/sample/optcarrot/vendor/optcarrot/ppu.rb +0 -1468
  39. data/sample/optcarrot/vendor/optcarrot/rom.rb +0 -143
  40. data/sample/optcarrot/vendor/optcarrot.rb +0 -14
  41. data/sample/optcarrot.rb +0 -354
  42. data/sample/paint/assets/bucket.png +0 -0
  43. data/sample/paint/assets/cursor.png +0 -0
  44. data/sample/paint/assets/eraser.png +0 -0
  45. data/sample/paint/assets/pencil.png +0 -0
  46. data/sample/paint/assets/spray.png +0 -0
  47. data/sample/paint/layer.rb +0 -255
  48. data/sample/paint/layer_manager.rb +0 -179
  49. data/sample/paint/paint_demo.rb +0 -837
  50. data/sample/paint/sparse_pixel_buffer.rb +0 -202
  51. data/sample/sdl2_demo.rb +0 -318
  52. data/sample/threading_demo.rb +0 -494
  53. data/sample/yam/assets/MINESWEEPER_0.png +0 -0
  54. data/sample/yam/assets/MINESWEEPER_1.png +0 -0
  55. data/sample/yam/assets/MINESWEEPER_2.png +0 -0
  56. data/sample/yam/assets/MINESWEEPER_3.png +0 -0
  57. data/sample/yam/assets/MINESWEEPER_4.png +0 -0
  58. data/sample/yam/assets/MINESWEEPER_5.png +0 -0
  59. data/sample/yam/assets/MINESWEEPER_6.png +0 -0
  60. data/sample/yam/assets/MINESWEEPER_7.png +0 -0
  61. data/sample/yam/assets/MINESWEEPER_8.png +0 -0
  62. data/sample/yam/assets/MINESWEEPER_F.png +0 -0
  63. data/sample/yam/assets/MINESWEEPER_M.png +0 -0
  64. data/sample/yam/assets/MINESWEEPER_X.png +0 -0
  65. data/sample/yam/assets/click.wav +0 -0
  66. data/sample/yam/assets/explosion.wav +0 -0
  67. data/sample/yam/assets/flag.wav +0 -0
  68. data/sample/yam/assets/music.mp3 +0 -0
  69. data/sample/yam/assets/sweep.wav +0 -0
  70. data/sample/yam/yam.rb +0 -587
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fe2f5c480c472d6479ac1b92088a748e1216cb1a15472e9e98fbd3c2e4fd347
4
- data.tar.gz: c4d2da92180136ca756955258ea59402b27b9019e9d492f0969a7c346dcc053a
3
+ metadata.gz: 7fdcad5500b3501e3d2b83d29678b76f8cc043b2f44e5e0af66a300fd8fa03ac
4
+ data.tar.gz: ca0b209ebe033d7e246271bda5491c8192279e063fdb5e45f2bb719fcd0cb5dc
5
5
  SHA512:
6
- metadata.gz: 6a10e7f92ca08b1385288829cf0188beb408c90cdf52aecb30a36652f2b299954318c4b3a64434ea0158950f05e105db5a289c115dab4506fa91b7ab6e70f26e
7
- data.tar.gz: 7cd908cf513d6f92cad5c89d04cb49f7bf3fb3e9d7868af4d0d0fbb7db04e2a7a712c3e6e1e2fd107fd4df4b84140b49d720b2357cc9b9452067a8f5b7bfe2f6
6
+ metadata.gz: d69b03be50a1ad912ba7f8f0610ae7ba6163b19bc342a9f763112a467ddef39f2b8d0e5e2317463c91096d02674f6641d345370c35c09b71f37055d40ceab926
7
+ data.tar.gz: b720547bd7a11f6a3f7c15ec33c78d2c86c42e80ea9b273dd50803e5d2012c6dd286ae2ebd30275ca9b85eaa26866da00df8d0982a21cde97361d8fa98a1df10
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
@@ -102,6 +102,27 @@ Two other control flow signals are available for advanced use:
102
102
 
103
103
  If a callback raises a Ruby exception, it becomes a Tcl error. The exception message is preserved and can be caught on the Tcl side with `catch`.
104
104
 
105
+ ## Menus
106
+
107
+ Build a menu bar with standard `menu` widgets:
108
+
109
+ ```ruby
110
+ app = Teek::App.new(title: 'My App')
111
+
112
+ app.command(:menu, '.menubar')
113
+ app.command('.', :configure, menu: '.menubar')
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, '.') })
119
+
120
+ app.show
121
+ app.mainloop
122
+ ```
123
+
124
+ > **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
+
105
126
  ## List operations
106
127
 
107
128
  Convert between Ruby arrays and Tcl list strings:
@@ -221,3 +242,29 @@ task.stop # Stop completely
221
242
  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.
222
243
 
223
244
  See [`sample/threading_demo.rb`](sample/threading_demo.rb) for a complete file hasher example.
245
+
246
+ ## File Drop Target
247
+
248
+ Register any widget as a file drop target to receive OS-native drag-and-drop:
249
+
250
+ ```ruby
251
+ app = Teek::App.new(title: "Drop Demo")
252
+ app.show
253
+
254
+ app.register_drop_target('.')
255
+
256
+ app.bind('.', '<<DropFile>>', :data) do |data|
257
+ paths = app.split_list(data)
258
+ puts "Dropped: #{paths.inspect}"
259
+ end
260
+
261
+ app.mainloop
262
+ ```
263
+
264
+ 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).
265
+
266
+ See [`sample/drop_demo.rb`](sample/drop_demo.rb) for a complete example.
267
+
268
+ ## Known Issues
269
+
270
+ - **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,6 +2,9 @@ require "bundler/gem_tasks"
2
2
  require 'rake/testtask'
3
3
  require 'rake/clean'
4
4
 
5
+ # Sub-project Rakefiles (define sdl2:compile)
6
+ import 'teek-sdl2/Rakefile'
7
+
5
8
  # Documentation tasks - all doc gems are in docs_site/Gemfile
6
9
  namespace :docs do
7
10
  desc "Install docs dependencies (docs_site/Gemfile)"
@@ -112,20 +115,15 @@ CLOBBER.include('teek-sdl2/lib/*.bundle', 'teek-sdl2/lib/*.so', 'teek-sdl2/ext/*
112
115
  # Clean coverage artifacts before test runs to prevent accumulation
113
116
  CLEAN.include('coverage/.resultset.json', 'coverage/results')
114
117
 
115
- # Conditionally load rake-compiler
118
+ # rake compile = teek core only (tcltklib)
116
119
  if Gem::Specification.find_all_by_name('rake-compiler').any?
117
120
  require 'rake/extensiontask'
121
+
118
122
  Rake::ExtensionTask.new do |ext|
119
123
  ext.name = 'tcltklib'
120
124
  ext.ext_dir = 'ext/teek'
121
125
  ext.lib_dir = 'lib'
122
126
  end
123
-
124
- Rake::ExtensionTask.new do |ext|
125
- ext.name = 'teek_sdl2'
126
- ext.ext_dir = 'teek-sdl2/ext/teek_sdl2'
127
- ext.lib_dir = 'teek-sdl2/lib'
128
- end
129
127
  end
130
128
 
131
129
  namespace :screenshots do
@@ -209,55 +207,55 @@ end
209
207
 
210
208
  task test: [:compile, :clean_coverage]
211
209
 
212
- def detect_platform
213
- case RUBY_PLATFORM
214
- when /darwin/ then 'darwin'
215
- when /linux/ then 'linux'
216
- when /mingw|mswin/ then 'windows'
217
- else 'unknown'
218
- end
219
- end
220
-
221
210
  namespace :sdl2 do
222
- desc "Compile teek-sdl2 C extension"
223
- task compile: 'compile:teek_sdl2'
224
-
225
211
  Rake::TestTask.new(:test) do |t|
226
212
  t.libs << 'teek-sdl2/test' << 'teek-sdl2/lib'
227
213
  t.test_files = FileList['teek-sdl2/test/**/test_*.rb'] - FileList['teek-sdl2/test/test_helper.rb']
228
214
  t.ruby_opts << '-r test_helper'
229
215
  t.verbose = true
230
216
  end
231
- task test: 'compile:teek_sdl2'
217
+ task test: 'sdl2:compile'
232
218
  end
233
219
 
234
220
  task :default => :compile
235
221
 
236
222
  namespace :release do
237
- desc "Build gems, install to temp dir, run smoke test"
223
+ desc "Clean-slate smoke test: clobber, build gems, install fresh, verify"
238
224
  task :smoke do
239
225
  require 'tmpdir'
240
226
  require 'fileutils'
241
227
 
228
+ # Clean slate — nuke compiled extensions so nothing local leaks in
229
+ puts "Clobbering local build artifacts..."
230
+ Rake::Task['clobber'].invoke
231
+ Rake::Task['sdl2:clobber'].invoke
232
+
242
233
  Dir.mktmpdir('teek-smoke') do |tmpdir|
243
234
  gem_home = File.join(tmpdir, 'gems')
244
235
 
245
236
  # Build both gems
246
- puts "Building gems..."
237
+ puts "\nBuilding gems..."
247
238
  sh "gem build teek.gemspec -o #{tmpdir}/teek.gem 2>&1"
248
239
  Dir.chdir('teek-sdl2') { sh "gem build teek-sdl2.gemspec -o #{tmpdir}/teek-sdl2.gem 2>&1" }
249
240
 
250
- # Install into isolated GEM_HOME
251
- puts "\nInstalling gems..."
241
+ # Install into isolated GEM_HOME (no system gems, no stale versions)
242
+ puts "\nInstalling gems into #{gem_home}..."
252
243
  sh "GEM_HOME=#{gem_home} gem install #{tmpdir}/teek.gem --no-document 2>&1"
253
244
  sh "GEM_HOME=#{gem_home} gem install #{tmpdir}/teek-sdl2.gem --no-document 2>&1"
254
245
 
255
246
  # Run smoke test using only the installed gems (no -I, no bundle)
256
- puts "\nRunning SDL2 smoke test..."
247
+ puts "\nRunning smoke test..."
257
248
  smoke = <<~'RUBY'
258
249
  require "teek"
259
250
  require "teek/sdl2"
260
251
 
252
+ # Verify native extensions loaded from gem path, not local source
253
+ %w[tcltklib teek_sdl2].each do |ext|
254
+ path = $LOADED_FEATURES.find { |f| f.include?(ext) && f.end_with?(".bundle", ".so", ".dll") }
255
+ abort "#{ext}: native extension not found in $LOADED_FEATURES" unless path
256
+ abort "#{ext}: loaded from local source (#{path}), not installed gem" if path.include?("/ext/")
257
+ end
258
+
261
259
  app = Teek::App.new
262
260
  app.set_window_title("Release Smoke Test")
263
261
  app.set_window_geometry("320x240")
@@ -266,6 +264,7 @@ namespace :release do
266
264
 
267
265
  vp = Teek::SDL2::Viewport.new(app, width: 300, height: 200)
268
266
  vp.pack
267
+ app.update
269
268
 
270
269
  vp.render do |r|
271
270
  r.clear(30, 30, 30)
@@ -280,7 +279,7 @@ namespace :release do
280
279
 
281
280
  app.after(500) { vp.destroy; app.destroy }
282
281
  app.mainloop
283
- puts "Release smoke test passed (teek #{Teek::VERSION}, teek-sdl2 #{Teek::SDL2::VERSION})"
282
+ puts "release:smoke OK teek #{Teek::VERSION}, teek-sdl2 #{Teek::SDL2::VERSION}"
284
283
  RUBY
285
284
 
286
285
  smoke_file = File.join(tmpdir, 'smoke.rb')
@@ -301,6 +300,15 @@ namespace :docker do
301
300
  ruby_version == '4.0' ? base : "#{base}-ruby#{ruby_version}"
302
301
  end
303
302
 
303
+ def warn_if_containers_running(image_name)
304
+ running = `docker ps --filter ancestor=#{image_name} --format '{{.ID}} {{.Status}}'`.strip
305
+ return if running.empty?
306
+ count = running.lines.size
307
+ warn "\n⚠ #{count} container(s) already running on #{image_name}:"
308
+ running.lines.each { |l| warn " #{l.strip}" }
309
+ warn " This usually means a previous test suite is stuck. Consider: docker kill $(docker ps -q --filter ancestor=#{image_name})\n"
310
+ end
311
+
304
312
  def tcl_version_from_env
305
313
  version = ENV.fetch('TCL_VERSION', '9.0')
306
314
  unless ['8.6', '9.0'].include?(version)
@@ -345,6 +353,8 @@ namespace :docker do
345
353
  require 'fileutils'
346
354
  FileUtils.mkdir_p('coverage')
347
355
 
356
+ warn_if_containers_running(image_name)
357
+
348
358
  puts "Running tests in Docker (Ruby #{ruby_version}, Tcl #{tcl_version})..."
349
359
  cmd = "docker run --rm --init"
350
360
  cmd += " -v #{Dir.pwd}/coverage:/app/coverage"
@@ -374,6 +384,22 @@ namespace :docker do
374
384
  sh cmd
375
385
  end
376
386
 
387
+ desc "Force rebuild Docker image (no cache)"
388
+ task :rebuild do
389
+ tcl_version = tcl_version_from_env
390
+ ruby_version = ruby_version_from_env
391
+ image_name = docker_image_name(tcl_version, ruby_version)
392
+
393
+ puts "Rebuilding Docker image (no cache) for Ruby #{ruby_version}, Tcl #{tcl_version}..."
394
+ cmd = "docker build -f #{DOCKERFILE} --no-cache"
395
+ cmd += " --label #{DOCKER_LABEL}"
396
+ cmd += " --build-arg RUBY_VERSION=#{ruby_version}"
397
+ cmd += " --build-arg TCL_VERSION=#{tcl_version}"
398
+ cmd += " -t #{image_name} ."
399
+
400
+ sh cmd
401
+ end
402
+
377
403
  desc "Remove dangling Docker images from teek builds"
378
404
  task :prune do
379
405
  sh "docker image prune -f --filter label=#{DOCKER_LABEL}"
@@ -391,6 +417,8 @@ namespace :docker do
391
417
  require 'fileutils'
392
418
  FileUtils.mkdir_p('coverage')
393
419
 
420
+ warn_if_containers_running(image_name)
421
+
394
422
  puts "Running teek-sdl2 tests in Docker (Ruby #{ruby_version}, Tcl #{tcl_version})..."
395
423
  cmd = "docker run --rm --init"
396
424
  cmd += " -v #{Dir.pwd}/coverage:/app/coverage"
@@ -416,7 +444,7 @@ namespace :docker do
416
444
  FileUtils.rm_rf('coverage')
417
445
  FileUtils.mkdir_p('coverage/results')
418
446
 
419
- # Run both test suites with coverage enabled and distinct COVERAGE_NAMEs
447
+ # Run all three test suites with coverage enabled and distinct COVERAGE_NAMEs
420
448
  ENV['COVERAGE'] = '1'
421
449
 
422
450
  ENV['COVERAGE_NAME'] = 'main'
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}"
@@ -69,11 +84,29 @@ def find_tcltk
69
84
 
70
85
  abort "Tcl stub library not found" unless tcl_stub
71
86
  abort "Tk stub library not found" unless tk_stub
87
+
88
+ # Also link the real Tcl shared library so it's loaded at runtime.
89
+ # Some distros (e.g. Fedora) ship Tcl symbols in a separate .so that
90
+ # stubs alone don't pull in, causing dlsym() to fail at bootstrap.
91
+ have_library('tcl9.0') || have_library('tcl8.6') || have_library('tcl')
72
92
  end
73
93
 
74
94
  find_tcltk
75
95
 
76
96
  # Source files for the extension
77
- $srcs = ['tcltkbridge.c', 'tkphoto.c', 'tkfont.c', 'tkwin.c', 'tkeventsource.c']
97
+ $srcs = ['tcltkbridge.c', 'tkphoto.c', 'tkfont.c', 'tkwin.c', 'tkeventsource.c', 'tkdrop.c']
98
+
99
+ # Platform-specific file drop target
100
+ case RbConfig::CONFIG['host_os']
101
+ when /darwin/
102
+ $srcs << 'tkdrop_macos.m'
103
+ $LDFLAGS << ' -framework Cocoa'
104
+ when /mingw|mswin|cygwin/
105
+ $srcs << 'tkdrop_win.c'
106
+ have_library('ole32')
107
+ have_library('shell32')
108
+ else
109
+ $srcs << 'tkdrop_x11.c'
110
+ end
78
111
 
79
112
  create_makefile('tcltklib')
@@ -59,6 +59,39 @@ static void *get_tcl_proc(const char *name)
59
59
  }
60
60
  #endif
61
61
 
62
+ /* Try to dlopen the real Tcl shared library if it's not already loaded.
63
+ * Some distros (e.g. Fedora) ship Tcl/Tk in separate .so files and only
64
+ * the Tk combined library gets pulled in via stubs, leaving Tcl_CreateInterp
65
+ * unresolvable via RTLD_DEFAULT. */
66
+ #ifndef _WIN32
67
+ static void *tcl_dlhandle = NULL;
68
+
69
+ static void *
70
+ dlsym_tcl(const char *name)
71
+ {
72
+ void *sym = dlsym(RTLD_DEFAULT, name);
73
+ if (sym) return sym;
74
+
75
+ /* Try loading the real Tcl library explicitly */
76
+ if (!tcl_dlhandle) {
77
+ static const char *lib_names[] = {
78
+ "libtcl9.0.so", "libtcl8.6.so",
79
+ "libtcl9.0.dylib", "libtcl8.6.dylib",
80
+ NULL
81
+ };
82
+ int i;
83
+ for (i = 0; lib_names[i]; i++) {
84
+ tcl_dlhandle = dlopen(lib_names[i], RTLD_NOW | RTLD_GLOBAL);
85
+ if (tcl_dlhandle) break;
86
+ }
87
+ }
88
+ if (tcl_dlhandle) {
89
+ sym = dlsym(tcl_dlhandle, name);
90
+ }
91
+ return sym;
92
+ }
93
+ #endif
94
+
62
95
  static void
63
96
  find_executable_bootstrap(const char *argv0)
64
97
  {
@@ -71,7 +104,7 @@ find_executable_bootstrap(const char *argv0)
71
104
  #ifdef _WIN32
72
105
  real_find_executable = (void (*)(const char *))get_tcl_proc("Tcl_FindExecutable");
73
106
  #else
74
- real_find_executable = dlsym(RTLD_DEFAULT, "Tcl_FindExecutable");
107
+ real_find_executable = dlsym_tcl("Tcl_FindExecutable");
75
108
  #endif
76
109
  if (real_find_executable) {
77
110
  real_find_executable(argv0);
@@ -89,7 +122,7 @@ create_interp_bootstrap(void)
89
122
  #ifdef _WIN32
90
123
  real_create_interp = (Tcl_Interp *(*)(void))get_tcl_proc("Tcl_CreateInterp");
91
124
  #else
92
- real_create_interp = dlsym(RTLD_DEFAULT, "Tcl_CreateInterp");
125
+ real_create_interp = dlsym_tcl("Tcl_CreateInterp");
93
126
  #endif
94
127
  if (!real_create_interp) {
95
128
  return NULL;
@@ -1490,6 +1523,9 @@ Init_tcltklib(void)
1490
1523
  /* External event source integration (tkeventsource.c) */
1491
1524
  Init_tkeventsource(mTeek);
1492
1525
 
1526
+ /* File drop target support (tkdrop.c) */
1527
+ Init_tkdrop(cInterp);
1528
+
1493
1529
  /* Class methods for instance tracking */
1494
1530
  rb_define_singleton_method(cInterp, "instance_count", tcltkip_instance_count, 0);
1495
1531
  rb_define_singleton_method(cInterp, "instances", tcltkip_instances, 0);
@@ -42,4 +42,7 @@ void Init_tkwin(VALUE cInterp);
42
42
  /* External event source integration - defined in tkeventsource.c */
43
43
  void Init_tkeventsource(VALUE mTeek);
44
44
 
45
+ /* File drop target support - defined in tkdrop.c */
46
+ void Init_tkdrop(VALUE cInterp);
47
+
45
48
  #endif /* TCLTKBRIDGE_H */
data/ext/teek/tkdrop.c ADDED
@@ -0,0 +1,66 @@
1
+ /* tkdrop.c - File drop target support (common entry point)
2
+ *
3
+ * Provides Interp#register_drop_target(window_path) which delegates
4
+ * to platform-specific teek_register_drop_target().
5
+ *
6
+ * Based on tkdnd (https://github.com/petasis/tkdnd) as reference.
7
+ */
8
+
9
+ #include "tcltkbridge.h"
10
+ #include "tkdrop.h"
11
+
12
+ /* ---------------------------------------------------------
13
+ * Interp#register_drop_target(window_path)
14
+ *
15
+ * Register a Tk widget as a native file drop target.
16
+ * After registration, dropping a file onto the widget generates
17
+ * a <<DropFile>> virtual event with the file path in -data.
18
+ *
19
+ * Arguments:
20
+ * window_path - Tk widget path (e.g., ".", ".frame")
21
+ *
22
+ * Returns: nil
23
+ * --------------------------------------------------------- */
24
+
25
+ static VALUE
26
+ interp_register_drop_target(VALUE self, VALUE window_path)
27
+ {
28
+ struct tcltk_interp *tip = get_interp(self);
29
+ Tk_Window mainWin;
30
+ Tk_Window tkwin;
31
+ int result;
32
+
33
+ StringValue(window_path);
34
+
35
+ mainWin = Tk_MainWindow(tip->interp);
36
+ if (!mainWin) {
37
+ rb_raise(eTclError, "Tk not initialized (no main window)");
38
+ }
39
+
40
+ tkwin = Tk_NameToWindow(tip->interp, StringValueCStr(window_path), mainWin);
41
+ if (!tkwin) {
42
+ rb_raise(eTclError, "window not found: %s", StringValueCStr(window_path));
43
+ }
44
+
45
+ Tk_MakeWindowExist(tkwin);
46
+
47
+ result = teek_register_drop_target(tip->interp, tkwin, StringValueCStr(window_path));
48
+ if (result != TCL_OK) {
49
+ rb_raise(eTclError, "failed to register drop target: %s",
50
+ Tcl_GetStringResult(tip->interp));
51
+ }
52
+
53
+ return Qnil;
54
+ }
55
+
56
+ /* ---------------------------------------------------------
57
+ * Init_tkdrop - Register drop target methods on Interp
58
+ *
59
+ * Called from Init_tcltklib in tcltkbridge.c.
60
+ * --------------------------------------------------------- */
61
+
62
+ void
63
+ Init_tkdrop(VALUE cInterp)
64
+ {
65
+ rb_define_method(cInterp, "register_drop_target", interp_register_drop_target, 1);
66
+ }
data/ext/teek/tkdrop.h ADDED
@@ -0,0 +1,26 @@
1
+ /* tkdrop.h - Native file drop target support
2
+ *
3
+ * Cross-platform abstraction for registering Tk widgets as file drop targets.
4
+ * Each platform implements teek_register_drop_target() which hooks into the
5
+ * OS drag-and-drop system and generates <<DropFile>> virtual events.
6
+ *
7
+ * Based on tkdnd (https://github.com/petasis/tkdnd) as reference.
8
+ * See THIRD_PARTY_NOTICES for attribution.
9
+ */
10
+
11
+ #ifndef TKDROP_H
12
+ #define TKDROP_H
13
+
14
+ #include <tcl.h>
15
+ #include <tk.h>
16
+
17
+ /*
18
+ * Register a Tk window as a file drop target.
19
+ * When a file is dropped, generates <<DropFile>> with -data set to the path.
20
+ *
21
+ * Returns TCL_OK on success, TCL_ERROR on failure (with error in interp).
22
+ */
23
+ int teek_register_drop_target(Tcl_Interp *interp, Tk_Window tkwin,
24
+ const char *widget_path);
25
+
26
+ #endif /* TKDROP_H */
@@ -0,0 +1,141 @@
1
+ /* tkdrop_macos.m - macOS file drop target via Cocoa NSDraggingDestination
2
+ *
3
+ * Based on tkdnd (https://github.com/petasis/tkdnd) as reference.
4
+ * See THIRD_PARTY_NOTICES for attribution.
5
+ */
6
+
7
+ #import <Cocoa/Cocoa.h>
8
+ #include <tcl.h>
9
+ #include <tk.h>
10
+ #include "tkdrop.h"
11
+
12
+ #ifndef MAC_OSX_TK
13
+ #define MAC_OSX_TK
14
+ #endif
15
+ #include "tkPlatDecls.h"
16
+
17
+ /* --------------------------------------------------------- */
18
+
19
+ @interface TeekDropView : NSView <NSDraggingDestination>
20
+ {
21
+ Tcl_Interp *_interp;
22
+ char *_widgetPath;
23
+ }
24
+ - (instancetype)initWithFrame:(NSRect)frame
25
+ interp:(Tcl_Interp *)interp
26
+ widgetPath:(const char *)widgetPath;
27
+ @end
28
+
29
+ @implementation TeekDropView
30
+
31
+ - (instancetype)initWithFrame:(NSRect)frame
32
+ interp:(Tcl_Interp *)interp
33
+ widgetPath:(const char *)widgetPath
34
+ {
35
+ self = [super initWithFrame:frame];
36
+ if (self) {
37
+ _interp = interp;
38
+ _widgetPath = strdup(widgetPath);
39
+ [self setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable];
40
+ [self registerForDraggedTypes:@[NSPasteboardTypeFileURL]];
41
+ }
42
+ return self;
43
+ }
44
+
45
+ - (void)dealloc
46
+ {
47
+ free(_widgetPath);
48
+ [super dealloc];
49
+ }
50
+
51
+ - (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender
52
+ {
53
+ NSPasteboard *pb = [sender draggingPasteboard];
54
+ if ([pb canReadObjectForClasses:@[[NSURL class]]
55
+ options:@{NSPasteboardURLReadingFileURLsOnlyKey: @YES}]) {
56
+ return NSDragOperationCopy;
57
+ }
58
+ return NSDragOperationNone;
59
+ }
60
+
61
+ - (BOOL)performDragOperation:(id<NSDraggingInfo>)sender
62
+ {
63
+ NSPasteboard *pb = [sender draggingPasteboard];
64
+ NSArray<NSURL *> *urls = [pb readObjectsForClasses:@[[NSURL class]]
65
+ options:@{NSPasteboardURLReadingFileURLsOnlyKey: @YES}];
66
+ if (!urls || [urls count] == 0) {
67
+ return NO;
68
+ }
69
+
70
+ /* Build a Tcl list of all dropped file paths */
71
+ Tcl_Obj *listObj = Tcl_NewListObj(0, NULL);
72
+ Tcl_IncrRefCount(listObj);
73
+
74
+ for (NSURL *url in urls) {
75
+ NSString *path = [url path];
76
+ if (!path) continue;
77
+ Tcl_ListObjAppendElement(NULL, listObj,
78
+ Tcl_NewStringObj([path UTF8String], -1));
79
+ }
80
+
81
+ /* Generate single <<DropFile>> event with all paths as a Tcl list */
82
+ Tcl_Obj *script = Tcl_ObjPrintf(
83
+ "event generate %s <<DropFile>> -data {%s}",
84
+ _widgetPath, Tcl_GetString(listObj));
85
+ Tcl_IncrRefCount(script);
86
+ Tcl_EvalObjEx(_interp, script, TCL_EVAL_GLOBAL);
87
+ Tcl_DecrRefCount(script);
88
+ Tcl_DecrRefCount(listObj);
89
+
90
+ return YES;
91
+ }
92
+
93
+ /* Allow the drop view to be transparent to mouse events when not dragging */
94
+ - (NSView *)hitTest:(NSPoint)point
95
+ {
96
+ return nil;
97
+ }
98
+
99
+ @end
100
+
101
+ /* --------------------------------------------------------- */
102
+
103
+ int
104
+ teek_register_drop_target(Tcl_Interp *interp, Tk_Window tkwin,
105
+ const char *widget_path)
106
+ {
107
+ Drawable drawable = Tk_WindowId(tkwin);
108
+ if (!drawable) {
109
+ Tcl_SetResult(interp, "window has no native handle", TCL_STATIC);
110
+ return TCL_ERROR;
111
+ }
112
+
113
+ void *nswindow = Tk_MacOSXGetNSWindowForDrawable(drawable);
114
+ if (!nswindow) {
115
+ Tcl_SetResult(interp, "could not get NSWindow", TCL_STATIC);
116
+ return TCL_ERROR;
117
+ }
118
+
119
+ NSWindow *window = (__bridge NSWindow *)nswindow;
120
+ NSView *contentView = [window contentView];
121
+ if (!contentView) {
122
+ Tcl_SetResult(interp, "could not get content view", TCL_STATIC);
123
+ return TCL_ERROR;
124
+ }
125
+
126
+ /* Check if we already registered a drop view on this window */
127
+ for (NSView *subview in [contentView subviews]) {
128
+ if ([subview isKindOfClass:[TeekDropView class]]) {
129
+ return TCL_OK; /* Already registered */
130
+ }
131
+ }
132
+
133
+ TeekDropView *dropView = [[TeekDropView alloc]
134
+ initWithFrame:[contentView bounds]
135
+ interp:interp
136
+ widgetPath:widget_path];
137
+
138
+ [contentView addSubview:dropView];
139
+
140
+ return TCL_OK;
141
+ }