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,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
|
|
5
|
+
module Teek
|
|
6
|
+
module UI
|
|
7
|
+
# A DSL-declared image, backed by teek core's {Teek::Photo} (which
|
|
8
|
+
# owns the underlying Tk image's GC lifetime - see its own docs).
|
|
9
|
+
#
|
|
10
|
+
# Declared via {WidgetDSL#image} at build time - its Tcl image name
|
|
11
|
+
# is allocated purely in Ruby, no interpreter needed yet (same shape
|
|
12
|
+
# as {Var}'s own Tcl variable name), so it can be captured as a
|
|
13
|
+
# widget's `image:` option (or a later `handle.configure(image: ...)`
|
|
14
|
+
# value) before realize even happens. The real {Teek::Photo} - and
|
|
15
|
+
# the actual file load - only exists at #realize.
|
|
16
|
+
#
|
|
17
|
+
# #to_s returns the Tcl name (matching {Teek::Photo}'s own
|
|
18
|
+
# convention), so passing an Image directly as an `image:` option
|
|
19
|
+
# value works through teek's ordinary option-value serialization -
|
|
20
|
+
# no special-casing needed anywhere in the widget DSL for it.
|
|
21
|
+
class Image
|
|
22
|
+
# @return [String] the Tcl image name
|
|
23
|
+
attr_reader :name
|
|
24
|
+
|
|
25
|
+
# @api private
|
|
26
|
+
def initialize(name, path, opts)
|
|
27
|
+
@name = name
|
|
28
|
+
@path = path
|
|
29
|
+
@opts = opts
|
|
30
|
+
@photo = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Teek::Photo] the live, GC-owned Tk photo image, loaded
|
|
34
|
+
# from the file path this was declared with
|
|
35
|
+
# @raise [NotRealizedError] before realize
|
|
36
|
+
def photo
|
|
37
|
+
@photo or raise NotRealizedError
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Loads the backing {Teek::Photo} from this image's file path.
|
|
41
|
+
# Called once by {Session#realize}, before the widget tree
|
|
42
|
+
# realizes, so any widget's `image:` option already resolves to a
|
|
43
|
+
# real, loaded image by the time it's created.
|
|
44
|
+
# @api private
|
|
45
|
+
def realize(app)
|
|
46
|
+
@photo = Teek::Photo.new(app, name: @name, file: @path, **@opts)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [String] the Tcl image name - lets this be passed
|
|
50
|
+
# directly as a widget's `image:` option, at build time or via a
|
|
51
|
+
# later `handle.configure(image: ...)`, the same way
|
|
52
|
+
# {Teek::Photo} itself already can be.
|
|
53
|
+
def to_s
|
|
54
|
+
@name
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teek
|
|
4
|
+
module UI
|
|
5
|
+
# Translates the DSL's Tk-free key vocabulary (friendly symbols, "Ctrl-x"
|
|
6
|
+
# style modifier strings) into real Tk bind event patterns. Its own small
|
|
7
|
+
# lookup, kept separate from Handle so it's easy to extend.
|
|
8
|
+
module Keysyms
|
|
9
|
+
# Friendly name -> Tk keysym. Anything not listed here passes through
|
|
10
|
+
# as the literal keysym (so e.g. :q or "q" still works for plain letter
|
|
11
|
+
# keys without needing an entry).
|
|
12
|
+
FRIENDLY = {
|
|
13
|
+
enter: 'Return', return: 'Return', escape: 'Escape', tab: 'Tab',
|
|
14
|
+
space: 'space', backspace: 'BackSpace', delete: 'Delete', insert: 'Insert',
|
|
15
|
+
up: 'Up', down: 'Down', left: 'Left', right: 'Right',
|
|
16
|
+
home: 'Home', end: 'End', page_up: 'Prior', page_down: 'Next',
|
|
17
|
+
}.merge((1..12).to_h { |n| [:"f#{n}", "F#{n}"] }).freeze
|
|
18
|
+
|
|
19
|
+
# "Ctrl"/"Cmd"/etc, however people spell them -> Tk's own modifier keyword.
|
|
20
|
+
MODIFIER_ALIASES = {
|
|
21
|
+
'ctrl' => 'Control', 'control' => 'Control',
|
|
22
|
+
'alt' => 'Alt', 'option' => 'Alt', 'opt' => 'Alt',
|
|
23
|
+
'shift' => 'Shift',
|
|
24
|
+
'cmd' => 'Command', 'command' => 'Command', 'meta' => 'Meta',
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# @param spec [Symbol, String] a friendly key (+:enter+) or a
|
|
28
|
+
# "Modifier-Modifier-Key" string (+"Ctrl-Shift-s"+)
|
|
29
|
+
# @return [Array(Array<String>, String)] [tk_modifiers, tk_keysym]
|
|
30
|
+
def self.resolve(spec)
|
|
31
|
+
return [[], FRIENDLY.fetch(spec) { spec.to_s }] if spec.is_a?(Symbol)
|
|
32
|
+
|
|
33
|
+
parts = spec.to_s.split('-')
|
|
34
|
+
base = parts.pop
|
|
35
|
+
keysym = FRIENDLY.fetch(base.downcase.to_sym) { base }
|
|
36
|
+
modifiers = parts.map { |part| MODIFIER_ALIASES.fetch(part.downcase, part) }
|
|
37
|
+
[modifiers, keysym]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Tk event patterns to bind for a resolved [modifiers, keysym] pair -
|
|
41
|
+
# usually just one, but Shift+Tab is a known cross-platform gotcha:
|
|
42
|
+
# X11 delivers it as the distinct keysym ISO_Left_Tab, not Tab with a
|
|
43
|
+
# Shift modifier, so binding only <Shift-Tab> silently never fires
|
|
44
|
+
# there. Bind every spelling so the handler fires regardless of
|
|
45
|
+
# platform; on platforms where a given spelling never occurs, that
|
|
46
|
+
# binding is simply inert.
|
|
47
|
+
# @param modifiers [Array<String>]
|
|
48
|
+
# @param keysym [String]
|
|
49
|
+
# @return [Array<String>] Tk bind event patterns, e.g. +["<Control-s>"]+
|
|
50
|
+
def self.patterns_for(modifiers, keysym)
|
|
51
|
+
if keysym == 'Tab' && modifiers.include?('Shift')
|
|
52
|
+
without_shift = modifiers - ['Shift']
|
|
53
|
+
[
|
|
54
|
+
pattern(modifiers, 'Tab'),
|
|
55
|
+
pattern(without_shift, 'ISO_Left_Tab'),
|
|
56
|
+
pattern(modifiers, 'ISO_Left_Tab'),
|
|
57
|
+
].uniq
|
|
58
|
+
else
|
|
59
|
+
[pattern(modifiers, keysym)]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def pattern(modifiers, keysym)
|
|
67
|
+
"<#{(modifiers + [keysym]).join('-')}>"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'handle'
|
|
4
|
+
|
|
5
|
+
module Teek
|
|
6
|
+
module UI
|
|
7
|
+
# The build surface inside a `menu_bar`/`menu`/`context_menu` block - a
|
|
8
|
+
# separate, small vocabulary from {WidgetDSL} (deliberately NOT mixed
|
|
9
|
+
# into {Session}/yielded as `self` the way ordinary containers are),
|
|
10
|
+
# since menu entries reuse names ordinary widgets already own
|
|
11
|
+
# (`checkbox`/`radio` are ttk widgets one level up, menu entry kinds one
|
|
12
|
+
# level down here) - a shared receiver would collide.
|
|
13
|
+
#
|
|
14
|
+
# Menu structure realizes through {Realizer#create_menu_tree} rather
|
|
15
|
+
# than the generic per-node widget-creation path: nothing here is a Tk
|
|
16
|
+
# widget of its own except `#menu` (a nested cascade, itself a `menu`
|
|
17
|
+
# command) - `#item`/`#checkbox`/`#radio` are entries added to their
|
|
18
|
+
# parent's menu path, with no live Tk path of their own, addressed via
|
|
19
|
+
# their {WidgetType#addressing} strategy ({MenuEntryAddressing}) the
|
|
20
|
+
# same way {Handle} resolves any other type's - see
|
|
21
|
+
# {WidgetDSL#[]}. `#separator` stays unaddressable (nothing to
|
|
22
|
+
# enable/disable/relabel on a divider).
|
|
23
|
+
class MenuBuilder
|
|
24
|
+
# @api private
|
|
25
|
+
def initialize(document, stack)
|
|
26
|
+
@document = document
|
|
27
|
+
@stack = stack
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A nested cascade - recursive, so the same method builds both a
|
|
31
|
+
# menu_bar's top-level dropdowns (File/Edit/...) and any submenu
|
|
32
|
+
# nested inside one of those.
|
|
33
|
+
# @param name [Symbol, nil]
|
|
34
|
+
# @param label [String] the cascade's displayed label
|
|
35
|
+
# @param opts [Hash] extra Tk menu-entry options (e.g. +underline:+)
|
|
36
|
+
# @yieldparam m [MenuBuilder] this same builder, scoped to the new menu
|
|
37
|
+
# @return [Handle]
|
|
38
|
+
def menu(name = nil, label:, **opts, &block)
|
|
39
|
+
node = @document.create(type: :menu, name: name, opts: opts.merge(label: label))
|
|
40
|
+
@stack.last.add_child(node)
|
|
41
|
+
|
|
42
|
+
if block
|
|
43
|
+
@stack.push(node)
|
|
44
|
+
@document.notify(:push, node, current_path)
|
|
45
|
+
begin
|
|
46
|
+
block.call(self)
|
|
47
|
+
ensure
|
|
48
|
+
path = current_path
|
|
49
|
+
@stack.pop
|
|
50
|
+
@document.notify(:pop, node, path)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Handle.new(node)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A command entry.
|
|
58
|
+
# @param name [Symbol, nil] for `ui[:name]` lookup - addressable
|
|
59
|
+
# later as a Handle (`.enable`/`.disable`/`.configure`)
|
|
60
|
+
# @param label [String]
|
|
61
|
+
# @param opts [Hash] extra Tk menu-entry options (e.g. +accelerator:+)
|
|
62
|
+
# @yield called when the entry is invoked
|
|
63
|
+
# @return [Handle]
|
|
64
|
+
def item(name = nil, label:, **opts, &block)
|
|
65
|
+
opts = opts.merge(command: block) if block
|
|
66
|
+
Handle.new(append_entry(:menu_item, name, opts.merge(label: label)))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A separator entry.
|
|
70
|
+
# @return [nil]
|
|
71
|
+
def separator
|
|
72
|
+
append_entry(:menu_separator, nil, {})
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A checkbutton entry, bound to a reactive {Var} - ticked when the
|
|
77
|
+
# var is true, unticked when false, the same +bind:+ convention
|
|
78
|
+
# {WidgetDSL}'s own `checkbox` widget uses.
|
|
79
|
+
# @param name [Symbol, nil] see {#item}
|
|
80
|
+
# @param label [String]
|
|
81
|
+
# @param bind [Var]
|
|
82
|
+
# @param opts [Hash] extra Tk menu-entry options
|
|
83
|
+
# @return [Handle]
|
|
84
|
+
def checkbox(name = nil, label:, bind:, **opts)
|
|
85
|
+
Handle.new(append_entry(:menu_checkbox, name, opts.merge(label: label, bind: bind)))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A radiobutton entry - `bind:` is shared across every radio entry in
|
|
89
|
+
# the group, `value:` is what this one entry sets it to when chosen.
|
|
90
|
+
# @param name [Symbol, nil] see {#item}
|
|
91
|
+
# @param label [String]
|
|
92
|
+
# @param bind [Var]
|
|
93
|
+
# @param value [Object]
|
|
94
|
+
# @param opts [Hash] extra Tk menu-entry options
|
|
95
|
+
# @return [Handle]
|
|
96
|
+
def radio(name = nil, label:, bind:, value:, **opts)
|
|
97
|
+
Handle.new(append_entry(:menu_radio, name, opts.merge(label: label, bind: bind, value: value)))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def append_entry(type, name, opts)
|
|
103
|
+
node = @document.create(type: type, name: name, opts: normalize_shortcut(opts))
|
|
104
|
+
@stack.last.add_child(node)
|
|
105
|
+
node
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# +shortcut:+ is the friendly primary for Tk's own +accelerator:+
|
|
109
|
+
# menu-entry option - purely the text displayed next to the label
|
|
110
|
+
# (e.g. +"Ctrl+S"+), not an actual key binding; see the README's
|
|
111
|
+
# own note on wiring the real keystroke separately.
|
|
112
|
+
def normalize_shortcut(opts)
|
|
113
|
+
return opts unless opts.key?(:shortcut)
|
|
114
|
+
|
|
115
|
+
opts = opts.dup
|
|
116
|
+
opts[:accelerator] = opts.delete(:shortcut)
|
|
117
|
+
opts
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Same computation as {WidgetDSL#current_path} (not the SAME public
|
|
121
|
+
# method - a separate, small builder with no access to it) - just
|
|
122
|
+
# enough to give a +:push+/+:pop+ notification its own ancestry
|
|
123
|
+
# breadcrumb, matching what {WidgetDSL#push_stack}/{#pop_stack}
|
|
124
|
+
# already do for every other container.
|
|
125
|
+
def current_path
|
|
126
|
+
@stack.reject { |node| node.type == :root }.map(&:display_name).join(' > ')
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
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 {WidgetType#addressing} strategy for :menu_item/:menu_checkbox/
|
|
11
|
+
# :menu_radio - a menu entry has no independent Tk path of its own,
|
|
12
|
+
# only the enclosing menu does. #configure resolves the entry's
|
|
13
|
+
# CURRENT position fresh on every call via {Node#parent} rather than
|
|
14
|
+
# caching an index, so an earlier sibling being inserted or removed
|
|
15
|
+
# can never leave this addressing the wrong entry (Tk menu entries
|
|
16
|
+
# are addressed purely by numeric index, and TkMenu.c renumbers every
|
|
17
|
+
# entry after the one that changed).
|
|
18
|
+
class MenuEntryAddressing
|
|
19
|
+
# @api private
|
|
20
|
+
def initialize(node)
|
|
21
|
+
@node = node
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [String] the parent menu's real path, marked past the
|
|
25
|
+
# point a real Tk path stops applying - +!+ is illegal in a Tk
|
|
26
|
+
# path segment, so handing this to a raw Tk command fails loudly
|
|
27
|
+
# (an "invalid command name" Tcl error) instead of silently
|
|
28
|
+
# misbehaving.
|
|
29
|
+
def virtual_path
|
|
30
|
+
"#{menu.path}!#{@node.name || @node.key}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param opts [Hash]
|
|
34
|
+
# @return [void]
|
|
35
|
+
# @raise [NotRealizedError] before the parent menu is realized
|
|
36
|
+
def configure(**opts)
|
|
37
|
+
menu.app.command(menu.path, :entryconfigure, current_index, **opts)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash{Symbol => String}] every current option/value Tk
|
|
41
|
+
# reports for this entry right now
|
|
42
|
+
# @raise [NotRealizedError] before the parent menu is realized
|
|
43
|
+
def option_dump
|
|
44
|
+
OptionDumpParsing.parse(menu.app, menu.app.command(menu.path, :entryconfigure, current_index))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def menu
|
|
50
|
+
@node.parent&.realized or raise NotRealizedError
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The Document tree's own child order exactly matches the live
|
|
54
|
+
# menu's entry order - {Realizer#create_menu_tree} adds every
|
|
55
|
+
# child, in order, with one Tk `add` call each - so no live re-scan
|
|
56
|
+
# of the menu is needed to find this entry's current position.
|
|
57
|
+
def current_index
|
|
58
|
+
@node.parent.children.index(@node)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'screens'
|
|
4
|
+
|
|
5
|
+
module Teek
|
|
6
|
+
module UI
|
|
7
|
+
# Push/pop stack for modal window handles, so one modal can push
|
|
8
|
+
# another (e.g. Settings -> Replay Player) with the previous modal
|
|
9
|
+
# automatically re-shown once the new one is dismissed.
|
|
10
|
+
#
|
|
11
|
+
# The reveal/conceal-on-transition bookkeeping - including on-demand
|
|
12
|
+
# {Handle#realize!} of a not-yet-realized `lazy: true` modal, given
|
|
13
|
+
# `document:` (a "child window" opened fresh each time, say) - is
|
|
14
|
+
# exactly what {Screens} already does, so this wraps one internally
|
|
15
|
+
# rather than re-deriving it. What's actually different here is the
|
|
16
|
+
# on_enter/on_exit/on_focus_change lifecycle, useful for pause/resume-
|
|
17
|
+
# style hooks (e.g. pausing an emulator while any modal is open). Each
|
|
18
|
+
# window handle pushed here should typically be declared `modal: true`
|
|
19
|
+
# (as `ui.dialog` already defaults to) for `Handle#show` to actually
|
|
20
|
+
# grab input - ModalStack itself does no grabbing of its own.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# ui.modal = Teek::UI::ModalStack.new(
|
|
24
|
+
# on_enter: ->(name) { pause_emulation },
|
|
25
|
+
# on_exit: -> { unpause_emulation },
|
|
26
|
+
# on_focus_change: ->(name) { update_toast(name) },
|
|
27
|
+
# )
|
|
28
|
+
# ui.modal.push(:settings, ui[:settings])
|
|
29
|
+
# ui.modal.push(:replay, ui[:replay]) # settings auto-withdrawn
|
|
30
|
+
# ui.modal.pop # replay closed, settings re-shown
|
|
31
|
+
# ui.modal.pop # settings closed, on_exit fires
|
|
32
|
+
class ModalStack
|
|
33
|
+
# @param on_enter [Proc] called with (name) when the stack goes empty -> non-empty
|
|
34
|
+
# @param on_exit [Proc] called with no arguments when the stack goes non-empty -> empty
|
|
35
|
+
# @param on_focus_change [Proc, nil] called with (name) whenever the top modal changes -
|
|
36
|
+
# every push, and every pop that leaves a modal underneath (not the final pop, which
|
|
37
|
+
# fires on_exit instead)
|
|
38
|
+
# @param document [Document, nil] forwarded to the internal {Screens}
|
|
39
|
+
# - see {Screens#initialize} - needed only to lazily {Handle#realize!}
|
|
40
|
+
# a not-yet-realized modal on push
|
|
41
|
+
def initialize(on_enter:, on_exit:, on_focus_change: nil, document: nil)
|
|
42
|
+
@screens = Screens.new(document: document)
|
|
43
|
+
@on_enter = on_enter
|
|
44
|
+
@on_exit = on_exit
|
|
45
|
+
@on_focus_change = on_focus_change
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Boolean] true if any modal is open
|
|
49
|
+
def active?
|
|
50
|
+
@screens.active?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Symbol, nil] name of the topmost modal
|
|
54
|
+
def current
|
|
55
|
+
@screens.current
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Integer] number of modals on the stack
|
|
59
|
+
def size
|
|
60
|
+
@screens.size
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Push a modal window handle onto the stack. Whatever was on top (if
|
|
64
|
+
# any) is withdrawn first, with no callback of its own - it's
|
|
65
|
+
# stepping aside, not being dismissed. `on_enter` fires only if the
|
|
66
|
+
# stack was empty; `on_focus_change` fires unconditionally.
|
|
67
|
+
# @param name [Symbol]
|
|
68
|
+
# @param window [Handle] a `:window` handle
|
|
69
|
+
# @return [void]
|
|
70
|
+
def push(name, window)
|
|
71
|
+
was_empty = !@screens.active?
|
|
72
|
+
|
|
73
|
+
@screens.push(name, window)
|
|
74
|
+
|
|
75
|
+
@on_enter.call(name) if was_empty
|
|
76
|
+
@on_focus_change&.call(name)
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Pop the current modal off the stack. If a modal remains
|
|
81
|
+
# underneath, it's re-shown (by {Screens#pop}) and `on_focus_change`
|
|
82
|
+
# fires for it; otherwise the stack is now empty and `on_exit` fires
|
|
83
|
+
# instead.
|
|
84
|
+
# @return [Object, nil] the just-popped window, or +nil+ if the stack was empty
|
|
85
|
+
def pop
|
|
86
|
+
return nil unless @screens.active?
|
|
87
|
+
|
|
88
|
+
popped = @screens.pop
|
|
89
|
+
|
|
90
|
+
if @screens.active?
|
|
91
|
+
@on_focus_change&.call(@screens.current)
|
|
92
|
+
else
|
|
93
|
+
@on_exit.call
|
|
94
|
+
end
|
|
95
|
+
popped
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'teek/platform'
|
|
4
|
+
|
|
5
|
+
module Teek
|
|
6
|
+
module UI
|
|
7
|
+
# @api private
|
|
8
|
+
#
|
|
9
|
+
# Shared mouse-event vocabulary between {Handle} and {CanvasItem}'s own
|
|
10
|
+
# +on_right_click+, so both mean exactly the same "right click, however
|
|
11
|
+
# the platform spells it" and "these are the two things you're allowed
|
|
12
|
+
# to pop up."
|
|
13
|
+
module MouseEvents
|
|
14
|
+
# A right click, however the platform spells it - the real right
|
|
15
|
+
# mouse button everywhere (+<Button-3>+), plus macOS's two long-
|
|
16
|
+
# standing secondary-click gestures (+<Button-2>+, and
|
|
17
|
+
# +<Control-Button-1>+ from the one-button-mouse era) - NOT bound on
|
|
18
|
+
# other platforms, where Ctrl+click carries no such meaning (and on
|
|
19
|
+
# X11 specifically, Button-2 is the middle mouse button, a real,
|
|
20
|
+
# distinct button of its own) - binding them unconditionally there
|
|
21
|
+
# would silently fire a "right click" handler on gestures users
|
|
22
|
+
# never intended as one.
|
|
23
|
+
RIGHT_CLICK_EVENTS = (
|
|
24
|
+
Teek.platform.darwin? ? %w[<Button-2> <Button-3> <Control-Button-1>] : %w[<Button-3>]
|
|
25
|
+
).freeze
|
|
26
|
+
|
|
27
|
+
# Handle types +on_right_click(menu)+ accepts to pop up.
|
|
28
|
+
MENU_HANDLE_TYPES = %i[menu context_menu].freeze
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/teek/ui/node.rb
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'scope'
|
|
4
|
+
|
|
5
|
+
module Teek
|
|
6
|
+
module UI
|
|
7
|
+
# A single element of the retained-mode tree - a widget, layout
|
|
8
|
+
# container, reactive var, or deferred build-time op (the categories
|
|
9
|
+
# from the architecture doc; this class itself is generic across all of
|
|
10
|
+
# them). Plain Ruby, no Tk: constructible, mutable, and traversable with
|
|
11
|
+
# no interpreter, which is what makes the tree headless-testable.
|
|
12
|
+
#
|
|
13
|
+
# +key+ is this node's stable identity - the explicit +name+ if given,
|
|
14
|
+
# else whatever the owning {Document} assigns. +realized+ stays nil for
|
|
15
|
+
# the whole build phase; a realizer fills it in later with a live
|
|
16
|
+
# handle.
|
|
17
|
+
class Node
|
|
18
|
+
attr_reader :type, :name, :opts, :children, :events, :parent, :scope, :document
|
|
19
|
+
attr_accessor :key, :layout, :realized
|
|
20
|
+
attr_writer :lazy, :pending_destroy
|
|
21
|
+
|
|
22
|
+
# @param type [Symbol] node kind, e.g. +:button+, +:column+, +:var+
|
|
23
|
+
# @param name [Symbol, nil] explicit stable name, for addressing (+ui[:name]+)
|
|
24
|
+
# @param key [String, nil] stable identity; defaults to +name+'s string form
|
|
25
|
+
# @param opts [Hash] widget/node options as plain Ruby values
|
|
26
|
+
# @param scope [Scope] the component scope this node was built in -
|
|
27
|
+
# {Scope::TOP_LEVEL} (the default) for a build that never calls
|
|
28
|
+
# {WidgetDSL#component}
|
|
29
|
+
# @param document [Document, nil] back-reference to the owning
|
|
30
|
+
# {Document} - set by {Document#create}; +nil+ for a raw +Node.new+
|
|
31
|
+
# built directly (headless tests, mostly). Lets {Handle#destroy!}
|
|
32
|
+
# unregister a destroyed node's own name(s) without every caller
|
|
33
|
+
# needing to thread a Document reference through by hand.
|
|
34
|
+
def initialize(type:, name: nil, key: nil, opts: {}, scope: Scope::TOP_LEVEL, document: nil)
|
|
35
|
+
@type = type
|
|
36
|
+
@name = name
|
|
37
|
+
@key = key || name&.to_s
|
|
38
|
+
@opts = opts
|
|
39
|
+
@children = []
|
|
40
|
+
@layout = nil
|
|
41
|
+
@events = []
|
|
42
|
+
@realized = nil
|
|
43
|
+
@parent = nil
|
|
44
|
+
@scope = scope
|
|
45
|
+
@document = document
|
|
46
|
+
@lazy = false
|
|
47
|
+
@pending_destroy = false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Boolean] whether this node is excluded from the ambient
|
|
51
|
+
# +create+/+link+ tree walk ({Realizer#realize}, {Realizer#realize_subtree})
|
|
52
|
+
# - true only for a container built with +lazy: true+ (see
|
|
53
|
+
# {WidgetDSL#append_container}). A lazy node stays a normal,
|
|
54
|
+
# attached member of the retained tree; it just never gets a real
|
|
55
|
+
# Tk widget until something explicitly realizes it (see
|
|
56
|
+
# {Handle#realize!}).
|
|
57
|
+
def lazy?
|
|
58
|
+
@lazy
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] whether a deferred {Handle#destroy!} is
|
|
62
|
+
# currently scheduled (via +after idle+) but hasn't run yet -
|
|
63
|
+
# lets a second +destroy!+ call on the same still-pending handle
|
|
64
|
+
# no-op instead of double-scheduling.
|
|
65
|
+
def pending_destroy?
|
|
66
|
+
@pending_destroy
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param node [Node]
|
|
70
|
+
# @return [Node] the node just added
|
|
71
|
+
def add_child(node)
|
|
72
|
+
@children << node
|
|
73
|
+
node.parent = self
|
|
74
|
+
document&.notify(:append, self, node)
|
|
75
|
+
node
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A short, human label for this node - its type, plus the explicit
|
|
79
|
+
# name if it has one (e.g. +"column"+ or +"column(:ctrl)"+). Used by
|
|
80
|
+
# {TreeInspector} and the build stack's own +current_path+ breadcrumb
|
|
81
|
+
# ({WidgetDSL#current_path}) - deliberately bare (no leading marker,
|
|
82
|
+
# no "unnamed" filler text) since both of those read as a sequence
|
|
83
|
+
# of these, not a single prose sentence the way {Realizer}'s own
|
|
84
|
+
# private +describe+ does.
|
|
85
|
+
# @return [String]
|
|
86
|
+
def display_name
|
|
87
|
+
name ? "#{type}(:#{name})" : type.to_s
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Unlinks +node+ from this node's own children - the symmetric
|
|
91
|
+
# counterpart to {#add_child}, used by {Handle#destroy!} so a
|
|
92
|
+
# destroyed node stops being reachable from the retained tree at
|
|
93
|
+
# all (not just Tk-dead - actually gone from +children+, so a
|
|
94
|
+
# later sibling addition never iterates it, and it becomes
|
|
95
|
+
# collectable once nothing else references it).
|
|
96
|
+
# @param node [Node]
|
|
97
|
+
# @return [Node] the node just removed
|
|
98
|
+
def remove_child(node)
|
|
99
|
+
@children.delete(node)
|
|
100
|
+
node.parent = nil
|
|
101
|
+
node
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Depth-first, pre-order traversal of this node and its descendants.
|
|
105
|
+
# @yieldparam node [Node]
|
|
106
|
+
# @return [Enumerator] if no block given
|
|
107
|
+
def each(&block)
|
|
108
|
+
return enum_for(:each) unless block
|
|
109
|
+
|
|
110
|
+
block.call(self)
|
|
111
|
+
children.each { |child| child.each(&block) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# This node's address, computed purely from the retained tree
|
|
115
|
+
# (name/key + {#parent}) - no Tk involved, correct before realize.
|
|
116
|
+
# For an ordinary widget this already equals the real Tk path
|
|
117
|
+
# ({Realizer#allocate_path} walks this identical parent/segment
|
|
118
|
+
# structure); for anything without an independent Tk path of its
|
|
119
|
+
# own (a menu entry, say), an {Addressing} strategy extends past
|
|
120
|
+
# this with its own marker rather than pretending it's a real one.
|
|
121
|
+
# The other documented exception: a reusable component mounted more
|
|
122
|
+
# than once under the same real parent - {Realizer#allocate_path}
|
|
123
|
+
# only discovers that repeat (and disambiguates the later mounts'
|
|
124
|
+
# paths) at realize, so this can't predict it ahead of time either.
|
|
125
|
+
# A node that isn't attached anywhere yet (+parent+ nil, and not
|
|
126
|
+
# itself the root) is treated as top-level - the best answer
|
|
127
|
+
# available without a tree to place it in.
|
|
128
|
+
# @return [String] e.g. +"."+, +".toolbar"+, +".toolbar.save"+
|
|
129
|
+
def logical_path
|
|
130
|
+
return '.' if type == :root
|
|
131
|
+
|
|
132
|
+
prefix = (parent.nil? || parent.type == :root) ? '.' : "#{parent.logical_path}."
|
|
133
|
+
"#{prefix}#{name || key}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
protected
|
|
137
|
+
|
|
138
|
+
attr_writer :parent
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teek
|
|
4
|
+
module UI
|
|
5
|
+
# @api private
|
|
6
|
+
#
|
|
7
|
+
# Shared by every {WidgetType#addressing} strategy's own +#option_dump+
|
|
8
|
+
# (see {WidgetAddressing}, {MenuEntryAddressing}) - the raw Tcl command
|
|
9
|
+
# differs per strategy (+configure+ vs +entryconfigure <index>+), but
|
|
10
|
+
# both return the exact same nested-list shape, so the parsing itself
|
|
11
|
+
# lives in one place rather than two copies drifting apart.
|
|
12
|
+
module OptionDumpParsing
|
|
13
|
+
# A bare +configure+ (or +entryconfigure <index>+) call returns one
|
|
14
|
+
# Tcl sublist per option: +{name dbname dbclass default current}+ for
|
|
15
|
+
# an ordinary option, or a shorter 2-item +{name aliased-name}+ for a
|
|
16
|
+
# synonym (e.g. +-bg+ pointing at +-background+) - those carry no
|
|
17
|
+
# value of their own and are skipped.
|
|
18
|
+
# @param app [Teek::App]
|
|
19
|
+
# @param raw [String] the unparsed Tcl list +app.command+ returned
|
|
20
|
+
# @return [Hash{Symbol => String}] option name (no leading +-+) => current value
|
|
21
|
+
def self.parse(app, raw)
|
|
22
|
+
app.split_list(raw).each_with_object({}) do |item, dump|
|
|
23
|
+
parts = app.split_list(item)
|
|
24
|
+
next if parts.size < 5
|
|
25
|
+
|
|
26
|
+
dump[parts[0].sub(/\A-/, '').to_sym] = parts[4]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teek
|
|
4
|
+
module UI
|
|
5
|
+
# @api private
|
|
6
|
+
#
|
|
7
|
+
# ui.overlay's at: vocabulary - corners, center, and the four edge
|
|
8
|
+
# midpoints, spelled in plain English rather than Tk's own compass
|
|
9
|
+
# anchors (nw/n/ne/w/center/e/sw/s/se) - the same litmus test every
|
|
10
|
+
# other DSL name follows (decoding one should never need Tk
|
|
11
|
+
# knowledge). Each maps to `place`'s own -relx/-rely/-anchor, needed
|
|
12
|
+
# both to validate at: ({WidgetDSL#overlay}) and to actually place the
|
|
13
|
+
# widget ({Realizer#place_overlay}) - one shared table so the two can
|
|
14
|
+
# never drift out of sync with each other.
|
|
15
|
+
module OverlayAnchors
|
|
16
|
+
POSITIONS = {
|
|
17
|
+
top_left: { relx: 0.0, rely: 0.0, anchor: 'nw' },
|
|
18
|
+
top: { relx: 0.5, rely: 0.0, anchor: 'n' },
|
|
19
|
+
top_right: { relx: 1.0, rely: 0.0, anchor: 'ne' },
|
|
20
|
+
left: { relx: 0.0, rely: 0.5, anchor: 'w' },
|
|
21
|
+
center: { relx: 0.5, rely: 0.5, anchor: 'center' },
|
|
22
|
+
right: { relx: 1.0, rely: 0.5, anchor: 'e' },
|
|
23
|
+
bottom_left: { relx: 0.0, rely: 1.0, anchor: 'sw' },
|
|
24
|
+
bottom: { relx: 0.5, rely: 1.0, anchor: 's' },
|
|
25
|
+
bottom_right: { relx: 1.0, rely: 1.0, anchor: 'se' },
|
|
26
|
+
}.freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|