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
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Inspection
5
+ SOURCE_NODES = ObjectSpace::WeakMap.new
6
+
7
+ Entry = Data.define(:element, :type, :key, :test_id, :bounds, :style, :tooltip,
8
+ :context_menu, :handlers, :focusable, :focused, :children) do
9
+ def name = type
10
+ def object = element
11
+ def focusable? = focusable
12
+ def focused? = focused
13
+ end
14
+
15
+ Overlays = Data.define(:tooltip, :popup, :menu)
16
+ FrameInfo = Data.define(:number, :stats)
17
+
18
+ AccessibilitySnapshot = Data.define(:root) do
19
+ def query(role: nil, label: nil, states: {})
20
+ raise ArgumentError, "label must be a String or Regexp" unless label.nil? || label.is_a?(String) || label.is_a?(Regexp)
21
+ return [].freeze unless root
22
+
23
+ found = []
24
+ pending = [[root, []]]
25
+ until pending.empty?
26
+ node, path = pending.pop
27
+ if (!role || node.role == role) &&
28
+ (!label || (node.label && (label.is_a?(Regexp) ? label.match?(node.label) : node.label == label))) &&
29
+ states.all? { |key, value| node.states.key?(key) && node.states[key] == value }
30
+ found << [node, path.freeze].freeze
31
+ end
32
+ node.children.each_with_index.reverse_each { |child, index| pending << [child, path + [index]] }
33
+ end
34
+ found.freeze
35
+ end
36
+ end
37
+
38
+ Snapshot = Data.define(:root, :overlays, :text_runs, :frame, :accessibility, :hits) do
39
+ def find(test_id: nil, type: nil) = where(test_id: test_id, type: type).first
40
+
41
+ def where(test_id: nil, type: nil)
42
+ return [].freeze unless root
43
+ found = []
44
+ pending = [root]
45
+ until pending.empty?
46
+ entry = pending.pop
47
+ if (!test_id || entry.test_id == test_id) &&
48
+ (!type || (type.is_a?(Class) ? entry.element.is_a?(type) : entry.type == type))
49
+ found << entry
50
+ end
51
+ pending.concat(entry.children.reverse)
52
+ end
53
+ found.freeze
54
+ end
55
+
56
+ def at(point)
57
+ return unless point
58
+ owner = hits.reverse_each.find { |hit| hit.contains?(point) }&.owner
59
+ while owner
60
+ pending = [root].compact
61
+ until pending.empty?
62
+ entry = pending.pop
63
+ return entry if entry.element.equal?(owner)
64
+ pending.concat(entry.children)
65
+ end
66
+ owner = owner.respond_to?(:parent) ? owner.parent : nil
67
+ end
68
+ nil
69
+ end
70
+ end
71
+
72
+ module_function
73
+
74
+ def snapshot(window)
75
+ raise ArgumentError, "expected a rendered window" unless window.respond_to?(:last_root) && window.respond_to?(:dispatcher)
76
+
77
+ seen = {}
78
+ root = entry(window.last_root, window, seen)
79
+ popup = window.popup if window.respond_to?(:popup)
80
+ popup = Platform::Headless::Popup.new(labels: copy(popup.labels), enabled: copy(popup.enabled),
81
+ selected_index: popup.selected_index, bounds: popup.bounds) if popup
82
+ overlays = Overlays.new(tooltip: copy(window.tooltip_state), popup: popup,
83
+ menu: window.app&.respond_to?(:menu_bar) ? window.app.menu_bar : nil)
84
+ text_runs = copy(window.text_runs)
85
+ frame = FrameInfo.new(number: window.frame_number, stats: copy(window.frame_stats))
86
+ Snapshot.new(root: root, overlays: overlays, text_runs: text_runs, frame: frame,
87
+ accessibility: AccessibilitySnapshot.new(root: copy_accessibility(window.accessibility_tree.root)),
88
+ hits: window.dispatcher.hit_regions)
89
+ end
90
+
91
+ def idle?(app)
92
+ app.executor.idle? && app.windows.all? { |window| window.closed? || (!window.dirty? && !window.animation_active?) }
93
+ end
94
+
95
+ def perform(window, node, action) = Accessibility.perform(window, SOURCE_NODES[node] || node, action)
96
+
97
+ def copy_accessibility(node)
98
+ return unless node
99
+ result = Accessibility::Node.new(role: node.role, label: copy(node.label), value: copy(node.value),
100
+ bounds: node.bounds, states: copy(node.states),
101
+ children: node.children.map { |child| copy_accessibility(child) }.freeze, actions: copy(node.actions))
102
+ SOURCE_NODES[result] = node
103
+ result
104
+ end
105
+ private_class_method :copy_accessibility
106
+
107
+ def entry(object, window, seen)
108
+ return unless object && !seen[object.object_id]
109
+ seen[object.object_id] = true
110
+ style = object.resolved_style if object.respond_to?(:resolved_style)
111
+ style ||= object.root&.resolved_style if object.respond_to?(:root)
112
+ handle = object.focus_handle if object.respond_to?(:focus_handle)
113
+ items = object.context_menu_items if object.respond_to?(:context_menu_items)
114
+ menu = items&.map { |label, callback| [label.dup.freeze, !callback.nil?].freeze }&.freeze if items.is_a?(Array)
115
+ Entry.new(element: object, type: object.class.name.to_s.delete_prefix("Zaniah::").freeze,
116
+ key: object.respond_to?(:identity_key) ? copy(object.identity_key) : nil,
117
+ test_id: object.respond_to?(:test_id) ? copy(object.test_id) : nil,
118
+ bounds: object.respond_to?(:layout_node) ? object.layout_node&.bounds : nil,
119
+ style: snapshot_style(style),
120
+ tooltip: object.respond_to?(:tooltip_text) ? copy(object.tooltip_text) : nil,
121
+ context_menu: menu, handlers: object.respond_to?(:handlers) ? copy(object.handlers) : [].freeze,
122
+ focusable: !!handle&.focusable, focused: !!(handle && window.dispatcher.focused.equal?(handle)),
123
+ children: object.respond_to?(:children) ? object.children.filter_map { |child| entry(child, window, seen) }.freeze : [].freeze)
124
+ end
125
+ private_class_method :entry
126
+
127
+ def snapshot_style(style)
128
+ values = style ? style.to_h : {}.freeze
129
+ return values if values.frozen? && values.values.none? { |value| value.is_a?(String) || value.is_a?(Array) || value.is_a?(Hash) }
130
+ copy(values)
131
+ end
132
+ private_class_method :snapshot_style
133
+
134
+ def copy(value)
135
+ case value
136
+ when String then value.dup.freeze
137
+ when Array then value.map { |item| copy(item) }.freeze
138
+ when Hash then value.to_h { |key, item| [copy(key), copy(item)] }.freeze
139
+ else value
140
+ end
141
+ end
142
+ private_class_method :copy
143
+ end
144
+
145
+ def self.bundled_font_path = File.expand_path("../../assets/fonts/Abel-Regular.ttf", __dir__)
146
+ end
@@ -9,7 +9,7 @@ module Zaniah
9
9
  include Appearance
