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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'widget_validators'
4
+
5
+ module Teek
6
+ module UI
7
+ # @api private
8
+ #
9
+ # The opposite direction from {WidgetDSL#overlay}'s own guard: a node
10
+ # carrying overlay placement (+layout[:overlay]+) whose actual parent
11
+ # isn't a :canvas at all - only reachable via direct Node/Document
12
+ # manipulation, since {WidgetDSL#overlay} already refuses to run
13
+ # outside a ui.canvas block. Mirrors {GridValidator.check_stray_cell}
14
+ # exactly; overlay intent can land on any node type (whatever
15
+ # #overlay's block happens to build), so like that check - and unlike
16
+ # a type-dispatched {WidgetValidators} entry - this can't be keyed off
17
+ # a single node type. {Validator} calls this directly for every node
18
+ # in the same single tree walk.
19
+ module OverlayValidator
20
+ # @param node [Node]
21
+ # @param parent [Node, nil]
22
+ # @param errors [Array<String>]
23
+ # @return [void]
24
+ def self.check_stray_overlay(node, parent, errors)
25
+ return unless node.layout && node.layout[:overlay]
26
+ return if parent && parent.type == :canvas
27
+
28
+ errors << "#{WidgetValidators.describe(node)} has an overlay position but its parent " \
29
+ "(#{WidgetValidators.describe(parent)}) isn't a ui.canvas - its placement would be silently ignored"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'widget_validators'
4
+
5
+ module Teek
6
+ module UI
7
+ # @api private
8
+ #
9
+ # A pane's own contract: it must be declared directly inside a
10
+ # ui.split. Only reachable via direct Node/Document manipulation,
11
+ # since {WidgetDSL#pane} already refuses to run outside a ui.split
12
+ # block - the same defense-in-depth {TabValidator} does for tabs.
13
+ # Composed into {WidgetValidators} via :pane's own {WidgetType#validator}
14
+ # (see +widget_types/pane.rb+).
15
+ module PaneValidator
16
+ # @param node [Node] a :pane node - {WidgetValidators} only dispatches
17
+ # here for that type
18
+ # @param parent [Node, nil]
19
+ # @param document [Document]
20
+ # @param errors [Array<String>] appended to, never raised
21
+ # @return [void]
22
+ def self.call(node, parent, document, errors)
23
+ return if parent && parent.type == :split
24
+
25
+ errors << "#{WidgetValidators.describe(node)} is a :pane but its parent " \
26
+ "(#{WidgetValidators.describe(parent)}) isn't a ui.split"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ module UI
5
+ # What a {Node} gets in its +realized+ slot once the realizer creates a
6
+ # live widget for it: which app owns it, its live Tk path (what a
7
+ # {Handle} acts on - +#configure+, event bindings, +on_close+, ...),
8
+ # and the path its parent's own layout should actually place
9
+ # (+arrange_path+, defaulting to the same as +path+).
10
+ #
11
+ # These two paths only diverge for a node the realizer auto-wraps in a
12
+ # scrollbar (a bare list/text_area/table/tree/canvas, when scrolling
13
+ # applies - see Realizer#create_native_scrollable): +path+ stays the
14
+ # real widget, so a {Handle} keeps acting on it directly, but the
15
+ # widget's actual Tk *parent* is now the wrapper frame the scrollbar
16
+ # lives in - +arrange_path+ points there instead, since that's what
17
+ # has to be packed/gridded into the surrounding layout.
18
+ RealizedNode = Data.define(:app, :path, :arrange_path) do
19
+ # @api private
20
+ def initialize(app:, path:, arrange_path: path)
21
+ super
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,577 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'realized_node'
4
+ require_relative 'widget_types'
5
+ require_relative 'overlay_anchors'
6
+
7
+ module Teek
8
+ module UI
9
+ # Walks a {Document} and realizes it into a live {Teek::App} - two
10
+ # passes (Resolved decision #4 in the architecture doc):
11
+ #
12
+ # 1. +create+ - creates every widget, allocates a hierarchical/meaningful
13
+ # Tk path per node, fills each node's +realized+ slot.
14
+ # 2. +link+ - applies (placeholder, see below) geometry and wires event
15
+ # bindings, resolving +target:+ references by name. Runs after
16
+ # +create+ has finished the WHOLE tree, so a target declared later in
17
+ # the build already has a live path by the time it's looked up - that
18
+ # ordering is what makes forward references work.
19
+ #
20
+ # Every widget creation and mutation goes through {Teek::App#command}, so
21
+ # teek's interceptor/leak-cleanup layer applies automatically.
22
+ #
23
+ # @note Layout is real for +:column+/+:row+ (flow packing driven by
24
+ # +gap:+/+align:+/+pad:+ and each child's +grow:+), +:grid+ (real Tk
25
+ # grid arrangement driven by +#cell+/+#stretch+), and a +ui.canvas+
26
+ # child positioned via +#overlay+ (Tk +place+, driven by an anchor -
27
+ # see {OverlayAnchors}) - everything else is still a placeholder,
28
+ # its children just packing top-to-bottom with no options.
29
+ class Realizer
30
+ # DSL-reserved opts keys - layout keywords (gap:/align:/pad:/
31
+ # stretch_columns/stretch_rows) plus other entries the DSL stashes on
32
+ # node.opts for the realizer to pick up later (on_close:, and title:/
33
+ # geometry:/resizable:/transient:/modal: for :window nodes, applied by
34
+ # {WindowRealize}; x:/y: for :scrollable and native-scrollable nodes,
35
+ # applied by #create_scrollable/#create_native_scrollable; scroll:
36
+ # for native-scrollable nodes, applied by #auto_scrollable?; tab_label
37
+ # for :tab nodes, applied by {TabRealize}; pane_weight for :pane
38
+ # nodes, applied by {PaneRealize}) - none of these are real Tk
39
+ # options, so none are ever passed through to a widget-creation call.
40
+ # +:split+'s own +orient:+ isn't reserved - it's a real
41
+ # `ttk::panedwindow` option already, so it passes straight through.
42
+ RESERVED_OPTIONS = %i[
43
+ gap align pad stretch_columns stretch_rows on_close
44
+ title geometry resizable transient modal x y scroll tab_label pane_weight
45
+ ].freeze
46
+
47
+ # @api private
48
+ # @param default_scroll [Boolean, nil] app-wide override for whether
49
+ # native scrollable widgets auto-attach a scrollbar - see
50
+ # {Teek::UI.app}'s own +scroll:+. +nil+ defers to the global
51
+ # default ({Teek::UI.auto_scroll}/{Teek::UI.auto_scroll_canvas}).
52
+ def initialize(app, document, default_scroll: nil)
53
+ @app = app
54
+ @document = document
55
+ @default_scroll = default_scroll
56
+ end
57
+
58
+ # @return [void]
59
+ def realize
60
+ create(@document.root, '.')
61
+ link(@document.root)
62
+ end
63
+
64
+ # Realize a single already-built (but not-yet-realized) node - and its
65
+ # descendants - into an already-running app, scoped under a parent
66
+ # that's realized already. Reuses the exact same create/link machinery
67
+ # {#realize} uses for the initial tree, just entered at an arbitrary
68
+ # node instead of the document root - see {Session#add}.
69
+ # @param node [Node] freshly built, not yet realized
70
+ # @param parent_node [Node] already realized - node becomes its child
71
+ # @return [void]
72
+ def realize_subtree(node, parent_node)
73
+ create(node, parent_node.realized.path)
74
+ # re-arrange ALL of parent_node's children (old + new), not just the
75
+ # new one in isolation - gap:/align: positioning depends on a
76
+ # child's index relative to every sibling, not just itself.
77
+ arrange_children(parent_node)
78
+ link(node)
79
+ end
80
+
81
+ private
82
+
83
+ # Node types with no Tk representation of their own - skipped by
84
+ # create's widget-creation step, and (for :raw_op) by every
85
+ # container's arrangement step too, since they have no realized path.
86
+ NON_WIDGET_TYPES = %i[root raw_op].freeze
87
+
88
+ # {WidgetTypes} only - every node type reaching here is registered
89
+ # (either a real widget type, or handled earlier via
90
+ # {WidgetType#custom_create?} - see #create). Raises for anything
91
+ # else, e.g. a hand-built {Document} carrying a bogus type the DSL
92
+ # itself could never produce.
93
+ def tk_command_for(type)
94
+ registered = WidgetTypes.for_type(type)
95
+ return registered.tk_command if registered
96
+
97
+ raise ArgumentError, "no Tk command mapped for node type :#{type}"
98
+ end
99
+
100
+ def create(node, parent_path)
101
+ registered = WidgetTypes.for_type(node.type)
102
+
103
+ # A type whose entire realize model doesn't fit the generic
104
+ # sequence below at all (e.g. :menu_bar/:context_menu, realized
105
+ # via #create_menu_tree's own bespoke traversal) owns everything
106
+ # from here - no generic widget-creation call, no #post_create, no
107
+ # child-creation/#arrange, no normal #link processing either (see
108
+ # #link, which checks the same flag).
109
+ if registered&.custom_create?
110
+ registered.custom_create(self, node, parent_path)
111
+ return
112
+ end
113
+
114
+ if auto_scrollable?(node)
115
+ create_native_scrollable(node, allocate_path(node, parent_path))
116
+ return
117
+ end
118
+
119
+ path =
120
+ if NON_WIDGET_TYPES.include?(node.type)
121
+ parent_path
122
+ else
123
+ allocate_path(node, parent_path)
124
+ end
125
+
126
+ unless NON_WIDGET_TYPES.include?(node.type)
127
+ @app.command(tk_command_for(node.type), path, **node.opts.except(*RESERVED_OPTIONS))
128
+ node.realized = RealizedNode.new(app: @app, path: path)
129
+ registered&.post_create(@app, node, path, parent_path)
130
+ end
131
+
132
+ if registered&.custom_children?
133
+ registered.custom_children(self, node, path)
134
+ else
135
+ node.children.each { |child| create(child, path) unless child.lazy? }
136
+ end
137
+ end
138
+
139
+ # Whether +node+ should auto-attach a scrollbar with no explicit
140
+ # +ui.scrollable+ wrapper - only ever true for a
141
+ # {WidgetType#natively_scrollable?} type, and then only once
142
+ # #resolve_scroll settles the 3-level override (widget's own
143
+ # +scroll:+, then the session's app-wide default, then the global one).
144
+ def auto_scrollable?(node)
145
+ natively_scrollable?(node.type) && resolve_scroll(node)
146
+ end
147
+
148
+ # A registered type's own +natively_scrollable?+ - +false+ for
149
+ # anything unregistered.
150
+ def natively_scrollable?(type)
151
+ WidgetTypes.for_type(type)&.natively_scrollable? || false
152
+ end
153
+
154
+ # A registered type's own {WidgetType#global_scroll_default} (canvas
155
+ # points at +Teek::UI.auto_scroll_canvas+; everything else at the
156
+ # shared +Teek::UI.auto_scroll+) - +auto_scroll+ itself for anything
157
+ # unregistered, since #auto_scrollable? only ever calls this for an
158
+ # already natively-scrollable (and therefore registered) type.
159
+ def resolve_scroll(node)
160
+ opt = node.opts[:scroll]
161
+ return opt unless opt.nil?
162
+ return @default_scroll unless @default_scroll.nil?
163
+
164
+ registered = WidgetTypes.for_type(node.type)
165
+ registered ? registered.global_scroll_default : Teek::UI.auto_scroll
166
+ end
167
+
168
+ # Wraps a bare native-scrollable widget (list/text_area/table/tree/
169
+ # canvas) in the same wrapper-frame-plus-scrollbar structure
170
+ # +ui.scrollable+'s native case used to build explicitly - except
171
+ # nothing in the DSL asked for it, so it has to happen without
172
+ # disturbing what +node+'s own path means to the rest of the app.
173
+ #
174
+ # +path+ (the node's own allocated path, e.g. `.panel.log`) becomes
175
+ # an invisible wrapper frame instead of the widget itself; the real
176
+ # widget lives one level deeper, at +path+.widget. +node.realized+
177
+ # points at the real widget (so a {Handle}'s +#configure+/events/
178
+ # +#path+ keep acting on it directly, unchanged from the
179
+ # non-scrolling case) - only its +arrange_path+ is the wrapper,
180
+ # since that's the widget's actual Tk parent now, and what the
181
+ # surrounding layout needs to pack/grid in the widget's place. See
182
+ # {RealizedNode}.
183
+ def create_native_scrollable(node, path)
184
+ widget_path = "#{path}.widget"
185
+ tk_command = tk_command_for(node.type)
186
+
187
+ @app.command('ttk::frame', path)
188
+ @app.command(tk_command, widget_path, **node.opts.except(*RESERVED_OPTIONS))
189
+ node.realized = RealizedNode.new(app: @app, path: widget_path, arrange_path: path)
190
+
191
+ wire_scrollbars(path, widget_path, x: node.opts.fetch(:x, false), y: node.opts.fetch(:y, true))
192
+ node.children.each { |child| create(child, widget_path) }
193
+ end
194
+
195
+ # A :scrollable's own widget (created just before this runs) is a
196
+ # plain ttk::frame at +path+ - this fills it in, taking over child
197
+ # creation instead of the generic node.children.each loop #create
198
+ # otherwise uses. There's no Tk protocol to hook a scrollbar into
199
+ # arbitrary widgets (unlike the native-scrollable case above, which
200
+ # is why this is the ONLY thing left for +ui.scrollable+ to do -
201
+ # see #auto_scrollable?), so children are created inside an embedded
202
+ # frame instead - +path+.canvas.viewport - packed inside a canvas
203
+ # that the scrollbar drives. The viewport's own size changes (as its
204
+ # content changes) keep the canvas's -scrollregion in sync; unless
205
+ # horizontal scrolling is on, the canvas's own size changes keep the
206
+ # viewport's width matched to it too, so content isn't left
207
+ # narrower than the visible area.
208
+ def create_scrollable(node, path)
209
+ y = node.opts.fetch(:y, true)
210
+ x = node.opts.fetch(:x, false)
211
+
212
+ canvas_path = "#{path}.canvas"
213
+ viewport_path = "#{canvas_path}.viewport"
214
+ @app.command('canvas', canvas_path, highlightthickness: 0)
215
+ @app.command('ttk::frame', viewport_path)
216
+ window_id = @app.command(canvas_path, :create, :window, 0, 0, window: viewport_path, anchor: 'nw')
217
+
218
+ node.children.each { |grandchild| create(grandchild, viewport_path) }
219
+
220
+ @app.bind(viewport_path, '<Configure>') {
221
+ @app.command(canvas_path, :configure, scrollregion: @app.command(canvas_path, :bbox, :all))
222
+ }
223
+ unless x
224
+ @app.bind(canvas_path, '<Configure>', :width) { |width|
225
+ @app.command(canvas_path, :itemconfigure, window_id, width: width)
226
+ }
227
+ end
228
+
229
+ wire_scrollbars(path, canvas_path, x: x, y: y)
230
+ wire_wheel_scroll(canvas_path, viewport_path, node, x: x, y: y)
231
+ end
232
+
233
+ # The frame case's own arrangement (see :scrollable's own +arrange:+) -
234
+ # children live in the embedded viewport frame (see #create_scrollable)
235
+ # - fill/expand by default so content stretches to the visible width
236
+ # instead of hugging its own natural size.
237
+ def arrange_scrollable_frame(node, children)
238
+ children.each { |child| @app.command(:pack, child.realized.arrange_path, fill: 'both', expand: true) }
239
+ end
240
+
241
+ # Grids +target_path+ (the scrollable widget/canvas) against a
242
+ # scrollbar per requested axis, inside +path+ (their shared parent).
243
+ # The scrollbar auto-hides once its own -yscrollcommand/
244
+ # -xscrollcommand tells it the content fully fits (Tk hands that
245
+ # callback the visible fraction as +first+/+last+ - 0.0/1.0 means
246
+ # "all of it" - see #auto_hide_scrollbar) - real "overflow: auto",
247
+ # not a bar that's always there whether it's needed or not.
248
+ def wire_scrollbars(path, target_path, x:, y:)
249
+ vsb_path = "#{path}.vsb"
250
+ hsb_path = "#{path}.hsb"
251
+
252
+ if y
253
+ @app.command('ttk::scrollbar', vsb_path, orient: 'vertical', command: "#{target_path} yview")
254
+ @app.command(:grid, vsb_path, row: 0, column: 1, sticky: 'ns')
255
+ auto_hide_scrollbar(target_path, vsb_path, option: :yscrollcommand)
256
+ end
257
+ if x
258
+ @app.command('ttk::scrollbar', hsb_path, orient: 'horizontal', command: "#{target_path} xview")
259
+ @app.command(:grid, hsb_path, row: 1, column: 0, sticky: 'ew')
260
+ auto_hide_scrollbar(target_path, hsb_path, option: :xscrollcommand)
261
+ end
262
+
263
+ @app.command(:grid, target_path, row: 0, column: 0, sticky: 'nsew')
264
+ @app.command(:grid, :columnconfigure, path, 0, weight: 1)
265
+ @app.command(:grid, :rowconfigure, path, 0, weight: 1)
266
+ end
267
+
268
+ # Tk's own `grid remove` un-maps a widget but (unlike `grid forget`)
269
+ # remembers its grid options, so showing it again later is just a
270
+ # bare `grid <path>` - no need to re-derive/re-pass row:/column:/
271
+ # sticky: ourselves.
272
+ #
273
+ # Tk only re-invokes -yscrollcommand/-xscrollcommand when the
274
+ # reported fraction actually *changes* - an empty widget that gains
275
+ # a few rows can go straight from "0.0 1.0" (nothing to scroll) to
276
+ # "0.0 1.0" again (still nothing to scroll), with the callback never
277
+ # firing in between. That would leave the eagerly-gridded scrollbar
278
+ # stuck shown forever, so #after_idle also checks the real, current
279
+ # fraction directly (via a plain +yview+ query) once - after every
280
+ # widget this build creates has gone through its first geometry
281
+ # pass, same as the callback itself would eventually see.
282
+ def auto_hide_scrollbar(target_path, scrollbar_path, option:)
283
+ shown = true
284
+ apply = lambda do |first, last|
285
+ fits = first.to_f <= 0.0 && last.to_f >= 1.0
286
+ if fits && shown
287
+ @app.command(:grid, :remove, scrollbar_path)
288
+ shown = false
289
+ elsif !fits && !shown
290
+ @app.command(:grid, scrollbar_path)
291
+ shown = true
292
+ end
293
+ end
294
+
295
+ @app.command(target_path, :configure, option => proc { |first, last|
296
+ @app.command(scrollbar_path, :set, first, last)
297
+ apply.call(first, last)
298
+ })
299
+ @app.after_idle {
300
+ first, last = @app.command(target_path, :yview).split
301
+ apply.call(first, last)
302
+ }
303
+ end
304
+
305
+ # Tk's own Scrollbar class binds <MouseWheel> at this same ratio (see
306
+ # scrlbar.tcl) - matched here so wheeling directly over the frame
307
+ # case's content feels identical to wheeling over its scrollbar.
308
+ WHEEL_UNITS_PER_NOTCH = 40.0
309
+
310
+ # The frame case's scrollbar and -yscrollcommand/-xscrollcommand
311
+ # wiring (see #wire_scrollbars) only covers dragging the scrollbar
312
+ # itself - the canvas has no default wheel handling of its own (a
313
+ # bare canvas isn't a Scrollbar), and neither do any of the
314
+ # arbitrary widgets embedded in its viewport. A <MouseWheel>/
315
+ # <Button-4>/<Button-5> binding placed directly on the canvas alone
316
+ # wouldn't fire for those descendants either: Tk delivers pointer
317
+ # events to whichever widget is actually under the cursor, and a
318
+ # child widget layered inside the viewport intercepts them before
319
+ # the canvas ever sees them.
320
+ #
321
+ # The fix is the classic one - give the canvas, the viewport, and
322
+ # every widget already inside it (walked recursively, since new
323
+ # widgets can nest arbitrarily deep) a shared custom bindtag, and
324
+ # bind the wheel handler once on that tag instead of on any single
325
+ # widget. Every widget carrying the tag then responds identically,
326
+ # regardless of which one the pointer happens to be over - the same
327
+ # mechanism Tk's own class bindings (Button, Entry, ...) use, just
328
+ # scoped to this one scrollable region instead of a widget class.
329
+ #
330
+ # @note widgets added later via +Session#add+ don't pick up the tag
331
+ # automatically - wheel-scrolling a scrollable frame's dynamically
332
+ # added content isn't covered yet.
333
+ def wire_wheel_scroll(canvas_path, viewport_path, node, x:, y:)
334
+ return unless x || y
335
+
336
+ tag = "TeekScrollRegion#{canvas_path.tr('.', '_')}"
337
+ add_bindtag(canvas_path, tag)
338
+ add_bindtag(viewport_path, tag)
339
+ node.children.each { |child| child.each { |descendant| add_bindtag(descendant.realized.path, tag) if descendant.realized } }
340
+
341
+ if y
342
+ @app.bind(tag, '<MouseWheel>', :mouse_wheel) { |delta|
343
+ @app.command(canvas_path, :yview, :scroll, wheel_units(delta), :units)
344
+ }
345
+ @app.bind(tag, '<Button-4>') { @app.command(canvas_path, :yview, :scroll, -1, :units) }
346
+ @app.bind(tag, '<Button-5>') { @app.command(canvas_path, :yview, :scroll, 1, :units) }
347
+ end
348
+ if x
349
+ @app.bind(tag, '<Shift-MouseWheel>', :mouse_wheel) { |delta|
350
+ @app.command(canvas_path, :xview, :scroll, wheel_units(delta), :units)
351
+ }
352
+ @app.bind(tag, '<Shift-Button-4>') { @app.command(canvas_path, :xview, :scroll, -1, :units) }
353
+ @app.bind(tag, '<Shift-Button-5>') { @app.command(canvas_path, :xview, :scroll, 1, :units) }
354
+ end
355
+ end
356
+
357
+ # Tk 9's own `scroll number units` accepts (and documents rounding
358
+ # for) a fractional number - Tcl 8.6's stricter integer parsing
359
+ # rejects a value like +"3.0"+ outright ("expected integer but got
360
+ # ..."), raised deep inside the widget's own command implementation
361
+ # from within this very callback. Rounding to a real Ruby Integer
362
+ # here (away from zero, matching Tk 9's own documented rounding)
363
+ # keeps the string Tcl sees free of a decimal point on every
364
+ # version, rather than relying on 9.x's more lenient parsing.
365
+ def wheel_units(delta)
366
+ (delta.to_f / -WHEEL_UNITS_PER_NOTCH).round
367
+ end
368
+
369
+ def add_bindtag(path, tag)
370
+ current = @app.split_list(@app.command(:bindtags, path))
371
+ @app.command(:bindtags, path, current + [tag])
372
+ end
373
+
374
+ # Builds one menu widget (a menu_bar, a nested cascade, or a
375
+ # standalone context_menu) plus every entry it holds, recursing into
376
+ # nested cascades depth-first so a cascade's own menu exists before
377
+ # the `add cascade` entry that references it is added to its parent.
378
+ # A menu_bar additionally attaches itself to parent_path's own -menu
379
+ # option once its whole subtree is built. Registered as :menu_bar/
380
+ # :context_menu's own `custom_create:` (see #create) - takes
381
+ # +parent_path+, not an already-allocated +path+, since it replaces
382
+ # #create's entire per-node handling for this node, allocation included.
383
+ def create_menu_tree(node, parent_path)
384
+ path = allocate_path(node, parent_path)
385
+ @app.menu(path)
386
+ node.realized = RealizedNode.new(app: @app, path: path)
387
+
388
+ node.children.each do |child|
389
+ case child.type
390
+ when :menu
391
+ create_menu_tree(child, path)
392
+ @app.command(path, :add, :cascade, **child.opts, menu: child.realized.path)
393
+ when :menu_item
394
+ @app.command(path, :add, :command, **menu_entry_opts(child))
395
+ when :menu_separator
396
+ @app.command(path, :add, :separator)
397
+ when :menu_checkbox
398
+ @app.command(path, :add, :checkbutton, **menu_entry_opts(child))
399
+ when :menu_radio
400
+ @app.command(path, :add, :radiobutton, **menu_entry_opts(child))
401
+ else
402
+ raise ArgumentError, "#{describe(child)} isn't valid inside a menu"
403
+ end
404
+ end
405
+
406
+ @app.command(parent_path, :configure, menu: path) if node.type == :menu_bar
407
+ end
408
+
409
+ def menu_entry_opts(node)
410
+ bind = node.opts[:bind]
411
+ bind ? node.opts.except(:bind).merge(variable: bind.name) : node.opts
412
+ end
413
+
414
+ def link(node)
415
+ # A #create custom_create? type owns its whole subtree (see
416
+ # #create) - no arrangement, events, close-handler, or child
417
+ # recursion to do here either.
418
+ return if WidgetTypes.for_type(node.type)&.custom_create?
419
+
420
+ arrange_children(node)
421
+ node.events.each { |binding| wire_event(node, binding) }
422
+ run_raw_op(node) if node.type == :raw_op
423
+ wire_close_handler(node) if node.opts[:on_close]
424
+ node.children.each { |child| link(child) unless child.lazy? }
425
+ end
426
+
427
+ def run_raw_op(node)
428
+ node.opts[:block].call(@app)
429
+ end
430
+
431
+ def wire_close_handler(node)
432
+ @app.on_close(window: node.realized.path, &node.opts[:on_close])
433
+ end
434
+
435
+ # Whether a geometry manager should skip this child entirely: :root/
436
+ # :raw_op have no realized path at all (and aren't {WidgetType}s, so
437
+ # there's nothing to register this against); everything else reports
438
+ # it via its own {WidgetType#arranged?} - false for a type placed
439
+ # some other way entirely (a toplevel window, a tab/pane placed by
440
+ # its own container's `add`, a menu_bar/context_menu attached via
441
+ # -menu config).
442
+ def unarranged?(type)
443
+ return true if NON_WIDGET_TYPES.include?(type)
444
+
445
+ registered = WidgetTypes.for_type(type)
446
+ registered ? !registered.arranged? : false
447
+ end
448
+
449
+ def arrange_children(node)
450
+ # Any not-yet-realized child has no +.realized+ to pack/place -
451
+ # excluded here regardless of type or WHY it isn't realized yet
452
+ # (a lazy: true node not explicitly realized, see {Node#lazy?};
453
+ # or a batch-mate #realize_subtree hasn't reached yet, when
454
+ # #add's own block declared more than one new sibling at once -
455
+ # every new child gets #create'd, and this same
456
+ # #arrange_children called again for the parent, in its own
457
+ # separate iteration of that same loop). Once a child IS
458
+ # realized, a later #arrange_children call on this same node
459
+ # (e.g. from a LATER iteration of that same loop, or a further
460
+ # sibling added afterward) picks it up normally - this only ever
461
+ # excludes a STILL-unrealized one.
462
+ candidates = node.children.reject { |child| !child.realized }
463
+ overlaid, rest = candidates.partition { |child| child.layout && child.layout[:overlay] }
464
+ arrangeable = rest.reject { |child| unarranged?(child.type) }
465
+ registered = WidgetTypes.for_type(node.type)
466
+
467
+ if registered&.arrange?
468
+ registered.arrange(self, node, arrangeable)
469
+ else
470
+ arrangeable.each { |child| @app.command(:pack, child.realized.arrange_path) }
471
+ end
472
+
473
+ overlaid.each { |child| place_overlay(node, child) }
474
+ end
475
+
476
+ # `place`s an overlay-tagged child (see {WidgetDSL#overlay}) at its
477
+ # anchor's -relx/-rely/-anchor, `-in` its parent - the canvas
478
+ # #arrange_children is currently walking, whatever container that
479
+ # turns out to be validated to (see {OverlayValidator}). Runs
480
+ # independently of whatever geometry manager arranges the rest of
481
+ # that parent's children, since `place` coexists with `pack`/`grid`
482
+ # on the same master as long as it targets a different slave.
483
+ def place_overlay(parent, child)
484
+ at = child.layout[:overlay][:at]
485
+ position = OverlayAnchors::POSITIONS.fetch(at)
486
+ @app.command(:place, child.realized.arrange_path, in: parent.realized.path, **position)
487
+ end
488
+
489
+ def arrange_flow(node, children, flow)
490
+ gap = node.opts.fetch(:gap, 0)
491
+ align = node.opts.fetch(:align, :start)
492
+ pad = node.opts.fetch(:pad, 0)
493
+ last_index = children.length - 1
494
+
495
+ children.each_with_index do |child, index|
496
+ opts = flow_pack_opts(
497
+ flow: flow, child: child, index: index, last_index: last_index,
498
+ gap: gap, align: align, pad: pad
499
+ )
500
+ @app.command(:pack, child.realized.arrange_path, **opts)
501
+ end
502
+ end
503
+
504
+ def arrange_grid(node, children)
505
+ gap = node.opts.fetch(:gap, 0)
506
+
507
+ children.each do |child|
508
+ cell = child.layout && child.layout[:cell]
509
+ unless cell
510
+ # {GridValidator.check_missing_cell} is the primary,
511
+ # pre-realize detection for this - this stays as a
512
+ # belt-and-suspenders backstop for the one path that skips
513
+ # validation entirely, {Session#add}'s incremental realize.
514
+ raise ArgumentError, "#{describe(child)} is a direct child of a grid but was never placed with " \
515
+ "g.cell(row:, col:) { ... }"
516
+ end
517
+
518
+ opts = { row: cell[:row], column: cell[:col], sticky: 'ew', padx: gap, pady: gap }
519
+ opts[:columnspan] = cell[:span] if cell[:span].to_i > 1
520
+ @app.command(:grid, child.realized.arrange_path, **opts)
521
+ end
522
+
523
+ Array(node.opts[:stretch_columns]).each { |col| @app.command(:grid, :columnconfigure, node.realized.path, col, weight: 1) }
524
+ Array(node.opts[:stretch_rows]).each { |row| @app.command(:grid, :rowconfigure, node.realized.path, row, weight: 1) }
525
+ end
526
+
527
+ def describe(node)
528
+ node.name ? "##{node.type}(:#{node.name})" : "an unnamed ##{node.type}"
529
+ end
530
+
531
+ def flow_pack_opts(flow:, child:, index:, last_index:, gap:, align:, pad:)
532
+ opts = { side: flow[:side] }
533
+ opts[flow[:main_pad]] = [index.zero? ? pad : gap, index == last_index ? pad : 0]
534
+ opts[flow[:cross_pad]] = pad
535
+
536
+ grow = child.layout && child.layout[:grow]
537
+ stretch = align == :stretch
538
+ fills = [(flow[:main_fill] if grow), (flow[:cross_fill] if stretch)].compact
539
+ opts[:fill] = fills.length == 2 ? 'both' : fills.first unless fills.empty?
540
+ opts[:expand] = true if grow
541
+ unless stretch
542
+ opts[:anchor] = flow[:anchor].fetch(align) {
543
+ raise ArgumentError, "invalid align: #{align.inspect} (expected :start, :center, :end, or :stretch)"
544
+ }
545
+ end
546
+
547
+ opts
548
+ end
549
+
550
+ def wire_event(node, binding)
551
+ target_node =
552
+ if binding.target
553
+ @document.find(binding.target, scope: node.scope) or
554
+ raise ArgumentError, "event target :#{binding.target} not found in the document"
555
+ else
556
+ node
557
+ end
558
+
559
+ @app.bind(target_node.realized.path, binding.event, *binding.subs) { |*args| binding.handler.call(*args) }
560
+ end
561
+
562
+ def allocate_path(node, parent_path)
563
+ # node.key is unique for the whole Document, persisted at node
564
+ # creation (Document#create). Disambiguation against a same-
565
+ # parent repeat (a reusable component mounted more than once,
566
+ # including a lazily-{Handle#realize!}d one realized well after
567
+ # the initial pass) lives on {Document#claim_path_segment}, not
568
+ # here - a per-Realizer-instance counter would lose memory of
569
+ # earlier claims across separate realize_subtree calls (each
570
+ # gets its own Realizer instance, e.g. one per Session#add call
571
+ # or lazy screen realize).
572
+ segment = @document.claim_path_segment(parent_path, node.name ? node.name.to_s : node.key)
573
+ parent_path == '.' ? ".#{segment}" : "#{parent_path}.#{segment}"
574
+ end
575
+ end
576
+ end
577
+ end