zaniah 0.6.3 → 0.7.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/docs/adr/013-public-inspection-api.md +16 -0
  4. data/docs/adr/014-unified-command-dispatch.md +40 -0
  5. data/docs/components.md +14 -3
  6. data/docs/gallery/command-palette-dark.png +0 -0
  7. data/docs/gallery/command-palette-high_contrast.png +0 -0
  8. data/docs/gallery/command-palette-light.png +0 -0
  9. data/docs/inspection.md +19 -0
  10. data/docs/text.md +19 -0
  11. data/docs/tui.md +2 -2
  12. data/lib/zaniah/accessibility/tree.rb +9 -0
  13. data/lib/zaniah/app.rb +3 -1
  14. data/lib/zaniah/configuration.rb +1 -1
  15. data/lib/zaniah/devtools/inspector.rb +5 -29
  16. data/lib/zaniah/element.rb +4 -1
  17. data/lib/zaniah/input/action_registry.rb +12 -3
  18. data/lib/zaniah/input/dispatcher.rb +47 -1
  19. data/lib/zaniah/input/focus_handle.rb +3 -2
  20. data/lib/zaniah/input/keymap.rb +31 -5
  21. data/lib/zaniah/inspection.rb +146 -0
  22. data/lib/zaniah/platform/headless/window.rb +10 -1
  23. data/lib/zaniah/platform/tui/window.rb +8 -0
  24. data/lib/zaniah/task_executor.rb +2 -0
  25. data/lib/zaniah/text.rb +44 -1
  26. data/lib/zaniah/text_buffer.rb +2 -0
  27. data/lib/zaniah/ui/charts.rb +60 -47
  28. data/lib/zaniah/ui/choice_fields.rb +12 -6
  29. data/lib/zaniah/ui/editors.rb +52 -0
  30. data/lib/zaniah/ui/matcher.rb +88 -0
  31. data/lib/zaniah/ui/modal.rb +121 -10
  32. data/lib/zaniah/ui/rich_text_surface.rb +3 -8
  33. data/lib/zaniah/ui.rb +1 -0
  34. data/lib/zaniah/unicode.rb +28 -0
  35. data/lib/zaniah/version.rb +1 -1
  36. data/lib/zaniah.rb +1 -0
  37. data/sig/accessibility.rbs +1 -0
  38. data/sig/app.rbs +2 -0
  39. data/sig/elements.rbs +3 -1
  40. data/sig/input.rbs +29 -2
  41. data/sig/inspection.rbs +57 -0
  42. data/sig/platform.rbs +5 -0
  43. data/sig/ui.rbs +28 -2
  44. data/sig/zaniah.rbs +7 -2
  45. metadata +8 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5d0895db7cb838e553fc0e7de6c20df016c9c56d3938cfaa94dc99cc47f2ab2
4
- data.tar.gz: ef786f1e8ee6e87da921f317bd7901d97553e1fbe9e3e6969bb5df588a300754
3
+ metadata.gz: 74d3240032d7ae845a8a1f8f3208cb102ef0b68637cc184ba0f8860ac57b05ee
4
+ data.tar.gz: 4166002c76247745610218aed5d9090459889b9b383b97b669b199adac46761b
5
5
  SHA512:
6
- metadata.gz: e784d7df56037b50a88e4e8e4fdccbba5f9544b5e7d56b88480f78468627210919f8a87093ea6d461ba56355bb179c7fbe96d8d52d7990a6107d96ce73fc9872
7
- data.tar.gz: e9e9726a52da9e277d6ebc56f6587a08ca0550e4fbfd83fbb25ef8fe75b58cb8badc976af92b179e530342d9da96aeda8f91332d395f12c77963bfee1c808f19
6
+ metadata.gz: d3e80aba0a90f7e946ce916e63e3eed342cac2b1c0eda9cb9dd14112f1c1a9affd4d0ee13bc2cfc81769bcbd12afa4288849825fe1a093396b6ee43893f68a7d
7
+ data.tar.gz: a1bd580a3da60ac7705f4329d7f7a4b435bdd85638e074710a84b2339a40744e5b1e82d7df243ac9eff439a29ca359e6737e7fa731cb86b6dbedd62b585e5db3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 — 2026-09-24
4
+
5
+ - Add a stable frame inspection API with accessibility queries and DevTools integration.
6
+ - Unify focused and application commands with validation, availability, and checked state.
7
+ - Add headless and TUI clipboard support, including OSC 52 output.
8
+ - Add desktop editing shortcuts and copy, cut, paste, undo, and redo for plain and rich text while leaving conflicting TUI shortcuts unbound.
9
+ - Add configurable matching and accessible, highlighted results to the command palette and combobox.
10
+
11
+ ## 0.6.4 — 2026-09-24
12
+
13
+ - Share Cartesian chart scales, axes, grid lines, ticks, and color-keyed legends across chart types.
14
+
3
15
  ## 0.6.3 — 2026-09-23