10
10
  DEFAULT_CLEAR = "#181b20"
11
11
 
12
- attr_reader :content_size, :scene, :device, :dispatcher, :scale_factor, :text_runs, :pointer_position, :cursor_style, :animator, :clock, :accessibility_tree, :accessibility_revision, :frame_stats
12
+ attr_reader :content_size, :scene, :device, :dispatcher, :scale_factor, :text_runs, :pointer_position, :cursor_style, :animator, :clock, :accessibility_tree, :accessibility_revision, :frame_stats, :last_root, :frame_number
13
13
  attr_accessor :text_system, :ime_state, :title, :app, :devtools
14
14
 
15
15
  def initialize(width: 800, height: 600, title: "Zaniah UI", scale_factor: 1,
@@ -23,6 +23,7 @@ module Zaniah
23
23
  @accessibility_tree, @accessibility_revision = Accessibility::Tree.new, 0
24
24
  @frame_stats = {fps: 0.0, frame_ms: 0.0, layout_ms: 0.0, prepaint_ms: 0.0, paint_ms: 0.0, command_count: 0}.freeze
25
25
  @state, @used_state, @text_runs = {}, {}, []
26
+ @last_root, @frame_number = nil, 0
26
27
  @dirty, @closed, @pointer_down, @cursor_style = true, false, false, :arrow
27
28
  end
28
29
 
@@ -38,6 +39,10 @@ module Zaniah
38
39
  def animation_active? = !!@animator&.active?
39
40
  def closed? = @closed
40
41
  def pointer_down? = @pointer_down
42
+ def clipboard = @clipboard.to_s
43
+ def clipboard=(text)
44
+ @clipboard = text.to_s
45
+ end
41
46
  def set_cursor(style)
42
47
  raise ArgumentError, "unknown cursor #{style}" unless %i[arrow text pointer crosshair resize_horizontal resize_vertical].include?(style)
43
48
  return style if @cursor_style == style
@@ -65,6 +70,8 @@ module Zaniah
65
70
  selected_index: @menu[:index], bounds: popup_bounds(@menu))
