zaniah 0.6.4 → 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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -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 +11 -2
  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 +1 -1
  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/choice_fields.rb +12 -6
  28. data/lib/zaniah/ui/editors.rb +52 -0
  29. data/lib/zaniah/ui/matcher.rb +88 -0
  30. data/lib/zaniah/ui/modal.rb +121 -10
  31. data/lib/zaniah/ui/rich_text_surface.rb +3 -8
  32. data/lib/zaniah/ui.rb +1 -0
  33. data/lib/zaniah/unicode.rb +28 -0
  34. data/lib/zaniah/version.rb +1 -1
  35. data/lib/zaniah.rb +1 -0
  36. data/sig/accessibility.rbs +1 -0
  37. data/sig/app.rbs +2 -0
  38. data/sig/elements.rbs +3 -1
  39. data/sig/input.rbs +29 -2
  40. data/sig/inspection.rbs +57 -0
  41. data/sig/platform.rbs +5 -0
  42. data/sig/ui.rbs +28 -2
  43. data/sig/zaniah.rbs +7 -2
  44. metadata +8 -2
@@ -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)
@@ -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
@@ -46,6 +46,7 @@ module Zaniah
46
46
  @buffer = TextBuffer.new(text)
47
47
  @paragraph_styles = Array.new(text.count("\n") + 1) { {} }
48
48
  @selection, @selectable, @editable = TextSelection.new(0), !!selectable, !!editable
49
+ @undo_states, @redo_states = [], []
49
50
  end
50
51
 
51
52
  def text = @buffer.to_s
@@ -83,6 +84,7 @@ module Zaniah
83
84
  first, finish = checked_range(range)
84
85
  changes = normalize_style(style)
85
86
  return self if first == finish || changes.empty?
87
+ record_edit
86
88
  boundaries = [0, text.bytesize, first, finish, *@spans.flat_map { |span| [span.start, span.finish] }].uniq.sort
87
89
  @spans = merge_spans(boundaries.each_cons(2).filter_map do |from, to|
88
90
  next if from == to
@@ -98,6 +100,7 @@ module Zaniah
98
100
  offset = checked_offset(offset)
99
101
  validate_text(value)
100
102
  return self if value.empty?
103
+ record_edit
101
104
  inherited = style ? normalize_style(style) : style_at(offset)
102
105
  size, newlines = value.bytesize, value.count("\n")
103
106
  @buffer.insert(offset, value)
@@ -122,6 +125,7 @@ module Zaniah
122
125
  def delete(range)
123
126
  first, finish = checked_range(range)
124
127
  return self if first == finish
128
+ record_edit
125
129
  old = text
126
130
  first_line = @buffer.line_at(first)
127
131
  removed_lines = old.byteslice(first...finish).count("\n")
@@ -142,6 +146,7 @@ module Zaniah
142
146
  first, finish = checked_range(range)
143
147
  validate_text(value)
144
148
  return delete(first...finish) if value.empty?
149
+ record_edit
145
150
  inherited = style ? normalize_style(style) : style_at(first)
146
151
  first_line, last_line = @buffer.line_at(first), @buffer.line_at(finish)
147
152
  before_styles = @paragraph_styles.take(first_line)
@@ -187,6 +192,8 @@ module Zaniah
187
192
  first_line, last_line = @buffer.line_at(first), @buffer.line_at(finish)
188
193
  last_line = [last_line, @paragraph_styles.length - 1].min
189
194
  values = {align: align, list: list, level: level}.compact
195
+ return self if values.empty?
196
+ record_edit
190
197
  (first_line..last_line).each { |index| @paragraph_styles[index] = @paragraph_styles[index].merge(values).freeze }
191
198
  @on_change&.call(text, self)
192
199
  @cx&.window&.request_frame
@@ -208,10 +215,24 @@ module Zaniah
208
215
  head = @selection.head
209
216
  case action
210
217
  when :select_all then self.selection = TextSelection.new(0, text.bytesize)
218
+ when :copy then @cx.window.clipboard = text.byteslice(@selection.range)
219
+ when :cut
220
+ @cx.window.clipboard = text.byteslice(@selection.range)
221
+ delete(@selection.range)
222
+ when :paste then replace_selection(@cx.window.clipboard.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace))
223
+ when :undo, :redo then restore_edit(action)
211
224
  when :move_left then self.selection = TextSelection.new(@selection.collapsed? ? Unicode.previous_boundary(text, head) : @selection.range.begin)
