teek-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +452 -0
  4. data/lib/teek/ui/canvas_item.rb +270 -0
  5. data/lib/teek/ui/component_handle.rb +32 -0
  6. data/lib/teek/ui/document.rb +185 -0
  7. data/lib/teek/ui/errors.rb +41 -0
  8. data/lib/teek/ui/event_binding.rb +22 -0
  9. data/lib/teek/ui/event_bus.rb +53 -0
  10. data/lib/teek/ui/grid_validator.rb +89 -0
  11. data/lib/teek/ui/handle.rb +585 -0
  12. data/lib/teek/ui/image.rb +58 -0
  13. data/lib/teek/ui/keysyms.rb +72 -0
  14. data/lib/teek/ui/menu_builder.rb +130 -0
  15. data/lib/teek/ui/menu_entry_addressing.rb +62 -0
  16. data/lib/teek/ui/modal_stack.rb +99 -0
  17. data/lib/teek/ui/mouse_events.rb +31 -0
  18. data/lib/teek/ui/node.rb +141 -0
  19. data/lib/teek/ui/option_dump_parsing.rb +31 -0
  20. data/lib/teek/ui/overlay_anchors.rb +29 -0
  21. data/lib/teek/ui/overlay_validator.rb +33 -0
  22. data/lib/teek/ui/pane_validator.rb +30 -0
  23. data/lib/teek/ui/realized_node.rb +25 -0
  24. data/lib/teek/ui/realizer.rb +577 -0
  25. data/lib/teek/ui/scope.rb +40 -0
  26. data/lib/teek/ui/screens.rb +141 -0
  27. data/lib/teek/ui/session.rb +417 -0
  28. data/lib/teek/ui/tab_validator.rb +30 -0
  29. data/lib/teek/ui/text_content.rb +321 -0
  30. data/lib/teek/ui/tree_inspector.rb +97 -0
  31. data/lib/teek/ui/validator.rb +131 -0
  32. data/lib/teek/ui/var.rb +98 -0
  33. data/lib/teek/ui/version.rb +7 -0
  34. data/lib/teek/ui/widget_addressing.rb +47 -0
  35. data/lib/teek/ui/widget_dsl.rb +512 -0
  36. data/lib/teek/ui/widget_type.rb +242 -0
  37. data/lib/teek/ui/widget_types/button.rb +7 -0
  38. data/lib/teek/ui/widget_types/canvas.rb +15 -0
  39. data/lib/teek/ui/widget_types/checkbox.rb +7 -0
  40. data/lib/teek/ui/widget_types/column.rb +14 -0
  41. data/lib/teek/ui/widget_types/context_menu.rb +15 -0
  42. data/lib/teek/ui/widget_types/divider.rb +10 -0
  43. data/lib/teek/ui/widget_types/dropdown.rb +7 -0
  44. data/lib/teek/ui/widget_types/grid.rb +12 -0
  45. data/lib/teek/ui/widget_types/group.rb +7 -0
  46. data/lib/teek/ui/widget_types/label.rb +7 -0
  47. data/lib/teek/ui/widget_types/list.rb +7 -0
  48. data/lib/teek/ui/widget_types/menu_bar.rb +26 -0
  49. data/lib/teek/ui/widget_types/menu_checkbox.rb +14 -0
  50. data/lib/teek/ui/widget_types/menu_item.rb +20 -0
  51. data/lib/teek/ui/widget_types/menu_radio.rb +14 -0
  52. data/lib/teek/ui/widget_types/number_box.rb +7 -0
  53. data/lib/teek/ui/widget_types/pane.rb +37 -0
  54. data/lib/teek/ui/widget_types/panel.rb +7 -0
  55. data/lib/teek/ui/widget_types/progress.rb +7 -0
  56. data/lib/teek/ui/widget_types/radio.rb +7 -0
  57. data/lib/teek/ui/widget_types/row.rb +14 -0
  58. data/lib/teek/ui/widget_types/scrollable.rb +17 -0
  59. data/lib/teek/ui/widget_types/slider.rb +7 -0
  60. data/lib/teek/ui/widget_types/spacer.rb +14 -0
  61. data/lib/teek/ui/widget_types/split.rb +17 -0
  62. data/lib/teek/ui/widget_types/tab.rb +35 -0
  63. data/lib/teek/ui/widget_types/table.rb +9 -0
  64. data/lib/teek/ui/widget_types/tabs.rb +10 -0
  65. data/lib/teek/ui/widget_types/text_area.rb +7 -0
  66. data/lib/teek/ui/widget_types/text_box.rb +7 -0
  67. data/lib/teek/ui/widget_types/tree.rb +9 -0
  68. data/lib/teek/ui/widget_types/window.rb +50 -0
  69. data/lib/teek/ui/widget_types.rb +121 -0
  70. data/lib/teek/ui/widget_validators.rb +62 -0
  71. data/lib/teek/ui.rb +54 -0
  72. data/teek-ui.gemspec +24 -0
  73. data/test/support/fake_app.rb +57 -0
  74. data/test/test_busy.rb +82 -0
  75. data/test/test_canvas_items.rb +551 -0
  76. data/test/test_clipboard.rb +87 -0
  77. data/test/test_close_handling.rb +118 -0
  78. data/test/test_component.rb +151 -0
  79. data/test/test_component_realtk.rb +120 -0
  80. data/test/test_debug_info.rb +158 -0
  81. data/test/test_document.rb +317 -0
  82. data/test/test_event_bus.rb +163 -0
  83. data/test/test_events.rb +164 -0
  84. data/test/test_fake_app_contract.rb +62 -0
  85. data/test/test_grid.rb +97 -0
  86. data/test/test_handle.rb +386 -0
  87. data/test/test_handle_destroy_realtk.rb +344 -0
  88. data/test/test_helper.rb +20 -0
  89. data/test/test_image.rb +24 -0
  90. data/test/test_image_realtk.rb +146 -0
  91. data/test/test_incremental_realize.rb +203 -0
  92. data/test/test_keysyms.rb +76 -0
  93. data/test/test_layout.rb +185 -0
  94. data/test/test_lazy_realize.rb +128 -0
  95. data/test/test_managed_window.rb +304 -0
  96. data/test/test_menu_dsl.rb +230 -0
  97. data/test/test_menu_entry_addressing.rb +73 -0
  98. data/test/test_menu_realize.rb +296 -0
  99. data/test/test_modal_handle.rb +147 -0
  100. data/test/test_modal_stack.rb +219 -0
  101. data/test/test_modal_stack_realtk.rb +131 -0
  102. data/test/test_native_scrollable.rb +250 -0
  103. data/test/test_node.rb +193 -0
  104. data/test/test_overlay.rb +139 -0
  105. data/test/test_raw.rb +58 -0
  106. data/test/test_reactive_vars.rb +118 -0
  107. data/test/test_realizer.rb +264 -0
  108. data/test/test_scope.rb +38 -0
  109. data/test/test_screens.rb +260 -0
  110. data/test/test_screens_realtk.rb +314 -0
  111. data/test/test_scrollable.rb +185 -0
  112. data/test/test_scrollable_wheel.rb +172 -0
  113. data/test/test_scrollbar_auto_hide.rb +127 -0
  114. data/test/test_split.rb +145 -0
  115. data/test/test_tabs.rb +201 -0
  116. data/test/test_text_content.rb +664 -0
  117. data/test/test_toast.rb +126 -0
  118. data/test/test_tree_inspector.rb +183 -0
  119. data/test/test_ui.rb +428 -0
  120. data/test/test_validator.rb +209 -0
  121. data/test/test_var.rb +32 -0
  122. data/test/test_widget_dsl.rb +621 -0
  123. data/test/test_widget_introspection.rb +126 -0
  124. data/test/test_widget_types.rb +429 -0
  125. data/test/test_widget_types_realtk.rb +82 -0
  126. data/test/test_widget_validators.rb +82 -0
  127. metadata +207 -0
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'widget_validators'
4
+ require_relative 'widget_types'
5
+
6
+ module Teek
7
+ module UI
8
+ # @api private
9
+ #
10
+ # A grid's own contract: every direct child needs a cell
11
+ # (+g.cell(row:, col:) { }+), and no two children can claim the same
12
+ # one. {Realizer#arrange_grid} still raises on a missing cell too
13
+ # (kept as a belt-and-suspenders backstop for the one path that skips
14
+ # validation entirely - {Session#add}'s incremental realize), but this
15
+ # is the primary detection, so the mistake surfaces pre-realize,
16
+ # collected alongside every other problem, instead of crashing
17
+ # mid-realize. Composed into {WidgetValidators} via :grid's own
18
+ # {WidgetType#validator} (see +widget_types/grid.rb+).
19
+ module GridValidator
20
+ # @param node [Node] a :grid node - {WidgetValidators} only dispatches
21
+ # here for that type
22
+ # @param parent [Node, nil]
23
+ # @param document [Document]
24
+ # @param errors [Array<String>] appended to, never raised
25
+ # @return [void]
26
+ def self.call(node, parent, document, errors)
27
+ check_missing_cell(node, errors)
28
+ check_cell_collisions(node, errors)
29
+ end
30
+
31
+ def self.check_missing_cell(node, errors)
32
+ node.children.each do |child|
33
+ next unless needs_cell?(child.type)
34
+ next if child.layout && child.layout[:cell]
35
+
36
+ errors << "#{WidgetValidators.describe(child)} is a direct child of a grid but was never placed with " \
37
+ "g.cell(row:, col:) { ... }"
38
+ end
39
+ end
40
+
41
+ def self.check_cell_collisions(node, errors)
42
+ node.children
43
+ .group_by { |child| child.layout && child.layout[:cell] && [child.layout[:cell][:row], child.layout[:cell][:col]] }
44
+ .each do |position, children|
45
+ next if position.nil? || children.length <= 1
46
+
47
+ row, col = position
48
+ errors << "#{WidgetValidators.describe(node)} has more than one widget at row #{row}, col #{col}: " \
49
+ "#{children.map { |c| WidgetValidators.describe(c) }.join(', ')}"
50
+ end
51
+ end
52
+
53
+ # :raw_op has no widget of its own at all (mirrors
54
+ # {Realizer::NON_WIDGET_TYPES}); every other type reports whether it
55
+ # needs a cell via its own {WidgetType#arranged?} (mirrors
56
+ # {Realizer#unarranged?}) - true (needs a cell) for anything
57
+ # unregistered, since every type a grid can hold is WidgetType-registered.
58
+ def self.needs_cell?(type)
59
+ return false if type == :raw_op
60
+
61
+ registered = WidgetTypes.for_type(type)
62
+ registered.nil? || registered.arranged?
63
+ end
64
+
65
+ private_class_method :check_missing_cell, :check_cell_collisions, :needs_cell?
66
+
67
+ # The opposite direction from {.check_missing_cell}: a node carrying a
68
+ # grid-cell position (+layout[:cell]+) whose actual parent isn't a
69
+ # :grid at all - only reachable via direct Node/Document manipulation,
70
+ # since {WidgetDSL#cell} already refuses to run outside a ui.grid
71
+ # block. Cell intent can land on any node type (whatever #cell's
72
+ # block happens to build), so unlike {.call} above - dispatched
73
+ # through the registry only when *visiting a :grid* - this can't be
74
+ # keyed off a single node type there. {Validator} calls this directly
75
+ # for every node instead, in the same single tree walk.
76
+ # @param node [Node]
77
+ # @param parent [Node, nil]
78
+ # @param errors [Array<String>]
79
+ # @return [void]
80
+ def self.check_stray_cell(node, parent, errors)
81
+ return unless node.layout && node.layout[:cell]
82
+ return if parent && parent.type == :grid
83
+
84
+ errors << "#{WidgetValidators.describe(node)} has a grid cell position but its parent " \
85
+ "(#{WidgetValidators.describe(parent)}) isn't a ui.grid - its row/col/span would be silently ignored"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,585 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'realized_node'
5
+ require_relative 'event_binding'
6
+ require_relative 'keysyms'
7
+ require_relative 'mouse_events'
8
+ require_relative 'canvas_item'
9
+ require_relative 'text_content'
10
+ require_relative 'widget_addressing'
11
+ require_relative 'widget_types'
12
+ require_relative 'realizer'
13
+
14
+ module Teek
15
+ module UI
16
+ # The single handle type for a node, valid across both phases (Resolved
17
+ # decision #3 in the architecture doc - no separate build-time NodeRef).
18
+ # During build you compose/name/record-events on it; live methods
19
+ # (#path, #configure) raise {NotRealizedError} until the node's
20
+ # +realized+ slot is filled in by the realizer, then the same Handle
21
+ # object drives the real widget through it.
22
+ class Handle
23
+ RIGHT_CLICK_EVENTS = MouseEvents::RIGHT_CLICK_EVENTS
24
+ MENU_HANDLE_TYPES = MouseEvents::MENU_HANDLE_TYPES
25
+
26
+ # @api private
27
+ def initialize(node)
28
+ @node = node
29
+ @addressing = (WidgetTypes.for_type(node.type)&.addressing || WidgetAddressing).new(node)
30
+ end
31
+
32
+ # @return [Symbol] the node's type, e.g. +:button+
33
+ def type
34
+ @node.type
35
+ end
36
+
37
+ # @return [Symbol, nil] the node's explicit name
38
+ def name
39
+ @node.name
40
+ end
41
+
42
+ # @return [String] this node's live address - the real Tk widget
43
+ # path for an ordinary widget, or (for a type with none of its
44
+ # own, e.g. a menu entry) its {WidgetType#addressing} strategy's
45
+ # own marked, Tk-path-shaped virtual path - see
46
+ # {MenuEntryAddressing#virtual_path}
47
+ # @raise [NotRealizedError] before realize
48
+ def path
49
+ @addressing.virtual_path
50
+ end
51
+
52
+ # @return [Teek::App] the underlying app this widget was realized into
53
+ # @raise [NotRealizedError] before realize
54
+ def app
55
+ realized.app
56
+ end
57
+
58
+ # Mutate the live widget's (or, for a type with none of its own,
59
+ # e.g. a menu entry - the entry's own) options - delegated entirely
60
+ # to this node type's {WidgetType#addressing} strategy, so Handle
61
+ # itself carries no per-type knowledge of how to reach it.
62
+ # @param opts [Hash] e.g. +text: "Go"+
63
+ # @raise [NotRealizedError] before realize
64
+ def configure(**opts)
65
+ @addressing.configure(**opts)
66
+ end
67
+
68
+ # What Tk currently thinks this widget's (or, for a type with none
69
+ # of its own, e.g. a menu entry - the entry's own) options are right
70
+ # now, straight from a bare +configure+ - for when a prior
71
+ # {#configure} call seems like it silently didn't take, or just
72
+ # exploring what's actually set on a live widget. Delegated to this
73
+ # node type's {WidgetType#addressing} strategy, same as {#configure}.
74
+ # @return [Hash{Symbol => String}] option name (no leading +-+) => current value
75
+ # @raise [NotRealizedError] before realize
76
+ def options
77
+ @addressing.option_dump
78
+ end
79
+
80
+ # Shorthand for +configure(state: :normal)+ - Tk's own default
81
+ # state, meaningful for anything with a +-state+ option (a menu
82
+ # entry, a ttk widget, ...).
83
+ # @return [self]
84
+ # @raise [NotRealizedError] before realize
85
+ def enable
86
+ configure(state: :normal)
87
+ self
88
+ end
89
+
90
+ # Shorthand for +configure(state: :disabled)+ - greyed out, not
91
+ # interactive/invocable.
92
+ # @return [self]
93
+ # @raise [NotRealizedError] before realize
94
+ def disable
95
+ configure(state: :disabled)
96
+ self
97
+ end
98
+
99
+ # Tears down this node's live widget (and everything under it),
100
+ # releasing its callbacks via teek's existing +<Destroy>+ cleanup,
101
+ # and resets it so a later push of a fresh mount rebuilds it from
102
+ # scratch. The rebuild gets a fresh Tk path, not necessarily this
103
+ # same one - path segments are claimed once and never recycled (see
104
+ # {Document#claim_path_segment}), so this stays safe even if another
105
+ # instance sharing this same local name is still alive elsewhere
106
+ # under the same parent. Typically called after popping a screen you
107
+ # don't want to keep warm (see {Screens}) - popping alone only
108
+ # conceals, exactly as before; this is a separate, explicit step -
109
+ # +ui.screens.pop&.destroy!+/+ui.modal.pop&.destroy!+.
110
+ #
111
+ # Destroying a widget SYNCHRONOUSLY from inside the click handler
112
+ # of one of its own descendants (a dialog's own "Close" button
113
+ # tearing down the dialog it lives in) is a real Tk hazard:
114
+ # `ttk::button` (and others) queue their own internal bindings for
115
+ # that SAME click, which then run against a widget that's already
116
+ # gone. +defer+ absorbs this automatically - no need to know about
117
+ # it or reach for `ui.after` yourself.
118
+ # @param defer [Boolean, nil] +nil+ (the default) auto-detects:
119
+ # defers to the next Tk idle point ({Teek.in_callback?} true -
120
+ # the hazard above) so the current click finishes first, or
121
+ # destroys synchronously otherwise (a script/test with no event
122
+ # loop running has nothing to defer TO, and wants "gone when
123
+ # this call returns" semantics). Pass explicitly to override
124
+ # either way. Calling this again on the same handle while its
125
+ # own deferred destroy hasn't run yet is a safe no-op.
126
+ #
127
+ # The one residual to know about: when this DOES defer, it
128
+ # returns before the widget is actually gone - the node still
129
+ # reports realized and its old Tk path still exists until the
130
+ # deferred teardown runs. Don't +destroy!+ then immediately
131
+ # rebuild a fresh mount at that SAME name/path in the same
132
+ # handler expecting the old one to already be gone; either pass
133
+ # +defer: false+ to force it synchronous first, or build the
134
+ # replacement under a genuinely distinct mount (the normal
135
+ # "fresh `lazy: true` component per open" pattern already does
136
+ # this, since {Document#claim_path_segment} never reuses a path
137
+ # segment anyway).
138
+ # @return [nil]
139
+ # @raise [NotRealizedError] if this node was never realized
140
+ def destroy!(defer: nil)
141
+ return nil if @node.pending_destroy?
142
+
143
+ should_defer = defer.nil? ? Teek.in_callback? : defer
144
+ if should_defer
145
+ app = realized.app
146
+ @node.pending_destroy = true
147
+ app.after_idle { perform_destroy! }
148
+ else
149
+ perform_destroy!
150
+ end
151
+ nil
152
+ end
153
+
154
+ # Fires on a left click.
155
+ # @yield called with no arguments
156
+ # @return [self]
157
+ def on_click(&block)
158
+ bind_event('<Button-1>', block)
159
+ self
160
+ end
161
+
162
+ # Fires on a right click, however the platform spells it (Button-3 on
163
+ # Linux/Windows, Button-2 or Control-Button-1 on macOS). Either handle
164
+ # it yourself with a block, or hand it a `:menu`/`:context_menu`
165
+ # handle to pop up at the click's screen position - not both.
166
+ # @param menu [Handle, nil] a `:menu` or `:context_menu` handle to tk_popup
167
+ # @yield called with no arguments (only when +menu+ isn't given)
168
+ # @return [self]
169
+ # @raise [ArgumentError] if given neither or both, or +menu+ isn't a menu handle
170
+ def on_right_click(menu = nil, &block)
171
+ if menu && block
172
+ raise ArgumentError, "on_right_click takes either a menu handle or a block, not both"
173
+ elsif menu
174
+ unless MENU_HANDLE_TYPES.include?(menu.type)
175
+ raise ArgumentError, "on_right_click(menu) needs a :menu or :context_menu handle (got a :#{menu.type})"
176
+ end
177
+
178
+ popup = lambda do |root_x, root_y|
179
+ realized.app.popup_menu(menu.path, x: root_x, y: root_y)
180
+ end
181
+ RIGHT_CLICK_EVENTS.each { |event| bind_event(event, popup, subs: %i[root_x root_y]) }
182
+ elsif block
183
+ RIGHT_CLICK_EVENTS.each { |event| bind_event(event, block) }
184
+ else
185
+ raise ArgumentError, "on_right_click needs either a menu handle or a block"
186
+ end
187
+ self
188
+ end
189
+
190
+ # Fires while dragging (left button held down and moving). Delivers
191
+ # Integer x/y, converted through the widget's own canvasx/canvasy when
192
+ # bound to a canvas so callers never have to remember to do that
193
+ # themselves.
194
+ # @yield [x, y] Integer coordinates
195
+ # @return [self]
196
+ def on_drag(&block)
197
+ drag_type = type
198
+ wrapped = lambda do |raw_x, raw_y|
199
+ block.call(*convert_drag_coords(drag_type, raw_x, raw_y))
200
+ end
201
+ bind_event('<B1-Motion>', wrapped, subs: %i[x y])
202
+ self
203
+ end
204
+
205
+ # Fires on a key press. +spec+ is either a friendly Symbol (+:enter+,
206
+ # +:escape+, +:up+, ...) or a "Modifier-Modifier-Key" String
207
+ # (+"Ctrl-s"+, +"Ctrl-Shift-s"+) - see {Keysyms}.
208
+ # @param spec [Symbol, String]
209
+ # @yield called with no arguments
210
+ # @return [self]
211
+ def on_key(spec, &block)
212
+ modifiers, keysym = Keysyms.resolve(spec)
213
+ Keysyms.patterns_for(modifiers, keysym).each { |event| bind_event(event, block) }
214
+ self
215
+ end
216
+
217
+ # Fires when the window's close button (titlebar close box, Cmd-W,
218
+ # Alt-F4, ...) is pressed. Teek's own default (destroy the window)
219
+ # only applies when nothing else has claimed it - the block decides
220
+ # whether the window actually closes; call `.destroy` yourself if you
221
+ # want that. Only valid on a `ui.window` handle.
222
+ # @yield called with no arguments
223
+ # @return [self]
224
+ # @raise [ArgumentError] if this handle isn't a window
225
+ def on_close(&block)
226
+ unless type == :window
227
+ raise ArgumentError, "on_close only makes sense on a window (got a :#{type})"
228
+ end
229
+
230
+ if @node.realized
231
+ @node.realized.app.on_close(window: @node.realized.path, &block)
232
+ else
233
+ @node.opts[:on_close] = block
234
+ end
235
+ self
236
+ end
237
+
238
+ # Fires when the selected tab changes (Tk's <<NotebookTabChanged>>).
239
+ # The block receives the newly selected tab's own name (the Symbol
240
+ # given via `t.tab(label, name)`) if it has one, otherwise its plain
241
+ # zero-based index - preferring a name over a raw Tk index, same as
242
+ # `ui[:name]` lookup does everywhere else in the DSL. Only valid on a
243
+ # `ui.tabs` handle.
244
+ # @yield [name_or_index] Symbol or Integer
245
+ # @return [self]
246
+ # @raise [ArgumentError] if this handle isn't a tabs container
247
+ def on_tab_changed(&block)
248
+ unless type == :tabs
249
+ raise ArgumentError, "on_tab_changed only makes sense on a tabs container (got a :#{type})"
250
+ end
251
+
252
+ wrapped = lambda {
253
+ index = realized.app.command(realized.path, :index, :current).to_i
254
+ tab_node = @node.children[index]
255
+ block.call(tab_node&.name || index)
256
+ }
257
+ bind_event('<<NotebookTabChanged>>', wrapped)
258
+ self
259
+ end
260
+
261
+ # Show the window modally: grabs input and sets focus on it
262
+ # immediately. Release it explicitly with {#grab_release} (typically
263
+ # from the window's own dismiss/close handling) when the dialog is
264
+ # done - not released automatically just because this method
265
+ # returns, since a modal dialog stays grabbed for its whole visible
266
+ # lifetime, not just its setup. Released immediately if the optional
267
+ # setup block itself raises, or if the window is destroyed while
268
+ # still grabbed - see {Teek::Window#modal}, which this delegates to
269
+ # entirely (no grab/focus/destroy-safety-net logic lives here).
270
+ # Only valid on a `ui.window` handle.
271
+ # @param global [Boolean] see {Teek::Window#grab_set}
272
+ # @yield optional - runs with the grab and focus already set
273
+ # @return [void]
274
+ # @raise [ArgumentError] if this handle isn't a window
275
+ # @raise [NotRealizedError] before realize
276
+ def modal(global: false, &block)
277
+ unless type == :window
278
+ raise ArgumentError, "modal only makes sense on a window (got a :#{type})"
279
+ end
280
+
281
+ window.modal(global: global, &block)
282
+ end
283
+
284
+ # Release a grab previously set with {#modal} - "grab" is X11/Tk
285
+ # jargon for capturing all input to one window, which is what a
286
+ # modal dialog is doing while it's up. Only valid on a `ui.window`
287
+ # handle. See {Teek::Window#grab_release}.
288
+ # @return [void]
289
+ # @raise [ArgumentError] if this handle isn't a window
290
+ # @raise [NotRealizedError] before realize
291
+ def release_focus
292
+ unless type == :window
293
+ raise ArgumentError, "release_focus only makes sense on a window (got a :#{type})"
294
+ end
295
+
296
+ window.grab_release
297
+ end
298
+ alias_method :grab_release, :release_focus
299
+
300
+ # Reveal the window: positions it just to the right of the parent
301
+ # it's nested under (root, or another window if this one's nested
302
+ # inside it), deiconifies, raises it to the front, and - only if
303
+ # this window was declared `modal: true` - grabs input and focuses
304
+ # it too (via {#modal}). Only valid on a `ui.window` handle.
305
+ # @return [self]
306
+ # @raise [ArgumentError] if this handle isn't a window
307
+ # @raise [NotRealizedError] before realize
308
+ def show
309
+ unless type == :window
310
+ raise ArgumentError, "show only makes sense on a window (got a :#{type})"
311
+ end
312
+
313
+ position_near_parent
314
+ window.deiconify
315
+ realized.app.command(:raise, realized.path)
316
+ modal if @node.opts[:modal]
317
+ self
318
+ end
319
+
320
+ # Hide the window: releases any grab {#show} set (a no-op if it
321
+ # wasn't modal - {Teek::Window#grab_release} is always safe to call)
322
+ # and withdraws it. Only valid on a `ui.window` handle.
323
+ # @return [self]
324
+ # @raise [ArgumentError] if this handle isn't a window
325
+ # @raise [NotRealizedError] before realize
326
+ def hide
327
+ unless type == :window
328
+ raise ArgumentError, "hide only makes sense on a window (got a :#{type})"
329
+ end
330
+
331
+ grab_release
332
+ window.withdraw
333
+ self
334
+ end
335
+
336
+ # A straight line through the given points - +[x1, y1, x2, y2, ...]+,
337
+ # flat or nested, two or more points. Only valid on a `ui.canvas` handle.
338
+ # @param coords [Array<Numeric>]
339
+ # @param opts [Hash] item options, e.g. +fill:+/+width:+/+tags:+
340
+ # @return [CanvasItem]
341
+ # @raise [ArgumentError] if this handle isn't a canvas
342
+ # @raise [NotRealizedError] before realize
343
+ def line(*coords, **opts)
344
+ create_canvas_item(:line, coords, opts)
345
+ end
346
+
347
+ # An ellipse inscribed in the bounding box +[x1, y1, x2, y2]+. Only
348
+ # valid on a `ui.canvas` handle.
349
+ # @param coords [Array<Numeric>]
350
+ # @param opts [Hash] item options, e.g. +fill:+/+outline:+/+tags:+
351
+ # @return [CanvasItem]
352
+ # @raise [ArgumentError] if this handle isn't a canvas
353
+ # @raise [NotRealizedError] before realize
354
+ def ellipse(*coords, **opts)
355
+ create_canvas_item(:oval, coords, opts)
356
+ end
357
+ alias_method :oval, :ellipse
358
+
359
+ # A closed shape through the given points - +[x1, y1, x2, y2, ...]+,
360
+ # flat or nested, three or more points. Only valid on a `ui.canvas` handle.
361
+ # @param coords [Array<Numeric>]
362
+ # @param opts [Hash] item options, e.g. +fill:+/+smooth:+/+tags:+
363
+ # @return [CanvasItem]
364
+ # @raise [ArgumentError] if this handle isn't a canvas
365
+ # @raise [NotRealizedError] before realize
366
+ def polygon(*coords, **opts)
367
+ create_canvas_item(:polygon, coords, opts)
368
+ end
369
+
370
+ # A rectangle with corners +[x1, y1, x2, y2]+. Only valid on a
371
+ # `ui.canvas` handle.
372
+ # @param coords [Array<Numeric>]
373
+ # @param opts [Hash] item options, e.g. +fill:+/+outline:+/+tags:+
374
+ # @return [CanvasItem]
375
+ # @raise [ArgumentError] if this handle isn't a canvas
376
+ # @raise [NotRealizedError] before realize
377
+ def rectangle(*coords, **opts)
378
+ create_canvas_item(:rectangle, coords, opts)
379
+ end
380
+
381
+ # Text anchored at +[x, y]+. Only valid on a `ui.canvas` handle.
382
+ # @param coords [Array<Numeric>] a single +[x, y]+ point
383
+ # @param opts [Hash] item options, e.g. +text:+/+fill:+/+font:+/+anchor:+/+tags:+
384
+ # @return [CanvasItem]
385
+ # @raise [ArgumentError] if this handle isn't a canvas
386
+ # @raise [NotRealizedError] before realize
387
+ def text(*coords, **opts)
388
+ create_canvas_item(:text, coords, opts)
389
+ end
390
+
391
+ # An arc/pie-slice/chord along the oval inscribed in the bounding
392
+ # box +[x1, y1, x2, y2]+. Only valid on a `ui.canvas` handle.
393
+ # @param coords [Array<Numeric>]
394
+ # @param opts [Hash] item options, e.g. +start:+/+extent:+/+style:+/+tags:+
395
+ # @return [CanvasItem]
396
+ # @raise [ArgumentError] if this handle isn't a canvas
397
+ # @raise [NotRealizedError] before realize
398
+ def arc(*coords, **opts)
399
+ create_canvas_item(:arc, coords, opts)
400
+ end
401
+
402
+ # A stipple bitmap anchored at +[x, y]+. Only valid on a `ui.canvas` handle.
403
+ # @param coords [Array<Numeric>] a single +[x, y]+ point
404
+ # @param opts [Hash] item options, e.g. +bitmap:+/+foreground:+/+tags:+
405
+ # @return [CanvasItem]
406
+ # @raise [ArgumentError] if this handle isn't a canvas
407
+ # @raise [NotRealizedError] before realize
408
+ def bitmap(*coords, **opts)
409
+ create_canvas_item(:bitmap, coords, opts)
410
+ end
411
+
412
+ # Every event binding declared on this node so far, in declaration
413
+ # order - +on_click+/+on_key+/+on_drag+/+on_right_click+ and
414
+ # friends all funnel through here. Meaningful at any phase: before
415
+ # realize these are still queued (nothing wired to Tcl yet), after
416
+ # realize they're the bindings actually in effect - covers both a
417
+ # binding declared in the original build block and one added later
418
+ # (e.g. from inside {Session#add}), so this stays a true live
419
+ # picture, not just a record of what was queued pre-realize. Each
420
+ # entry's own +handler+ is the real Proc that runs, so
421
+ # +.source_location+ answers "what code does this" directly.
422
+ # @return [Array<EventBinding>]
423
+ def events
424
+ @node.events
425
+ end
426
+
427
+ # A handle onto whatever items currently carry +tag+ - zero, one, or
428
+ # many (see {CanvasItem}, which addresses a tag and an id
429
+ # identically). Doesn't create anything; a shape-creation method
430
+ # (e.g. {#line}) already returns a single-item handle for its own
431
+ # new item, this is for addressing a shared +tags:+ group (or
432
+ # reaching an item by an id you already have) after the fact. Only
433
+ # valid on a `ui.canvas` handle.
434
+ # @param tag [String, Symbol, Integer]
435
+ # @return [CanvasItem]
436
+ # @raise [ArgumentError] if this handle isn't a canvas
437
+ # @raise [NotRealizedError] before realize
438
+ def tagged(tag)
439
+ raise_unless_canvas!('tagged')
440
+ CanvasItem.new(realized.app, realized.path, tag)
441
+ end
442
+
443
+ # The rich text API for this widget's content - insert/get/delete,
444
+ # named formats (Tk's own "tag" concept), markers, search, and
445
+ # embedded images. See {TextContent} for the full surface. Only
446
+ # valid on a `ui.text_area` handle.
447
+ # @return [TextContent]
448
+ # @raise [ArgumentError] if this handle isn't a text_area
449
+ # @raise [NotRealizedError] before realize
450
+ def text_content
451
+ raise_unless_text_area!('text_content')
452
+ TextContent.new(realized.app, realized.path)
453
+ end
454
+
455
+ private
456
+
457
+ def realized
458
+ @node.realized or raise NotRealizedError
459
+ end
460
+
461
+ # The actual teardown {#destroy!} defers or runs immediately -
462
+ # clears the pending flag first so a LATER, genuinely fresh
463
+ # destroy! (after a rebuild) is never mistaken for a still-pending
464
+ # one. Unlinks the node from the retained tree afterward (see
465
+ # #unlink!) so it stops being reachable at all, not just Tk-dead.
466
+ def perform_destroy!
467
+ @node.pending_destroy = false
468
+ realized.app.destroy(realized.path)
469
+ @node.realized = nil
470
+ unlink!
471
+ end
472
+
473
+ # Removes this node (and every named descendant of its own
474
+ # subtree - Tk destroys descendants recursively, so their names
475
+ # need to stop resolving too) from {Document}'s name index, then
476
+ # removes the node itself from its own parent's +children+. Both
477
+ # steps are plain Ruby, safe to run even if +@node.document+ is
478
+ # nil (a raw +Node.new+ built directly, mostly in headless tests)
479
+ # or +@node.parent+ is nil (already unlinked, or never attached).
480
+ def unlink!
481
+ document = @node.document
482
+ @node.each { |descendant| document.unregister(descendant) } if document
483
+ @node.parent&.remove_child(@node)
484
+ end
485
+
486
+ # @return [Boolean] whether this node has a live Tk widget yet -
487
+ # true for any ordinary handle once the tree's been realized;
488
+ # only ever false past that point for a +lazy: true+ container
489
+ # (see {WidgetDSL#append_container}) not yet {#realize!}d, or
490
+ # one that's been {#destroy!}ed since. Internal - {Screens#push}/
491
+ # {ModalStack#push} are what actually decide when to realize a
492
+ # lazy screen; an app author never needs to check this directly.
493
+ def realized?
494
+ !@node.realized.nil?
495
+ end
496
+
497
+ # Realizes this node (and everything declared inside it) into a
498
+ # live Tk widget, if it isn't already - the on-demand counterpart
499
+ # to +lazy: true+. A no-op if already realized. This node's own
500
+ # {Node#parent} must already be realized (true for anything
501
+ # reachable from an already-running app, which is the only
502
+ # situation a lazy node is realized from). Internal - called via
503
+ # +send+ by {Screens#push}/{ModalStack#push} (the only intended
504
+ # callers); a +lazy: true+ screen "just works" through them, with
505
+ # nothing for an app author to trigger by hand.
506
+ # @param document [Document] the same {Document} this node belongs
507
+ # to - needed to resolve any +target:+ event bindings by name
508
+ # @return [self]
509
+ # @raise [NotRealizedError] if this node's own parent isn't realized yet
510
+ def realize!(document)
511
+ return self if realized?
512
+
513
+ parent_node = @node.parent
514
+ parent_realized = parent_node&.realized or
515
+ raise NotRealizedError, "can't realize this node - its own parent isn't realized yet"
516
+
517
+ Realizer.new(parent_realized.app, document).realize_subtree(@node, parent_node)
518
+ self
519
+ end
520
+
521
+ def window
522
+ realized.app.window(realized.path)
523
+ end
524
+
525
+ def create_canvas_item(shape, coords, opts)
526
+ raise_unless_canvas!(shape)
527
+ id = realized.app.command(realized.path, :create, shape, *coords.flatten, **opts)
528
+ CanvasItem.new(realized.app, realized.path, id)
529
+ end
530
+
531
+ def raise_unless_canvas!(method_name)
532
+ unless type == :canvas
533
+ raise ArgumentError, "##{method_name} only makes sense on a canvas (got a :#{type})"
534
+ end
535
+ end
536
+
537
+ def raise_unless_text_area!(method_name)
538
+ unless type == :text_area
539
+ raise ArgumentError, "##{method_name} only makes sense on a text_area (got a :#{type})"
540
+ end
541
+ end
542
+
543
+ # Positions the window just to the right of the parent it's nested
544
+ # under - root by default, or another window's own path when this
545
+ # one is declared inside it. Tk toplevel paths stay hierarchical
546
+ # even though they're independent OS windows (allocate_path nests
547
+ # them the same way any other widget path nests), so the parent's
548
+ # path is just everything before this window's own last path
549
+ # segment - no separate bookkeeping needed to recover it later.
550
+ def position_near_parent
551
+ parent_x, parent_y, parent_width, = realized.app.interp.window_geometry(toplevel_parent_path)
552
+ window.set_geometry("+#{parent_x + parent_width + 12}+#{parent_y}")
553
+ end
554
+
555
+ def toplevel_parent_path
556
+ path = realized.path
557
+ last_dot = path.rindex('.')
558
+ last_dot && last_dot.positive? ? path[0...last_dot] : '.'
559
+ end
560
+
561
+ def bind_event(event, handler, subs: [])
562
+ binding = EventBinding.new(event: event, handler: handler, subs: subs)
563
+ @node.events << binding
564
+ wire(@node.realized, binding) if @node.realized
565
+ end
566
+
567
+ def wire(realized_node, binding)
568
+ realized_node.app.bind(realized_node.path, binding.event, *binding.subs) { |*args|
569
+ binding.handler.call(*args)
570
+ }
571
+ end
572
+
573
+ def convert_drag_coords(drag_type, raw_x, raw_y)
574
+ if drag_type == :canvas
575
+ info = @node.realized
576
+ x = info.app.command(info.path, :canvasx, raw_x).to_f.round
577
+ y = info.app.command(info.path, :canvasy, raw_y).to_f.round
578
+ [x, y]
579
+ else
580
+ [raw_x.to_i, raw_y.to_i]
581
+ end
582
+ end
583
+ end
584
+ end
585
+ end