4
16
 
5
17
  - Render supplied text grid cells directly as cell surfaces, avoiding an extra wrapper per visible cell.
@@ -0,0 +1,16 @@
1
+ # ADR 013: Expose a stable inspection boundary
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ External test drivers and DevTools currently read render state through internal instance variables or maintain duplicate tree walkers. Internal layout and platform state can change without notice, which makes downstream tooling fragile. One option is to expose each underlying object directly; another is to publish a read-only view of the latest frame.
9
+
10
+ ## Decision
11
+
12
+ Publish `Zaniah::Inspection.snapshot(window)` as the external read boundary, backed by small, explicit read accessors on the existing objects. DevTools uses the same boundary. Snapshot values are frozen; the `Entry#element` reference remains live only until the next frame. During the 0.x series, a breaking change to this public API requires at least one minor release of deprecation before removal.
13
+
14
+ ## Consequences
15
+
16
+ Inspection is constructed only when requested, avoiding per-frame traversal cost. The small accessors and compatibility period constrain internal refactors, but test drivers no longer depend on private instance variables. Native accessibility actions still require a current node and window.
@@ -0,0 +1,40 @@
1
+ # ADR 014: Route UI commands through focused handlers before app actions
2
+
3
+ - Status: Proposed
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ Keyboard actions currently reach only focused handlers. Menus, palettes, context
9
+ menus, and accessibility actions need the same behavior, including a way to ask
10
+ whether an item is enabled without executing it. Routing each source separately
11
+ would make focus precedence and disabled states diverge.
12
+
13
+ Native menu accelerators introduce a separate risk: macOS can process a
14
+ `keyEquivalent` in addition to the framework keymap. Keeping both active could
15
+ execute one command twice.
16
+
17
+ ## Decision
18
+
19
+ Use `Dispatcher#perform` for all command sources. Focused handlers take precedence
20
+ from leaf to root, followed by `App#actions`. A focused handle can provide a
21
+ side-effect-free `validate` callback: `false` blocks the command, `true` enables
22
+ it, and `nil` defers to the next handle. `available?` asks validators and the app
23
+ registry without invoking action handlers. Legacy focused handlers without a
24
+ validator remain executable but cannot advertise availability by themselves.
25
+
26
+ An unvalidated legacy focus handler still executes before a disabled app
27
+ command, even though `available?` can only report the app command as disabled.
28
+ Such focused actions need `validate` to advertise their own availability.
29
+
30
+ Keyboard focus movement remains a dispatcher concern. Native menu shortcuts
31
+ must be routed through the Zaniah keymap exactly once; platform adapters must
32
+ verify that native `keyEquivalent` processing does not also invoke the command.
33
+
34
+ ## Consequences
35
+
36
+ Menus and palettes can share dispatch and validation with keyboard input, and
37
+ app-wide commands remain available when no focus handler consumes them. Focused
38
+ actions intended for menus must provide `validate`; an opaque handler alone
39
+ cannot safely report support without being run. Native shortcut behavior needs
40
+ platform verification before this ADR can be accepted.
data/docs/components.md CHANGED
@@ -59,7 +59,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
59
59
  | L2 | `NumberInput` | TextField plus `min:`, `max:`, `step:`; `increment`, `decrement` | numeric | textbox |
60
60
  | L2 | `TagInput` | `(tags, separator:, ...)`; `on_tags_change` | badge list + editor | textbox |
61
61
  | L2 | `Select` | `(items, label:, value:, disabled:)`; `on_change` | single choice | combobox |
