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,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
|