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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teek
|
|
4
|
+
# @api private
|
|
5
|
+
#
|
|
6
|
+
# Registry of per-Tk-widget-type interceptors that App#command consults
|
|
7
|
+
# before falling back to its own generic handling. Each interceptor is a
|
|
8
|
+
# labeled block registered under a widget type string (the same strings
|
|
9
|
+
# used in WIDGET_COMMANDS); App#command looks up the type for the path it
|
|
10
|
+
# was given (see App#record_widget_type) and tries every interceptor
|
|
11
|
+
# registered for that type.
|
|
12
|
+
#
|
|
13
|
+
# An interceptor block receives (app, path, args, kwargs) and must
|
|
14
|
+
# return nil if this call isn't its concern - Tcl results from
|
|
15
|
+
# App#raw_command are always Strings, never nil, so nil is an
|
|
16
|
+
# unambiguous "not mine" sentinel - or the Tcl result if it handled the
|
|
17
|
+
# call itself (typically by calling App#raw_command internally).
|
|
18
|
+
#
|
|
19
|
+
# Multiple widget types can share the same interceptor logic (text and
|
|
20
|
+
# ttk::treeview tag bindings are byte-identical in Tcl shape) by
|
|
21
|
+
# registering the same block under each type. The +label+ is what
|
|
22
|
+
# App#command reports if two DIFFERENT interceptors both claim the same
|
|
23
|
+
# call for the same type - it raises AmbiguousCommandError naming both
|
|
24
|
+
# labels rather than silently picking one, so whoever's debugging can
|
|
25
|
+
# tell which interceptors collided (built-in shape-matching bug, a
|
|
26
|
+
# custom interceptor overlapping a built-in one, two custom interceptors
|
|
27
|
+
# overlapping each other, ...).
|
|
28
|
+
class CommandInterceptors
|
|
29
|
+
Entry = Struct.new(:label, :block)
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def register(type, label, &block)
|
|
33
|
+
interceptors[type.to_s] << Entry.new(label.to_s, block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def for_type(type)
|
|
37
|
+
interceptors[type.to_s]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def interceptors
|
|
43
|
+
@interceptors ||= Hash.new { |h, k| h[k] = [] }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/teek/debugger.rb
CHANGED
|
@@ -27,8 +27,7 @@ module Teek
|
|
|
27
27
|
app.command(:wm, 'geometry', TOP, '400x500')
|
|
28
28
|
|
|
29
29
|
# Don't let closing the debugger kill the app
|
|
30
|
-
|
|
31
|
-
app.command(:wm, 'protocol', TOP, 'WM_DELETE_WINDOW', close_proc)
|
|
30
|
+
app.on_close(window: TOP) { app.command(:wm, 'withdraw', TOP) }
|
|
32
31
|
|
|
33
32
|
setup_ui
|
|
34
33
|
sync_widget_tree
|
|
@@ -246,10 +245,9 @@ module Teek
|
|
|
246
245
|
@app.command(:pack, vars_tree, fill: :both, expand: 1)
|
|
247
246
|
|
|
248
247
|
# Right-click context menu
|
|
249
|
-
@app.
|
|
248
|
+
vars_ctx = @app.menu("#{NB}.vars.ctx")
|
|
250
249
|
watch_proc = proc { |*| watch_selected_variable }
|
|
251
|
-
|
|
252
|
-
label: 'Watch', command: watch_proc)
|
|
250
|
+
vars_ctx.command(:add, :command, label: 'Watch', command: watch_proc)
|
|
253
251
|
|
|
254
252
|
@app.command(:bind, vars_tree, '<Button-3>', proc { |*|
|
|
255
253
|
# Select the row under cursor, then show context menu
|
|
@@ -257,7 +255,7 @@ module Teek
|
|
|
257
255
|
set item [#{vars_tree} identify item [winfo pointerx #{vars_tree}] [winfo pointery #{vars_tree}]]
|
|
258
256
|
if {$item ne {}} { #{vars_tree} selection set $item }
|
|
259
257
|
")
|
|
260
|
-
@app.
|
|
258
|
+
@app.popup_menu(vars_ctx, x: @app.winfo.pointerx, y: @app.winfo.pointery)
|
|
261
259
|
})
|
|
262
260
|
|
|
263
261
|
# Double-click to watch
|
|
@@ -530,17 +528,16 @@ module Teek
|
|
|
530
528
|
@app.command(:bind, watch_tree, '<<TreeviewSelect>>', select_proc)
|
|
531
529
|
|
|
532
530
|
# Right-click to unwatch
|
|
533
|
-
@app.
|
|
531
|
+
watches_ctx = @app.menu("#{NB}.watches.ctx")
|
|
534
532
|
unwatch_proc = proc { |*| unwatch_selected }
|
|
535
|
-
|
|
536
|
-
label: 'Unwatch', command: unwatch_proc)
|
|
533
|
+
watches_ctx.command(:add, :command, label: 'Unwatch', command: unwatch_proc)
|
|
537
534
|
|
|
538
535
|
@app.command(:bind, watch_tree, '<Button-3>', proc { |*|
|
|
539
536
|
@app.tcl_eval("
|
|
540
537
|
set item [#{watch_tree} identify item [winfo pointerx #{watch_tree}] [winfo pointery #{watch_tree}]]
|
|
541
538
|
if {$item ne {}} { #{watch_tree} selection set $item }
|
|
542
539
|
")
|
|
543
|
-
@app.
|
|
540
|
+
@app.popup_menu(watches_ctx, x: @app.winfo.pointerx, y: @app.winfo.pointery)
|
|
544
541
|
})
|
|
545
542
|
end
|
|
546
543
|
|
data/lib/teek/demo_support.rb
CHANGED
|
@@ -94,8 +94,8 @@ module TeekDemo
|
|
|
94
94
|
|
|
95
95
|
try_signal = proc do
|
|
96
96
|
app.tcl_eval('update idletasks')
|
|
97
|
-
width = app.
|
|
98
|
-
height = app.
|
|
97
|
+
width = app.winfo.width(window)
|
|
98
|
+
height = app.winfo.height(window)
|
|
99
99
|
|
|
100
100
|
if width >= 10 && height >= 10
|
|
101
101
|
@_recording_ready_sent = true
|
data/lib/teek/dialogs.rb
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teek
|
|
4
|
+
class App
|
|
5
|
+
# Show the native "choose file to open" dialog.
|
|
6
|
+
#
|
|
7
|
+
# @param filetypes [Array<Array>, nil] e.g.
|
|
8
|
+
# +[["PNG Images", ".png"], ["All Files", "*"]]+ - the second
|
|
9
|
+
# element of each pair can also be an array of extensions
|
|
10
|
+
# (+["Images", [".png", ".jpg"]]+)
|
|
11
|
+
# @param initialdir [String, nil] directory the dialog starts in
|
|
12
|
+
# @param initialfile [String, nil] filename pre-filled in the dialog
|
|
13
|
+
# @param title [String, nil] dialog window title
|
|
14
|
+
# @param multiple [Boolean] allow selecting more than one file
|
|
15
|
+
# @param parent [String, nil] parent window (defaults to the root window)
|
|
16
|
+
# @return [String, Array<String>, nil] the chosen path (an array of
|
|
17
|
+
# paths if +multiple:+), or +nil+ if the dialog was cancelled
|
|
18
|
+
# @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/getOpenFile.htm tk_getOpenFile
|
|
19
|
+
def choose_open_file(filetypes: nil, initialdir: nil, initialfile: nil,
|
|
20
|
+
title: nil, multiple: false, parent: nil)
|
|
21
|
+
args = ['tk_getOpenFile']
|
|
22
|
+
args.push('-filetypes', build_filetypes(filetypes)) if filetypes
|
|
23
|
+
args.push('-initialdir', initialdir) if initialdir
|
|
24
|
+
args.push('-initialfile', initialfile) if initialfile
|
|
25
|
+
args.push('-title', title) if title
|
|
26
|
+
args.push('-parent', parent) if parent
|
|
27
|
+
args.push('-multiple', bool_to_tcl(true)) if multiple
|
|
28
|
+
|
|
29
|
+
result = tcl_invoke(*args)
|
|
30
|
+
return nil if result.empty?
|
|
31
|
+
|
|
32
|
+
multiple ? split_list(result) : result
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Show the native "choose file to save" dialog.
|
|
36
|
+
#
|
|
37
|
+
# @param filetypes [Array<Array>, nil] see {#choose_open_file}
|
|
38
|
+
# @param initialdir [String, nil] directory the dialog starts in
|
|
39
|
+
# @param initialfile [String, nil] filename pre-filled in the dialog
|
|
40
|
+
# @param title [String, nil] dialog window title
|
|
41
|
+
# @param defaultextension [String, nil] extension appended if the
|
|
42
|
+
# typed filename doesn't already have one
|
|
43
|
+
# @param confirmoverwrite [Boolean] ask before overwriting an
|
|
44
|
+
# existing file (Tk's own default is true; pass false to skip the
|
|
45
|
+
# confirmation)
|
|
46
|
+
# @param parent [String, nil] parent window (defaults to the root window)
|
|
47
|
+
# @return [String, nil] the chosen path, or +nil+ if cancelled
|
|
48
|
+
# @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/getOpenFile.htm tk_getSaveFile
|
|
49
|
+
def choose_save_file(filetypes: nil, initialdir: nil, initialfile: nil,
|
|
50
|
+
title: nil, defaultextension: nil, confirmoverwrite: true, parent: nil)
|
|
51
|
+
args = ['tk_getSaveFile']
|
|
52
|
+
args.push('-filetypes', build_filetypes(filetypes)) if filetypes
|
|
53
|
+
args.push('-initialdir', initialdir) if initialdir
|
|
54
|
+
args.push('-initialfile', initialfile) if initialfile
|
|
55
|
+
args.push('-title', title) if title
|
|
56
|
+
args.push('-defaultextension', defaultextension) if defaultextension
|
|
57
|
+
args.push('-confirmoverwrite', bool_to_tcl(false)) unless confirmoverwrite
|
|
58
|
+
args.push('-parent', parent) if parent
|
|
59
|
+
|
|
60
|
+
result = tcl_invoke(*args)
|
|
61
|
+
result.empty? ? nil : result
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Show a message box with one or more buttons.
|
|
65
|
+
#
|
|
66
|
+
# @param message [String] the main message text
|
|
67
|
+
# @param title [String, nil] dialog window title
|
|
68
|
+
# @param detail [String, nil] additional explanatory text, shown
|
|
69
|
+
# smaller below +message+
|
|
70
|
+
# @param icon [:error, :info, :question, :warning] icon to display
|
|
71
|
+
# @param type [:ok, :okcancel, :abortretryignore, :yesno, :yesnocancel, :retrycancel]
|
|
72
|
+
# which button(s) to show
|
|
73
|
+
# @param default [Symbol, nil] which button is focused by default
|
|
74
|
+
# (e.g. +:cancel+); defaults to Tk's own choice if omitted
|
|
75
|
+
# @param parent [String, nil] parent window (defaults to the root window)
|
|
76
|
+
# @return [Symbol] the pressed button - +:ok+, +:cancel+, +:yes+,
|
|
77
|
+
# +:no+, +:abort+, +:retry+, or +:ignore+
|
|
78
|
+
# @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/messageBox.htm tk_messageBox
|
|
79
|
+
def message_box(message:, title: nil, detail: nil, icon: :info, type: :ok,
|
|
80
|
+
default: nil, parent: nil)
|
|
81
|
+
args = ['tk_messageBox', '-message', message.to_s]
|
|
82
|
+
args.push('-title', title) if title
|
|
83
|
+
args.push('-detail', detail) if detail
|
|
84
|
+
args.push('-icon', icon.to_s) if icon
|
|
85
|
+
args.push('-type', type.to_s) if type
|
|
86
|
+
args.push('-default', default.to_s) if default
|
|
87
|
+
args.push('-parent', parent) if parent
|
|
88
|
+
|
|
89
|
+
tcl_invoke(*args).to_sym
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Show the native color picker dialog.
|
|
93
|
+
#
|
|
94
|
+
# @param initial [String, nil] initial color (e.g. +"#ff0000"+)
|
|
95
|
+
# @param title [String, nil] dialog window title
|
|
96
|
+
# @param parent [String, nil] parent window (defaults to the root window)
|
|
97
|
+
# @return [String, nil] the chosen color as +"#rrggbb"+, or +nil+ if cancelled
|
|
98
|
+
# @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/chooseColor.htm tk_chooseColor
|
|
99
|
+
def choose_color(initial: nil, title: nil, parent: nil)
|
|
100
|
+
args = ['tk_chooseColor']
|
|
101
|
+
args.push('-initialcolor', initial) if initial
|
|
102
|
+
args.push('-title', title) if title
|
|
103
|
+
args.push('-parent', parent) if parent
|
|
104
|
+
|
|
105
|
+
result = tcl_invoke(*args)
|
|
106
|
+
result.empty? ? nil : result
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Pop up a menu at the given screen coordinates.
|
|
110
|
+
#
|
|
111
|
+
# @param menu [Widget, String] the menu to pop up
|
|
112
|
+
# @param x [Integer] screen x coordinate
|
|
113
|
+
# @param y [Integer] screen y coordinate
|
|
114
|
+
# @param entry [Integer, String, nil] index or label of the entry to
|
|
115
|
+
# show as active
|
|
116
|
+
# @return [void]
|
|
117
|
+
# @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/menu.htm#M42 tk_popup
|
|
118
|
+
def popup_menu(menu, x:, y:, entry: nil)
|
|
119
|
+
args = ['tk_popup', menu.to_s, x.to_s, y.to_s]
|
|
120
|
+
args << entry.to_s if entry
|
|
121
|
+
tcl_invoke(*args)
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# Builds the nested Tcl list -filetypes expects:
|
|
128
|
+
# {{name extensionOrExtensionList} {name2 ...}}
|
|
129
|
+
def build_filetypes(filetypes)
|
|
130
|
+
entries = filetypes.map do |name, exts|
|
|
131
|
+
ext_arg = exts.is_a?(Array) ? make_list(*exts) : exts.to_s
|
|
132
|
+
make_list(name.to_s, ext_arg)
|
|
133
|
+
end
|
|
134
|
+
make_list(*entries)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
# Live-scan helper for the "menu" {CommandInterceptors} entry below.
|
|
10
|
+
#
|
|
11
|
+
# Menu entries are not windows (only the menu itself is one), so they
|
|
12
|
+
# never fire <Destroy>; entry deletion is silent and Tk renumbers the
|
|
13
|
+
# survivors internally. Because of that, entry-level callbacks can't be
|
|
14
|
+
# tracked by index or by any per-entry event - the only sound way to know
|
|
15
|
+
# which callbacks are still needed is to ask Tk what's actually live
|
|
16
|
+
# after every mutating call and release whatever dropped out.
|
|
17
|
+
module MenuInterceptor
|
|
18
|
+
ENTRY_SUBCOMMANDS = %w[add insert entryconfigure delete].freeze
|
|
19
|
+
|
|
20
|
+
LIVE_COMMANDS_TCL_PROC = <<~TCL.freeze
|
|
21
|
+
proc ::teek_menu_live_commands {path} {
|
|
22
|
+
set result {}
|
|
23
|
+
if {![winfo exists $path]} { return $result }
|
|
24
|
+
set last [$path index end]
|
|
25
|
+
if {$last eq "none"} { return $result }
|
|
26
|
+
for {set i 0} {$i <= $last} {incr i} {
|
|
27
|
+
set cmd ""
|
|
28
|
+
catch {set cmd [$path entrycget $i -command]}
|
|
29
|
+
lappend result $cmd
|
|
30
|
+
}
|
|
31
|
+
return $result
|
|
32
|
+
}
|
|
33
|
+
TCL
|
|
34
|
+
|
|
35
|
+
def self.live_command_ids(app, path)
|
|
36
|
+
app.ensure_tcl_helper(:menu_live_commands) { LIVE_COMMANDS_TCL_PROC }
|
|
37
|
+
raw = app.tcl_eval("::teek_menu_live_commands #{path}")
|
|
38
|
+
# \z anchors this to a BARE `ruby_callback <id>` with nothing after -
|
|
39
|
+
# correct today because teek never appends %-substitutions to a menu
|
|
40
|
+
# entry's -command (that only happens for App#bind's widget bindings).
|
|
41
|
+
# If substitution support is ever added here, this regex silently
|
|
42
|
+
# stops matching and those ids leak on rebuild/delete - drop the \z
|
|
43
|
+
# anchor (match just the leading `ruby_callback <id>`) if that changes.
|
|
44
|
+
app.split_list(raw).each_with_object({}) do |cmd, ids|
|
|
45
|
+
ids[Regexp.last_match(1)] = Regexp.last_match(1) if cmd =~ /\Aruby_callback (\S+)\z/
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Any add/insert/entryconfigure/delete on a menu goes through raw_command
|
|
51
|
+
# unchanged (any command: Proc it carries is already registered correctly
|
|
52
|
+
# by raw_command itself), then reconciles tracked entry callbacks against
|
|
53
|
+
# Tk's live entrycget values - see {MenuInterceptor}.
|
|
54
|
+
CommandInterceptors.register('menu', 'menu_entry') do |app, path, args, kwargs|
|
|
55
|
+
next nil unless MenuInterceptor::ENTRY_SUBCOMMANDS.include?(args[0]&.to_s)
|
|
56
|
+
|
|
57
|
+
result = app.raw_command(path, *args, **kwargs)
|
|
58
|
+
app.callback_registry.reconcile([:menu, path]) { |_before| MenuInterceptor.live_command_ids(app, path) }
|
|
59
|
+
result
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/teek/photo.rb
CHANGED
|
@@ -50,10 +50,45 @@ module Teek
|
|
|
50
50
|
@counter += 1
|
|
51
51
|
"teek_photo#{@counter}"
|
|
52
52
|
end
|
|
53
|
+
|
|
54
|
+
# @api private
|
|
55
|
+
#
|
|
56
|
+
# Builds the proc registered as this instance's finalizer. Must not
|
|
57
|
+
# close over +self+ (the Photo instance) or any of its ivars
|
|
58
|
+
# directly - a finalizer that references its own object keeps that
|
|
59
|
+
# object permanently reachable, so it would never actually be
|
|
60
|
+
# collected. +name+/+app+ are plain local captures instead.
|
|
61
|
+
#
|
|
62
|
+
# Finalizers can run on any thread, so the delete goes through
|
|
63
|
+
# {Interp#queue_for_main} (fire-and-forget) rather than #tcl_eval,
|
|
64
|
+
# which would block on a cross-thread queue wait if called from a
|
|
65
|
+
# background thread - risky from inside a GC finalizer. The +catch+
|
|
66
|
+
# means a concurrent explicit #delete (or an interpreter that's
|
|
67
|
+
# already torn down by the time this runs) is a silent no-op, not
|
|
68
|
+
# an error with nowhere to go.
|
|
69
|
+
def finalizer_for(name, app)
|
|
70
|
+
proc do
|
|
71
|
+
begin
|
|
72
|
+
app.interp.queue_for_main(proc { app.tcl_eval("catch {image delete #{name}}") })
|
|
73
|
+
rescue Teek::TclError
|
|
74
|
+
# interpreter already torn down - nothing to clean up
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
53
78
|
end
|
|
54
79
|
|
|
55
80
|
# Create a new photo image.
|
|
56
81
|
#
|
|
82
|
+
# The underlying Tk image is automatically deleted once this Photo
|
|
83
|
+
# object is garbage collected and nothing else references it - keep
|
|
84
|
+
# it around (an ivar, a collection, etc.) for as long as you need the
|
|
85
|
+
# image, the same contract as File or Socket. If only the image
|
|
86
|
+
# *name* is kept (e.g. passed into a widget's +image:+) and this
|
|
87
|
+
# wrapper is dropped, the image can be reclaimed out from under that
|
|
88
|
+
# reference; Tk shows a broken image rather than crashing, but the
|
|
89
|
+
# image won't come back - call {#delete} explicitly rather than
|
|
90
|
+
# relying on GC timing if you need deterministic cleanup.
|
|
91
|
+
#
|
|
57
92
|
# @param app [Teek::App] the application instance
|
|
58
93
|
# @param name [String, nil] Tcl image name (auto-generated if nil)
|
|
59
94
|
# @param width [Integer, nil] image width in pixels
|
|
@@ -78,6 +113,21 @@ module Teek
|
|
|
78
113
|
kwargs[:gamma] = gamma if gamma
|
|
79
114
|
|
|
80
115
|
@app.command(:image, :create, :photo, @name, **kwargs)
|
|
116
|
+
ObjectSpace.define_finalizer(self, self.class.finalizer_for(@name, @app))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Invoke a photo subcommand not covered by a dedicated method above
|
|
120
|
+
# (e.g. +copy+, +read+, +write+). Prepends the image name as the Tcl
|
|
121
|
+
# command, exactly like {Widget#command} does for widgets.
|
|
122
|
+
#
|
|
123
|
+
# @example Subsample a copy of another photo
|
|
124
|
+
# thumb = Teek::Photo.new(app)
|
|
125
|
+
# thumb.command(:copy, source_photo, subsample: 4)
|
|
126
|
+
# @param args positional arguments
|
|
127
|
+
# @param kwargs keyword arguments mapped to +-key value+ pairs
|
|
128
|
+
# @return [String] the Tcl result
|
|
129
|
+
def command(*args, **kwargs)
|
|
130
|
+
@app.command(@name, *args, **kwargs)
|
|
81
131
|
end
|
|
82
132
|
|
|
83
133
|
# Write RGBA pixel data to the image.
|
|
@@ -197,8 +247,13 @@ module Teek
|
|
|
197
247
|
|
|
198
248
|
# Delete this photo image and free its resources.
|
|
199
249
|
#
|
|
250
|
+
# Cancels the GC finalizer registered by {#initialize} - otherwise a
|
|
251
|
+
# later collection could delete an unrelated image if this name gets
|
|
252
|
+
# reused (e.g. an explicit +name:+ passed to a later Photo).
|
|
253
|
+
#
|
|
200
254
|
# @return [void]
|
|
201
255
|
def delete
|
|
256
|
+
ObjectSpace.undefine_finalizer(self)
|
|
202
257
|
@app.tcl_eval("image delete #{@name}")
|
|
203
258
|
end
|
|
204
259
|
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
# Live-scan helper for the shared "text"/"ttk::treeview" {CommandInterceptors}
|
|
10
|
+
# entry below - both widgets have byte-identical `tag bind`/`tag names` shapes.
|
|
11
|
+
#
|
|
12
|
+
# Tags aren't windows, so a tag's bound callback never fires <Destroy> on
|
|
13
|
+
# its own; the widget that owns it is typically long-lived and reused
|
|
14
|
+
# (log panes, editors, tree views), so tags churn while the widget
|
|
15
|
+
# persists. Unlike menu entries, a tag name is a stable hash key Tk never
|
|
16
|
+
# renumbers, so reconciling is a straightforward full scan: enumerate
|
|
17
|
+
# every live tag (`tag names`), read back what's bound to each
|
|
18
|
+
# (`tag bind $tag` / `tag bind $tag $seq`), and release whatever dropped out.
|
|
19
|
+
module TagBindInterceptor
|
|
20
|
+
MUTATING_SUBCOMMANDS = %w[bind delete].freeze
|
|
21
|
+
|
|
22
|
+
LIVE_COMMANDS_TCL_PROC = <<~TCL.freeze
|
|
23
|
+
proc ::teek_tag_live_commands {path} {
|
|
24
|
+
set result {}
|
|
25
|
+
foreach tag [$path tag names] {
|
|
26
|
+
foreach seq [$path tag bind $tag] {
|
|
27
|
+
lappend result [$path tag bind $tag $seq]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return $result
|
|
31
|
+
}
|
|
32
|
+
TCL
|
|
33
|
+
|
|
34
|
+
def self.live_command_ids(app, path)
|
|
35
|
+
app.ensure_tcl_helper(:tag_live_commands) { LIVE_COMMANDS_TCL_PROC }
|
|
36
|
+
raw = app.tcl_eval("::teek_tag_live_commands #{path}")
|
|
37
|
+
# \z anchors this to a BARE `ruby_callback <id>` with nothing after.
|
|
38
|
+
# raw_command's generic positional-Proc handling technically allows a
|
|
39
|
+
# caller to pass %-substitutions to a tag-bind-shaped app.command
|
|
40
|
+
# call (the same mechanism App#bind uses), but nothing in teek does
|
|
41
|
+
# that today. If a caller ever does, this regex silently stops
|
|
42
|
+
# matching and that id leaks on rebuild/delete - drop the \z anchor
|
|
43
|
+
# (match just the leading `ruby_callback <id>`) if that changes.
|
|
44
|
+
app.split_list(raw).each_with_object({}) do |cmd, ids|
|
|
45
|
+
ids[Regexp.last_match(1)] = Regexp.last_match(1) if cmd =~ /\Aruby_callback (\S+)\z/
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A `tag bind`/`tag delete` call goes through raw_command unchanged (a
|
|
50
|
+
# bound Proc is registered exactly like any other bind-shaped
|
|
51
|
+
# positional arg), then reconciles tracked tag callbacks against Tk's
|
|
52
|
+
# live tag-bind state.
|
|
53
|
+
def self.call(app, path, args, kwargs)
|
|
54
|
+
return nil unless args[0]&.to_s == 'tag' && MUTATING_SUBCOMMANDS.include?(args[1]&.to_s)
|
|
55
|
+
|
|
56
|
+
result = app.raw_command(path, *args, **kwargs)
|
|
57
|
+
app.callback_registry.reconcile([:tag_bind, path]) { |_before| live_command_ids(app, path) }
|
|
58
|
+
result
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# text and ttk::treeview share byte-identical `tag bind`/`tag names`
|
|
63
|
+
# shapes, so both register the same interceptor method.
|
|
64
|
+
CommandInterceptors.register('text', 'tag_bind') { |*a| TagBindInterceptor.call(*a) }
|
|
65
|
+
CommandInterceptors.register('ttk::treeview', 'tag_bind') { |*a| TagBindInterceptor.call(*a) }
|
|
66
|
+
end
|
data/lib/teek/version.rb
CHANGED
data/lib/teek/widget.rb
CHANGED
|
@@ -36,7 +36,9 @@ module Teek
|
|
|
36
36
|
# btn.command(:invoke) # => .ttkbutton1 invoke
|
|
37
37
|
#
|
|
38
38
|
# @param args positional arguments
|
|
39
|
-
# @param kwargs keyword arguments mapped to -key value pairs
|
|
39
|
+
# @param kwargs keyword arguments mapped to -key value pairs; any Proc
|
|
40
|
+
# value (e.g. command:) is tracked and released if reconfigured or
|
|
41
|
+
# when this widget is destroyed
|
|
40
42
|
# @return [String] the Tcl result
|
|
41
43
|
def command(*args, **kwargs)
|
|
42
44
|
@app.command(@path, *args, **kwargs)
|
|
@@ -51,7 +53,19 @@ module Teek
|
|
|
51
53
|
# Check if this widget still exists in the Tk interpreter.
|
|
52
54
|
# @return [Boolean]
|
|
53
55
|
def exist?
|
|
54
|
-
@app.
|
|
56
|
+
@app.winfo.exists?(@path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Integer] current width in pixels
|
|
60
|
+
# @see Winfo#width
|
|
61
|
+
def width
|
|
62
|
+
@app.winfo.width(@path)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Integer] current height in pixels
|
|
66
|
+
# @see Winfo#height
|
|
67
|
+
def height
|
|
68
|
+
@app.winfo.height(@path)
|
|
55
69
|
end
|
|
56
70
|
|
|
57
71
|
# Pack this widget.
|
|
@@ -88,6 +102,15 @@ module Teek
|
|
|
88
102
|
@app.unbind(@path, event)
|
|
89
103
|
end
|
|
90
104
|
|
|
105
|
+
# Register a handler for this window's close button (WM_DELETE_WINDOW).
|
|
106
|
+
# Meant for toplevels; see {App#on_close} for the full behavior.
|
|
107
|
+
# @yield called when the window's close button is pressed
|
|
108
|
+
# @return [void]
|
|
109
|
+
# @see App#on_close
|
|
110
|
+
def on_close(&block)
|
|
111
|
+
@app.on_close(window: @path, &block)
|
|
112
|
+
end
|
|
113
|
+
|
|
91
114
|
def inspect
|
|
92
115
|
"#<Teek::Widget #{@path}>"
|
|
93
116
|
end
|
data/lib/teek/winfo.rb
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teek
|
|
4
|
+
# Thin, typed wrapper around Tk's `winfo` command family - one method
|
|
5
|
+
# per subquery, coerced to the right Ruby type, reached via {App#winfo}.
|
|
6
|
+
#
|
|
7
|
+
# Grouped behind a single accessor instead of a dozen-plus flat App
|
|
8
|
+
# methods, since `winfo` is itself one big, well-known Tcl command
|
|
9
|
+
# namespace - knowing Tcl's `winfo width` gets you to {#width} directly.
|
|
10
|
+
# Every method accepts a path String or anything that responds to
|
|
11
|
+
# +to_s+ with one (a {Widget}, for instance).
|
|
12
|
+
#
|
|
13
|
+
# @see https://www.tcl-lang.org/man/tcl9.0/TkCmd/winfo.htm winfo
|
|
14
|
+
class Winfo
|
|
15
|
+
# @api private
|
|
16
|
+
def initialize(app)
|
|
17
|
+
@app = app
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param path [String, Widget]
|
|
21
|
+
# @return [Integer] current width in pixels
|
|
22
|
+
def width(path)
|
|
23
|
+
query('width', path).to_i
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param path [String, Widget]
|
|
27
|
+
# @return [Integer] current height in pixels
|
|
28
|
+
def height(path)
|
|
29
|
+
query('height', path).to_i
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param path [String, Widget]
|
|
33
|
+
# @return [Integer] requested (natural) width in pixels
|
|
34
|
+
def reqwidth(path)
|
|
35
|
+
query('reqwidth', path).to_i
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param path [String, Widget]
|
|
39
|
+
# @return [Integer] requested (natural) height in pixels
|
|
40
|
+
def reqheight(path)
|
|
41
|
+
query('reqheight', path).to_i
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param path [String, Widget]
|
|
45
|
+
# @return [Integer] x coordinate of the window's top-left corner, in screen pixels
|
|
46
|
+
def rootx(path)
|
|
47
|
+
query('rootx', path).to_i
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param path [String, Widget]
|
|
51
|
+
# @return [Integer] y coordinate of the window's top-left corner, in screen pixels
|
|
52
|
+
def rooty(path)
|
|
53
|
+
query('rooty', path).to_i
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param path [String, Widget]
|
|
57
|
+
# @return [Integer] x coordinate relative to the parent widget
|
|
58
|
+
def x(path)
|
|
59
|
+
query('x', path).to_i
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param path [String, Widget]
|
|
63
|
+
# @return [Integer] y coordinate relative to the parent widget
|
|
64
|
+
def y(path)
|
|
65
|
+
query('y', path).to_i
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @param path [String, Widget] any window on the same screen (default: the root window)
|
|
69
|
+
# @return [Integer] the mouse pointer's current x coordinate, in screen pixels
|
|
70
|
+
def pointerx(path = '.')
|
|
71
|
+
query('pointerx', path).to_i
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @param path [String, Widget] any window on the same screen (default: the root window)
|
|
75
|
+
# @return [Integer] the mouse pointer's current y coordinate, in screen pixels
|
|
76
|
+
def pointery(path = '.')
|
|
77
|
+
query('pointery', path).to_i
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param path [String, Widget]
|
|
81
|
+
# @return [Boolean] whether a window currently exists at +path+
|
|
82
|
+
def exists?(path)
|
|
83
|
+
query('exists', path) == '1'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param path [String, Widget]
|
|
87
|
+
# @return [String] the Tk widget class (e.g. +"TButton"+, +"Frame"+)
|
|
88
|
+
def class_name(path)
|
|
89
|
+
query('class', path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @param path [String, Widget]
|
|
93
|
+
# @return [Boolean] whether the window is currently mapped (actually displayed)
|
|
94
|
+
def ismapped?(path)
|
|
95
|
+
query('ismapped', path) == '1'
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def query(subcommand, path)
|
|
101
|
+
@app.tcl_invoke('winfo', subcommand, path.to_s)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|