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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -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 +14 -3
- 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 +2 -2
- 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/charts.rb +60 -47
- 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
|
@@ -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, " ") }
|
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
data/lib/zaniah/ui/charts.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
103
|
-
@series.each_with_index
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
197
|
-
|
|
198
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 = @
|
|
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
|