62
- | L2 | `Combobox` | `(items, value:, label:, placeholder:, disabled:)`; `on_change` | editable, filtered choices | combobox |
62
+ | L2 | `Combobox` | `(items, value:, label:, placeholder:, disabled:, matcher:)`; `on_change` | editable, filtered choices with highlighted matches | combobox |
63
63
  | L2 | `MultiSelect` | `(items, value:, label:, disabled:)`; `on_change` | multiple selected badges | listbox |
64
64
  | L2 | `DatePicker` | `(value, min:, max:, label:, disabled:)`; `on_change` | ISO date, day/week keyboard steps | combobox |
65
65
  | L2 | `TimePicker` | `(value, step:, label:, disabled:)`; `on_change` | 24-hour time, minute/hour keyboard steps | combobox |
@@ -77,7 +77,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
77
77
  | L3 | `Modal`, `Dialog` | `(content, title:, open:, close_on_scrim:, width:)` | focus trap, scrim, Esc | dialog/modal |
78
78
  | L3 | `Drawer` | Modal plus `side:` | left/right | dialog/modal |
79
79
  | L3 | `Toast` | `(message, variant:, queue:)`; `dismiss` | info/success/warning/danger | live status |
80
- | L3 | `CommandPalette` | `(commands, open:, placeholder:)` | searchable modal | dialog/list |
80
+ | L3 | `CommandPalette` | `(commands, open:, placeholder:, matcher:)`; `.from(app.actions)` | searchable modal, Up/Down/Enter | dialog/list |
81
81
  | L3 | `SplitPane` | `(first, second, orientation:, ratio:, min:, max:)`; `on_change` | horizontal/vertical, draggable separator | group/separator |
82
82
  | L3 | `PaneGrid` | `(panes, columns:, rows:, divider_size:, minimum:, keyboard_step:)`; `replace`, `on_resize` | arbitrary resizable grid, stable pane IDs | group/separator |
83
83
  | L3 | `Resizable` | `(content, width:, height:, min_width:, min_height:, max_width:, max_height:)`; `on_resize` | drag or keyboard resize | group/separator |
@@ -87,7 +87,9 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
87
87
  | L4 | `Grid` | `(rows:, columns:, row_height:, column_width:, frozen_rows:, frozen_columns:)`; `scroll_to`, range `selection`, `on_select`, `on_edit`, `on_fill`, `on_resize` | two-axis virtualization, frozen panes, visible-cell resize/fill callbacks | table |
88
88
  | L4 | `TreeView` | `(items, height:, selected:)`; `expand`, `collapse`, `replace`, `replace_children`, `invalidate`, lazy `children` proc | arrows/Home/End | tree/treeitem |
89
89
  | L5 | `Sparkline` | `(values, width:, height:, color:, label:)` | line + tooltip | image |
90
- | L5 | `LineChart`, `BarChart` | `(series, width:, height:, colors:, label:)` | axes, legend, tooltip | image |
90
+ | L5 | `LineChart`, `BarChart`, `StackedBarChart`, `AreaChart` | `(series, width:, height:, colors:, label:)`; `AreaChart(stacked:)` | shared axes, ticks, grid lines, color-keyed legend, tooltip | image |
91
+ | L5 | `PieChart`, `DonutChart` | `(data, width:, height:, colors:, label:)` | slices, shared color-keyed legend, tooltip | image |
92
+ | L5 | `ScatterChart` | `(series, width:, height:, colors:, label:)` | shared axes, ticks, grid lines, color-keyed legend, nearest-point tooltip | image |
91
93
  | L5 | `Validation` | `required`, `format`, `length`, `number`, `rule` | composable rules | n/a |
92
94
  | L5 | `FormField` | `(name, value:, label:, control:, validation:, hint:)` | errors + describedby | group/control/alert |
93
95
  | L5 | `Form` | `field`, `on_change`, `on_submit`, `values`, `valid?` | validates before submit | form |
@@ -98,6 +100,15 @@ All input components are keyboard operable. Disabled controls remain visible but
98
100
  removed from focus traversal. Overlay components close on Esc; modal overlays restore
99
101
  the previous focus. See [TUI](tui.md) for terminal representations.
100
102
 
