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,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require_relative '../../test/tk_test_helper'
5
+
6
+ class TestMenuRealize < Minitest::Test
7
+ include TeekTestHelper
8
+
9
+ def test_menu_bar_attaches_to_the_root_window
10
+ assert_tk_app("a top-level menu_bar should attach via -menu to the root window") do
11
+ require 'teek/ui'
12
+
13
+ session = Teek::UI.app(title: 'Menu Realize Test') do |ui|
14
+ ui.menu_bar(:mb) { |mb| mb.menu(label: 'File') }
15
+ end
16
+ session.run_async
17
+ session.app.update
18
+
19
+ assert_equal session[:mb].path, session.app.tcl_eval('. cget -menu')
20
+
21
+ session.app.destroy
22
+ end
23
+ end
24
+
25
+ def test_menu_bar_attaches_to_a_ui_window_not_just_the_root
26
+ assert_tk_app("a menu_bar declared inside ui.window should attach to that window, not the root") do
27
+ require 'teek/ui'
28
+
29
+ session = Teek::UI.app(title: 'Menu Realize Test') do |ui|
30
+ ui.window(:settings) { |w| w.menu_bar(:mb) { |mb| mb.menu(label: 'File') } }
31
+ end
32
+ session.run_async
33
+ session.app.update
34
+
35
+ settings_path = session[:settings].path
36
+ assert_equal session[:mb].path, session.app.tcl_eval("#{settings_path} cget -menu")
37
+ refute_equal session[:mb].path, session.app.tcl_eval('. cget -menu'),
38
+ "the root window should not also pick up the settings window's menu bar"
39
+
40
+ session.app.destroy
41
+ end
42
+ end
43
+
44
+ def test_nested_menu_realizes_as_a_cascade_under_its_parents_path
45
+ assert_tk_app("a nested .menu should realize as a real menu widget, added as a cascade entry") do
46
+ require 'teek/ui'
47
+
48
+ session = Teek::UI.app(title: 'Menu Realize Test') do |ui|
49
+ ui.menu_bar(:mb) { |mb| mb.menu(:file, label: 'File') }
50
+ end
51
+ session.run_async
52
+ session.app.update
53
+
54
+ mb_path = session[:mb].path
55
+ file_path = session[:file].path
56
+ assert session.app.winfo.exists?(file_path)
57
+ assert file_path.start_with?("#{mb_path}."),
58
+ "a submenu's path (#{file_path}) should be nested under its parent menu's path (#{mb_path}) - Tk requires it"
59
+
60
+ assert_equal 'cascade', session.app.tcl_eval("#{mb_path} type 0")
61
+ assert_equal 'File', session.app.command(mb_path, :entrycget, 0, '-label')
62
+ assert_equal file_path, session.app.command(mb_path, :entrycget, 0, '-menu')
63
+
64
+ session.app.destroy
65
+ end
66
+ end
67
+
68
+ def test_item_fires_its_block_on_invoke
69
+ assert_tk_app("a menu item's block should fire like any other command entry") do
70
+ require 'teek/ui'
71
+
72
+ fired = false
73
+ session = Teek::UI.app(title: 'Menu Realize Test') do |ui|
74
+ ui.menu_bar { |mb| mb.menu(:file, label: 'File') { |f| f.item(label: 'Open') { fired = true } } }
75
+ end
76
+ session.run_async
77
+ session.app.update
78
+
79
+ session.app.tcl_eval("#{session[:file].path} invoke 0")
80
+
81
+ assert fired, "the item's block did not fire"
82
+
83
+ session.app.destroy
84
+ end
85
+ end
86
+
87
+ def test_named_item_is_addressable_via_ui_name_and_supports_enable_disable_configure
88
+ assert_tk_app("a named item should be addressable via ui[:name] and support .enable/.disable/.configure") do
89
+ require 'teek/ui'
90
+
91
+ session = Teek::UI.app(title: 'Menu Realize Test') do |ui|
92
+ ui.menu_bar do |mb|
93
+ mb.menu(:file, label: 'File') do |f|
94
+ f.item(label: 'New') { }
95
+ f.item(:quick_load, label: 'Quick Load') { }
96
+ end
97
+ end
98
+ end
99
+ session.run_async
100
+ session.app.update
101
+
102
+ file_path = session[:file].path
103
+ item = session[:quick_load]
104
+
105
+ assert_equal 'normal', session.app.command(file_path, :entrycget, 1, '-state')
106
+
107
+ item.disable
108
+ assert_equal 'disabled', session.app.command(file_path, :entrycget, 1, '-state')
109
+
110
+ item.enable
111
+ assert_equal 'normal', session.app.command(file_path, :entrycget, 1, '-state')
112
+
113
+ item.configure(label: 'Load Recent Save')
114
+ assert_equal 'Load Recent Save', session.app.command(file_path, :entrycget, 1, '-label')
115
+
116
+ session.app.destroy
117
+ end
118
+ end
119
+
120
+ def test_named_item_stays_correctly_addressed_after_an_earlier_sibling_is_removed
121
+ assert_tk_app("addressing a named item should stay correct even after an earlier sibling entry is removed") do
122
+ require 'teek/ui'
123
+
124
+ session = Teek::UI.app(title: 'Menu Realize Test') do |ui|
125
+ ui.menu_bar do |mb|
126
+ mb.menu(:file, label: 'File') do |f|
127
+ f.item(label: 'New') { }
128
+ f.item(:quick_load, label: 'Quick Load') { }
129
+ end
130
+ end
131
+ end
132
+ session.run_async
133
+ session.app.update
134
+
135
+ file_path = session[:file].path
136
+ # Removing "New" shifts Quick Load's live index from 1 down to 0 -
137
+ # Tk itself renumbers around the delete; addressing by name should
138
+ # still land on the right entry with no stale cached index.
139
+ session.app.command(file_path, :delete, 0)
140
+
141
+ session[:quick_load].disable
142
+
143
+ assert_equal 'disabled', session.app.command(file_path, :entrycget, 0, '-state')
144
+
145
+ session.app.destroy
146
+ end
147
+ end
148
+
149
+ def test_named_item_virtual_path_is_marked_and_rejected_by_a_raw_tk_call
150
+ assert_tk_app("a named item's .path should be marked past the real Tk boundary, and rejected if used raw") do
151
+ require 'teek/ui'
152
+
153
+ session = Teek::UI.app(title: 'Menu Realize Test') do |ui|
154
+ ui.menu_bar { |mb| mb.menu(:file, label: 'File') { |f| f.item(:quick_load, label: 'Quick Load') { } } }
155
+ end
156
+ session.run_async
157
+ session.app.update
158
+
159
+ file_path = session[:file].path
160
+ virtual_path = session[:quick_load].path
161
+ assert_equal "#{file_path}!quick_load", virtual_path
162
+
163
+ error = assert_raises(Teek::TclError) { session.app.tcl_eval("#{virtual_path} entrycget 0 -label") }
164
+ assert_match(/invalid command name/i, error.message)
165
+
166
+ session.app.destroy
167
+ end
168
+ end
169
+
170
+ def test_separator_realizes_as_a_real_separator_entry
171
+ assert_tk_app("ui.separator should realize as a real Tk separator entry") do
172
+ require 'teek/ui'
173
+
174
+ session = Teek::UI.app(title: 'Menu Realize Test') do |ui|
175
+ ui.menu_bar { |mb| mb.menu(:file, label: 'File') { |f| f.item(label: 'Open') { }; f.separator } }
176
+ end
177
+ session.run_async
178
+ session.app.update
179
+
180
+ assert_equal 'separator', session.app.tcl_eval("#{session[:file].path} type 1")
181
+
182
+ session.app.destroy
183
+ end
184
+ end
185
+
186
+ def test_checkbox_entry_stays_in_sync_with_its_bound_var
187
+ assert_tk_app("a menu checkbox entry bound to a var should toggle that var on invoke") do
188
+ require 'teek/ui'
189
+
190
+ wrap = nil
191
+ session = Teek::UI.app(title: 'Menu Realize Test') do |ui|
192
+ wrap = ui.var(false)
193
+ ui.menu_bar { |mb| mb.menu(:edit, label: 'Edit') { |e| e.checkbox(label: 'Word Wrap', bind: wrap) } }
194
+ end
195
+ session.run_async
196
+ session.app.update
197
+
198
+ assert_equal false, wrap.value
199
+
200
+ session.app.tcl_eval("#{session[:edit].path} invoke 0")
201
+
202
+ assert_equal true, wrap.value, "invoking the checkbutton entry should flip the bound var"
203
+
204
+ session.app.destroy
205
+ end
206
+ end
207
+
208
+ def test_radio_entries_share_one_var_and_set_its_own_value_on_invoke
209
+ assert_tk_app("menu radio entries bound to the same var should set it to their own value on invoke") do
210
+ require 'teek/ui'
211
+
212
+ size = nil
213
+ session = Teek::UI.app(title: 'Menu Realize Test') do |ui|
214
+ size = ui.var('small')
215
+ ui.menu_bar do |mb|
216
+ mb.menu(:edit, label: 'Edit') do |e|
217
+ e.radio(label: 'Small', bind: size, value: 'small')
218
+ e.radio(label: 'Large', bind: size, value: 'large')
219
+ end
220
+ end
221
+ end
222
+ session.run_async
223
+ session.app.update
224
+
225
+ session.app.tcl_eval("#{session[:edit].path} invoke 1")
226
+
227
+ assert_equal 'large', size.value
228
+
229
+ session.app.destroy
230
+ end
231
+ end
232
+
233
+ def test_rebuilding_a_menu_does_not_leak_entry_callbacks
234
+ assert_tk_app("rebuilding a context menu's contents on every right-click should not accumulate entry callbacks") do
235
+ require 'teek/ui'
236
+
237
+ session = Teek::UI.app(title: 'Menu Realize Test') { |ui| ui.panel(:host) }
238
+ session.run_async
239
+ session.app.update
240
+ baseline = session.app.interp.callback_ids.length
241
+
242
+ ctx = nil
243
+ 5.times do
244
+ session.app.destroy(ctx.path) if ctx
245
+ session.add(:host) { |a| ctx = a.context_menu { |m| m.item(label: 'Delete') { }; m.item(label: 'Rename') { } } }
246
+ end
247
+ session.app.destroy(ctx.path)
248
+
249
+ assert_equal baseline, session.app.interp.callback_ids.length,
250
+ "rebuilding the context menu repeatedly should not accumulate callbacks"
251
+
252
+ session.app.destroy
253
+ end
254
+ end
255
+
256
+ def test_context_menu_pops_up_at_the_right_clicked_widgets_root_coordinates
257
+ assert_tk_app("a widget wired via on_right_click(context_menu) should tk_popup the right menu at the click's screen coordinates") do
258
+ require 'teek/ui'
259
+
260
+ session = Teek::UI.app(title: 'Menu Realize Test') do |ui|
261
+ ctx = ui.context_menu(:ctx) { |m| m.item(label: 'Delete') { } }
262
+ ui.canvas(:board).on_right_click(ctx)
263
+ end
264
+ session.run_async
265
+ session.app.update
266
+
267
+ session.app.tcl_eval(<<~TCL)
268
+ proc tk_popup {args} {
269
+ set ::last_popup_call $args
270
+ }
271
+ TCL
272
+
273
+ session.app.tcl_eval("event generate #{session[:board].path} <Button-3> -rootx 123 -rooty 456")
274
+
275
+ captured = session.app.split_list(session.app.tcl_eval('set ::last_popup_call'))
276
+ assert_equal [session[:ctx].path, '123', '456'], captured
277
+
278
+ session.app.destroy
279
+ end
280
+ end
281
+
282
+ def test_context_menu_declared_standalone_does_not_auto_attach_anywhere
283
+ assert_tk_app("a context_menu should exist as a real widget but never auto-attach via -menu") do
284
+ require 'teek/ui'
285
+
286
+ session = Teek::UI.app(title: 'Menu Realize Test') { |ui| ui.context_menu(:ctx) { |m| m.item(label: 'Delete') { } } }
287
+ session.run_async
288
+ session.app.update
289
+
290
+ assert session.app.winfo.exists?(session[:ctx].path)
291
+ assert_equal '', session.app.tcl_eval('. cget -menu')
292
+
293
+ session.app.destroy
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require_relative '../../test/tk_test_helper'
5
+
6
+ # Handle#modal/#grab_release are thin delegates to Teek::Window (see base
7
+ # teek's own test_modal.rb) - no grab/focus/destroy-safety-net logic is
8
+ # reimplemented here. These confirm the DSL layer actually wires through
9
+ # to the real thing end to end, using the same grab current/status style
10
+ # base teek's own tests use.
11
+ #
12
+ # A ui.window is withdrawn (not viewable) until shown - Tk's own `grab
13
+ # set` requires a viewable window, so every test below calls .show first,
14
+ # the same way a real caller would before making a window modal.
15
+ class TestModalHandle < Minitest::Test
16
+ include TeekTestHelper
17
+
18
+ def test_modal_grabs_and_focuses_the_window
19
+ assert_tk_app("a window handle's modal should grab input and force focus onto it") do
20
+ require 'teek/ui'
21
+
22
+ session = Teek::UI.app(title: 'Modal Handle Test') { |ui| ui.window(:settings) }
23
+ session.run_async
24
+ session.app.update
25
+
26
+ session[:settings].show
27
+ session[:settings].modal
28
+
29
+ path = session[:settings].path
30
+ assert_equal path, session.app.tcl_eval("grab current #{path}")
31
+ assert_equal path, session.app.tcl_eval('focus')
32
+
33
+ session[:settings].grab_release
34
+ session.app.destroy
35
+ end
36
+ end
37
+
38
+ def test_grab_release_releases_the_grab
39
+ assert_tk_app("grab_release should clear the grab set by modal") do
40
+ require 'teek/ui'
41
+
42
+ session = Teek::UI.app(title: 'Modal Handle Test') { |ui| ui.window(:settings) }
43
+ session.run_async
44
+ session.app.update
45
+
46
+ session[:settings].show
47
+ session[:settings].modal
48
+ session[:settings].grab_release
49
+
50
+ path = session[:settings].path
51
+ assert_equal '', session.app.tcl_eval("grab current #{path}")
52
+
53
+ session.app.destroy
54
+ end
55
+ end
56
+
57
+ def test_modal_releases_the_grab_if_the_setup_block_raises
58
+ assert_tk_app("modal should release the grab immediately if its setup block raises, not leave it stuck") do
59
+ require 'teek/ui'
60
+
61
+ session = Teek::UI.app(title: 'Modal Handle Test') { |ui| ui.window(:settings) }
62
+ session.run_async
63
+ session.app.update
64
+ session[:settings].show
65
+
66
+ path = session[:settings].path
67
+ error = assert_raises(RuntimeError) { session[:settings].modal { raise 'boom' } }
68
+ assert_equal 'boom', error.message
69
+
70
+ assert_equal '', session.app.tcl_eval("grab current #{path}")
71
+
72
+ session.app.destroy
73
+ end
74
+ end
75
+
76
+ def test_modal_grab_persists_after_a_successful_setup_block
77
+ assert_tk_app("modal's grab should still be held after a successful setup block - released explicitly, not automatically") do
78
+ require 'teek/ui'
79
+
80
+ session = Teek::UI.app(title: 'Modal Handle Test') { |ui| ui.window(:settings) }
81
+ session.run_async
82
+ session.app.update
83
+ session[:settings].show
84
+
85
+ ran = false
86
+ session[:settings].modal { ran = true }
87
+
88
+ assert ran, "the setup block should run"
89
+ path = session[:settings].path
90
+ assert_equal path, session.app.tcl_eval("grab current #{path}")
91
+
92
+ session[:settings].grab_release
93
+ session.app.destroy
94
+ end
95
+ end
96
+
97
+ def test_modal_releases_the_grab_if_the_window_is_destroyed_while_grabbed
98
+ assert_tk_app("modal should release the grab if its window is destroyed without an explicit grab_release") do
99
+ require 'teek/ui'
100
+
101
+ session = Teek::UI.app(title: 'Modal Handle Test') { |ui| ui.window(:settings) }
102
+ session.run_async
103
+ session.app.update
104
+ session[:settings].show
105
+
106
+ path = session[:settings].path
107
+ session[:settings].modal
108
+ assert_equal path, session.app.tcl_eval("grab current #{path}")
109
+
110
+ session.app.destroy(path)
111
+
112
+ assert_equal '', session.app.tcl_eval('grab current')
113
+
114
+ session.app.destroy
115
+ end
116
+ end
117
+
118
+ def test_modal_raises_on_a_non_window_handle
119
+ assert_tk_app("modal on a non-window handle should raise a clear error") do
120
+ require 'teek/ui'
121
+
122
+ session = Teek::UI.app(title: 'Modal Handle Test') { |ui| ui.button(:go, text: 'Go') }
123
+ session.run_async
124
+ session.app.update
125
+
126
+ error = assert_raises(ArgumentError) { session[:go].modal }
127
+ assert_match(/window/i, error.message)
128
+
129
+ session.app.destroy
130
+ end
131
+ end
132
+
133
+ def test_grab_release_raises_on_a_non_window_handle
134
+ assert_tk_app("grab_release on a non-window handle should raise a clear error") do
135
+ require 'teek/ui'
136
+
137
+ session = Teek::UI.app(title: 'Modal Handle Test') { |ui| ui.button(:go, text: 'Go') }
138
+ session.run_async
139
+ session.app.update
140
+
141
+ error = assert_raises(ArgumentError) { session[:go].grab_release }
142
+ assert_match(/window/i, error.message)
143
+
144
+ session.app.destroy
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'test_helper'
4
+ require 'teek/ui/modal_stack'
5
+
6
+ # Pure stack-bookkeeping and callback-lifecycle tests, no Tk involved -
7
+ # ModalStack wraps Screens for the actual reveal/conceal, which is already
8
+ # covered (headlessly and against real Tk) by test_screens.rb/
9
+ # test_screens_realtk.rb. These focus on what's actually new here: when
10
+ # on_enter/on_exit/on_focus_change fire.
11
+ class TestModalStack < Minitest::Test
12
+ FakeWindow = Struct.new(:shows, :hides) do
13
+ def initialize(shows = [], hides = [])
14
+ super
15
+ end
16
+
17
+ def type
18
+ :window
19
+ end
20
+
21
+ def show
22
+ shows << true
23
+ end
24
+
25
+ def hide
26
+ hides << true
27
+ end
28
+ end
29
+
30
+ FakeLazyWindow = Struct.new(:shows, :hides, :realized_flag, :realize_calls) do
31
+ def initialize(shows = [], hides = [], realized_flag = false)
32
+ super(shows, hides, realized_flag, [])
33
+ end
34
+
35
+ def type
36
+ :window
37
+ end
38
+
39
+ def show
40
+ shows << true
41
+ end
42
+
43
+ def hide
44
+ hides << true
45
+ end
46
+
47
+ def realized?
48
+ realized_flag
49
+ end
50
+
51
+ def realize!(document)
52
+ realize_calls << document
53
+ self.realized_flag = true
54
+ end
55
+ end
56
+
57
+ def test_starts_empty
58
+ stack = Teek::UI::ModalStack.new(on_enter: ->(_) { }, on_exit: -> { })
59
+
60
+ refute stack.active?
61
+ assert_nil stack.current
62
+ assert_equal 0, stack.size
63
+ end
64
+
65
+ def test_push_on_an_empty_stack_fires_on_enter_and_on_focus_change_and_shows_the_window
66
+ enters = []
67
+ focus_changes = []
68
+ settings = FakeWindow.new
69
+ stack = Teek::UI::ModalStack.new(
70
+ on_enter: ->(name) { enters << name },
71
+ on_exit: -> { },
72
+ on_focus_change: ->(name) { focus_changes << name },
73
+ )
74
+
75
+ stack.push(:settings, settings)
76
+
77
+ assert stack.active?
78
+ assert_equal :settings, stack.current
79
+ assert_equal 1, stack.size
80
+ assert_equal [:settings], enters
81
+ assert_equal [:settings], focus_changes
82
+ assert_equal [true], settings.shows
83
+ end
84
+
85
+ def test_pushing_a_second_modal_does_not_fire_on_enter_again_but_hides_the_first_and_shows_the_second
86
+ enters = []
87
+ focus_changes = []
88
+ settings = FakeWindow.new
89
+ replay = FakeWindow.new
90
+ stack = Teek::UI::ModalStack.new(
91
+ on_enter: ->(name) { enters << name },
92
+ on_exit: -> { },
93
+ on_focus_change: ->(name) { focus_changes << name },
94
+ )
95
+ stack.push(:settings, settings)
96
+
97
+ stack.push(:replay, replay)
98
+
99
+ assert_equal :replay, stack.current
100
+ assert_equal 2, stack.size
101
+ assert_equal [:settings], enters, "on_enter should only fire on the empty -> non-empty transition"
102
+ assert_equal [:settings, :replay], focus_changes
103
+ assert_equal [true], settings.hides, "the previous top should be withdrawn, not dismissed"
104
+ assert_equal [true], replay.shows
105
+ end
106
+
107
+ def test_pop_with_a_modal_remaining_reveals_it_and_fires_on_focus_change_not_on_exit
108
+ exits = 0
109
+ focus_changes = []
110
+ settings = FakeWindow.new
111
+ replay = FakeWindow.new
112
+ stack = Teek::UI::ModalStack.new(
113
+ on_enter: ->(_) { },
114
+ on_exit: -> { exits += 1 },
115
+ on_focus_change: ->(name) { focus_changes << name },
116
+ )
117
+ stack.push(:settings, settings)
118
+ stack.push(:replay, replay)
119
+ focus_changes.clear
120
+
121
+ stack.pop
122
+
123
+ assert_equal :settings, stack.current
124
+ assert_equal 1, stack.size
125
+ assert_equal [true], replay.hides
126
+ assert_equal [true, true], settings.shows, "settings should be re-shown"
127
+ assert_equal [:settings], focus_changes
128
+ assert_equal 0, exits
129
+ end
130
+
131
+ def test_popping_the_last_modal_fires_on_exit_not_on_focus_change
132
+ exits = 0
133
+ focus_changes = []
134
+ settings = FakeWindow.new
135
+ stack = Teek::UI::ModalStack.new(
136
+ on_enter: ->(_) { },
137
+ on_exit: -> { exits += 1 },
138
+ on_focus_change: ->(name) { focus_changes << name },
139
+ )
140
+ stack.push(:settings, settings)
141
+ focus_changes.clear
142
+
143
+ stack.pop
144
+
145
+ refute stack.active?
146
+ assert_nil stack.current
147
+ assert_equal 0, stack.size
148
+ assert_equal [true], settings.hides
149
+ assert_equal 1, exits
150
+ assert_equal [], focus_changes
151
+ end
152
+
153
+ def test_pop_on_an_empty_stack_is_a_safe_no_op
154
+ exits = 0
155
+ stack = Teek::UI::ModalStack.new(on_enter: ->(_) { }, on_exit: -> { exits += 1 })
156
+
157
+ stack.pop
158
+
159
+ refute stack.active?
160
+ assert_equal 0, exits
161
+ end
162
+
163
+ def test_on_focus_change_is_optional
164
+ settings = FakeWindow.new
165
+ stack = Teek::UI::ModalStack.new(on_enter: ->(_) { }, on_exit: -> { })
166
+
167
+ stack.push(:settings, settings)
168
+ stack.pop
169
+
170
+ assert_equal [true], settings.shows
171
+ assert_equal [true], settings.hides
172
+ end
173
+
174
+ def test_document_is_forwarded_to_the_internal_screens_for_lazy_realize
175
+ settings = FakeLazyWindow.new
176
+ stack = Teek::UI::ModalStack.new(on_enter: ->(_) { }, on_exit: -> { }, document: :the_document)
177
+
178
+ stack.push(:settings, settings)
179
+
180
+ assert_equal [:the_document], settings.realize_calls
181
+ end
182
+
183
+ def test_pop_returns_the_popped_window
184
+ settings = FakeWindow.new
185
+ stack = Teek::UI::ModalStack.new(on_enter: ->(_) { }, on_exit: -> { })
186
+ stack.push(:settings, settings)
187
+
188
+ assert_same settings, stack.pop
189
+ end
190
+
191
+ def test_pop_on_an_empty_stack_returns_nil
192
+ stack = Teek::UI::ModalStack.new(on_enter: ->(_) { }, on_exit: -> { })
193
+
194
+ assert_nil stack.pop
195
+ end
196
+
197
+ def test_a_push_push_pop_pop_sequence_fires_on_enter_once_on_focus_change_thrice_on_exit_once
198
+ enters = []
199
+ exits = 0
200
+ focus_changes = []
201
+ settings = FakeWindow.new
202
+ replay = FakeWindow.new
203
+ stack = Teek::UI::ModalStack.new(
204
+ on_enter: ->(name) { enters << name },
205
+ on_exit: -> { exits += 1 },
206
+ on_focus_change: ->(name) { focus_changes << name },
207
+ )
208
+
209
+ stack.push(:settings, settings)
210
+ stack.push(:replay, replay)
211
+ stack.pop
212
+ stack.pop
213
+
214
+ assert_equal [:settings], enters
215
+ assert_equal [:settings, :replay, :settings], focus_changes
216
+ assert_equal 1, exits
217
+ refute stack.active?
218
+ end
219
+ end