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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/docs/adr/013-public-inspection-api.md +16 -0
- data/docs/adr/014-unified-command-dispatch.md +40 -0
- data/docs/components.md +11 -2
- data/docs/gallery/command-palette-dark.png +0 -0
- data/docs/gallery/command-palette-high_contrast.png +0 -0
- data/docs/gallery/command-palette-light.png +0 -0
- data/docs/inspection.md +19 -0
- data/docs/text.md +19 -0
- data/docs/tui.md +1 -1
- data/lib/zaniah/accessibility/tree.rb +9 -0
- data/lib/zaniah/app.rb +3 -1
- data/lib/zaniah/configuration.rb +1 -1
- data/lib/zaniah/devtools/inspector.rb +5 -29
- data/lib/zaniah/element.rb +4 -1
- data/lib/zaniah/input/action_registry.rb +12 -3
- data/lib/zaniah/input/dispatcher.rb +47 -1
- data/lib/zaniah/input/focus_handle.rb +3 -2
- data/lib/zaniah/input/keymap.rb +31 -5
- data/lib/zaniah/inspection.rb +146 -0
- data/lib/zaniah/platform/headless/window.rb +10 -1
- data/lib/zaniah/platform/tui/window.rb +8 -0
- data/lib/zaniah/task_executor.rb +2 -0
- data/lib/zaniah/text.rb +44 -1
- data/lib/zaniah/text_buffer.rb +2 -0
- data/lib/zaniah/ui/choice_fields.rb +12 -6
- data/lib/zaniah/ui/editors.rb +52 -0
- data/lib/zaniah/ui/matcher.rb +88 -0
- data/lib/zaniah/ui/modal.rb +121 -10
- data/lib/zaniah/ui/rich_text_surface.rb +3 -8
- data/lib/zaniah/ui.rb +1 -0
- data/lib/zaniah/unicode.rb +28 -0
- data/lib/zaniah/version.rb +1 -1
- data/lib/zaniah.rb +1 -0
- data/sig/accessibility.rbs +1 -0
- data/sig/app.rbs +2 -0
- data/sig/elements.rbs +3 -1
- data/sig/input.rbs +29 -2
- data/sig/inspection.rbs +57 -0
- data/sig/platform.rbs +5 -0
- data/sig/ui.rbs +28 -2
- data/sig/zaniah.rbs +7 -2
- 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, " ") }
|
data/lib/zaniah/task_executor.rb
CHANGED
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
|
|
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
|
data/lib/zaniah/text_buffer.rb
CHANGED
|
@@ -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 = @
|
|
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)
|
|
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
|
data/lib/zaniah/ui/editors.rb
CHANGED
|
@@ -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
|
data/lib/zaniah/ui/modal.rb
CHANGED
|
@@ -101,29 +101,140 @@ module Zaniah
|
|
|
101
101
|
class CommandPalette < Modal
|
|
102
102
|
attr_reader :query
|
|
103
103
|
|
|
104
|
-
def
|
|
105
|
-
|
|
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 =
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
126
|
-
|
|
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:
|
|
20
|
-
|
|
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"
|
data/lib/zaniah/unicode.rb
CHANGED
|
@@ -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"
|
data/lib/zaniah/version.rb
CHANGED
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"
|