103
+ `Combobox` and `CommandPalette` default to case-insensitive substring matching in
104
+ input order. Pass `matcher:` to either component, or set a default with
105
+ `Zaniah.configure { |config| config.matcher = matcher }`. A matcher implements
106
+ `match(query, labels)` and returns `UI::Matcher::Match` values with an original
107
+ label `index`, descending `score`, and half-open UTF-8 byte `ranges` for highlighting.
108
+ It may also implement `refine(previous_matches, query)` for incremental queries.
109
+ `CommandPalette.from(app.actions)` uses registered action titles and disables
110
+ unavailable actions; shortcuts will be added with `UI::Kbd` in the menu phase.
111
+
101
112
  `UI::RichText` accepts UTF-8 byte ranges at grapheme boundaries. Inline styles are
102
113
  `bold`, `italic`, `size`, `color`, `font`, and `link`; paragraphs support start,
103
114
  center, end, and non-final-line justification, plus bullet/ordered lists and levels.
Binary file
Binary file
@@ -0,0 +1,19 @@
1
+ # Frame inspection
2
+
3
+ `Zaniah::Inspection` gives test drivers and DevTools a public, read-only view of the latest rendered frame. It works with headless and native windows.
4
+
5
+ ```ruby
6
+ window.render(root)
7
+ snapshot = Zaniah::Inspection.snapshot(window)
8
+ save = snapshot.find(test_id: "save")
9
+ buttons = snapshot.where(type: Zaniah::UI::Button)
10
+ frontmost = snapshot.at(Zaniah::Point.new(120, 40))
11
+ node, path = snapshot.accessibility.query(role: :button, label: /save/i).first
12
+ Zaniah::Inspection.perform(window, node, :press)
13
+ ```
14
+
15
+ `snapshot.root` and its children expose type, key, test ID, window-space bounds, resolved style, tooltip, context menu, handlers, and focus state. `snapshot.overlays` exposes tooltip, popup, and menu state; `snapshot.text_runs` contains rendered text; `snapshot.frame` contains a monotonically increasing frame number and timing statistics. `snapshot.find` returns the first match, `where` returns all matches, and `at` uses the frontmost registered hit region. A newly opened window has no root until its first render.
16
+
17
+ The snapshot containers and copied collections are frozen. `entry.element` is deliberately a live element reference: use it only in the same frame, because the next render may replace it. Accessibility nodes are copied for inspection; pass one to `Inspection.perform` to invoke an action on its current source node. Actions require a node from the current frame. To wait for a settled UI, call `Zaniah::Inspection.idle?(app)` after draining queued work and rendering dirty windows.
18
+
19
+ The public API is declared in [`sig/inspection.rbs`](../sig/inspection.rbs), and its independently runnable contract test is [`test/inspection_contract_test.rb`](../test/inspection_contract_test.rb). Breaking changes receive at least one minor version of deprecation during 0.x, as recorded in [ADR 013](adr/013-public-inspection-api.md).
data/docs/text.md CHANGED
@@ -164,6 +164,25 @@ Supported inline styles are `bold`, `italic`, `size`, `color`, `font`, and
164
164
  receives `(text, rich_text)`; selection changes are reported by `on_select`.
165
165
  IME composition uses the existing `TextBuffer` composition path.
166
166
 
167
+ ## Editing shortcuts
168
+
169
+ Editable text fields and rich text share the same actions from keyboard, menu,
170
+ and command dispatch. The default desktop keys are:
171
+
172
+ | Action | macOS | Windows | Linux |
173
+ | --- | --- | --- | --- |
174
+ | Undo / redo | Cmd+Z / Cmd+Shift+Z | Ctrl+Z / Ctrl+Y or Ctrl+Shift+Z | Ctrl+Z / Ctrl+Shift+Z |
175
+ | Cut / copy / paste / select all | Cmd+X/C/V/A | Ctrl+X/C/V/A | Ctrl+X/C/V/A |
176
+ | Previous / next word | Option+Left/Right | Ctrl+Left/Right | Ctrl+Left/Right |
177
+ | Document start / end | Cmd+Up/Down | Ctrl+Home/End | Ctrl+Home/End |
178
+
179
+ Shift with a word arrow extends the selection. Up/Down moves between logical
180
+ lines in multiline fields; Home/End moves to the current line's edges. Copy
181
+ requires a selection, and password fields never copy or cut. Paste and undo
182
+ are disabled during IME composition. The TUI does not assign these desktop
183
+ editing shortcuts by default because terminal control keys can conflict;
184
+ applications may provide a custom keymap.
185
+
167
186
  ## Inline and block overlays
