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 +4 -4
- data/Gemfile +2 -0
- data/README.md +62 -7
- data/Rakefile +97 -91
- data/ext/teek/extconf.rb +15 -0
- data/ext/teek/tcltkbridge.c +69 -4
- data/ext/teek/tkdrop_x11.c +2 -2
- data/lib/teek/callback_registry.rb +80 -0
- data/lib/teek/canvas_bind_interceptor.rb +68 -0
- data/lib/teek/command_interceptors.rb +47 -0
- data/lib/teek/debugger.rb +7 -10
- data/lib/teek/demo_support.rb +2 -2
- data/lib/teek/dialogs.rb +137 -0
- data/lib/teek/menu_interceptor.rb +61 -0
- data/lib/teek/photo.rb +55 -0
- data/lib/teek/tag_bind_interceptor.rb +66 -0
- data/lib/teek/version.rb +1 -1
- data/lib/teek/widget.rb +25 -2
- data/lib/teek/winfo.rb +104 -0
- data/lib/teek/wm.rb +82 -0
- data/lib/teek.rb +317 -57
- data/teek.gemspec +2 -2
- metadata +11 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ff08eb5884f74dc6461c5ff1163a025b44b798e2e480bd4f2855210dd0f0fdf
|
|
4
|
+
data.tar.gz: 8e012ccf6a6fffe427630c033b288f784ed455552b064870b0bce054d11e62ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 878e28a9a7493abdccdce1a4b28e372ade4648b12392cdb0815581b4590ab69b601eceb1da686e01719c6930fe1e88c333d4751e80f3af7875ce84a2a391957c
|
|
7
|
+
data.tar.gz: 0cd4b0ae71353720e185455e3966b954b9b2e2237417ce0efc596ad9915234f6b79923d318a54a1f42ddf9a839e8e58857462017192f750cd1d4ef31ce92710a
|
data/Gemfile
CHANGED
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
|
|
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.
|
|
113
|
-
app.command('.', :configure, menu:
|
|
112
|
+
menubar = app.menu('.menubar')
|
|
113
|
+
app.command('.', :configure, menu: menubar)
|
|
114
114
|
|
|
115
|
-
app.
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
5
|
+
# Sub-project Rakefiles (define sdl2:compile)
|
|
6
6
|
import 'teek-sdl2/Rakefile'
|
|
7
|
-
|
|
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 "
|
|
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 "
|
|
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
|
|
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 "
|
|
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-
|
|
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}"
|
data/ext/teek/tcltkbridge.c
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
data/ext/teek/tkdrop_x11.c
CHANGED
|
@@ -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
|