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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ module UI
5
+ # A component's build-time namespace - ambient builder state, kept on
6
+ # a stack parallel to {WidgetDSL}'s own +@stack+ (see
7
+ # {WidgetDSL#component}). A real object rather than a bare String/nil
8
+ # on purpose: {TOP_LEVEL} is one unmistakable sentinel, checked by
9
+ # identity (+#top_level?+/+#equal?+) rather than a value any caller
10
+ # could accidentally collide with (+nil+, an empty string, a label
11
+ # someone else also chose, ...). Two +Scope+ instances are never the
12
+ # same scope just because they share a +label+ - {Document} keys on
13
+ # identity, so every {WidgetDSL#component} call gets a genuinely
14
+ # fresh, distinct scope regardless of what label (if any) it's given.
15
+ class Scope
16
+ # @return [Symbol, String, nil] a human-readable label, for error
17
+ # messages/debugging - never part of identity/equality
18
+ attr_reader :label
19
+
20
+ # @return [Scope, nil] the enclosing scope this one was opened
21
+ # inside - +nil+ only for {TOP_LEVEL} itself
22
+ attr_reader :parent
23
+
24
+ # @api private
25
+ def initialize(label = nil, parent: nil)
26
+ @label = label
27
+ @parent = parent
28
+ end
29
+
30
+ # @return [Boolean] whether this is {TOP_LEVEL}
31
+ def top_level?
32
+ equal?(TOP_LEVEL)
33
+ end
34
+ end
35
+
36
+ # The single sentinel for "not inside any component" - the default
37
+ # scope for a build that never calls {WidgetDSL#component} at all.
38
+ Scope::TOP_LEVEL = Scope.new(:top_level).freeze
39
+ end
40
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ module UI
5
+ # Push/pop stack for content screens - works directly against ordinary
6
+ # DSL handles (a `ui.panel`/`ui.box`, or a `ui.window`) instead of
7
+ # requiring a bespoke per-screen class with its own show/hide/cleanup
8
+ # protocol. Pushing conceals the current screen (if any) before
9
+ # revealing the new one; popping reverses it, re-revealing whatever is
10
+ # now on top.
11
+ #
12
+ # A `:window` handle is revealed/concealed through its own {Handle#show}/
13
+ # {Handle#hide} (deiconify/raise/modal, or grab-release/withdraw);
14
+ # anything else is packed to fill its parent, or pack-forgotten via the
15
+ # plain `pack`/`pack forget` primitive.
16
+ #
17
+ # A screen being pushed/replaced-in can also be a `lazy: true` node
18
+ # that hasn't been realized yet (see {WidgetDSL#append_container}) -
19
+ # it's realized on demand, right before being revealed, with nothing
20
+ # extra to call by hand, as long as this stack was constructed with
21
+ # `document:`. A screen with no opinion on laziness at all (any plain
22
+ # object exposing just `type`/`path`/`app` or `type`/`show`/`hide`,
23
+ # the original "push an already-built Handle" usage) behaves exactly
24
+ # as before either way. Concealing never destroys a screen's widget -
25
+ # see {Handle#destroy!} for that as a separate, explicit step
26
+ # (typically `screens.pop&.destroy!`).
27
+ #
28
+ # @example
29
+ # ui.screens.push(:picker, ui[:picker])
30
+ # ui.screens.push(:emulator, ui[:emulator]) # picker concealed
31
+ # ui.screens.pop # emulator concealed, picker revealed
32
+ # ui.screens.replace_current(ui[:emulator]) # in-place swap, same stack depth
33
+ class Screens
34
+ Entry = Data.define(:name, :screen)
35
+
36
+ # @api private
37
+ # @param document [Document, nil] needed only to lazily {Handle#realize!}
38
+ # a not-yet-realized screen on push - omit if every screen pushed
39
+ # onto this stack is already realized
40
+ def initialize(document: nil)
41
+ @stack = []
42
+ @document = document
43
+ end
44
+
45
+ # @return [Boolean] true if any screen is on the stack
46
+ def active?
47
+ !@stack.empty?
48
+ end
49
+
50
+ # @return [Symbol, nil] name of the topmost screen
51
+ def current
52
+ @stack.last&.name
53
+ end
54
+
55
+ # @return [Handle, nil] the topmost screen's handle
56
+ def current_screen
57
+ @stack.last&.screen
58
+ end
59
+
60
+ # @return [Integer] number of screens on the stack
61
+ def size
62
+ @stack.length
63
+ end
64
+
65
+ # Push a screen onto the stack. The previous screen (if any) is
66
+ # concealed before the new one is realized (if it isn't already)
67
+ # and revealed.
68
+ # @param name [Symbol] identifier (e.g. +:picker+, +:emulator+)
69
+ # @param screen [Handle] a +:window+ handle, or any other container/widget handle
70
+ # @return [void]
71
+ def push(name, screen)
72
+ conceal(@stack.last.screen) if @stack.last
73
+ @stack.push(Entry.new(name: name, screen: screen))
74
+ ensure_realized(screen)
75
+ reveal(screen)
76
+ nil
77
+ end
78
+
79
+ # Replace the current screen in-place, without changing stack depth -
80
+ # the existing screen is concealed, the new one takes its name,
81
+ # realizes if needed, and is revealed.
82
+ # @param screen [Handle]
83
+ # @return [void]
84
+ def replace_current(screen)
85
+ entry = @stack.last or return
86
+ conceal(entry.screen)
87
+ @stack[-1] = Entry.new(name: entry.name, screen: screen)
88
+ ensure_realized(screen)
89
+ reveal(screen)
90
+ nil
91
+ end
92
+
93
+ # Pop the current screen off the stack. The popped screen is
94
+ # concealed (never destroyed - see {Handle#destroy!} to additionally
95
+ # release it); if a screen remains underneath, it's revealed again.
96
+ # @return [Object, nil] the just-popped screen, or +nil+ if the stack was empty
97
+ def pop
98
+ entry = @stack.pop or return nil
99
+ conceal(entry.screen)
100
+ reveal(@stack.last.screen) if @stack.last
101
+ entry.screen
102
+ end
103
+
104
+ private
105
+
106
+ # +realized?+/+realize!+ are internal on {Handle} (an app author
107
+ # never triggers this by hand - a `lazy: true` screen just works
108
+ # through {#push}/{#replace_current}) - `respond_to?(..., true)`
109
+ # and `send` reach past that on purpose, since this IS one of
110
+ # {Handle#realize!}'s two intended callers. A screen with no
111
+ # opinion on laziness at all (doesn't respond to +realized?+, even
112
+ # privately - the original "push an already-built Handle" usage)
113
+ # skips this entirely, exactly as before.
114
+ def ensure_realized(screen)
115
+ return unless screen.respond_to?(:realized?, true) && !screen.send(:realized?)
116
+
117
+ unless @document
118
+ raise ArgumentError, "this screen isn't realized yet and this Screens has no document: to realize it with"
119
+ end
120
+
121
+ screen.send(:realize!, @document)
122
+ end
123
+
124
+ def reveal(screen)
125
+ if screen.type == :window
126
+ screen.show
127
+ else
128
+ screen.app.command(:pack, screen.path, fill: :both, expand: 1)
129
+ end
130
+ end
131
+
132
+ def conceal(screen)
133
+ if screen.type == :window
134
+ screen.hide
135
+ else
136
+ screen.app.command(:pack, :forget, screen.path)
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,417 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'document'
5
+ require_relative 'widget_dsl'
6
+ require_relative 'realizer'
7
+ require_relative 'validator'
8
+ require_relative 'event_bus'
9
+ require_relative 'scope'
10
+
11
+ module Teek
12
+ module UI
13
+ # The object yielded to (and returned by) {Teek::UI.app} - owns the
14
+ # build-phase {Document} and the realize/run lifecycle, and (via
15
+ # {WidgetDSL}) the `ui.<widget>` build surface itself.
16
+ #
17
+ # Building is Tk-free: {Teek::UI.app} never constructs a {Teek::App}, so
18
+ # the block runs (and #document is buildable/inspectable) with no
19
+ # interpreter at all. Nothing talks to Tk until #realize (called by #run
20
+ # and #run_async, or directly) actually creates one and walks the tree
21
+ # into it via {Realizer}.
22
+ class Session
23
+ include WidgetDSL
24
+
25
+ # A `#every`/`#after` call queued before realize - see {#flush_timers!}.
26
+ # @api private
27
+ Timer = Data.define(:kind, :ms, :on_error, :block)
28
+
29
+ # @return [Document] the build-phase tree - constructible and
30
+ # traversable with no interpreter, before or after realize.
31
+ attr_reader :document
32
+
33
+ # @return [Array<Var>] reactive variables declared in this build
34
+ attr_reader :vars
35
+
36
+ # @return [Array<Image>] images declared in this build - retained
37
+ # here for the session's whole lifetime, so a widget's `image:`
38
+ # never outlives the {Teek::Photo} it points at (see {Image}).
39
+ attr_reader :images
40
+
41
+ # @api private
42
+ def initialize(title: nil, scroll: nil, app_opts: {})
43
+ @title = title
44
+ @scroll = scroll
45
+ @app_opts = app_opts
46
+ @document = Document.new
47
+ @stack = [@document.root]
48
+ @scope_stack = [Scope::TOP_LEVEL]
49
+ @vars = []
50
+ @images = []
51
+ @app = nil
52
+ @in_add = false
53
+ @bus = EventBus.new
54
+ @timers = []
55
+ @toast_path = nil
56
+ @toast_timer_id = nil
57
+ end
58
+
59
+ # @return [Teek::App] the underlying app - the DSL's escape hatch.
60
+ # Anything the DSL doesn't wrap yet is one call away: `ui.app.command(...)`.
61
+ # @raise [NotRealizedError] if called before #realize
62
+ def app
63
+ raise_unless_realized!
64
+ @app
65
+ end
66
+
67
+ # A live snapshot of currently-registered callbacks, grouped by
68
+ # what registered them - "is my app leaking callbacks, and where."
69
+ # A tag absent from the result means nothing of that kind is
70
+ # currently registered (not a zero entry). Safe to call any time
71
+ # after realize; see `run`/`run_async`'s `debug:` for printing this
72
+ # automatically instead of calling it yourself.
73
+ # @return [Hash{Symbol => Integer}] +:event_bindings+, +:menu_entries+,
74
+ # +:canvas_item_binds+, +:tag_binds+, +:widget_option_callbacks+
75
+ # (+-command+/+-textvariable+/etc.), +:window_close_handlers+
76
+ # @raise [NotRealizedError] if called before #realize
77
+ def debug_info
78
+ raise_unless_realized!
79
+ counts = @app.callback_registry.counts_by_tag
80
+ {
81
+ event_bindings: counts[:bind],
82
+ menu_entries: counts[:menu],
83
+ canvas_item_binds: counts[:canvas_bind],
84
+ tag_binds: counts[:tag_bind],
85
+ widget_option_callbacks: counts[:widget_option],
86
+ window_close_handlers: counts[:wm_protocol],
87
+ }.reject { |_, count| count.zero? }
88
+ end
89
+
90
+ # Reverse lookup: given a real Tk path (from an error message, a
91
+ # +winfo+ query, or poking around in a REPL), find which widget it
92
+ # belongs to - the counterpart to the name-based +ui[:name]+. See
93
+ # {Document#find_by_path} for exactly what counts as a match.
94
+ # @param path [String] e.g. +".toolbar.save"+
95
+ # @return [Handle, nil]
96
+ # @raise [NotRealizedError] if called before #realize
97
+ def find_by_path(path)
98
+ raise_unless_realized!
99
+ node = @document.find_by_path(path)
100
+ node && Handle.new(node)
101
+ end
102
+
103
+ # Validate the build tree, then create the underlying {Teek::App} and
104
+ # realize the tree into it, if that hasn't happened yet. Idempotent -
105
+ # calling it again after the first time just returns the same app.
106
+ #
107
+ # Atomic in two senses: a validation failure means no interpreter is
108
+ # ever constructed at all, and even once realizing starts, the app's
109
+ # root window stays withdrawn until the whole tree is realized, so a
110
+ # mid-realize error never leaves a half-built window visible either
111
+ # way. On failure the session is left exactly as if #realize had never
112
+ # been called - it isn't left half-realized (or half-validated).
113
+ # @param strict [Boolean] see {Validator.validate!}
114
+ # @return [Teek::App]
115
+ # @raise [ValidationError] if the build tree has problems
116
+ def realize(strict: false)
117
+ return @app if @app
118
+
119
+ Validator.validate!(@document, strict: strict)
120
+
121
+ app = Teek::App.new(title: @title, **@app_opts)
122
+ begin
123
+ # vars/images realize first, so a widget bound/pointed to one
124
+ # displays correctly (a value, a loaded image) from the moment
125
+ # it's created instead of starting blank/broken.
126
+ @vars.each { |v| v.realize(app) }
127
+ @images.each { |img| img.realize(app) }
128
+ Realizer.new(app, @document, default_scroll: @scroll).realize
129
+ flush_timers!(app)
130
+ rescue
131
+ app.destroy
132
+ raise
133
+ end
134
+ @app = app
135
+ end
136
+
137
+ # Realize, show the window, and enter the Tk event loop. Blocks until
138
+ # the app exits.
139
+ # @param strict [Boolean] see {Validator.validate!}
140
+ # @param debug [Boolean] print {#debug_info}'s summary to stderr,
141
+ # once right before entering the event loop and once after it
142
+ # returns - eyeball whether the app leaked any callbacks over
143
+ # its run without writing any diagnostic code yourself.
144
+ # @return [void]
145
+ def run(strict: false, debug: false)
146
+ realize(strict: strict)
147
+ @app.show
148
+ print_debug_info if debug
149
+ @app.mainloop
150
+ print_debug_info if debug
151
+ end
152
+
153
+ # Realize and show the window without entering the event loop, for
154
+ # interactive/REPL use. Returns immediately.
155
+ #
156
+ # @note this does not (yet) service the event loop automatically between
157
+ # REPL prompts - call `ui.app.update` yourself to process pending
158
+ # events while exploring interactively, the same manual-pump workaround
159
+ # {Teek::App#mainloop}'s own REPL warning documents. A REPL session
160
+ # helper that services the loop for you on its own is future work,
161
+ # not built yet.
162
+ # @param strict [Boolean] see {Validator.validate!}
163
+ # @param debug [Boolean] print {#debug_info}'s summary to stderr
164
+ # right after realize
165
+ # @return [self]
166
+ def run_async(strict: false, debug: false)
167
+ realize(strict: strict)
168
+ @app.show
169
+ print_debug_info if debug
170
+ self
171
+ end
172
+
173
+ # @see EventBus#on
174
+ # @return [Proc] the block, to pass to a later #off
175
+ def on(event, &block)
176
+ @bus.on(event, &block)
177
+ end
178
+
179
+ # @see EventBus#emit
180
+ # @return [void]
181
+ def emit(event, *args, **kwargs)
182
+ @bus.emit(event, *args, **kwargs)
183
+ end
184
+
185
+ # @see EventBus#off
186
+ # @return [void]
187
+ def off(event, block)
188
+ @bus.off(event, block)
189
+ end
190
+
191
+ # Same queue-then-wire shape as an `on_*` event binding: called
192
+ # inside the build block, it queues and registers once the tree
193
+ # realizes; called after, it registers immediately - same method,
194
+ # correct behavior either way, so a tick loop can be declared right
195
+ # alongside the UI it drives instead of being forced out to a
196
+ # separate post-`run_async` step.
197
+ # @see Teek::App#every
198
+ # @return [Object, nil] the live timer object (`.cancel`-able) once
199
+ # realized; `nil` if queued - there's no live timer to hand back
200
+ # yet, since nothing has registered with Tcl at that point
201
+ def every(ms, on_error: :raise, &block)
202
+ if @app
203
+ @app.every(ms, on_error: on_error, &block)
204
+ else
205
+ @timers << Timer.new(kind: :every, ms: ms, on_error: on_error, block: block)
206
+ nil
207
+ end
208
+ end
209
+
210
+ # @see #every
211
+ # @see Teek::App#after
212
+ # @return [Object, nil] see {#every}
213
+ def after(ms, on_error: :raise, &block)
214
+ if @app
215
+ @app.after(ms, on_error: on_error, &block)
216
+ else
217
+ @timers << Timer.new(kind: :after, ms: ms, on_error: on_error, block: block)
218
+ nil
219
+ end
220
+ end
221
+
222
+ # Show the native "choose file to open" dialog.
223
+ # @see Teek::App#choose_open_file
224
+ # @raise [NotRealizedError] if called before #realize
225
+ def open_file(filetypes: nil, initialdir: nil, initialfile: nil, title: nil, multiple: false, parent: nil)
226
+ raise_unless_realized!
227
+ @app.choose_open_file(filetypes: filetypes, initialdir: initialdir, initialfile: initialfile,
228
+ title: title, multiple: multiple, parent: parent)
229
+ end
230
+
231
+ # Show the native "choose file to save" dialog.
232
+ # @see Teek::App#choose_save_file
233
+ # @raise [NotRealizedError] if called before #realize
234
+ def save_file(filetypes: nil, initialdir: nil, initialfile: nil, title: nil,
235
+ defaultextension: nil, confirmoverwrite: true, parent: nil)
236
+ raise_unless_realized!
237
+ @app.choose_save_file(filetypes: filetypes, initialdir: initialdir, initialfile: initialfile, title: title,
238
+ defaultextension: defaultextension, confirmoverwrite: confirmoverwrite, parent: parent)
239
+ end
240
+
241
+ # Show a message box with one or more buttons.
242
+ # @see Teek::App#message_box
243
+ # @raise [NotRealizedError] if called before #realize
244
+ def message(message:, title: nil, detail: nil, icon: :info, type: :ok, default: nil, parent: nil)
245
+ raise_unless_realized!
246
+ @app.message_box(message: message, title: title, detail: detail, icon: icon,
247
+ type: type, default: default, parent: parent)
248
+ end
249
+
250
+ # Show the native color picker dialog.
251
+ # @see Teek::App#choose_color
252
+ # @raise [NotRealizedError] if called before #realize
253
+ def choose_color(initial: nil, title: nil, parent: nil)
254
+ raise_unless_realized!
255
+ @app.choose_color(initial: initial, title: title, parent: parent)
256
+ end
257
+
258
+ # Show the native "choose directory" dialog.
259
+ # @see Teek::App#choose_dir
260
+ # @raise [NotRealizedError] if called before #realize
261
+ def choose_dir(initialdir: nil, mustexist: false, title: nil, parent: nil)
262
+ raise_unless_realized!
263
+ @app.choose_dir(initialdir: initialdir, mustexist: mustexist, title: title, parent: parent)
264
+ end
265
+
266
+ # Show a busy cursor over +window+ for the duration of the block -
267
+ # {Teek::App#busy} already restores it even if the block raises,
268
+ # nothing extra to do here for that.
269
+ # @param window [String] Tk window path
270
+ # @yield the work to perform while busy
271
+ # @return the block's return value
272
+ # @raise [NotRealizedError] if called before #realize
273
+ def busy(window: '.', &block)
274
+ raise_unless_realized!
275
+ @app.busy(window: window, &block)
276
+ end
277
+
278
+ # @return [Teek::Clipboard] +.set(text)+/+.get+/+.clear+ - text
279
+ # widgets don't need this at all for their own copy/cut/paste
280
+ # (Tk wires that to the platform's expected keys already); this is
281
+ # for reading/writing the clipboard directly from app code.
282
+ # @raise [NotRealizedError] if called before #realize
283
+ def clipboard
284
+ raise_unless_realized!
285
+ @app.clipboard
286
+ end
287
+
288
+ # Milliseconds a {#toast} stays visible when no +duration:+ is given.
289
+ DEFAULT_TOAST_DURATION_MS = 1500
290
+
291
+ # Briefly flash a message near the bottom of the window - "Saved"
292
+ # after a save action, "Settings" when a modal gains focus, that
293
+ # kind of transient feedback, not a persistent status. Reuses one
294
+ # widget across every call rather than building a new one each
295
+ # time: calling this again while a toast is already showing
296
+ # replaces it (new text, restarted timer) instead of stacking a
297
+ # second one, and the earlier toast's own pending auto-dismiss is
298
+ # cancelled so it can never fire late and hide the replacement.
299
+ # @param message [String]
300
+ # @param duration [Integer] milliseconds before it auto-dismisses
301
+ # @return [void]
302
+ # @raise [NotRealizedError] if called before #realize
303
+ def toast(message, duration: DEFAULT_TOAST_DURATION_MS)
304
+ raise_unless_realized!
305
+ ensure_toast_widget
306
+ @app.command(@toast_path, :configure, text: message)
307
+ @app.command(:place, @toast_path, in: '.', relx: 0.5, rely: 1.0, anchor: 's', y: -12)
308
+ @app.after_cancel(@toast_timer_id) if @toast_timer_id
309
+ @toast_timer_id = @app.after(duration) { @app.command(:place, :forget, @toast_path) }
310
+ end
311
+
312
+ # Build and immediately realize a subtree into the already-running
313
+ # app, as a child of an already-realized widget named +parent_name+ -
314
+ # for dynamic UIs (adding cards/rows/menu entries at runtime), not
315
+ # just the initial build. The block uses the exact same widget DSL as
316
+ # everywhere else (`a.button(...)`, `a.column(...) { }`, ...); new
317
+ # widgets show up immediately, routed through the same
318
+ # {Teek::App#command}/leak-cleanup path the initial realize uses, so
319
+ # destroying an added widget reclaims its callbacks the normal way.
320
+ #
321
+ # Unlike the initial #realize, this does not run {Validator} - it's
322
+ # already-known-good territory (the session realized once already);
323
+ # validating one small addition on every call would be wasted work.
324
+ # @param parent_name [Symbol] an already-realized widget's name
325
+ # @yieldparam ui [Session] the same builder, block-scoped under +parent_name+
326
+ # @return [nil]
327
+ # @raise [NotRealizedError] if the session, or the named parent, isn't realized yet
328
+ # @raise [ArgumentError] if no widget is declared under +parent_name+
329
+ def add(parent_name)
330
+ raise_unless_realized!
331
+
332
+ parent_node = @document.find(parent_name) or
333
+ raise ArgumentError, "no widget named :#{parent_name} in this build"
334
+ raise NotRealizedError, "##{parent_name} is not realized yet" unless parent_node.realized
335
+
336
+ before = parent_node.children.length
337
+ vars_before = @vars.length
338
+ images_before = @images.length
339
+ push_stack(parent_node)
340
+ @in_add = true
341
+ begin
342
+ yield self if block_given?
343
+ ensure
344
+ @in_add = false
345
+ pop_stack
346
+ end
347
+
348
+ # A var/image declared inside this block needs to be real before
349
+ # the new widget subtree realizes, exactly like the initial
350
+ # #realize orders them - a widget referencing one via
351
+ # bind:/image: assumes it's already backed by the time IT gets
352
+ # created (see Var#realize/Image#realize).
353
+ @vars[vars_before..].each { |v| v.realize(@app) }
354
+ @images[images_before..].each { |img| img.realize(@app) }
355
+
356
+ realizer = Realizer.new(@app, @document, default_scroll: @scroll)
357
+ # A lazy: true child built in this block (see WidgetDSL#append_container)
358
+ # stays unrealized here too, exactly like one built during the
359
+ # initial realize - it's realized later, on demand (see Handle#realize!).
360
+ parent_node.children[before..].each { |child| realizer.realize_subtree(child, parent_node) unless child.lazy? }
361
+
362
+ nil
363
+ end
364
+
365
+ private
366
+
367
+ # Creates the one toast label this session will ever need, the
368
+ # first time {#toast} is called - a no-op on every later call.
369
+ # Claims its path segment through {Document#claim_path_segment}
370
+ # like any ordinary widget would, so the vanishingly unlikely case
371
+ # of a build already using +:toast+ as a top-level widget name
372
+ # gets disambiguated instead of silently colliding with it.
373
+ def ensure_toast_widget
374
+ return if @toast_path
375
+
376
+ segment = @document.claim_path_segment('.', 'toast')
377
+ @toast_path = ".#{segment}"
378
+ @app.command('ttk::label', @toast_path, background: '#323232', foreground: 'white', padding: [14, 8])
379
+ end
380
+
381
+ # Registers every timer queued via `#every`/`#after` before realize
382
+ # against the now-live +app+, in declaration order - mirrors how
383
+ # {Realizer#link} wires queued event bindings once the whole tree
384
+ # is up. Called from inside #realize's own begin block (not after
385
+ # +@app+ is set), so a bad timer registration is caught by the
386
+ # SAME atomicity guarantee as the rest of realize.
387
+ def flush_timers!(app)
388
+ @timers.each do |timer|
389
+ case timer.kind
390
+ when :every then app.every(timer.ms, on_error: timer.on_error, &timer.block)
391
+ when :after then app.after(timer.ms, on_error: timer.on_error, &timer.block)
392
+ end
393
+ end
394
+ @timers.clear
395
+ end
396
+
397
+ def raise_unless_realized!
398
+ raise NotRealizedError unless @app
399
+ end
400
+
401
+ # @see #run, #run_async's own +debug:+ - always stderr, never
402
+ # stdout (this is diagnostics, not program output).
403
+ def print_debug_info
404
+ warn "[teek-ui debug] live callbacks: #{debug_info}"
405
+ end
406
+
407
+ # @return [Boolean] whether {WidgetDSL}'s build methods (ui.button,
408
+ # ui.panel, ui.raw, ui.var, ...) are still allowed to append to the
409
+ # tree - true before the initial realize, and again for the
410
+ # duration of an {#add} block (which re-opens it, scoped to that
411
+ # one call), false everywhere else. See {WidgetDSL#raise_if_closed!}.
412
+ def build_open?
413
+ @app.nil? || @in_add
414
+ end
415
+ end
416
+ end
417
+ 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 tab's own contract: it must be declared directly inside a ui.tabs.
10
+ # Only reachable via direct Node/Document manipulation, since
11
+ # {WidgetDSL#tab} already refuses to run outside a ui.tabs block - the
12
+ # same defense-in-depth {GridValidator.check_stray_cell} does for grid.
13
+ # Composed into {WidgetValidators} via :tab's own {WidgetType#validator}
14
+ # (see +widget_types/tab.rb+).
15
+ module TabValidator
16
+ # @param node [Node] a :tab 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 == :tabs
24
+
25
+ errors << "#{WidgetValidators.describe(node)} is a :tab but its parent " \
26
+ "(#{WidgetValidators.describe(parent)}) isn't a ui.tabs"
27
+ end
28
+ end
29
+ end
30
+ end