168
187
 
169
188
  Attach non-editable UI elements to text without inserting bytes into the source:
data/docs/tui.md CHANGED
@@ -20,13 +20,13 @@ tests and non-window integrations.
20
20
  | Tabs, Accordion, Collapsible | selected tab in brackets and `[+]`/`[-]` disclosure markers |
21
21
  | ScrollView, Scrollbar | clipped child content and `│`/`─` track |
22
22
  | Breadcrumb, Pagination, bars | slash-separated path, page status, space-separated content |
23
- | Modal, Dialog, Drawer, CommandPalette | box-drawing overlay; focus stays inside until dismissed |
23
+ | Modal, Dialog, Drawer, CommandPalette | box-drawing overlay; palette filters results and supports Up/Down/Enter; focus stays inside until dismissed |
24
24
  | SplitPane, PaneGrid, Resizable, DockPanel | `│`/`─` separators, resize corner, or ordered dock regions |
25
25
  | ListView | visible rows with `>` on the selected item |
26
26
  | Toast | live status text |
27
27
  | Table, DataGrid | header and visible rows separated with `|` |
28
28
  | TreeView | indentation with `▸`/`▾` expansion markers |
29
- | Sparkline, LineChart, BarChart | eight-level Unicode sparkline summary |
29
+ | Sparkline and all chart types | eight-level Unicode sparkline summary |
30
30
  | Form, FormField | labeled values, `!` errors, and a submit button |
31
31
  | CodeEditor, RichText | numbered plain-text lines or concatenated text runs |
32
32
 
@@ -36,6 +36,15 @@ module Zaniah
36
36
 
37
37
  def find(&predicate) = each.find { |node, _path| predicate.call(node) }&.first
38
38
 
39
+ def query(role: nil, label: nil, states: {})
40
+ raise ArgumentError, "label must be a String or Regexp" unless label.nil? || label.is_a?(String) || label.is_a?(Regexp)
41
+ each.select do |node, _path|
42
+ (role.nil? || node.role == role) &&
43
+ (label.nil? || (node.label && (label.is_a?(Regexp) ? label.match?(node.label) : node.label == label))) &&
44
+ states.all? { |key, value| node.states.key?(key) && node.states[key] == value }
45
+ end
46
+ end
47
+
39
48
  def perform(node, action)
40
49
  owner = @action_owners&.[](node.object_id)
41
50
  owner&.accessibility_action(node, action)
data/lib/zaniah/app.rb CHANGED
@@ -7,13 +7,14 @@ require_relative "task_executor"
7
7
 
8
8
  module Zaniah
9
9
  class App
10
- attr_reader :windows, :executor
10
+ attr_reader :windows, :executor, :actions
11
11
 
12
12
  def initialize(clock: MONOTONIC_CLOCK)
13
13
  @slots, @generations, @free, @windows, @globals = [], [], [], [], {theme: Theme.dark}
14
14
  @listeners, @effects, @updating, @flushing = {}, [], 0, false
15
15
  @clock = clock
16
16
  @executor = TaskExecutor.new(clock: clock)
17
+ @actions = Input::ActionRegistry.new
17
18
  end
18
19
 
19
20
  def new_entity
@@ -107,6 +108,7 @@ module Zaniah
107
108
  options[:clock] ||= @clock
108
109
  window = Platform.open_window(**options)
109
110
  window.app = self
111
+ window.dispatcher.window = window
110
112
  @windows << window
111
113
  @globals[:theme] = platform_theme(window)
112
114
  window.on_appearance do |appearance|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zaniah
4
- Configuration = Struct.new(:font_raster, :shaper, :font_db, :segmenter, keyword_init: true)
4
+ Configuration = Struct.new(:font_raster, :shaper, :font_db, :segmenter, :matcher, keyword_init: true)
5
5
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Zaniah
4
4
  module DevTools
5
- Entry = Data.define(:object, :name, :key, :test_id, :bounds, :style, :children)
5
+ Entry = Inspection::Entry
6
6
 
7
7
  class Inspector
8
8
  attr_reader :window, :snapshot, :selected
@@ -32,8 +32,9 @@ module Zaniah
32
32
  true
33
33
  end
34
34
 
