teek-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +452 -0
  4. data/lib/teek/ui/canvas_item.rb +270 -0
  5. data/lib/teek/ui/component_handle.rb +32 -0
  6. data/lib/teek/ui/document.rb +185 -0
  7. data/lib/teek/ui/errors.rb +41 -0
  8. data/lib/teek/ui/event_binding.rb +22 -0
  9. data/lib/teek/ui/event_bus.rb +53 -0
  10. data/lib/teek/ui/grid_validator.rb +89 -0
  11. data/lib/teek/ui/handle.rb +585 -0
  12. data/lib/teek/ui/image.rb +58 -0
  13. data/lib/teek/ui/keysyms.rb +72 -0
  14. data/lib/teek/ui/menu_builder.rb +130 -0
  15. data/lib/teek/ui/menu_entry_addressing.rb +62 -0
  16. data/lib/teek/ui/modal_stack.rb +99 -0
  17. data/lib/teek/ui/mouse_events.rb +31 -0
  18. data/lib/teek/ui/node.rb +141 -0
  19. data/lib/teek/ui/option_dump_parsing.rb +31 -0
  20. data/lib/teek/ui/overlay_anchors.rb +29 -0
  21. data/lib/teek/ui/overlay_validator.rb +33 -0
  22. data/lib/teek/ui/pane_validator.rb +30 -0
  23. data/lib/teek/ui/realized_node.rb +25 -0
  24. data/lib/teek/ui/realizer.rb +577 -0
  25. data/lib/teek/ui/scope.rb +40 -0
  26. data/lib/teek/ui/screens.rb +141 -0
  27. data/lib/teek/ui/session.rb +417 -0
  28. data/lib/teek/ui/tab_validator.rb +30 -0
  29. data/lib/teek/ui/text_content.rb +321 -0
  30. data/lib/teek/ui/tree_inspector.rb +97 -0
  31. data/lib/teek/ui/validator.rb +131 -0
  32. data/lib/teek/ui/var.rb +98 -0
  33. data/lib/teek/ui/version.rb +7 -0
  34. data/lib/teek/ui/widget_addressing.rb +47 -0
  35. data/lib/teek/ui/widget_dsl.rb +512 -0
  36. data/lib/teek/ui/widget_type.rb +242 -0
  37. data/lib/teek/ui/widget_types/button.rb +7 -0
  38. data/lib/teek/ui/widget_types/canvas.rb +15 -0
  39. data/lib/teek/ui/widget_types/checkbox.rb +7 -0
  40. data/lib/teek/ui/widget_types/column.rb +14 -0
  41. data/lib/teek/ui/widget_types/context_menu.rb +15 -0
  42. data/lib/teek/ui/widget_types/divider.rb +10 -0
  43. data/lib/teek/ui/widget_types/dropdown.rb +7 -0
  44. data/lib/teek/ui/widget_types/grid.rb +12 -0
  45. data/lib/teek/ui/widget_types/group.rb +7 -0
  46. data/lib/teek/ui/widget_types/label.rb +7 -0
  47. data/lib/teek/ui/widget_types/list.rb +7 -0
  48. data/lib/teek/ui/widget_types/menu_bar.rb +26 -0
  49. data/lib/teek/ui/widget_types/menu_checkbox.rb +14 -0
  50. data/lib/teek/ui/widget_types/menu_item.rb +20 -0
  51. data/lib/teek/ui/widget_types/menu_radio.rb +14 -0
  52. data/lib/teek/ui/widget_types/number_box.rb +7 -0
  53. data/lib/teek/ui/widget_types/pane.rb +37 -0
  54. data/lib/teek/ui/widget_types/panel.rb +7 -0
  55. data/lib/teek/ui/widget_types/progress.rb +7 -0
  56. data/lib/teek/ui/widget_types/radio.rb +7 -0
  57. data/lib/teek/ui/widget_types/row.rb +14 -0
  58. data/lib/teek/ui/widget_types/scrollable.rb +17 -0
  59. data/lib/teek/ui/widget_types/slider.rb +7 -0
  60. data/lib/teek/ui/widget_types/spacer.rb +14 -0
  61. data/lib/teek/ui/widget_types/split.rb +17 -0
  62. data/lib/teek/ui/widget_types/tab.rb +35 -0
  63. data/lib/teek/ui/widget_types/table.rb +9 -0
  64. data/lib/teek/ui/widget_types/tabs.rb +10 -0
  65. data/lib/teek/ui/widget_types/text_area.rb +7 -0
  66. data/lib/teek/ui/widget_types/text_box.rb +7 -0
  67. data/lib/teek/ui/widget_types/tree.rb +9 -0
  68. data/lib/teek/ui/widget_types/window.rb +50 -0
  69. data/lib/teek/ui/widget_types.rb +121 -0
  70. data/lib/teek/ui/widget_validators.rb +62 -0
  71. data/lib/teek/ui.rb +54 -0
  72. data/teek-ui.gemspec +24 -0
  73. data/test/support/fake_app.rb +57 -0
  74. data/test/test_busy.rb +82 -0
  75. data/test/test_canvas_items.rb +551 -0
  76. data/test/test_clipboard.rb +87 -0
  77. data/test/test_close_handling.rb +118 -0
  78. data/test/test_component.rb +151 -0
  79. data/test/test_component_realtk.rb +120 -0
  80. data/test/test_debug_info.rb +158 -0
  81. data/test/test_document.rb +317 -0
  82. data/test/test_event_bus.rb +163 -0
  83. data/test/test_events.rb +164 -0
  84. data/test/test_fake_app_contract.rb +62 -0
  85. data/test/test_grid.rb +97 -0
  86. data/test/test_handle.rb +386 -0
  87. data/test/test_handle_destroy_realtk.rb +344 -0
  88. data/test/test_helper.rb +20 -0
  89. data/test/test_image.rb +24 -0
  90. data/test/test_image_realtk.rb +146 -0
  91. data/test/test_incremental_realize.rb +203 -0
  92. data/test/test_keysyms.rb +76 -0
  93. data/test/test_layout.rb +185 -0
  94. data/test/test_lazy_realize.rb +128 -0
  95. data/test/test_managed_window.rb +304 -0
  96. data/test/test_menu_dsl.rb +230 -0
  97. data/test/test_menu_entry_addressing.rb +73 -0
  98. data/test/test_menu_realize.rb +296 -0
  99. data/test/test_modal_handle.rb +147 -0
  100. data/test/test_modal_stack.rb +219 -0
  101. data/test/test_modal_stack_realtk.rb +131 -0
  102. data/test/test_native_scrollable.rb +250 -0
  103. data/test/test_node.rb +193 -0
  104. data/test/test_overlay.rb +139 -0
  105. data/test/test_raw.rb +58 -0
  106. data/test/test_reactive_vars.rb +118 -0
  107. data/test/test_realizer.rb +264 -0
  108. data/test/test_scope.rb +38 -0
  109. data/test/test_screens.rb +260 -0
  110. data/test/test_screens_realtk.rb +314 -0
  111. data/test/test_scrollable.rb +185 -0
  112. data/test/test_scrollable_wheel.rb +172 -0
  113. data/test/test_scrollbar_auto_hide.rb +127 -0
  114. data/test/test_split.rb +145 -0
  115. data/test/test_tabs.rb +201 -0
  116. data/test/test_text_content.rb +664 -0
  117. data/test/test_toast.rb +126 -0
  118. data/test/test_tree_inspector.rb +183 -0
  119. data/test/test_ui.rb +428 -0
  120. data/test/test_validator.rb +209 -0
  121. data/test/test_var.rb +32 -0
  122. data/test/test_widget_dsl.rb +621 -0
  123. data/test/test_widget_introspection.rb +126 -0
  124. data/test/test_widget_types.rb +429 -0
  125. data/test/test_widget_types_realtk.rb +82 -0
  126. data/test/test_widget_validators.rb +82 -0
  127. metadata +207 -0
@@ -0,0 +1,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
@@ -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