212
225
  when :move_right then self.selection = TextSelection.new(@selection.collapsed? ? Unicode.next_boundary(text, head) : @selection.range.end)
213
226
  when :select_left then self.selection = TextSelection.new(@selection.anchor, Unicode.previous_boundary(text, head))
214
227
  when :select_right then self.selection = TextSelection.new(@selection.anchor, Unicode.next_boundary(text, head))
228
+ when :word_left, :word_right, :select_word_left, :select_word_right
229
+ direction = action.to_s.end_with?("left") ? :left : :right
230
+ target = Unicode.word_boundary(text, head, direction)
231
+ self.selection = action.to_s.start_with?("select") ? TextSelection.new(@selection.anchor, target) : TextSelection.new(target)
232
+ when :line_up, :line_down
233
+ self.selection = TextSelection.new(Unicode.neighbor_line_offset(text, head, action == :line_up ? -1 : 1))
234
+ when :document_start then self.selection = TextSelection.new(0)
235
+ when :document_end then self.selection = TextSelection.new(text.bytesize)
215
236
  when :line_start, :select_line_start then move_line_edge(action, :start)
216
237
  when :line_end, :select_line_end then move_line_edge(action, :end)
217
238
  when :delete_backward then delete_backward
@@ -222,6 +243,21 @@ module Zaniah
222
243
  true
223
244
  end
224
245
 
246
+ def validate_text_action(action)
247
+ case action
248
+ when :copy then !@selection.collapsed?
249
+ when :cut then @editable && !@selection.collapsed?
250
+ when :paste then @editable && !@buffer.composition
251
+ when :undo then @editable && !@buffer.composition && !@undo_states.empty?
252
+ when :redo then @editable && !@redo_states.empty?
253
+ when :delete_backward, :delete_forward, :insert_newline then @editable
254
+ when :select_all, :move_left, :move_right, :select_left, :select_right,
255
+ :word_left, :word_right, :select_word_left, :select_word_right,
256
+ :line_up, :line_down, :document_start, :document_end,
257
+ :line_start, :line_end, :select_line_start, :select_line_end then true
258
+ end
259
+ end
260
+
225
261
  def input(event)
226
262
  case event
227
263
  when Input::TextInput
@@ -257,6 +293,22 @@ module Zaniah
257
293
 
258
294
  private
259
295
 
296
+ def edit_state = [text, @spans, @paragraph_styles.dup, @selection]
297
+
298
+ def record_edit
299
+ @undo_states << edit_state
300
+ @redo_states.clear
301
+ end
302
+
303
+ def restore_edit(action)
304
+ from, to = action == :undo ? [@undo_states, @redo_states] : [@redo_states, @undo_states]
305
+ return false if from.empty?
306
+ to << edit_state
307
+ value, @spans, @paragraph_styles, @selection = from.pop
308
+ @buffer.replace(0...@buffer.bytesize, value)
309
+ changed
310
+ end
311
+
260
312
  def normalize_runs(value)
261
313
  text, spans = +"", []