35
- def frame(element)
36
- @snapshot = collect(element)
35
+ def frame(_element)
36
+ @inspection = Inspection.snapshot(window)
37
+ @snapshot = @inspection.root
37
38
  return unless visible?
38
39
  @selected = hovered
39
40
  paint_highlight(@selected.bounds) if @selected&.bounds
@@ -42,33 +43,8 @@ module Zaniah
42
43
 
43
44
  private
44
45
 
45
- def collect(object, seen = {})
46
- return if !object || seen[object.object_id]
47
- seen[object.object_id] = true
48
- node = object.respond_to?(:layout_node) ? object.layout_node : nil
49
- style = if object.respond_to?(:resolved_style) && object.resolved_style
50
- object.resolved_style.to_h
51
- elsif object.respond_to?(:root) && object.root&.respond_to?(:resolved_style) && object.root.resolved_style
52
- object.root.resolved_style.to_h
53
- else {}
54
- end
55
- children = object.respond_to?(:children) ? object.children.filter_map { |child| collect(child, seen) } : []
56
- Entry.new(object: object, name: object.class.name.sub("Zaniah::", ""),
57
- key: object.respond_to?(:identity_key) ? object.identity_key : nil,
58
- test_id: object.respond_to?(:test_id) ? object.test_id : nil,
59
- bounds: node&.bounds, style: style.freeze, children: children.freeze)
60
- end
61
-
62
46
  def hovered
63
- owner = window.dispatcher.hits.reverse.find { |hit| window.pointer_position && hit.bounds.contains?(window.pointer_position) }&.owner
64
- find_entry(@snapshot, owner) || @snapshot
65
- end
66
-
67
- def find_entry(entry, object)
68
- return unless entry && object
69
- return entry if entry.object.equal?(object)
70
- entry.children.each { |child| return found if (found = find_entry(child, object)) }
71
- nil
47
+ @inspection.at(window.pointer_position) || @snapshot
72
48
  end
73
49
 
74
50
  def paint_highlight(bounds)
@@ -52,16 +52,19 @@ module Zaniah
52
52
  def opacity(value) = style(opacity: value)
53
53
  def ring(width, color = nil, offset = 0) = style(ring: Ring.new(width, color, offset))
54
54
  def cursor(value) = style(cursor: value)
55
- def focusable(tab_index: 0, context: {}, &on_action)
55
+ def focusable(tab_index: 0, context: {}, validate: (validate_omitted = true), &on_action)
56
56
  @focus_handle ||= Input::FocusHandle.new(owner: self)
57
57
  @focus_handle.tab_index, @focus_handle.focusable = Integer(tab_index), true
58
58
  @focus_handle.context.merge!(context)
59
+ @focus_handle.validate = validate unless validate_omitted
59
60
  @focus_handle.on_action = on_action if on_action
60
61
  self
61
62
  end
62
63
  def focus_handle = @focus_handle
63
64
  def tooltip(text) = (@tooltip = text.to_s; self)
65
+ def tooltip_text = @tooltip
64
66
  def context_menu(items) = (@context_menu = items; self)
67
+ def context_menu_items = @context_menu
65
68
  def flex = style(display: :flex)
66
69
  def flex_row = style(flex_direction: :row)
67
70
  def flex_col = style(flex_direction: :column)
@@ -2,11 +2,20 @@
2
2
 
3
3
  module Zaniah
4
4
  module Input
5
+ Command = Data.define(:name, :title, :category, :enabled, :checked, :handler)
6
+
5
7
  class ActionRegistry
6
8
  def initialize = @actions = {}
7
- def register(name, description: name, &block) = @actions[name.to_s] = [description, block]
8
- def call(name, *args) = @actions.fetch(name.to_s)[1].call(*args)
9
- def entries = @actions.transform_values(&:first).freeze
9
+
10
+ def register(name, description: nil, title: nil, category: nil, enabled: nil, checked: nil, &block)
11
+ title ||= description || name
12
+ @actions[name.to_s] = Command.new(name, title, category, enabled, checked, block)
13
+ [title, block]
14
+ end
15
+
16
+ def command(name) = @actions[name.to_s]
17
+ def call(name, *args) = @actions.fetch(name.to_s).handler.call(*args)
18
+ def entries = @actions.transform_values(&:title).freeze
10
19
  end
