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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/README.md +452 -0
- data/lib/teek/ui/canvas_item.rb +270 -0
- data/lib/teek/ui/component_handle.rb +32 -0
- data/lib/teek/ui/document.rb +185 -0
- data/lib/teek/ui/errors.rb +41 -0
- data/lib/teek/ui/event_binding.rb +22 -0
- data/lib/teek/ui/event_bus.rb +53 -0
- data/lib/teek/ui/grid_validator.rb +89 -0
- data/lib/teek/ui/handle.rb +585 -0
- data/lib/teek/ui/image.rb +58 -0
- data/lib/teek/ui/keysyms.rb +72 -0
- data/lib/teek/ui/menu_builder.rb +130 -0
- data/lib/teek/ui/menu_entry_addressing.rb +62 -0
- data/lib/teek/ui/modal_stack.rb +99 -0
- data/lib/teek/ui/mouse_events.rb +31 -0
- data/lib/teek/ui/node.rb +141 -0
- data/lib/teek/ui/option_dump_parsing.rb +31 -0
- data/lib/teek/ui/overlay_anchors.rb +29 -0
- data/lib/teek/ui/overlay_validator.rb +33 -0
- data/lib/teek/ui/pane_validator.rb +30 -0
- data/lib/teek/ui/realized_node.rb +25 -0
- data/lib/teek/ui/realizer.rb +577 -0
- data/lib/teek/ui/scope.rb +40 -0
- data/lib/teek/ui/screens.rb +141 -0
- data/lib/teek/ui/session.rb +417 -0
- data/lib/teek/ui/tab_validator.rb +30 -0
- data/lib/teek/ui/text_content.rb +321 -0
- data/lib/teek/ui/tree_inspector.rb +97 -0
- data/lib/teek/ui/validator.rb +131 -0
- data/lib/teek/ui/var.rb +98 -0
- data/lib/teek/ui/version.rb +7 -0
- data/lib/teek/ui/widget_addressing.rb +47 -0
- data/lib/teek/ui/widget_dsl.rb +512 -0
- data/lib/teek/ui/widget_type.rb +242 -0
- data/lib/teek/ui/widget_types/button.rb +7 -0
- data/lib/teek/ui/widget_types/canvas.rb +15 -0
- data/lib/teek/ui/widget_types/checkbox.rb +7 -0
- data/lib/teek/ui/widget_types/column.rb +14 -0
- data/lib/teek/ui/widget_types/context_menu.rb +15 -0
- data/lib/teek/ui/widget_types/divider.rb +10 -0
- data/lib/teek/ui/widget_types/dropdown.rb +7 -0
- data/lib/teek/ui/widget_types/grid.rb +12 -0
- data/lib/teek/ui/widget_types/group.rb +7 -0
- data/lib/teek/ui/widget_types/label.rb +7 -0
- data/lib/teek/ui/widget_types/list.rb +7 -0
- data/lib/teek/ui/widget_types/menu_bar.rb +26 -0
- data/lib/teek/ui/widget_types/menu_checkbox.rb +14 -0
- data/lib/teek/ui/widget_types/menu_item.rb +20 -0
- data/lib/teek/ui/widget_types/menu_radio.rb +14 -0
- data/lib/teek/ui/widget_types/number_box.rb +7 -0
- data/lib/teek/ui/widget_types/pane.rb +37 -0
- data/lib/teek/ui/widget_types/panel.rb +7 -0
- data/lib/teek/ui/widget_types/progress.rb +7 -0
- data/lib/teek/ui/widget_types/radio.rb +7 -0
- data/lib/teek/ui/widget_types/row.rb +14 -0
- data/lib/teek/ui/widget_types/scrollable.rb +17 -0
- data/lib/teek/ui/widget_types/slider.rb +7 -0
- data/lib/teek/ui/widget_types/spacer.rb +14 -0
- data/lib/teek/ui/widget_types/split.rb +17 -0
- data/lib/teek/ui/widget_types/tab.rb +35 -0
- data/lib/teek/ui/widget_types/table.rb +9 -0
- data/lib/teek/ui/widget_types/tabs.rb +10 -0
- data/lib/teek/ui/widget_types/text_area.rb +7 -0
- data/lib/teek/ui/widget_types/text_box.rb +7 -0
- data/lib/teek/ui/widget_types/tree.rb +9 -0
- data/lib/teek/ui/widget_types/window.rb +50 -0
- data/lib/teek/ui/widget_types.rb +121 -0
- data/lib/teek/ui/widget_validators.rb +62 -0
- data/lib/teek/ui.rb +54 -0
- data/teek-ui.gemspec +24 -0
- data/test/support/fake_app.rb +57 -0
- data/test/test_busy.rb +82 -0
- data/test/test_canvas_items.rb +551 -0
- data/test/test_clipboard.rb +87 -0
- data/test/test_close_handling.rb +118 -0
- data/test/test_component.rb +151 -0
- data/test/test_component_realtk.rb +120 -0
- data/test/test_debug_info.rb +158 -0
- data/test/test_document.rb +317 -0
- data/test/test_event_bus.rb +163 -0
- data/test/test_events.rb +164 -0
- data/test/test_fake_app_contract.rb +62 -0
- data/test/test_grid.rb +97 -0
- data/test/test_handle.rb +386 -0
- data/test/test_handle_destroy_realtk.rb +344 -0
- data/test/test_helper.rb +20 -0
- data/test/test_image.rb +24 -0
- data/test/test_image_realtk.rb +146 -0
- data/test/test_incremental_realize.rb +203 -0
- data/test/test_keysyms.rb +76 -0
- data/test/test_layout.rb +185 -0
- data/test/test_lazy_realize.rb +128 -0
- data/test/test_managed_window.rb +304 -0
- data/test/test_menu_dsl.rb +230 -0
- data/test/test_menu_entry_addressing.rb +73 -0
- data/test/test_menu_realize.rb +296 -0
- data/test/test_modal_handle.rb +147 -0
- data/test/test_modal_stack.rb +219 -0
- data/test/test_modal_stack_realtk.rb +131 -0
- data/test/test_native_scrollable.rb +250 -0
- data/test/test_node.rb +193 -0
- data/test/test_overlay.rb +139 -0
- data/test/test_raw.rb +58 -0
- data/test/test_reactive_vars.rb +118 -0
- data/test/test_realizer.rb +264 -0
- data/test/test_scope.rb +38 -0
- data/test/test_screens.rb +260 -0
- data/test/test_screens_realtk.rb +314 -0
- data/test/test_scrollable.rb +185 -0
- data/test/test_scrollable_wheel.rb +172 -0
- data/test/test_scrollbar_auto_hide.rb +127 -0
- data/test/test_split.rb +145 -0
- data/test/test_tabs.rb +201 -0
- data/test/test_text_content.rb +664 -0
- data/test/test_toast.rb +126 -0
- data/test/test_tree_inspector.rb +183 -0
- data/test/test_ui.rb +428 -0
- data/test/test_validator.rb +209 -0
- data/test/test_var.rb +32 -0
- data/test/test_widget_dsl.rb +621 -0
- data/test/test_widget_introspection.rb +126 -0
- data/test/test_widget_types.rb +429 -0
- data/test/test_widget_types_realtk.rb +82 -0
- data/test/test_widget_validators.rb +82 -0
- metadata +207 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'option_dump_parsing'
|
|
5
|
+
|
|
6
|
+
module Teek
|
|
7
|
+
module UI
|
|
8
|
+
# @api private
|
|
9
|
+
#
|
|
10
|
+
# The default {WidgetType#addressing} strategy - an ordinary Tk
|
|
11
|
+
# widget with an independent path of its own, driving +#path+/
|
|
12
|
+
# +#configure+ through it directly. See {WidgetType#addressing} for
|
|
13
|
+
# how a type opts into a different strategy (e.g. {MenuEntryAddressing}).
|
|
14
|
+
class WidgetAddressing
|
|
15
|
+
# @api private
|
|
16
|
+
def initialize(node)
|
|
17
|
+
@node = node
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [String] the real Tk widget path
|
|
21
|
+
# @raise [NotRealizedError] before realize
|
|
22
|
+
def virtual_path
|
|
23
|
+
realized.path
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param opts [Hash]
|
|
27
|
+
# @return [void]
|
|
28
|
+
# @raise [NotRealizedError] before realize
|
|
29
|
+
def configure(**opts)
|
|
30
|
+
realized.app.command(realized.path, :configure, **opts)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Hash{Symbol => String}] every current option/value Tk
|
|
34
|
+
# reports for this widget right now
|
|
35
|
+
# @raise [NotRealizedError] before realize
|
|
36
|
+
def option_dump
|
|
37
|
+
OptionDumpParsing.parse(realized.app, realized.app.command(realized.path, :configure))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def realized
|
|
43
|
+
@node.realized or raise NotRealizedError
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'handle'
|
|
5
|
+
require_relative 'component_handle'
|
|
6
|
+
require_relative 'var'
|
|
7
|
+
require_relative 'image'
|
|
8
|
+
require_relative 'menu_builder'
|
|
9
|
+
require_relative 'screens'
|
|
10
|
+
require_relative 'modal_stack'
|
|
11
|
+
require_relative 'widget_types'
|
|
12
|
+
require_relative 'overlay_anchors'
|
|
13
|
+
require_relative 'scope'
|
|
14
|
+
|
|
15
|
+
module Teek
|
|
16
|
+
module UI
|
|
17
|
+
# The build surface: `ui.<widget>` methods that APPEND nodes to the
|
|
18
|
+
# {Document} tree. They never touch Tk - widgets become live only when
|
|
19
|
+
# the realizer runs at realize.
|
|
20
|
+
#
|
|
21
|
+
# Names are deliberately Tk-free (the litmus test: if decoding a name
|
|
22
|
+
# needs Tk knowledge, the name is wrong) - see the design sketch for the
|
|
23
|
+
# full vocabulary rationale.
|
|
24
|
+
#
|
|
25
|
+
# Mixed into {Session} rather than living on a separate accessor, so the
|
|
26
|
+
# DSL reads as `ui.button(...)`, not `ui.widgets.button(...)`.
|
|
27
|
+
#
|
|
28
|
+
# Included classes must provide +@document+ (a {Document}), +@stack+
|
|
29
|
+
# (an Array of {Node}, current-parent stack seeded with
|
|
30
|
+
# +@document.root+), +@scope_stack+ (an Array of {Scope}, current-scope
|
|
31
|
+
# stack seeded with +[Scope::TOP_LEVEL]+ - see {#component}), +@vars+
|
|
32
|
+
# (an Array of {Var}), and +@images+ (an Array of {Image}) - {Session}
|
|
33
|
+
# sets all five up in +initialize+. They must also provide
|
|
34
|
+
# +#build_open?+ (a predicate the tree-mutating methods below check
|
|
35
|
+
# via {#raise_if_closed!} - true before the initial realize and again
|
|
36
|
+
# for the duration of an +#add+ block, false otherwise).
|
|
37
|
+
module WidgetDSL
|
|
38
|
+
# Every widget/container type - leaf or container, plain or special -
|
|
39
|
+
# gets its own `ui.<type>` method(s) here, generated from its own
|
|
40
|
+
# {WidgetType} descriptor (see {WidgetTypes}). {WidgetTypes.on_register}
|
|
41
|
+
# replays every type already registered (every built-in loads before
|
|
42
|
+
# this file does) and keeps firing for any registered later, so a
|
|
43
|
+
# type registered by third-party code lights up here with no edit to
|
|
44
|
+
# this file at all. A type reachable only via a bespoke,
|
|
45
|
+
# hand-written method below (`#tab`/`#pane`/`#split`, `#menu_bar`/
|
|
46
|
+
# `#context_menu`) sets its own descriptor's `dsl:` to a no-op, so
|
|
47
|
+
# this never shadows those with a same-named generic method.
|
|
48
|
+
WidgetTypes.on_register { |widget_type| widget_type.define_dsl_method!(self) }
|
|
49
|
+
|
|
50
|
+
# `box` is a bare alternate spelling of `panel` - same node type, so
|
|
51
|
+
# the realizer only ever has to know about `:panel`.
|
|
52
|
+
def box(name = nil, **opts, &block)
|
|
53
|
+
append_container(:panel, name, opts, &block)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# `window` with dialog-appropriate defaults - modal and fixed-size,
|
|
57
|
+
# for the common "small modal window" case (confirmations, pickers).
|
|
58
|
+
# Same underlying node type as `window`, just different defaults for
|
|
59
|
+
# `modal:`/`resizable:` - both still overridable.
|
|
60
|
+
# @return [Handle]
|
|
61
|
+
def dialog(name = nil, modal: true, resizable: false, **opts, &block)
|
|
62
|
+
append_container(:window, name, opts.merge(modal: modal, resizable: resizable), &block)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Look up a named widget declared in the CURRENT scope: at the top
|
|
66
|
+
# level outside any {#component}, that's everything built outside
|
|
67
|
+
# one; inside a component's own block, only that component's own
|
|
68
|
+
# names - a sibling component's (or the top level's) same-named
|
|
69
|
+
# node is never found this way, and vice versa. See {#component}.
|
|
70
|
+
# @param name [Symbol]
|
|
71
|
+
# @return [Handle, nil]
|
|
72
|
+
def [](name)
|
|
73
|
+
node = @document.find(name, scope: current_scope)
|
|
74
|
+
node && Handle.new(node)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The current build-parent ancestry, as a readable breadcrumb (e.g.
|
|
78
|
+
# +"column > row"+) - derived from +@stack+, the one thing only the
|
|
79
|
+
# builder (not the {Document}) knows: which containers are
|
|
80
|
+
# currently open. Useful in a build-time error message (+"X added
|
|
81
|
+
# outside a Y; current parent: #{current_path}"+) or just to orient
|
|
82
|
+
# yourself while poking around mid-build.
|
|
83
|
+
# @return [String] +"(top level)"+ when nothing is currently open
|
|
84
|
+
def current_path
|
|
85
|
+
crumbs = @stack.reject { |node| node.type == :root }.map(&:display_name)
|
|
86
|
+
crumbs.empty? ? '(top level)' : crumbs.join(' > ')
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Position the single widget declared in the block at (row, col) in
|
|
90
|
+
# the enclosing `ui.grid`. Only valid directly inside a grid's block.
|
|
91
|
+
# @param row [Integer]
|
|
92
|
+
# @param col [Integer]
|
|
93
|
+
# @param span [Integer] how many columns this cell spans
|
|
94
|
+
# @return [void]
|
|
95
|
+
def cell(row:, col:, span: 1)
|
|
96
|
+
grid_node = current_grid!('cell')
|
|
97
|
+
|
|
98
|
+
before = grid_node.children.length
|
|
99
|
+
yield self if block_given?
|
|
100
|
+
placed = grid_node.children[before..]
|
|
101
|
+
|
|
102
|
+
unless placed.length == 1
|
|
103
|
+
raise ArgumentError, "cell needs exactly one widget declared in its block (got #{placed.length})"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
node = placed.first
|
|
107
|
+
node.layout = (node.layout || {}).merge(cell: { row: row, col: col, span: span })
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Mark which columns/rows of the enclosing `ui.grid` absorb leftover
|
|
111
|
+
# space - the named replacement for `grid columnconfigure -weight`.
|
|
112
|
+
# Only valid directly inside a grid's block.
|
|
113
|
+
# @param columns [Array<Integer>]
|
|
114
|
+
# @param rows [Array<Integer>]
|
|
115
|
+
# @return [void]
|
|
116
|
+
def stretch(columns: [], rows: [])
|
|
117
|
+
grid_node = current_grid!('stretch')
|
|
118
|
+
|
|
119
|
+
grid_node.opts[:stretch_columns] = Array(columns) if columns.any?
|
|
120
|
+
grid_node.opts[:stretch_rows] = Array(rows) if rows.any?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Floats the single widget declared in the block on top of the
|
|
124
|
+
# enclosing `ui.canvas`, positioned at a fixed corner/edge/center
|
|
125
|
+
# anchor via Tk's `place` geometry manager - a "use sparingly"
|
|
126
|
+
# escape valve for the one legitimate absolute-position case (a
|
|
127
|
+
# status readout or button bar layered over canvas content), not a
|
|
128
|
+
# general-purpose layout mode. Stays correctly positioned across a
|
|
129
|
+
# canvas resize with nothing to redo by hand - `place`'s relative
|
|
130
|
+
# coordinates are fractions of the canvas's current size, recomputed
|
|
131
|
+
# live by Tk on every resize. Only valid directly inside a
|
|
132
|
+
# `ui.canvas` block.
|
|
133
|
+
# @param at [Symbol] one of {OverlayAnchors::POSITIONS}'s keys
|
|
134
|
+
# @return [void]
|
|
135
|
+
# @raise [ArgumentError] if declared anywhere other than directly
|
|
136
|
+
# inside ui.canvas, given an unrecognized at:, or its block builds
|
|
137
|
+
# anything other than exactly one widget
|
|
138
|
+
def overlay(at:)
|
|
139
|
+
canvas_node = current_canvas!('overlay')
|
|
140
|
+
unless OverlayAnchors::POSITIONS.key?(at)
|
|
141
|
+
raise ArgumentError, "overlay's at: must be one of #{OverlayAnchors::POSITIONS.keys.join(', ')} (got #{at.inspect})"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
before = canvas_node.children.length
|
|
145
|
+
yield if block_given?
|
|
146
|
+
placed = canvas_node.children[before..]
|
|
147
|
+
|
|
148
|
+
unless placed.length == 1
|
|
149
|
+
raise ArgumentError, "overlay needs exactly one widget declared in its block (got #{placed.length})"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
node = placed.first
|
|
153
|
+
node.layout = (node.layout || {}).merge(overlay: { at: at })
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# One page of an enclosing `ui.tabs`, labeled `label` in the tab bar.
|
|
157
|
+
# Only valid directly inside a `ui.tabs` block; its own block builds
|
|
158
|
+
# the pane's content with the ordinary widget DSL, same as any other
|
|
159
|
+
# container.
|
|
160
|
+
# @param label [String] the tab's title, shown in the tab bar
|
|
161
|
+
# @param name [Symbol, nil] for `ui[:name]` lookup, same as any widget
|
|
162
|
+
# @return [Handle]
|
|
163
|
+
# @raise [ArgumentError] if declared anywhere other than directly inside ui.tabs
|
|
164
|
+
def tab(label, name = nil, **opts, &block)
|
|
165
|
+
current_tabs!('tab')
|
|
166
|
+
append_container(:tab, name, opts.merge(tab_label: label), &block)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Orientation values a `ui.split` accepts - the same plain words Tk's
|
|
170
|
+
# own -orient option uses, so no translation is needed at realize.
|
|
171
|
+
ORIENTATIONS = %i[horizontal vertical].freeze
|
|
172
|
+
|
|
173
|
+
# A draggable split - two or more `#pane`-declared regions, resizable
|
|
174
|
+
# by dragging the sash between them. Maps to `ttk::panedwindow`.
|
|
175
|
+
# @param name [Symbol, nil] for `ui[:name]` lookup, same as any widget
|
|
176
|
+
# @param orientation [Symbol] +:horizontal+ (panes side by side, a
|
|
177
|
+
# vertical sash) or +:vertical+ (panes stacked, a horizontal sash)
|
|
178
|
+
# @yieldparam s [self] build panes with `s.pane { ... }`
|
|
179
|
+
# @return [Handle]
|
|
180
|
+
# @raise [ArgumentError] if orientation isn't :horizontal or :vertical
|
|
181
|
+
def split(name = nil, orientation: :horizontal, **opts, &block)
|
|
182
|
+
unless ORIENTATIONS.include?(orientation)
|
|
183
|
+
raise ArgumentError, "split's orientation must be :horizontal or :vertical (got #{orientation.inspect})"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
append_container(:split, name, opts.merge(orient: orientation.to_s), &block)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# One region of an enclosing `ui.split`. Only valid directly inside a
|
|
190
|
+
# `ui.split` block; its own block builds the pane's content with the
|
|
191
|
+
# ordinary widget DSL, same as any other container.
|
|
192
|
+
# @param name [Symbol, nil] for `ui[:name]` lookup, same as any widget
|
|
193
|
+
# @param weight [Integer, nil] how much of the leftover space this
|
|
194
|
+
# pane absorbs when the split is resized, relative to its sibling
|
|
195
|
+
# panes' weights - unset panes get Tk's own default (0, fixed size
|
|
196
|
+
# until dragged). The same plain word `ttk::panedwindow` itself
|
|
197
|
+
# uses for this.
|
|
198
|
+
# @return [Handle]
|
|
199
|
+
# @raise [ArgumentError] if declared anywhere other than directly inside ui.split
|
|
200
|
+
def pane(name = nil, weight: nil, **opts, &block)
|
|
201
|
+
current_split!('pane')
|
|
202
|
+
opts = weight.nil? ? opts : opts.merge(pane_weight: weight)
|
|
203
|
+
append_container(:pane, name, opts, &block)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Node types a menu_bar is allowed to attach to - the root window
|
|
207
|
+
# itself, or a ui.window toplevel. Attaching a -menu to anything else
|
|
208
|
+
# (a plain frame) isn't a real Tk option, so this fails fast at
|
|
209
|
+
# declaration time rather than surfacing as a cryptic Tcl error later.
|
|
210
|
+
MENU_BAR_HOSTS = %i[root window].freeze
|
|
211
|
+
|
|
212
|
+
# A window's menu bar - the row of top-level dropdowns (File/Edit/...)
|
|
213
|
+
# along its top edge. Valid at the top level of a build or directly
|
|
214
|
+
# inside `ui.window` - attaches to whichever of those it's declared
|
|
215
|
+
# in once realized.
|
|
216
|
+
# @param name [Symbol, nil]
|
|
217
|
+
# @yieldparam mb [MenuBuilder]
|
|
218
|
+
# @return [Handle]
|
|
219
|
+
# @raise [ArgumentError] if declared anywhere other than the top level or directly inside ui.window
|
|
220
|
+
def menu_bar(name = nil, **opts, &block)
|
|
221
|
+
raise_if_closed!
|
|
222
|
+
parent = @stack.last
|
|
223
|
+
unless MENU_BAR_HOSTS.include?(parent.type)
|
|
224
|
+
raise ArgumentError, "menu_bar can only be declared at the top level of a build or directly inside ui.window"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
node = @document.create(type: :menu_bar, name: name, opts: opts, scope: current_scope)
|
|
228
|
+
parent.add_child(node)
|
|
229
|
+
build_menu_subtree(node, &block)
|
|
230
|
+
Handle.new(node)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# A standalone popup menu - built the same declarative way as a
|
|
234
|
+
# menu_bar's dropdowns, but not attached to anything automatically.
|
|
235
|
+
# Wire it to a widget with `handle.on_right_click(this)`.
|
|
236
|
+
# @param name [Symbol, nil]
|
|
237
|
+
# @yieldparam m [MenuBuilder]
|
|
238
|
+
# @return [Handle]
|
|
239
|
+
def context_menu(name = nil, **opts, &block)
|
|
240
|
+
raise_if_closed!
|
|
241
|
+
node = @document.create(type: :context_menu, name: name, opts: opts, scope: current_scope)
|
|
242
|
+
@stack.last.add_child(node)
|
|
243
|
+
build_menu_subtree(node, &block)
|
|
244
|
+
Handle.new(node)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# The build-time escape hatch. A widget has no Tk path yet during
|
|
248
|
+
# build, so `app.command(handle.path, ...)` mid-build can't work -
|
|
249
|
+
# `ui.raw` defers the block instead, running it at realize with the
|
|
250
|
+
# live app in scope. It's a closure, so it can still reference sibling
|
|
251
|
+
# widgets by name (`ui[:other].path`) even if they're declared later -
|
|
252
|
+
# by the time any raw block runs, the whole tree has already been
|
|
253
|
+
# realized once over (same forward-reference guarantee event target:
|
|
254
|
+
# gets). For anything after realize, a live {Handle}/`session.app` is
|
|
255
|
+
# the escape hatch instead - see the README for the full split.
|
|
256
|
+
# @yieldparam app [Teek::App]
|
|
257
|
+
# @return [nil]
|
|
258
|
+
def raw(&block)
|
|
259
|
+
raise_if_closed!
|
|
260
|
+
node = @document.create(type: :raw_op, opts: { block: block })
|
|
261
|
+
@stack.last.add_child(node)
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Declare a reactive variable. Its Tcl variable name is allocated now
|
|
266
|
+
# (no interpreter needed - it's just a string); the variable itself
|
|
267
|
+
# only becomes real at realize. Bind it to widgets with `bind:`.
|
|
268
|
+
# @param initial [Object] initial value - its class decides how
|
|
269
|
+
# {Var#value} coerces later (Integer/Float/Boolean typed, else String)
|
|
270
|
+
# @return [Var]
|
|
271
|
+
def var(initial)
|
|
272
|
+
raise_if_closed!
|
|
273
|
+
@var_count = (@var_count || 0) + 1
|
|
274
|
+
v = Var.new("::teek_ui_var_#{@var_count}", initial)
|
|
275
|
+
@vars << v
|
|
276
|
+
v
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Declare an image, loaded from a file. Its Tcl image name is
|
|
280
|
+
# allocated now (no interpreter needed - it's just a string); the
|
|
281
|
+
# backing {Teek::Photo} - and the actual file load - only becomes
|
|
282
|
+
# real at realize. Pass it as a widget's `image:` option (or a
|
|
283
|
+
# later `handle.configure(image: ...)`) to display it.
|
|
284
|
+
# @param path [String] path to an image file (any format Tk's own
|
|
285
|
+
# `image create photo -file` accepts - PNG, GIF, ...)
|
|
286
|
+
# @param opts [Hash] forwarded to {Teek::Photo.new} - e.g.
|
|
287
|
+
# +width:+/+height:+/+format:+/+palette:+/+gamma:+
|
|
288
|
+
# @return [Image]
|
|
289
|
+
def image(path, **opts)
|
|
290
|
+
raise_if_closed!
|
|
291
|
+
@image_count = (@image_count || 0) + 1
|
|
292
|
+
img = Image.new("teek_ui_image_#{@image_count}", path, opts)
|
|
293
|
+
@images << img
|
|
294
|
+
img
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Opens a fresh {Scope} around the block, so names declared inside
|
|
298
|
+
# it (`ui.button(:save)`, ...) never collide with the same name
|
|
299
|
+
# used elsewhere - in another component, or at the top level - no
|
|
300
|
+
# matter how many components share the same (or no) +label+, since
|
|
301
|
+
# {Scope} identity, not label, is what makes two scopes distinct.
|
|
302
|
+
# Splices its content directly into whatever's currently open -
|
|
303
|
+
# this is scope isolation only, not an extra layer of nesting, so
|
|
304
|
+
# a component built inside `ui.panel(:p) { }` attaches as an
|
|
305
|
+
# ordinary child of +:p+, exactly like any other widget declared
|
|
306
|
+
# right there would. The common 80% case - a plain method that
|
|
307
|
+
# takes +ui+ and appends into whatever's already open
|
|
308
|
+
# (`def toolbar(ui) = ui.row { ... }`) - needs none of this; reach
|
|
309
|
+
# for +#component+ only when scope isolation itself is the point
|
|
310
|
+
# (reuse across files, avoiding name collisions).
|
|
311
|
+
#
|
|
312
|
+
# The returned {ComponentHandle} is the disciplined way for the
|
|
313
|
+
# caller to reach into the component's own named widgets afterward
|
|
314
|
+
# (`screen.handle(:action)`/`screen[:action]`) - the global `ui[]`
|
|
315
|
+
# never sees into a component's scope (see {#[]}), so a component
|
|
316
|
+
# built in one file and mounted from another stays reachable only
|
|
317
|
+
# through the facade it hands back, not by guessing its internal
|
|
318
|
+
# names.
|
|
319
|
+
# @param label [Symbol, String, nil] a human-readable label for
|
|
320
|
+
# error messages/debugging - has no bearing on uniqueness
|
|
321
|
+
# @yieldparam c [self] build the component's content with the
|
|
322
|
+
# ordinary widget DSL, same as any other block
|
|
323
|
+
# @return [ComponentHandle]
|
|
324
|
+
def component(label = nil, &block)
|
|
325
|
+
raise_if_closed!
|
|
326
|
+
scope = Scope.new(label, parent: current_scope)
|
|
327
|
+
@scope_stack.push(scope)
|
|
328
|
+
begin
|
|
329
|
+
block.call(self) if block
|
|
330
|
+
ensure
|
|
331
|
+
@scope_stack.pop
|
|
332
|
+
end
|
|
333
|
+
ComponentHandle.new(@document, scope)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# A push/pop stack for content screens - see {Screens}. One stack per
|
|
337
|
+
# build, created on first access.
|
|
338
|
+
# @return [Screens]
|
|
339
|
+
def screens
|
|
340
|
+
@screens ||= Screens.new(document: @document)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# A push/pop stack for modal window handles - see {ModalStack}. `nil`
|
|
344
|
+
# until assigned; unlike {#screens} it isn't created automatically,
|
|
345
|
+
# since its callbacks (`on_enter:`/`on_exit:`) are mandatory and
|
|
346
|
+
# app-specific: `ui.modal = Teek::UI::ModalStack.new(on_enter:, on_exit:)`.
|
|
347
|
+
# @return [ModalStack, nil]
|
|
348
|
+
attr_accessor :modal
|
|
349
|
+
|
|
350
|
+
private
|
|
351
|
+
|
|
352
|
+
# The tree is only ever walked into Tk once (at realize) - a node
|
|
353
|
+
# appended afterward, outside an {Session#add} block, would just sit
|
|
354
|
+
# in the tree forever and never show up, with no error to say why.
|
|
355
|
+
# Every tree-mutating build method checks this first instead.
|
|
356
|
+
def raise_if_closed!
|
|
357
|
+
raise ClosedBuilderError unless build_open?
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# @api private - the ONLY place +@stack+ (the build-parent stack)
|
|
361
|
+
# is pushed. Notifies {Document#notify}'s +:push+ event with the
|
|
362
|
+
# ancestry this node now heads - see {Document#subscribe}.
|
|
363
|
+
def push_stack(node)
|
|
364
|
+
@stack.push(node)
|
|
365
|
+
@document.notify(:push, node, current_path)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# @api private - the ONLY place +@stack+ is popped. Symmetric with
|
|
369
|
+
# {#push_stack}: the +:pop+ event's own path is captured BEFORE
|
|
370
|
+
# popping, so it still includes the node being popped, exactly like
|
|
371
|
+
# the +:push+ event for that same node did.
|
|
372
|
+
# @return [Node] the popped node
|
|
373
|
+
def pop_stack
|
|
374
|
+
path = current_path
|
|
375
|
+
node = @stack.pop
|
|
376
|
+
@document.notify(:pop, node, path)
|
|
377
|
+
node
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def build_menu_subtree(node, &block)
|
|
381
|
+
return unless block
|
|
382
|
+
|
|
383
|
+
push_stack(node)
|
|
384
|
+
begin
|
|
385
|
+
block.call(MenuBuilder.new(@document, @stack))
|
|
386
|
+
ensure
|
|
387
|
+
pop_stack
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def append_leaf(type, name, opts)
|
|
392
|
+
raise_if_closed!
|
|
393
|
+
validate_scroll!(type, opts)
|
|
394
|
+
opts, layout = extract_dsl_opts(resolve_bind(type, opts))
|
|
395
|
+
node = @document.create(type: type, name: name, opts: opts, scope: current_scope)
|
|
396
|
+
node.layout = layout if layout
|
|
397
|
+
@stack.last.add_child(node)
|
|
398
|
+
Handle.new(node)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def current_scope
|
|
402
|
+
@scope_stack.last
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def resolve_bind(type, opts)
|
|
406
|
+
return opts unless opts.key?(:bind)
|
|
407
|
+
|
|
408
|
+
tk_option = bind_option_for(type) or
|
|
409
|
+
raise ArgumentError, "##{type} doesn't support bind: (no bindable Tk option is mapped for it)"
|
|
410
|
+
opts.reject { |k, _| k == :bind }.merge(tk_option => opts[:bind].name)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# A registered type's own `bind_option:` - `nil` (raising below) for
|
|
414
|
+
# anything unregistered or genuinely unsupported.
|
|
415
|
+
def bind_option_for(type)
|
|
416
|
+
WidgetTypes.for_type(type)&.bind_option
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def validate_scroll!(type, opts)
|
|
420
|
+
return unless opts.key?(:scroll)
|
|
421
|
+
return if natively_scrollable_for?(type)
|
|
422
|
+
|
|
423
|
+
raise ArgumentError, "##{type} doesn't support scroll: (only #{scrollable_type_names.join('/')} do)"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# A registered type's own `natively_scrollable?` - `false` for
|
|
427
|
+
# anything unregistered.
|
|
428
|
+
def natively_scrollable_for?(type)
|
|
429
|
+
WidgetTypes.for_type(type)&.natively_scrollable? || false
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# The full set of types `scroll:` actually works on, for the error
|
|
433
|
+
# message above.
|
|
434
|
+
def scrollable_type_names
|
|
435
|
+
WidgetTypes.each.select(&:natively_scrollable?).map(&:type).sort
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# `grow:`/`lazy:` are DSL-only intents, not real Tk options - pull
|
|
439
|
+
# them off opts (so neither ever reaches a widget-creation call) and
|
|
440
|
+
# onto the node's own dedicated slots instead: `grow:` becomes part
|
|
441
|
+
# of {Node#layout} (the realizer's flow packing looks for it there),
|
|
442
|
+
# `lazy:` becomes {Node#lazy?} (the realizer's tree walk skips
|
|
443
|
+
# creating this subtree until something explicitly realizes it
|
|
444
|
+
# later - see {Handle#realize!}). A leaf's own `lazy:` return value
|
|
445
|
+
# is simply unused by its caller - only a container has anywhere to
|
|
446
|
+
# put it.
|
|
447
|
+
# @return [Array(Hash, Hash, Boolean)] cleaned opts, layout (or nil), lazy
|
|
448
|
+
def extract_dsl_opts(opts)
|
|
449
|
+
layout = opts.key?(:grow) ? { grow: opts[:grow] } : nil
|
|
450
|
+
lazy = opts.fetch(:lazy, false)
|
|
451
|
+
[opts.reject { |k, _| k == :grow || k == :lazy }, layout, lazy]
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def current_grid!(method_name)
|
|
455
|
+
grid_node = @stack.last
|
|
456
|
+
unless grid_node.type == :grid
|
|
457
|
+
raise ArgumentError, "##{method_name} can only be used directly inside ui.grid"
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
grid_node
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def current_tabs!(method_name)
|
|
464
|
+
tabs_node = @stack.last
|
|
465
|
+
unless tabs_node.type == :tabs
|
|
466
|
+
raise ArgumentError, "##{method_name} can only be used directly inside ui.tabs"
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
tabs_node
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def current_split!(method_name)
|
|
473
|
+
split_node = @stack.last
|
|
474
|
+
unless split_node.type == :split
|
|
475
|
+
raise ArgumentError, "##{method_name} can only be used directly inside ui.split"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
split_node
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def current_canvas!(method_name)
|
|
482
|
+
canvas_node = @stack.last
|
|
483
|
+
unless canvas_node.type == :canvas
|
|
484
|
+
raise ArgumentError, "##{method_name} can only be used directly inside ui.canvas"
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
canvas_node
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def append_container(type, name, opts)
|
|
491
|
+
raise_if_closed!
|
|
492
|
+
validate_scroll!(type, opts)
|
|
493
|
+
opts, layout, lazy = extract_dsl_opts(opts)
|
|
494
|
+
node = @document.create(type: type, name: name, opts: opts, scope: current_scope)
|
|
495
|
+
node.layout = layout if layout
|
|
496
|
+
node.lazy = lazy
|
|
497
|
+
@stack.last.add_child(node)
|
|
498
|
+
|
|
499
|
+
if block_given?
|
|
500
|
+
push_stack(node)
|
|
501
|
+
begin
|
|
502
|
+
yield self
|
|
503
|
+
ensure
|
|
504
|
+
pop_stack
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
Handle.new(node)
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
end
|