262
314
  Array(value).each do |run|
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module UI
5
+ module Matcher
6
+ Match = Data.define(:index, :score, :ranges)
7
+
8
+ class Substring
9
+ def match(query, labels)
10
+ needle = query.to_s.downcase
11
+ labels.each_with_index.filter_map do |label, index|
12
+ label = label.to_s
13
+ position = label.downcase.index(needle)
14
+ next unless position
15
+ ranges = needle.empty? ? [] : [byte_range(label, position, needle.length)]
16
+ Match.new(index: index, score: 0.0, ranges: ranges)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def byte_range(label, position, length)
23
+ offsets = []
24
+ byte = 0
25
+ label.grapheme_clusters.each do |cluster|
26
+ cluster.downcase.length.times { offsets << (byte...(byte + cluster.bytesize)) }
27
+ byte += cluster.bytesize
28
+ end
29
+ offsets[position].begin...offsets[position + length - 1].end
30
+ end
31
+ end
32
+
33
+ class Session
34
+ def initialize(labels, matcher)
35
+ @labels, @matcher = labels, matcher
36
+ @query, @results = nil, nil
37
+ end
38
+
39
+ def results(query)
40
+ return @results if query == @query
41
+ @results = if @results && query.start_with?(@query) && @matcher.respond_to?(:refine)
42
+ @matcher.refine(@results, query)
43
+ else
44
+ @matcher.match(query, @labels)
45
+ end
46
+ @query = query.dup.freeze
47
+ @results
48
+ end
49
+ end
50
+
51
+ def self.segments(label, ranges)
52
+ parts = []
53
+ offset = 0
54
+ label.grapheme_clusters.each do |cluster|
55
+ last = offset + cluster.bytesize
56
+ highlighted = ranges.any? { |range| range.begin < last && range.end > offset }
57
+ if parts.last && parts.last[1] == highlighted
58
+ parts.last[0] << cluster
59
+ else
60
+ parts << [cluster.dup, highlighted]
61
+ end
62
+ offset = last
63
+ end
64
+ parts
65
+ end
66
+ end
67
+
68
+ class HighlightedButton < Button
69
+ def initialize(label, ranges:, **options)
70
+ super(label, **options)
71
+ @ranges = ranges
72
+ end
73
+
74
+ def build(cx)
75
+ @highlight_color = cx.theme.colors.accent
76
+ super.gap(0)
77
+ end
78
+
79
+ private
80
+
81
+ def button_content(style)
82
+ [Div.new.flex_row.gap(0).children(Matcher.segments(@label, @ranges).map do |part, highlighted|
83
+ Text.new(part, size: style[:font_size], color: highlighted ? @highlight_color : style[:foreground])
84
+ end)]
85
+ end
86
+ end
87
+ end
88
+ end
@@ -101,29 +101,140 @@ module Zaniah
101
101
  class CommandPalette < Modal
102
102
  attr_reader :query
103
103
 
104
- def initialize(commands, open: false, placeholder: "Type a command…")
105
- @commands, @query, @placeholder = commands.to_a, "", placeholder
104
+ def self.from(registry, **options)
105
+ commands = registry.entries.map do |name, title|
106
+ command = registry.command(name)
107
+ action = command&.name || name
108
+ enabled = lambda do |cx|
109
+ if cx&.dispatcher&.respond_to?(:available?)
110
+ cx.dispatcher.available?(action) == :enabled
111
+ elsif command&.enabled
112
+ command.enabled.arity.zero? ? command.enabled.call : command.enabled.call(cx)
113
+ else
114
+ true
115
+ end
116
+ end
117
+ callback = lambda do |_event, cx|
118
+ if cx.dispatcher.respond_to?(:perform)
119
+ cx.dispatcher.perform(action, source: :palette)
120
+ else
121
+ registry.call(name, cx)
122
+ end
123
+ end
124
+ [title, callback, enabled]
125
+ end
126
+ new(commands, **options)
127
+ end
128
+
129
+ def initialize(commands, open: false, placeholder: "Type a command…", matcher: nil)
130
+ @commands = commands.to_a.map { |label, callback, enabled| [label.to_s, callback, enabled] }
131
+ @matcher = Matcher::Session.new(@commands.map(&:first), matcher || Zaniah.configuration.matcher || Matcher::Substring.new)
132
+ @query, @placeholder, @selected_index = "", placeholder, 0
133
+ @focus_search = !!open
106
134
  super(Div.new, title: "Command palette", open: open, width: 520)
135
+ @focus_scope.context[:in_palette] = true
136
+ @focus_scope.on_action = ->(action) { palette_action(action) }
107
137
  end
108
138
 
109
139
  def build(cx)
110
- field = SearchInput.new(@query, placeholder: @placeholder).on_change do |value, context|
140
+ field = @search = SearchInput.new(@query, placeholder: @placeholder).on_change do |value, context|
111
141
  @query = value
142
+ @selected_index = 0
112
143
  context.window.request_frame
113
144
  end