11
20
  end
12
21
  end
@@ -3,9 +3,18 @@
3
3
  module Zaniah
4
4
  module Input
5
5
  Hit = Data.define(:bounds, :owner)
6
+ HitRegion = Data.define(:bounds, :owner, :transform, :clip) do
7
+ def contains?(point)
8
+ return false if clip && !clip.contains?(point)
9
+ bounds.contains?(transform.inverse.apply(point))
10
+ rescue ArgumentError
11
+ false
12
+ end
13
+ end
6
14
 
7
15
  class Dispatcher
8
16
  attr_reader :focused, :focus_origin, :focus_tree
17
+ attr_accessor :window
9
18
 
10
19
  def initialize(keymap: Keymap.default_ui)
11
20
  @keymap, @hits = keymap, []
@@ -45,10 +54,46 @@ module Zaniah
45
54
  focus(target, origin: :keyboard)
46
55
  return action
47
56
  end
48
- chain.each { |handle| break if handle.on_action&.call(action) }
57
+ perform(action, source: :keyboard)
49
58
  action
50
59
  end
51
60
 
61
+ def perform(action, source: :keyboard)
62
+ chain = @focused ? @focused.ancestors : []
63
+ chain.each do |handle|
64
+ if handle.validate
65
+ state = handle.validate.call(action)
66
+ return false if state == false
67
+ next if state.nil?
68
+ end
69
+ return true if handle.on_action&.call(action)
70
+ end
71
+ command = @window&.app&.actions&.command(action)
72
+ return false unless command&.handler
73
+ context = FrameContext.new(@window)
74
+ return false if command.enabled&.call(context) == false
75
+ command.handler.call(context)
76
+ true
77
+ end
78
+
79
+ def available?(action)
80
+ chain = @focused ? @focused.ancestors : []
81
+ chain.each do |handle|
82
+ next unless handle.validate
83
+ state = handle.validate.call(action)
84
+ return state ? :enabled : :disabled unless state.nil?
85
+ end
86
+ command = @window&.app&.actions&.command(action)
87
+ return :unhandled unless command
88
+ return :disabled unless command.handler
89
+ command.enabled&.call(FrameContext.new(@window)) == false ? :disabled : :enabled
90
+ end
91
+
92
+ def checked?(action)
93
+ command = @window&.app&.actions&.command(action)
94
+ command&.checked&.call(FrameContext.new(@window))
95
+ end
96
+
52
97
  def register_focus(handle)
53
98
  owner = handle.owner&.respond_to?(:parent) ? handle.owner.parent : nil
54
99
  while owner
@@ -75,6 +120,7 @@ module Zaniah
75
120
  @transforms.replace([Transform.identity])
76
121
  end
77
122
  def hits = @hits.map { |bounds, _, owner, _, clip| Hit.new(clip ? bounds.intersect(clip) : bounds, owner) }.freeze
123
+ def hit_regions = @hits.map { |bounds, _, owner, transform, clip| HitRegion.new(bounds, owner, transform, clip) }.freeze
78
124
 
79
125
  def hover_chain(position)
80
126
  return [] unless position
@@ -4,11 +4,12 @@ module Zaniah
4
4
  module Input
5
5
  class FocusHandle
6
6
  attr_reader :parent, :children, :context, :owner
7
- attr_accessor :on_action, :on_focus, :on_input, :tab_index, :focusable, :bounds
7
+ attr_accessor :on_action, :on_focus, :on_input, :validate, :tab_index, :focusable, :bounds
8
8
 
9
- def initialize(parent: nil, context: {}, owner: nil, tab_index: nil, focusable: true, bounds: nil)
9
+ def initialize(parent: nil, context: {}, owner: nil, tab_index: nil, focusable: true, bounds: nil, validate: nil)
10
10
  @parent, @context, @owner, @children = parent, context, owner, []
11
11
  @tab_index, @focusable, @bounds = tab_index, focusable, bounds
12
+ @validate = validate
12
13
  parent&.children&.push(self)
13
14
  end
14
15
 
@@ -6,6 +6,10 @@ require_relative "context_predicate"
6
6
 
7
7
  module Zaniah
8
8
  module Input
9
+ module StandardActions
10
+ EDIT = %i[undo redo cut copy paste select_all].freeze
11
+ end
12
+
9
13
  class Keymap