66
71
  end
67
72
 
73
+ def tooltip_state = @tooltip&.merge(text: @tooltip[:text].dup.freeze)&.freeze
74
+
68
75
  def resize(width, height)
69
76
  @content_size = Size.new(width, height)
70
77
  @device.resize(width, height)
@@ -173,6 +180,8 @@ module Zaniah
173
180
  prepaint_ms: (prepaint_finished - layout_finished) * 1000,
174
181
  paint_ms: (paint_finished - prepaint_finished) * 1000,
175
182
  command_count: @scene.commands.length / 4}.freeze
183
+ @last_root = element
184
+ @frame_number += 1
176
185
  @on_frame&.call(element, clear)
177
186
  @device.render(@scene, clear: clear) if present
178
187
  @text_system.end_frame if @text_system.respond_to?(:end_frame)
@@ -9,6 +9,7 @@ module Zaniah
9
9
  class Window < Headless::Window
10
10
  def initialize(input: $stdin, output: $stdout, **options)
11
11
  @input, @output = input, output
12
+ options[:keymap] ||= Input::Keymap.default_ui(platform: :tui, clock: options.fetch(:clock, MONOTONIC_CLOCK))
12
13
  super(**options)
13
14
  @text_system = TextRenderer.new
14
15
  @input_decoder = InputDecoder.new { |event| self.input(event) }
@@ -39,6 +40,13 @@ module Zaniah
39
40
 
40
41
  def feed_input(bytes) = @input_decoder.feed(bytes)
41
42
 
43
+ def clipboard=(text)
44
+ super
45
+ @output.write("\e]52;c;#{[clipboard].pack('m0')}\a")
46
+ @output.flush
47
+ clipboard
48
+ end
49
+
42
50
  def render(element, **options)
43
51
  super(element, **options, present: false)
44
52
  rows = Array.new((content_size.height / 20).ceil) { Array.new((content_size.width / 8).ceil, " ") }
@@ -81,6 +81,8 @@ module Zaniah
81
81
  @wake.synchronize { @condition.signal }
82
82
  end
83
83
 
84
+ def idle? = @foreground.empty?
85
+
84
86
  def drain
85
87
  now = @clock.call
86
88
  due, @timers = @timers.partition { |deadline, _| deadline <= now }
data/lib/zaniah/text.rb CHANGED
@@ -81,6 +81,7 @@ module Zaniah
81
81
  align: @text_align, ellipsis: @ellipsis, kinsoku: @kinsoku)
82
82
  @wrap, @line_height, @letter_spacing = mode, line_height, letter_spacing
