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,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'widget_validators'
|
|
4
|
+
require_relative 'widget_types'
|
|
5
|
+
|
|
6
|
+
module Teek
|
|
7
|
+
module UI
|
|
8
|
+
# @api private
|
|
9
|
+
#
|
|
10
|
+
# A grid's own contract: every direct child needs a cell
|
|
11
|
+
# (+g.cell(row:, col:) { }+), and no two children can claim the same
|
|
12
|
+
# one. {Realizer#arrange_grid} still raises on a missing cell too
|
|
13
|
+
# (kept as a belt-and-suspenders backstop for the one path that skips
|
|
14
|
+
# validation entirely - {Session#add}'s incremental realize), but this
|
|
15
|
+
# is the primary detection, so the mistake surfaces pre-realize,
|
|
16
|
+
# collected alongside every other problem, instead of crashing
|
|
17
|
+
# mid-realize. Composed into {WidgetValidators} via :grid's own
|
|
18
|
+
# {WidgetType#validator} (see +widget_types/grid.rb+).
|
|
19
|
+
module GridValidator
|
|
20
|
+
# @param node [Node] a :grid node - {WidgetValidators} only dispatches
|
|
21
|
+
# here for that type
|
|
22
|
+
# @param parent [Node, nil]
|
|
23
|
+
# @param document [Document]
|
|
24
|
+
# @param errors [Array<String>] appended to, never raised
|
|
25
|
+
# @return [void]
|
|
26
|
+
def self.call(node, parent, document, errors)
|
|
27
|
+
check_missing_cell(node, errors)
|
|
28
|
+
check_cell_collisions(node, errors)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.check_missing_cell(node, errors)
|
|
32
|
+
node.children.each do |child|
|
|
33
|
+
next unless needs_cell?(child.type)
|
|
34
|
+
next if child.layout && child.layout[:cell]
|
|
35
|
+
|
|
36
|
+
errors << "#{WidgetValidators.describe(child)} is a direct child of a grid but was never placed with " \
|
|
37
|
+
"g.cell(row:, col:) { ... }"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.check_cell_collisions(node, errors)
|
|
42
|
+
node.children
|
|
43
|
+
.group_by { |child| child.layout && child.layout[:cell] && [child.layout[:cell][:row], child.layout[:cell][:col]] }
|
|
44
|
+
.each do |position, children|
|
|
45
|
+
next if position.nil? || children.length <= 1
|
|
46
|
+
|
|
47
|
+
row, col = position
|
|
48
|
+
errors << "#{WidgetValidators.describe(node)} has more than one widget at row #{row}, col #{col}: " \
|
|
49
|
+
"#{children.map { |c| WidgetValidators.describe(c) }.join(', ')}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# :raw_op has no widget of its own at all (mirrors
|
|
54
|
+
# {Realizer::NON_WIDGET_TYPES}); every other type reports whether it
|
|
55
|
+
# needs a cell via its own {WidgetType#arranged?} (mirrors
|
|
56
|
+
# {Realizer#unarranged?}) - true (needs a cell) for anything
|
|
57
|
+
# unregistered, since every type a grid can hold is WidgetType-registered.
|
|
58
|
+
def self.needs_cell?(type)
|
|
59
|
+
return false if type == :raw_op
|
|
60
|
+
|
|
61
|
+
registered = WidgetTypes.for_type(type)
|
|
62
|
+
registered.nil? || registered.arranged?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private_class_method :check_missing_cell, :check_cell_collisions, :needs_cell?
|
|
66
|
+
|
|
67
|
+
# The opposite direction from {.check_missing_cell}: a node carrying a
|
|
68
|
+
# grid-cell position (+layout[:cell]+) whose actual parent isn't a
|
|
69
|
+
# :grid at all - only reachable via direct Node/Document manipulation,
|
|
70
|
+
# since {WidgetDSL#cell} already refuses to run outside a ui.grid
|
|
71
|
+
# block. Cell intent can land on any node type (whatever #cell's
|
|
72
|
+
# block happens to build), so unlike {.call} above - dispatched
|
|
73
|
+
# through the registry only when *visiting a :grid* - this can't be
|
|
74
|
+
# keyed off a single node type there. {Validator} calls this directly
|
|
75
|
+
# for every node instead, in the same single tree walk.
|
|
76
|
+
# @param node [Node]
|
|
77
|
+
# @param parent [Node, nil]
|
|
78
|
+
# @param errors [Array<String>]
|
|
79
|
+
# @return [void]
|
|
80
|
+
def self.check_stray_cell(node, parent, errors)
|
|
81
|
+
return unless node.layout && node.layout[:cell]
|
|
82
|
+
return if parent && parent.type == :grid
|
|
83
|
+
|
|
84
|
+
errors << "#{WidgetValidators.describe(node)} has a grid cell position but its parent " \
|
|
85
|
+
"(#{WidgetValidators.describe(parent)}) isn't a ui.grid - its row/col/span would be silently ignored"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'realized_node'
|
|
5
|
+
require_relative 'event_binding'
|
|
6
|
+
require_relative 'keysyms'
|
|
7
|
+
require_relative 'mouse_events'
|
|
8
|
+
require_relative 'canvas_item'
|
|
9
|
+
require_relative 'text_content'
|
|
10
|
+
require_relative 'widget_addressing'
|
|
11
|
+
require_relative 'widget_types'
|
|
12
|
+
require_relative 'realizer'
|
|
13
|
+
|
|
14
|
+
module Teek
|
|
15
|
+
module UI
|
|
16
|
+
# The single handle type for a node, valid across both phases (Resolved
|
|
17
|
+
# decision #3 in the architecture doc - no separate build-time NodeRef).
|
|
18
|
+
# During build you compose/name/record-events on it; live methods
|
|
19
|
+
# (#path, #configure) raise {NotRealizedError} until the node's
|
|
20
|
+
# +realized+ slot is filled in by the realizer, then the same Handle
|
|
21
|
+
# object drives the real widget through it.
|
|
22
|
+
class Handle
|
|
23
|
+
RIGHT_CLICK_EVENTS = MouseEvents::RIGHT_CLICK_EVENTS
|
|
24
|
+
MENU_HANDLE_TYPES = MouseEvents::MENU_HANDLE_TYPES
|
|
25
|
+
|
|
26
|
+
# @api private
|
|
27
|
+
def initialize(node)
|
|
28
|
+
@node = node
|
|
29
|
+
@addressing = (WidgetTypes.for_type(node.type)&.addressing || WidgetAddressing).new(node)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Symbol] the node's type, e.g. +:button+
|
|
33
|
+
def type
|
|
34
|
+
@node.type
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Symbol, nil] the node's explicit name
|
|
38
|
+
def name
|
|
39
|
+
@node.name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [String] this node's live address - the real Tk widget
|
|
43
|
+
# path for an ordinary widget, or (for a type with none of its
|
|
44
|
+
# own, e.g. a menu entry) its {WidgetType#addressing} strategy's
|
|
45
|
+
# own marked, Tk-path-shaped virtual path - see
|
|
46
|
+
# {MenuEntryAddressing#virtual_path}
|
|
47
|
+
# @raise [NotRealizedError] before realize
|
|
48
|
+
def path
|
|
49
|
+
@addressing.virtual_path
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Teek::App] the underlying app this widget was realized into
|
|
53
|
+
# @raise [NotRealizedError] before realize
|
|
54
|
+
def app
|
|
55
|
+
realized.app
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Mutate the live widget's (or, for a type with none of its own,
|
|
59
|
+
# e.g. a menu entry - the entry's own) options - delegated entirely
|
|
60
|
+
# to this node type's {WidgetType#addressing} strategy, so Handle
|
|
61
|
+
# itself carries no per-type knowledge of how to reach it.
|
|
62
|
+
# @param opts [Hash] e.g. +text: "Go"+
|
|
63
|
+
# @raise [NotRealizedError] before realize
|
|
64
|
+
def configure(**opts)
|
|
65
|
+
@addressing.configure(**opts)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# What Tk currently thinks this widget's (or, for a type with none
|
|
69
|
+
# of its own, e.g. a menu entry - the entry's own) options are right
|
|
70
|
+
# now, straight from a bare +configure+ - for when a prior
|
|
71
|
+
# {#configure} call seems like it silently didn't take, or just
|
|
72
|
+
# exploring what's actually set on a live widget. Delegated to this
|
|
73
|
+
# node type's {WidgetType#addressing} strategy, same as {#configure}.
|
|
74
|
+
# @return [Hash{Symbol => String}] option name (no leading +-+) => current value
|
|
75
|
+
# @raise [NotRealizedError] before realize
|
|
76
|
+
def options
|
|
77
|
+
@addressing.option_dump
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Shorthand for +configure(state: :normal)+ - Tk's own default
|
|
81
|
+
# state, meaningful for anything with a +-state+ option (a menu
|
|
82
|
+
# entry, a ttk widget, ...).
|
|
83
|
+
# @return [self]
|
|
84
|
+
# @raise [NotRealizedError] before realize
|
|
85
|
+
def enable
|
|
86
|
+
configure(state: :normal)
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Shorthand for +configure(state: :disabled)+ - greyed out, not
|
|
91
|
+
# interactive/invocable.
|
|
92
|
+
# @return [self]
|
|
93
|
+
# @raise [NotRealizedError] before realize
|
|
94
|
+
def disable
|
|
95
|
+
configure(state: :disabled)
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Tears down this node's live widget (and everything under it),
|
|
100
|
+
# releasing its callbacks via teek's existing +<Destroy>+ cleanup,
|
|
101
|
+
# and resets it so a later push of a fresh mount rebuilds it from
|
|
102
|
+
# scratch. The rebuild gets a fresh Tk path, not necessarily this
|
|
103
|
+
# same one - path segments are claimed once and never recycled (see
|
|
104
|
+
# {Document#claim_path_segment}), so this stays safe even if another
|
|
105
|
+
# instance sharing this same local name is still alive elsewhere
|
|
106
|
+
# under the same parent. Typically called after popping a screen you
|
|
107
|
+
# don't want to keep warm (see {Screens}) - popping alone only
|
|
108
|
+
# conceals, exactly as before; this is a separate, explicit step -
|
|
109
|
+
# +ui.screens.pop&.destroy!+/+ui.modal.pop&.destroy!+.
|
|
110
|
+
#
|
|
111
|
+
# Destroying a widget SYNCHRONOUSLY from inside the click handler
|
|
112
|
+
# of one of its own descendants (a dialog's own "Close" button
|
|
113
|
+
# tearing down the dialog it lives in) is a real Tk hazard:
|
|
114
|
+
# `ttk::button` (and others) queue their own internal bindings for
|
|
115
|
+
# that SAME click, which then run against a widget that's already
|
|
116
|
+
# gone. +defer+ absorbs this automatically - no need to know about
|
|
117
|
+
# it or reach for `ui.after` yourself.
|
|
118
|
+
# @param defer [Boolean, nil] +nil+ (the default) auto-detects:
|
|
119
|
+
# defers to the next Tk idle point ({Teek.in_callback?} true -
|
|
120
|
+
# the hazard above) so the current click finishes first, or
|
|
121
|
+
# destroys synchronously otherwise (a script/test with no event
|
|
122
|
+
# loop running has nothing to defer TO, and wants "gone when
|
|
123
|
+
# this call returns" semantics). Pass explicitly to override
|
|
124
|
+
# either way. Calling this again on the same handle while its
|
|
125
|
+
# own deferred destroy hasn't run yet is a safe no-op.
|
|
126
|
+
#
|
|
127
|
+
# The one residual to know about: when this DOES defer, it
|
|
128
|
+
# returns before the widget is actually gone - the node still
|
|
129
|
+
# reports realized and its old Tk path still exists until the
|
|
130
|
+
# deferred teardown runs. Don't +destroy!+ then immediately
|
|
131
|
+
# rebuild a fresh mount at that SAME name/path in the same
|
|
132
|
+
# handler expecting the old one to already be gone; either pass
|
|
133
|
+
# +defer: false+ to force it synchronous first, or build the
|
|
134
|
+
# replacement under a genuinely distinct mount (the normal
|
|
135
|
+
# "fresh `lazy: true` component per open" pattern already does
|
|
136
|
+
# this, since {Document#claim_path_segment} never reuses a path
|
|
137
|
+
# segment anyway).
|
|
138
|
+
# @return [nil]
|
|
139
|
+
# @raise [NotRealizedError] if this node was never realized
|
|
140
|
+
def destroy!(defer: nil)
|
|
141
|
+
return nil if @node.pending_destroy?
|
|
142
|
+
|
|
143
|
+
should_defer = defer.nil? ? Teek.in_callback? : defer
|
|
144
|
+
if should_defer
|
|
145
|
+
app = realized.app
|
|
146
|
+
@node.pending_destroy = true
|
|
147
|
+
app.after_idle { perform_destroy! }
|
|
148
|
+
else
|
|
149
|
+
perform_destroy!
|
|
150
|
+
end
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Fires on a left click.
|
|
155
|
+
# @yield called with no arguments
|
|
156
|
+
# @return [self]
|
|
157
|
+
def on_click(&block)
|
|
158
|
+
bind_event('<Button-1>', block)
|
|
159
|
+
self
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Fires on a right click, however the platform spells it (Button-3 on
|
|
163
|
+
# Linux/Windows, Button-2 or Control-Button-1 on macOS). Either handle
|
|
164
|
+
# it yourself with a block, or hand it a `:menu`/`:context_menu`
|
|
165
|
+
# handle to pop up at the click's screen position - not both.
|
|
166
|
+
# @param menu [Handle, nil] a `:menu` or `:context_menu` handle to tk_popup
|
|
167
|
+
# @yield called with no arguments (only when +menu+ isn't given)
|
|
168
|
+
# @return [self]
|
|
169
|
+
# @raise [ArgumentError] if given neither or both, or +menu+ isn't a menu handle
|
|
170
|
+
def on_right_click(menu = nil, &block)
|
|
171
|
+
if menu && block
|
|
172
|
+
raise ArgumentError, "on_right_click takes either a menu handle or a block, not both"
|
|
173
|
+
elsif menu
|
|
174
|
+
unless MENU_HANDLE_TYPES.include?(menu.type)
|
|
175
|
+
raise ArgumentError, "on_right_click(menu) needs a :menu or :context_menu handle (got a :#{menu.type})"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
popup = lambda do |root_x, root_y|
|
|
179
|
+
realized.app.popup_menu(menu.path, x: root_x, y: root_y)
|
|
180
|
+
end
|
|
181
|
+
RIGHT_CLICK_EVENTS.each { |event| bind_event(event, popup, subs: %i[root_x root_y]) }
|
|
182
|
+
elsif block
|
|
183
|
+
RIGHT_CLICK_EVENTS.each { |event| bind_event(event, block) }
|
|
184
|
+
else
|
|
185
|
+
raise ArgumentError, "on_right_click needs either a menu handle or a block"
|
|
186
|
+
end
|
|
187
|
+
self
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Fires while dragging (left button held down and moving). Delivers
|
|
191
|
+
# Integer x/y, converted through the widget's own canvasx/canvasy when
|
|
192
|
+
# bound to a canvas so callers never have to remember to do that
|
|
193
|
+
# themselves.
|
|
194
|
+
# @yield [x, y] Integer coordinates
|
|
195
|
+
# @return [self]
|
|
196
|
+
def on_drag(&block)
|
|
197
|
+
drag_type = type
|
|
198
|
+
wrapped = lambda do |raw_x, raw_y|
|
|
199
|
+
block.call(*convert_drag_coords(drag_type, raw_x, raw_y))
|
|
200
|
+
end
|
|
201
|
+
bind_event('<B1-Motion>', wrapped, subs: %i[x y])
|
|
202
|
+
self
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Fires on a key press. +spec+ is either a friendly Symbol (+:enter+,
|
|
206
|
+
# +:escape+, +:up+, ...) or a "Modifier-Modifier-Key" String
|
|
207
|
+
# (+"Ctrl-s"+, +"Ctrl-Shift-s"+) - see {Keysyms}.
|
|
208
|
+
# @param spec [Symbol, String]
|
|
209
|
+
# @yield called with no arguments
|
|
210
|
+
# @return [self]
|
|
211
|
+
def on_key(spec, &block)
|
|
212
|
+
modifiers, keysym = Keysyms.resolve(spec)
|
|
213
|
+
Keysyms.patterns_for(modifiers, keysym).each { |event| bind_event(event, block) }
|
|
214
|
+
self
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Fires when the window's close button (titlebar close box, Cmd-W,
|
|
218
|
+
# Alt-F4, ...) is pressed. Teek's own default (destroy the window)
|
|
219
|
+
# only applies when nothing else has claimed it - the block decides
|
|
220
|
+
# whether the window actually closes; call `.destroy` yourself if you
|
|
221
|
+
# want that. Only valid on a `ui.window` handle.
|
|
222
|
+
# @yield called with no arguments
|
|
223
|
+
# @return [self]
|
|
224
|
+
# @raise [ArgumentError] if this handle isn't a window
|
|
225
|
+
def on_close(&block)
|
|
226
|
+
unless type == :window
|
|
227
|
+
raise ArgumentError, "on_close only makes sense on a window (got a :#{type})"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
if @node.realized
|
|
231
|
+
@node.realized.app.on_close(window: @node.realized.path, &block)
|
|
232
|
+
else
|
|
233
|
+
@node.opts[:on_close] = block
|
|
234
|
+
end
|
|
235
|
+
self
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Fires when the selected tab changes (Tk's <<NotebookTabChanged>>).
|
|
239
|
+
# The block receives the newly selected tab's own name (the Symbol
|
|
240
|
+
# given via `t.tab(label, name)`) if it has one, otherwise its plain
|
|
241
|
+
# zero-based index - preferring a name over a raw Tk index, same as
|
|
242
|
+
# `ui[:name]` lookup does everywhere else in the DSL. Only valid on a
|
|
243
|
+
# `ui.tabs` handle.
|
|
244
|
+
# @yield [name_or_index] Symbol or Integer
|
|
245
|
+
# @return [self]
|
|
246
|
+
# @raise [ArgumentError] if this handle isn't a tabs container
|
|
247
|
+
def on_tab_changed(&block)
|
|
248
|
+
unless type == :tabs
|
|
249
|
+
raise ArgumentError, "on_tab_changed only makes sense on a tabs container (got a :#{type})"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
wrapped = lambda {
|
|
253
|
+
index = realized.app.command(realized.path, :index, :current).to_i
|
|
254
|
+
tab_node = @node.children[index]
|
|
255
|
+
block.call(tab_node&.name || index)
|
|
256
|
+
}
|
|
257
|
+
bind_event('<<NotebookTabChanged>>', wrapped)
|
|
258
|
+
self
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Show the window modally: grabs input and sets focus on it
|
|
262
|
+
# immediately. Release it explicitly with {#grab_release} (typically
|
|
263
|
+
# from the window's own dismiss/close handling) when the dialog is
|
|
264
|
+
# done - not released automatically just because this method
|
|
265
|
+
# returns, since a modal dialog stays grabbed for its whole visible
|
|
266
|
+
# lifetime, not just its setup. Released immediately if the optional
|
|
267
|
+
# setup block itself raises, or if the window is destroyed while
|
|
268
|
+
# still grabbed - see {Teek::Window#modal}, which this delegates to
|
|
269
|
+
# entirely (no grab/focus/destroy-safety-net logic lives here).
|
|
270
|
+
# Only valid on a `ui.window` handle.
|
|
271
|
+
# @param global [Boolean] see {Teek::Window#grab_set}
|
|
272
|
+
# @yield optional - runs with the grab and focus already set
|
|
273
|
+
# @return [void]
|
|
274
|
+
# @raise [ArgumentError] if this handle isn't a window
|
|
275
|
+
# @raise [NotRealizedError] before realize
|
|
276
|
+
def modal(global: false, &block)
|
|
277
|
+
unless type == :window
|
|
278
|
+
raise ArgumentError, "modal only makes sense on a window (got a :#{type})"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
window.modal(global: global, &block)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Release a grab previously set with {#modal} - "grab" is X11/Tk
|
|
285
|
+
# jargon for capturing all input to one window, which is what a
|
|
286
|
+
# modal dialog is doing while it's up. Only valid on a `ui.window`
|
|
287
|
+
# handle. See {Teek::Window#grab_release}.
|
|
288
|
+
# @return [void]
|
|
289
|
+
# @raise [ArgumentError] if this handle isn't a window
|
|
290
|
+
# @raise [NotRealizedError] before realize
|
|
291
|
+
def release_focus
|
|
292
|
+
unless type == :window
|
|
293
|
+
raise ArgumentError, "release_focus only makes sense on a window (got a :#{type})"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
window.grab_release
|
|
297
|
+
end
|
|
298
|
+
alias_method :grab_release, :release_focus
|
|
299
|
+
|
|
300
|
+
# Reveal the window: positions it just to the right of the parent
|
|
301
|
+
# it's nested under (root, or another window if this one's nested
|
|
302
|
+
# inside it), deiconifies, raises it to the front, and - only if
|
|
303
|
+
# this window was declared `modal: true` - grabs input and focuses
|
|
304
|
+
# it too (via {#modal}). Only valid on a `ui.window` handle.
|
|
305
|
+
# @return [self]
|
|
306
|
+
# @raise [ArgumentError] if this handle isn't a window
|
|
307
|
+
# @raise [NotRealizedError] before realize
|
|
308
|
+
def show
|
|
309
|
+
unless type == :window
|
|
310
|
+
raise ArgumentError, "show only makes sense on a window (got a :#{type})"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
position_near_parent
|
|
314
|
+
window.deiconify
|
|
315
|
+
realized.app.command(:raise, realized.path)
|
|
316
|
+
modal if @node.opts[:modal]
|
|
317
|
+
self
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Hide the window: releases any grab {#show} set (a no-op if it
|
|
321
|
+
# wasn't modal - {Teek::Window#grab_release} is always safe to call)
|
|
322
|
+
# and withdraws it. Only valid on a `ui.window` handle.
|
|
323
|
+
# @return [self]
|
|
324
|
+
# @raise [ArgumentError] if this handle isn't a window
|
|
325
|
+
# @raise [NotRealizedError] before realize
|
|
326
|
+
def hide
|
|
327
|
+
unless type == :window
|
|
328
|
+
raise ArgumentError, "hide only makes sense on a window (got a :#{type})"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
grab_release
|
|
332
|
+
window.withdraw
|
|
333
|
+
self
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# A straight line through the given points - +[x1, y1, x2, y2, ...]+,
|
|
337
|
+
# flat or nested, two or more points. Only valid on a `ui.canvas` handle.
|
|
338
|
+
# @param coords [Array<Numeric>]
|
|
339
|
+
# @param opts [Hash] item options, e.g. +fill:+/+width:+/+tags:+
|
|
340
|
+
# @return [CanvasItem]
|
|
341
|
+
# @raise [ArgumentError] if this handle isn't a canvas
|
|
342
|
+
# @raise [NotRealizedError] before realize
|
|
343
|
+
def line(*coords, **opts)
|
|
344
|
+
create_canvas_item(:line, coords, opts)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# An ellipse inscribed in the bounding box +[x1, y1, x2, y2]+. Only
|
|
348
|
+
# valid on a `ui.canvas` handle.
|
|
349
|
+
# @param coords [Array<Numeric>]
|
|
350
|
+
# @param opts [Hash] item options, e.g. +fill:+/+outline:+/+tags:+
|
|
351
|
+
# @return [CanvasItem]
|
|
352
|
+
# @raise [ArgumentError] if this handle isn't a canvas
|
|
353
|
+
# @raise [NotRealizedError] before realize
|
|
354
|
+
def ellipse(*coords, **opts)
|
|
355
|
+
create_canvas_item(:oval, coords, opts)
|
|
356
|
+
end
|
|
357
|
+
alias_method :oval, :ellipse
|
|
358
|
+
|
|
359
|
+
# A closed shape through the given points - +[x1, y1, x2, y2, ...]+,
|
|
360
|
+
# flat or nested, three or more points. Only valid on a `ui.canvas` handle.
|
|
361
|
+
# @param coords [Array<Numeric>]
|
|
362
|
+
# @param opts [Hash] item options, e.g. +fill:+/+smooth:+/+tags:+
|
|
363
|
+
# @return [CanvasItem]
|
|
364
|
+
# @raise [ArgumentError] if this handle isn't a canvas
|
|
365
|
+
# @raise [NotRealizedError] before realize
|
|
366
|
+
def polygon(*coords, **opts)
|
|
367
|
+
create_canvas_item(:polygon, coords, opts)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# A rectangle with corners +[x1, y1, x2, y2]+. Only valid on a
|
|
371
|
+
# `ui.canvas` handle.
|
|
372
|
+
# @param coords [Array<Numeric>]
|
|
373
|
+
# @param opts [Hash] item options, e.g. +fill:+/+outline:+/+tags:+
|
|
374
|
+
# @return [CanvasItem]
|
|
375
|
+
# @raise [ArgumentError] if this handle isn't a canvas
|
|
376
|
+
# @raise [NotRealizedError] before realize
|
|
377
|
+
def rectangle(*coords, **opts)
|
|
378
|
+
create_canvas_item(:rectangle, coords, opts)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Text anchored at +[x, y]+. Only valid on a `ui.canvas` handle.
|
|
382
|
+
# @param coords [Array<Numeric>] a single +[x, y]+ point
|
|
383
|
+
# @param opts [Hash] item options, e.g. +text:+/+fill:+/+font:+/+anchor:+/+tags:+
|
|
384
|
+
# @return [CanvasItem]
|
|
385
|
+
# @raise [ArgumentError] if this handle isn't a canvas
|
|
386
|
+
# @raise [NotRealizedError] before realize
|
|
387
|
+
def text(*coords, **opts)
|
|
388
|
+
create_canvas_item(:text, coords, opts)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# An arc/pie-slice/chord along the oval inscribed in the bounding
|
|
392
|
+
# box +[x1, y1, x2, y2]+. Only valid on a `ui.canvas` handle.
|
|
393
|
+
# @param coords [Array<Numeric>]
|
|
394
|
+
# @param opts [Hash] item options, e.g. +start:+/+extent:+/+style:+/+tags:+
|
|
395
|
+
# @return [CanvasItem]
|
|
396
|
+
# @raise [ArgumentError] if this handle isn't a canvas
|
|
397
|
+
# @raise [NotRealizedError] before realize
|
|
398
|
+
def arc(*coords, **opts)
|
|
399
|
+
create_canvas_item(:arc, coords, opts)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# A stipple bitmap anchored at +[x, y]+. Only valid on a `ui.canvas` handle.
|
|
403
|
+
# @param coords [Array<Numeric>] a single +[x, y]+ point
|
|
404
|
+
# @param opts [Hash] item options, e.g. +bitmap:+/+foreground:+/+tags:+
|
|
405
|
+
# @return [CanvasItem]
|
|
406
|
+
# @raise [ArgumentError] if this handle isn't a canvas
|
|
407
|
+
# @raise [NotRealizedError] before realize
|
|
408
|
+
def bitmap(*coords, **opts)
|
|
409
|
+
create_canvas_item(:bitmap, coords, opts)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Every event binding declared on this node so far, in declaration
|
|
413
|
+
# order - +on_click+/+on_key+/+on_drag+/+on_right_click+ and
|
|
414
|
+
# friends all funnel through here. Meaningful at any phase: before
|
|
415
|
+
# realize these are still queued (nothing wired to Tcl yet), after
|
|
416
|
+
# realize they're the bindings actually in effect - covers both a
|
|
417
|
+
# binding declared in the original build block and one added later
|
|
418
|
+
# (e.g. from inside {Session#add}), so this stays a true live
|
|
419
|
+
# picture, not just a record of what was queued pre-realize. Each
|
|
420
|
+
# entry's own +handler+ is the real Proc that runs, so
|
|
421
|
+
# +.source_location+ answers "what code does this" directly.
|
|
422
|
+
# @return [Array<EventBinding>]
|
|
423
|
+
def events
|
|
424
|
+
@node.events
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# A handle onto whatever items currently carry +tag+ - zero, one, or
|
|
428
|
+
# many (see {CanvasItem}, which addresses a tag and an id
|
|
429
|
+
# identically). Doesn't create anything; a shape-creation method
|
|
430
|
+
# (e.g. {#line}) already returns a single-item handle for its own
|
|
431
|
+
# new item, this is for addressing a shared +tags:+ group (or
|
|
432
|
+
# reaching an item by an id you already have) after the fact. Only
|
|
433
|
+
# valid on a `ui.canvas` handle.
|
|
434
|
+
# @param tag [String, Symbol, Integer]
|
|
435
|
+
# @return [CanvasItem]
|
|
436
|
+
# @raise [ArgumentError] if this handle isn't a canvas
|
|
437
|
+
# @raise [NotRealizedError] before realize
|
|
438
|
+
def tagged(tag)
|
|
439
|
+
raise_unless_canvas!('tagged')
|
|
440
|
+
CanvasItem.new(realized.app, realized.path, tag)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# The rich text API for this widget's content - insert/get/delete,
|
|
444
|
+
# named formats (Tk's own "tag" concept), markers, search, and
|
|
445
|
+
# embedded images. See {TextContent} for the full surface. Only
|
|
446
|
+
# valid on a `ui.text_area` handle.
|
|
447
|
+
# @return [TextContent]
|
|
448
|
+
# @raise [ArgumentError] if this handle isn't a text_area
|
|
449
|
+
# @raise [NotRealizedError] before realize
|
|
450
|
+
def text_content
|
|
451
|
+
raise_unless_text_area!('text_content')
|
|
452
|
+
TextContent.new(realized.app, realized.path)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
private
|
|
456
|
+
|
|
457
|
+
def realized
|
|
458
|
+
@node.realized or raise NotRealizedError
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# The actual teardown {#destroy!} defers or runs immediately -
|
|
462
|
+
# clears the pending flag first so a LATER, genuinely fresh
|
|
463
|
+
# destroy! (after a rebuild) is never mistaken for a still-pending
|
|
464
|
+
# one. Unlinks the node from the retained tree afterward (see
|
|
465
|
+
# #unlink!) so it stops being reachable at all, not just Tk-dead.
|
|
466
|
+
def perform_destroy!
|
|
467
|
+
@node.pending_destroy = false
|
|
468
|
+
realized.app.destroy(realized.path)
|
|
469
|
+
@node.realized = nil
|
|
470
|
+
unlink!
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Removes this node (and every named descendant of its own
|
|
474
|
+
# subtree - Tk destroys descendants recursively, so their names
|
|
475
|
+
# need to stop resolving too) from {Document}'s name index, then
|
|
476
|
+
# removes the node itself from its own parent's +children+. Both
|
|
477
|
+
# steps are plain Ruby, safe to run even if +@node.document+ is
|
|
478
|
+
# nil (a raw +Node.new+ built directly, mostly in headless tests)
|
|
479
|
+
# or +@node.parent+ is nil (already unlinked, or never attached).
|
|
480
|
+
def unlink!
|
|
481
|
+
document = @node.document
|
|
482
|
+
@node.each { |descendant| document.unregister(descendant) } if document
|
|
483
|
+
@node.parent&.remove_child(@node)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# @return [Boolean] whether this node has a live Tk widget yet -
|
|
487
|
+
# true for any ordinary handle once the tree's been realized;
|
|
488
|
+
# only ever false past that point for a +lazy: true+ container
|
|
489
|
+
# (see {WidgetDSL#append_container}) not yet {#realize!}d, or
|
|
490
|
+
# one that's been {#destroy!}ed since. Internal - {Screens#push}/
|
|
491
|
+
# {ModalStack#push} are what actually decide when to realize a
|
|
492
|
+
# lazy screen; an app author never needs to check this directly.
|
|
493
|
+
def realized?
|
|
494
|
+
!@node.realized.nil?
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Realizes this node (and everything declared inside it) into a
|
|
498
|
+
# live Tk widget, if it isn't already - the on-demand counterpart
|
|
499
|
+
# to +lazy: true+. A no-op if already realized. This node's own
|
|
500
|
+
# {Node#parent} must already be realized (true for anything
|
|
501
|
+
# reachable from an already-running app, which is the only
|
|
502
|
+
# situation a lazy node is realized from). Internal - called via
|
|
503
|
+
# +send+ by {Screens#push}/{ModalStack#push} (the only intended
|
|
504
|
+
# callers); a +lazy: true+ screen "just works" through them, with
|
|
505
|
+
# nothing for an app author to trigger by hand.
|
|
506
|
+
# @param document [Document] the same {Document} this node belongs
|
|
507
|
+
# to - needed to resolve any +target:+ event bindings by name
|
|
508
|
+
# @return [self]
|
|
509
|
+
# @raise [NotRealizedError] if this node's own parent isn't realized yet
|
|
510
|
+
def realize!(document)
|
|
511
|
+
return self if realized?
|
|
512
|
+
|
|
513
|
+
parent_node = @node.parent
|
|
514
|
+
parent_realized = parent_node&.realized or
|
|
515
|
+
raise NotRealizedError, "can't realize this node - its own parent isn't realized yet"
|
|
516
|
+
|
|
517
|
+
Realizer.new(parent_realized.app, document).realize_subtree(@node, parent_node)
|
|
518
|
+
self
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def window
|
|
522
|
+
realized.app.window(realized.path)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def create_canvas_item(shape, coords, opts)
|
|
526
|
+
raise_unless_canvas!(shape)
|
|
527
|
+
id = realized.app.command(realized.path, :create, shape, *coords.flatten, **opts)
|
|
528
|
+
CanvasItem.new(realized.app, realized.path, id)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def raise_unless_canvas!(method_name)
|
|
532
|
+
unless type == :canvas
|
|
533
|
+
raise ArgumentError, "##{method_name} only makes sense on a canvas (got a :#{type})"
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def raise_unless_text_area!(method_name)
|
|
538
|
+
unless type == :text_area
|
|
539
|
+
raise ArgumentError, "##{method_name} only makes sense on a text_area (got a :#{type})"
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Positions the window just to the right of the parent it's nested
|
|
544
|
+
# under - root by default, or another window's own path when this
|
|
545
|
+
# one is declared inside it. Tk toplevel paths stay hierarchical
|
|
546
|
+
# even though they're independent OS windows (allocate_path nests
|
|
547
|
+
# them the same way any other widget path nests), so the parent's
|
|
548
|
+
# path is just everything before this window's own last path
|
|
549
|
+
# segment - no separate bookkeeping needed to recover it later.
|
|
550
|
+
def position_near_parent
|
|
551
|
+
parent_x, parent_y, parent_width, = realized.app.interp.window_geometry(toplevel_parent_path)
|
|
552
|
+
window.set_geometry("+#{parent_x + parent_width + 12}+#{parent_y}")
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def toplevel_parent_path
|
|
556
|
+
path = realized.path
|
|
557
|
+
last_dot = path.rindex('.')
|
|
558
|
+
last_dot && last_dot.positive? ? path[0...last_dot] : '.'
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def bind_event(event, handler, subs: [])
|
|
562
|
+
binding = EventBinding.new(event: event, handler: handler, subs: subs)
|
|
563
|
+
@node.events << binding
|
|
564
|
+
wire(@node.realized, binding) if @node.realized
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def wire(realized_node, binding)
|
|
568
|
+
realized_node.app.bind(realized_node.path, binding.event, *binding.subs) { |*args|
|
|
569
|
+
binding.handler.call(*args)
|
|
570
|
+
}
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def convert_drag_coords(drag_type, raw_x, raw_y)
|
|
574
|
+
if drag_type == :canvas
|
|
575
|
+
info = @node.realized
|
|
576
|
+
x = info.app.command(info.path, :canvasx, raw_x).to_f.round
|
|
577
|
+
y = info.app.command(info.path, :canvasy, raw_y).to_f.round
|
|
578
|
+
[x, y]
|
|
579
|
+
else
|
|
580
|
+
[raw_x.to_i, raw_y.to_i]
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
end
|