114
- matches = @commands.select { |label, _| @query.empty? || label.to_s.downcase.include?(@query.downcase) }.first(20)
115
- results = Div.new.gap(1).children(matches.map do |label, callback|
116
- Button.new(label.to_s, variant: :ghost).w_full.on_click do |event, context|
117
- callback&.call(event, context)
118
- dismiss(event, context)
145
+ matches = filtered
146
+ unless matches[@selected_index] && enabled?(matches[@selected_index][0], cx)
147
+ @selected_index = matches.index { |entry, _| enabled?(entry, cx) } || 0
148
+ end
149
+ results = Div.new.gap(1).children(matches.map.with_index do |(entry, match), index|
150
+ label = entry[0]
151
+ button = match.ranges.empty? ? Button.new(label, variant: index == @selected_index ? :secondary : :ghost) :
152
+ HighlightedButton.new(label, ranges: match.ranges, variant: index == @selected_index ? :secondary : :ghost)
153
+ button.disabled(!enabled?(entry, cx)).w_full.on_click do |event, context|
154
+ choose(index, event, context)
119
155
  end
120
156
  end)
121
157
  @content = Div.new.gap(cx.theme.spacing[2]).child(field).child(results)
122
158
  super
123
159
  end
124
160
 
125
- def tui_cells(*) = "> #{@query}\n" + @commands.map(&:first).join("\n")
126
- def accessibility_node(cx) = @open && node(:dialog, label: "Command palette", states: {modal: true}, children: [Accessibility.node(role: :textbox, label: @placeholder, value: @query), Accessibility.node(role: :list, children: @commands.map { |label, _| Accessibility.node(role: :listitem, label: label) })], actions: [:dismiss])
161
+ def open(value = true)
162
+ @focus_search = true if value && !open?
163
+ super
164
+ end
165
+
166
+ def prepaint(bounds, state, cx)
167
+ super
168
+ return unless @focus_search && @open && @search&.focus_handle
169
+ cx.dispatcher.focus(@search.focus_handle, origin: :keyboard)
170
+ @focus_search = false
171
+ end
172
+
173
+ def tui_cells(*) = "> #{@query}\n" + filtered.map { |entry, _| entry[0] }.join("\n")
174
+
175
+ def accessibility_node(cx)
176
+ return unless @open
177
+ matches = filtered
178
+ active = matches[@selected_index]&.last&.index
179
+ items = matches.map.with_index do |(entry, match), index|
180
+ enabled = enabled?(entry, cx || @cx)
181
+ Accessibility.node(role: :listitem, id: "palette-option-#{match.index}", label: entry[0],
182
+ states: {selected: index == @selected_index, disabled: !enabled}, actions: enabled ? [:press] : [])
183
+ end
184
+ node(:dialog, label: "Command palette", states: {modal: true}, children: [
185
+ Accessibility.node(role: :searchbox, label: @placeholder, value: @query),
186
+ Accessibility.node(role: :list, states: {active_descendant: active && "palette-option-#{active}"}, children: items)
187
+ ], actions: [:dismiss])
188
+ end
189
+
190
+ def accessibility_action(item, action)
191
+ return dismiss(nil, @cx) if action == :dismiss
192
+ return false unless action == :press
193
+ index = filtered.index { |_entry, match| "palette-option-#{match.index}" == item.id }
194
+ index && choose(index, nil, @cx)
195
+ end
196
+
197
+ private
198
+
199
+ def filtered
200
+ @matcher.results(@query).first(20).filter_map do |match|
201
+ entry = @commands[match.index]
202
+ [entry, match] if entry
203
+ end
204
+ end
205
+
206
+ def enabled?(entry, cx)
207
+ callback, predicate = entry[1], entry[2]
208
+ callback && (!predicate || (predicate.arity.zero? ? predicate.call : predicate.call(cx)))
209
+ end
210
+
211
+ def palette_action(action)
212
+ case action
213
+ when :previous_option then move(-1)
214
+ when :next_option then move(1)
215
+ when :choose_option, :activate then choose(@selected_index, nil, @cx)
216
+ when :dismiss then dismiss(nil, @cx)
217
+ else false
218
+ end
219
+ end
220
+
221
+ def move(step)
222
+ matches = filtered
223
+ return false if matches.empty?
224
+ matches.length.times do
225
+ @selected_index = (@selected_index + step) % matches.length
226
+ break if enabled?(matches[@selected_index][0], @cx)
227
+ end
228
+ @cx&.window&.request_frame
229
+ true
230
+ end
231
+
232
+ def choose(index, event, cx)
233
+ entry = filtered[index]&.first
234
+ return false unless entry && enabled?(entry, cx)
235
+ entry[1].call(event, cx)
236
+ dismiss(event, cx)
237
+ end
127
238
  end
128
239
  end
129
240
  end
@@ -16,8 +16,9 @@ module Zaniah
16
16
  on_mouse_down { |event, _cx| begin_selection(event) }
17
17
  on_drag { |event, _cx| extend_selection(event) }
18
18
  end
19
- focusable(context: owner.editable? ? {in_text_field: true} : {in_text: true}) do |action|
20
- owner.editable? ? owner.text_action(action) : action == :select_all && select_all
19
+ focusable(context: {in_text_field: true, in_text: !owner.editable?, multiline: true},
20
+ validate: ->(action) { owner.validate_text_action(action) }) do |action|
21
+ owner.text_action(action)
21
22
  end
22
23
  @focus_handle.on_input = ->(event) { owner.input(event) }
23
24
  end
@@ -291,12 +292,6 @@ module Zaniah
291
292
  range = Unicode.word_range_at(@owner.text, [offset, @owner.text.bytesize].min)
292
293
  TextSelection.new(range.begin, range.end)
293
294
  end
294
-
295
- def select_all
296
- @owner.selection = TextSelection.new(0, @owner.text.bytesize)
297
- @owner.instance_variable_get(:@cx)&.window&.request_frame
298
- true
299
- end
300
295
  end
301
296
  end
302
297
  end
data/lib/zaniah/ui.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "ui/variants"
5
5
  require_relative "ui/component"
6
6
  require_relative "ui/primitives"
7
7
  require_relative "ui/controls"
8
+ require_relative "ui/matcher"
8
9
  require_relative "ui/overlays"
9
10
  require_relative "ui/text_inputs"
10
11
  require_relative "ui/choice_fields"
@@ -34,6 +34,34 @@ module Zaniah
34
34
  starts[first]...(starts[last] || text.bytesize)
35
35
  end
36
36
 
37
+ def self.word_boundary(text, offset, direction)
38
+ raise ArgumentError, "direction must be left or right" unless %i[left right].include?(direction)
39
+ if direction == :left
40
+ return 0 if offset.zero?
41
+ cursor = previous_boundary(text, offset)
42
+ while cursor.positive? && text.byteslice(cursor...next_boundary(text, cursor)).match?(/\A\s+\z/)
43
+ cursor = previous_boundary(text, cursor)
44
+ end
45
+ word_range_at(text, cursor).begin
46
+ else
47
+ return text.bytesize if offset >= text.bytesize
48
+ cursor = word_range_at(text, offset).end
49
+ while cursor < text.bytesize && text.byteslice(cursor...next_boundary(text, cursor)).match?(/\A\s+\z/)
50
+ cursor = next_boundary(text, cursor)
51
+ end
52
+ cursor
53
+ end
54
+ end
55
+
56
+ def self.neighbor_line_offset(text, offset, direction)
57
+ lines = text.split("\n", -1)
58
+ line = text.byteslice(0...offset).count("\n")
59
+ target = (line + direction).clamp(0, lines.length - 1)
60
+ start = lines.take(line).sum { |value| value.bytesize + 1 }
61
+ column = text.byteslice(start...offset).grapheme_clusters.length
62
+ lines.take(target).sum { |value| value.bytesize + 1 } + lines[target].grapheme_clusters.take(column).sum(&:bytesize)
63
+ end
64
+
37
65
  def self.width(text, ambiguous: 1)
38
66
  require "rbconfig"
39
67
  require "unicode/display_width"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zaniah
4
- VERSION = "0.6.4"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/zaniah.rb CHANGED
@@ -39,6 +39,7 @@ require_relative "zaniah/process_pool"
39
39
  require_relative "zaniah/input"
40
40
  require_relative "zaniah/drag_drop"
41
41
  require_relative "zaniah/element"
42
+ require_relative "zaniah/inspection"
42
43
  require_relative "zaniah/describe"
43
44
  Zaniah.autoload(:SVG, File.expand_path("zaniah/svg", __dir__))
44
45
  require_relative "zaniah/scroll_state"