83
83
  @text_align, @ellipsis, @kinsoku = align, ellipsis, kinsoku
84
+ @focus_handle.context[:multiline] = mode != :none || @text.include?("\n") if @focus_handle
84
85
  self
85
86
  end
86
87
 
@@ -88,7 +89,8 @@ module Zaniah
88
89
  @selectable = value
89
90
  @selection ||= TextSelection.new(0)
90
91
  return self unless value
91
- focusable(context: {in_text_field: true}) { |action| text_action(action) }
92
+ focusable(context: {in_text_field: true, multiline: @wrap != :none || @text.include?("\n")},
93
+ validate: ->(action) { validate_text_action(action) }) { |action| text_action(action) }
92
94
  @focus_handle.on_input = ->(event) { text_input(event) }
93
95
  on_mouse_down { |event, _| begin_selection(event) }
94
96
  on_drag { |event, _| extend_selection(event) }
@@ -104,6 +106,7 @@ module Zaniah
104
106
  end
105
107
 
106
108
  def request_layout(cx)
109
+ @cx = cx
107
110
  @text = @buffer.to_s if @buffer
108
111
  value = display_text
109
112
  @paragraph, @line = nil, nil
@@ -226,10 +229,24 @@ module Zaniah
226
229
  head = @selection.head
227
230
  case action
228
231
  when :select_all then @selection = TextSelection.new(0, @text.bytesize)
232
+ when :copy then @cx.window.clipboard = @text.byteslice(@selection.range)
233
+ when :cut
234
+ @cx.window.clipboard = @text.byteslice(@selection.range)
235
+ delete_range(@selection.range)
236
+ when :paste then replace_selection(@cx.window.clipboard.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace))
237
+ when :undo, :redo then history(action)
229
238
  when :move_left then @selection = TextSelection.new(@selection.collapsed? ? Unicode.previous_boundary(@text, head) : @selection.range.begin)
230
239
  when :move_right then @selection = TextSelection.new(@selection.collapsed? ? Unicode.next_boundary(@text, head) : @selection.range.end)
231
240
  when :select_left then @selection = TextSelection.new(@selection.anchor, Unicode.previous_boundary(@text, head))
232
241
  when :select_right then @selection = TextSelection.new(@selection.anchor, Unicode.next_boundary(@text, head))
242
+ when :word_left, :word_right, :select_word_left, :select_word_right
243
+ direction = action.to_s.end_with?("left") ? :left : :right
244
+ target = Unicode.word_boundary(@text, head, direction)
245
+ @selection = action.to_s.start_with?("select") ? TextSelection.new(@selection.anchor, target) : TextSelection.new(target)
246
+ when :line_up, :line_down
247
+ @selection = TextSelection.new(Unicode.neighbor_line_offset(@text, head, action == :line_up ? -1 : 1))
248
+ when :document_start then @selection = TextSelection.new(0)
249
+ when :document_end then @selection = TextSelection.new(@text.bytesize)
233
250
  when :line_start, :select_line_start then move_to_line_edge(action, :start)
234
251
  when :line_end, :select_line_end then move_to_line_edge(action, :end)
235
252
  when :delete_backward then delete_backward
@@ -237,9 +254,35 @@ module Zaniah
237
254
  when :insert_newline then replace_selection("\n") if @editable
238
255
  else return false
239
256
  end
257
+ @cx&.window&.request_frame
240
258
  true
241
259
  end
242
260
 