10
14
  class << self
11
15
  def default_ui(platform: RUBY_PLATFORM, **options)
@@ -13,8 +17,11 @@ module Zaniah
13
17
  end
14
18
 
15
19
  def platform_defaults(platform, **options)
16
- primary = platform.to_s.match?(/darwin|mac/) ? "cmd" : "ctrl"
17
- new(**options)
20
+ mac = platform.to_s.match?(/darwin|mac/)
21
+ windows = platform.to_s.match?(/mingw|mswin|windows/)
22
+ tui = platform.to_s == "tui"
23
+ primary = mac ? "cmd" : "ctrl"
24
+ map = new(**options)
18
25
  .bind("tab", :focus_next)
19
26
  .bind("shift-tab", :focus_previous)
20
27
  .bind("left", :focus_left, context: "!in_text_field && !in_list")
@@ -30,7 +37,6 @@ module Zaniah
30
37
  .bind("pagedown", :page_down, context: "in_list")
31
38
  .bind("up", :previous_option, context: "in_list")
32
39
  .bind("down", :next_option, context: "in_list")
33
- .bind("#{primary}-a", :select_all, context: "in_text_field || in_list")
34
40
  .bind("left", :move_left, context: "in_text_field")
35
41
  .bind("right", :move_right, context: "in_text_field")
36
42
  .bind("shift-left", :select_left, context: "in_text_field")
@@ -73,7 +79,6 @@ module Zaniah
73
79
  .bind("end", :last, context: "in_table")
74
80
  .bind("pageup", :page_up, context: "in_table")
75
81
  .bind("pagedown", :page_down, context: "in_table")
76
- .bind("#{primary}-a", :select_all, context: "in_table")
77
82
  .bind("up", :previous_option, context: "in_grid")
78
83
  .bind("down", :next_option, context: "in_grid")
79
84
  .bind("left", :previous_column, context: "in_grid")
@@ -85,17 +90,38 @@ module Zaniah
85
90
  .bind("home", :first, context: "in_grid")
86
91
  .bind("end", :last, context: "in_grid")
87
92
  .bind("enter", :activate, context: "in_grid")
88
- .bind("#{primary}-a", :select_all, context: "in_grid")
89
93
  .bind("up", :previous_option, context: "in_combobox")
90
94
  .bind("down", :next_option, context: "in_combobox")
91
95
  .bind("home", :first, context: "in_combobox")
92
96
  .bind("end", :last, context: "in_combobox")
93
97
  .bind("enter", :choose_option, context: "in_combobox")
94
98
  .bind("esc", :dismiss, context: "in_combobox")
99
+ .bind("up", :previous_option, context: "in_palette")
100
+ .bind("down", :next_option, context: "in_palette")
101
+ .bind("enter", :choose_option, context: "in_palette")
95
102
  .bind("left", :previous_option, context: "in_chart")
96
103
  .bind("right", :next_option, context: "in_chart")
97
104
  .bind("home", :first, context: "in_chart")
98
105
  .bind("end", :last, context: "in_chart")
106
+ .bind("up", :line_up, context: "in_text_field && multiline")
107
+ .bind("down", :line_down, context: "in_text_field && multiline")
108
+ return map if tui
109
+
110
+ word = mac ? "alt" : "ctrl"
111
+ map.bind("#{primary}-a", :select_all)
112
+ .bind("#{primary}-z", :undo)
113
+ .bind("#{primary}-shift-z", :redo)
114
+ .bind("#{primary}-x", :cut)
115
+ .bind("#{primary}-c", :copy)
116
+ .bind("#{primary}-v", :paste)
117
+ .bind("#{word}-left", :word_left, context: "in_text_field")
118
+ .bind("#{word}-right", :word_right, context: "in_text_field")
119
+ .bind("#{word}-shift-left", :select_word_left, context: "in_text_field")
120
+ .bind("#{word}-shift-right", :select_word_right, context: "in_text_field")
121
+ .bind(mac ? "cmd-up" : "ctrl-home", :document_start, context: "in_text_field")
122
+ .bind(mac ? "cmd-down" : "ctrl-end", :document_end, context: "in_text_field")
123
+ map.bind("ctrl-y", :redo) if windows
124
+ map
99
125
  end
100
126
  end
101
127