261
+ def validate_text_action(action)
262
+ case action
263
+ when :copy then !@secure && !@selection.collapsed?
264
+ when :cut then !!@editable && !@secure && !@selection.collapsed?
265
+ when :paste then !!@editable && !@buffer.composition
266
+ when :undo then !!@editable && !@buffer.composition && @buffer.can_undo?
267
+ when :redo then !!@editable && @buffer.can_redo?
268
+ when :delete_backward, :delete_forward, :insert_newline then !!@editable
269
+ when :select_all, :move_left, :move_right, :select_left, :select_right,
270
+ :word_left, :word_right, :select_word_left, :select_word_right,
271
+ :line_up, :line_down, :document_start, :document_end,
272
+ :line_start, :line_end, :select_line_start, :select_line_end then true
273
+ end
274
+ end
275
+
276
+ def history(action)
277
+ before = @buffer.to_s
278
+ @buffer.public_send(action)
279
+ @text = @buffer.to_s
280
+ return if @text == before
281
+ position = Unicode.grapheme_boundaries(@text).reverse.find { |offset| offset <= @selection.head } || 0
282
+ @selection = TextSelection.new(position)
283
+ @on_change&.call(@text)
284
+ end
285
+
243
286
  def text_input(event)
244
287
  case event
245
288
  when Input::TextInput
@@ -14,6 +14,8 @@ module Zaniah
14
14
 
15
15
  def to_s = @text.dup
16
16
  def bytesize = @text.bytesize
17
+ def can_undo? = !@undo.empty?
18
+ def can_redo? = !@redo.empty?
17
19
 
18
20
  def insert(offset, string)
19
21
  change(offset...offset, string, :insert)
@@ -32,14 +32,14 @@ module Zaniah
32
32
  result.freeze
33
33
  end
34
34
 
35
- def paint_line(scene, bounds, values, color)
36
- points = chart_points(bounds, values)
35
+ def paint_line(scene, bounds, values, color, range: nil)
36
+ points = chart_points(bounds, values, range: range)
37
37
  path = points.map.with_index { |(x, y), index| "#{index.zero? ? "M" : "L"}#{x},#{y}" }.join
38
38
  scene.path(path, stroke: color, width: 2)
39
39
  end
40
40
 
41
- def chart_points(bounds, values)
42
- minimum, maximum = values.minmax
41
+ def chart_points(bounds, values, range: nil)
42
+ minimum, maximum = range || values.minmax
43
43
  span = maximum == minimum ? 1.0 : maximum - minimum
44
44
  step = values.length == 1 ? 0 : bounds.width / (values.length - 1)
45
45
  values.map.with_index { |value, index| [bounds.x + index * step, bounds.bottom - (value - minimum) / span * bounds.height] }
@@ -83,6 +83,40 @@ module Zaniah
83
83
  span = maximum == minimum ? 1.0 : maximum - minimum
84
84
  values.map { |value| levels[((value - minimum) / span * (levels.length - 1)).round] }.join
85
85
  end
86
+
87
+ def chart_palette(cx)
88
+ palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
89
+ raise ArgumentError, "chart colors must not be empty" if palette.empty?
90
+ palette
91
+ end
92
+
93
+ def chart_legend(entries, palette)
94
+ Div.new.flex_row.gap(10).children(entries.each_with_index.map do |entry, index|
95
+ label = block_given? ? yield(entry) : entry.to_s
96
+ Div.new.flex_row.items_center.gap(4)
97
+ .child(Div.new.w(8).h(8).bg(palette[index % palette.length]))
98
+ .child(Label.new(label, size: :xs))
99
+ end)
100
+ end
101
+
102
+ def cartesian_axes(scene, bounds, color)
103
+ divisions = 4
104
+ grid = (1...divisions).flat_map do |index|
105
+ fraction = index.to_f / divisions
106
+ x = bounds.x + bounds.width * fraction
107
+ y = bounds.y + bounds.height * fraction
108
+ ["M#{x},#{bounds.y}V#{bounds.bottom}", "M#{bounds.x},#{y}H#{bounds.right}"]
109
+ end.join
110
+ ticks = (1...divisions).flat_map do |index|
111
+ fraction = index.to_f / divisions
112
+ x = bounds.x + bounds.width * fraction
113
+ y = bounds.y + bounds.height * fraction
114
+ ["M#{x},#{bounds.bottom}V#{bounds.bottom - 3}", "M#{bounds.x},#{y}H#{bounds.x + 3}"]
115
+ end.join
116
+ scene.path(grid, stroke: color, width: 0.5)
117
+ scene.path("M#{bounds.x},#{bounds.y}V#{bounds.bottom}H#{bounds.right}", stroke: color, width: 1)
118
+ scene.path(ticks, stroke: color, width: 1)
119
+ end
86
120
  end
87
121
 
88
122
  class LineChart < Sparkline
@@ -96,25 +130,21 @@ module Zaniah
96
130
  end
97
131
 
98
132
  def build(cx)
99
- palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
133
+ palette = chart_palette(cx)
134
+ range = @series.values.flatten.minmax
100
135
  canvas = @canvas = Canvas.new do |bounds, context|
101
136
  @plot_bounds = bounds.inset(Edges.new(12, 8, 20, 28))
102
- axes(context.scene, @plot_bounds, cx.theme.colors.border)
103
- @series.each_with_index { |(_name, values), index| paint_line(context.scene, @plot_bounds, values, palette[index % palette.length]) }
137
+ cartesian_axes(context.scene, @plot_bounds, cx.theme.colors.border)
138
+ @series.each_with_index do |(_name, values), index|
139
+ paint_line(context.scene, @plot_bounds, values, palette[index % palette.length], range: range)
140
+ end
104
141
  end.w(@width).h(@height).on_hover { |event, context| show_line_tooltip(event, context) }
105
142
  .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
106
- legend = Div.new.flex_row.gap(10).children(@series.keys.each_with_index.map do |name, index|
107
- Div.new.flex_row.items_center.gap(4).child(Div.new.w(8).h(8).bg(palette[index % palette.length])).child(Label.new(name, size: :xs))
108
- end)
109
- Div.new.gap(4).child(canvas).child(legend)
143
+ Div.new.gap(4).child(canvas).child(chart_legend(@series.keys, palette))
110
144
  end
111
145
 
112
146
  private
113
147
 
114
- def axes(scene, bounds, color)
115
- scene.path("M#{bounds.x},#{bounds.y}V#{bounds.bottom}H#{bounds.right}", stroke: color, width: 1)
116
- end
117
-
118
148
  def show_line_tooltip(event, cx)
119
149
  return unless @plot_bounds
120
150
  text = @series.map do |name, values|
@@ -131,14 +161,15 @@ module Zaniah
131
161
  end
132
162
 
133
163
  def build(cx)
134
- palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
164
+ palette = chart_palette(cx)
165
+ @axis_color = cx.theme.colors.border
135
166
  canvas = @canvas = Canvas.new do |bounds, context|
136
167
  @plot_bounds = bounds.inset(Edges.new(12, 8, 20, 28))
137
- axes(context.scene, @plot_bounds, cx.theme.colors.border)
168
+ cartesian_axes(context.scene, @plot_bounds, @axis_color)
138
169
  paint_bars(context.scene, @plot_bounds, palette)
139
170
  end.w(@width).h(@height).on_hover { |event, context| show_line_tooltip(event, context) }
140
171
  .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
141
- Div.new.gap(4).child(canvas).child(Label.new(@series.keys.join(" · "), size: :xs))
172
+ Div.new.gap(4).child(canvas).child(chart_legend(@series.keys, palette))
142
173
  end
143
174
 
144
175
  private
@@ -185,20 +216,16 @@ module Zaniah
185
216
  end
186
217
 
187
218
  def build(cx)
188
- palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
189
- raise ArgumentError, "chart colors must not be empty" if palette.empty?
219
+ palette = chart_palette(cx)
190
220
 
191
221
  @canvas = Canvas.new do |bounds, context|
192
222
  @plot_bounds = bounds
193
223
  paint_slices(context.scene, bounds, palette)
194
224
  end.w(@width).h(@height).on_hover { |event, context| show_slice_tooltip(event, context) }
195
225
  .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
196
- legend = Div.new.flex_row.gap(10).children(@slices.each_with_index.map do |(name, value), index|
197
- percent = value / @total * 100
198
- Div.new.flex_row.items_center.gap(4)
199
- .child(Div.new.w(8).h(8).bg(palette[index % palette.length]))
200
- .child(Label.new("#{name}: #{format("%.1f", percent)}%", size: :xs))
201
- end)
226
+ legend = chart_legend(@slices, palette) do |name, value|
227
+ "#{name}: #{format("%.1f", value / @total * 100)}%"
228
+ end
202
229
  Div.new.gap(4).child(@canvas).child(legend)
203
230
  end
204
231
 
@@ -308,20 +335,15 @@ module Zaniah
308
335
  end
309
336
 
310
337
  def build(cx)
311
- palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
312
- raise ArgumentError, "chart colors must not be empty" if palette.empty?
338
+ palette = chart_palette(cx)
313
339
 
314
340
  canvas = @canvas = Canvas.new do |bounds, context|
315
341
  @plot_bounds = bounds.inset(Edges.new(12, 8, 20, 28))
316
- paint_axes(context.scene, @plot_bounds, cx.theme.colors.border)
342
+ cartesian_axes(context.scene, @plot_bounds, cx.theme.colors.border)
317
343
  paint_points(context.scene, @plot_bounds, palette)
318
344
  end.w(@width).h(@height).on_hover { |event, context| show_scatter_tooltip(event, context) }
319
345
  .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
320
- legend = Div.new.flex_row.gap(10).children(@points.keys.each_with_index.map do |name, index|
321
- Div.new.flex_row.items_center.gap(4).child(Div.new.w(8).h(8).bg(palette[index % palette.length]))
322
- .child(Label.new(name, size: :xs))
323
- end)
324
- Div.new.gap(4).child(canvas).child(legend)
346
+ Div.new.gap(4).child(canvas).child(chart_legend(@points.keys, palette))
325
347
  end
326
348
 
327
349
  def tui_cells(*) = spark(@series.values.first)
@@ -341,10 +363,6 @@ module Zaniah
341
363
 
342
364
  private
343
365
 
344
- def paint_axes(scene, bounds, color)
345
- scene.path("M#{bounds.x},#{bounds.y}V#{bounds.bottom}H#{bounds.right}", stroke: color, width: 1)
346
- end
347
-
348
366
  def paint_points(scene, bounds, palette)
349
367
  all = @points.values.flatten(1)
350
368
  min_x, max_x = all.map(&:first).minmax
@@ -381,20 +399,15 @@ module Zaniah
381
399
  end
382
400
 
383
401
  def build(cx)
384
- palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
385
- raise ArgumentError, "chart colors must not be empty" if palette.empty?
402
+ palette = chart_palette(cx)
386
403
 
387
404
  canvas = @canvas = Canvas.new do |bounds, context|
388
405
  @plot_bounds = bounds.inset(Edges.new(12, 8, 20, 28))
389
- axes(context.scene, @plot_bounds, cx.theme.colors.border)
406
+ cartesian_axes(context.scene, @plot_bounds, cx.theme.colors.border)
390
407
  paint_areas(context.scene, @plot_bounds, palette)
391
408
  end.w(@width).h(@height).on_hover { |event, context| show_line_tooltip(event, context) }
392
409
  .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
393
- legend = Div.new.flex_row.gap(10).children(@series.keys.each_with_index.map do |name, index|
394
- Div.new.flex_row.items_center.gap(4).child(Div.new.w(8).h(8).bg(palette[index % palette.length]))
395
- .child(Label.new(name, size: :xs))
396
- end)
397
- Div.new.gap(4).child(canvas).child(legend)
410
+ Div.new.gap(4).child(canvas).child(chart_legend(@series.keys, palette))
398
411
  end
399
412
 
400
413
  private
@@ -457,7 +470,7 @@ module Zaniah
457
470
  value.negative? ? negative[index] = finish : positive[index] = finish
458
471
  end
459
472
  end
460
- scene.path("M#{bounds.x},#{zero}H#{bounds.right}", stroke: "#777", width: 1)
473
+ scene.path("M#{bounds.x},#{zero}H#{bounds.right}", stroke: @axis_color || "#777", width: 1)
461
474
  end
462
475
  end
463
476
  end
@@ -23,9 +23,10 @@ module Zaniah
23
23
  class Combobox < Component
24
24
  attr_reader :value, :query
25
25
 
26
- def initialize(items, value: nil, label: "Choose", placeholder: "Type to filter…", disabled: false)
26
+ def initialize(items, value: nil, label: "Choose", placeholder: "Type to filter…", disabled: false, matcher: nil)
27
27
  super()
28
28
  @items = normalize_items(items)
29
+ @matcher = Matcher::Session.new(@items.map(&:first), matcher || Zaniah.configuration.matcher || Matcher::Substring.new)
29
30
  @label, @placeholder, @disabled = label.to_s, placeholder.to_s, !!disabled
30
31
  @value = value
31
32
  @query = label_for(value).to_s
@@ -37,22 +38,27 @@ module Zaniah
37
38
 
38
39
  def build(cx)
39
40
  @cx = cx
40
- @matches = @items.select { |label, _| @query.empty? || label.downcase.include?(@query.downcase) }.first(8)
41
+ @matches = @matcher.results(@query).first(8).filter_map do |match|
42
+ item = @items[match.index]
43
+ [item, match] if item
44
+ end
41
45
  @selected_index = @selected_index.clamp(0, [@matches.length - 1, 0].max)
42
46
  field = TextField.new(@query, label: @label, placeholder: @placeholder, disabled: @disabled)
43
47
  .on_change { |text, context| @query = text; @open = true; @selected_index = 0; context&.window&.request_frame }
44
48
  root = Div.new.gap(cx.theme.spacing[1]).focusable(context: {in_combobox: true}) { |action| combo_action(action) }.child(field)
45
49
  if @open && !@matches.empty? && !@disabled
46
50
  root.child(Div.new.p(2).gap(1).bg(cx.theme.colors.surface).border(1).border_color(cx.theme.colors.border)
47
- .rounded(cx.theme.radii[:sm]).children(@matches.map.with_index do |(label, value), index|
48
- Button.new(label, size: :sm, variant: index == @selected_index ? :secondary : :ghost).w_full
51
+ .rounded(cx.theme.radii[:sm]).children(@matches.map.with_index do |((label, value), match), index|
52
+ button = match.ranges.empty? ? Button.new(label, size: :sm, variant: index == @selected_index ? :secondary : :ghost) :
53
+ HighlightedButton.new(label, ranges: match.ranges, size: :sm, variant: index == @selected_index ? :secondary : :ghost)
54
+ button.w_full
49
55
  .on_click { |event, context| choose(value, label, event, context) }
50
56
  end))
51
57
  end
52
58
  root
53
59
  end
54
60
 
55
- def tui_cells(*) = "#{@label}: [#{@query}█]" + (@open ? "\n" + @matches.map.with_index { |(label, _), index| "#{index == @selected_index ? ">" : " "} #{label}" }.join("\n") : "")
61
+ def tui_cells(*) = "#{@label}: [#{@query}█]" + (@open ? "\n" + @matches.map.with_index { |((label, _), _match), index| "#{index == @selected_index ? ">" : " "} #{label}" }.join("\n") : "")
56
62
  def accessibility_node(_cx) = node(:combobox, label: @label, value: @value,
57
63
  states: {expanded: @open, disabled: @disabled}, actions: @disabled ? [] : %i[focus set_value])
58
64
 
@@ -77,7 +83,7 @@ module Zaniah
77
83
  when :choose_option
78
84
  match = @matches[@selected_index]
79
85
  return false unless match
80
- return choose(match[1], match[0], nil, @cx)
86
+ return choose(match[0][1], match[0][0], nil, @cx)
81
87
  when :dismiss then @open = false
82
88
  else return false
83
89
  end