zaniah 0.5.0 → 0.5.1
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 +5 -0
- data/README.md +1 -1
- data/docs/components.md +13 -6
- data/lib/zaniah/ui/tree_view.rb +142 -13
- data/lib/zaniah/version.rb +1 -1
- data/sig/ui.rbs +2 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0a864e71f3de289770aafa36f520440c0adab078b763412544bca3f9fb32cacb
|
|
4
|
+
data.tar.gz: fc0e6fb0c05f8d0769305954cc5a730258947845b8438328df6d66ca172e80ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 16ff7f0e3cc0ce444b502eaff32faaab6cf503af5bf0a4ad547a305aa9394c91d9960052e60bc94ce775521bd54e42ddadfff49b6f896147ca00771e3e4aa59f
|
|
7
|
+
data.tar.gz: 959e485686a3e92a155916a306afc0eb7848f6556d5c1bc6cbfe9c30a605701213dd37c79c978d49b807c60b25e9a47a3cc1e3fff9731cf0ae5a0432a7522f51
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.1 — 2026-09-16
|
|
6
|
+
|
|
7
|
+
- Let lazy tree views accept externally loaded children and invalidate cached subtrees without blocking the UI thread.
|
|
8
|
+
- Prevent replacement tree sources from inheriting stale lazy children.
|
|
9
|
+
|
|
5
10
|
## 0.5.0 — 2026-09-15
|
|
6
11
|
|
|
7
12
|
- Make tree views lazy and viewport-virtualized while preserving stable-ID state across source rebuilds.
|
data/README.md
CHANGED
|
@@ -41,7 +41,7 @@ the drawing surface beneath the editor.
|
|
|
41
41
|
- Stable-ID split-pane grids with fixed, fractional, and minmax tracks
|
|
42
42
|
- OpenType shaping, font fallback, Japanese wrapping, text overlays, low-resolution text caching, selection, editing, and IME
|
|
43
43
|
- Themes, state styles, keyed animation, springs, and reduced-motion support
|
|
44
|
-
- Opt-in controls, overlays, tables, lazy
|
|
44
|
+
- Opt-in controls, overlays, tables, externally completable lazy virtual trees, charts, forms, and terminal fallbacks
|
|
45
45
|
- Spatial keyboard focus and a stable-ID, event-diffed cross-platform accessibility tree
|
|
46
46
|
- F12 inspector, frame statistics, hot reload, and headless golden-image tests
|
|
47
47
|
- Native input, IME, clipboard, file-drop, display, and filesystem events
|
data/docs/components.md
CHANGED
|
@@ -84,7 +84,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
|
|
|
84
84
|
| L3 | `DockPanel` | `(center:, top:, right:, bottom:, left:)` | five-region layout | group |
|
|
85
85
|
| L3 | `ListView` | `(items, height:, row_height:, selected:)`; `on_select` | virtual rows and keyboard selection | list/listitem |
|
|
86
86
|
| L4 | `Table`, `DataGrid` | `(rows, columns:, height:, selection:, row_key:)`; `on_sort`, `on_select`, `on_edit` | virtual rows, sorting, resizing, editing | table/row/cell |
|
|
87
|
-
| L4 | `TreeView` | `(items, height:, selected:)`; `expand`, `collapse`, `replace`, lazy `children` proc | arrows/Home/End | tree/treeitem |
|
|
87
|
+
| L4 | `TreeView` | `(items, height:, selected:)`; `expand`, `collapse`, `replace`, `replace_children`, `invalidate`, lazy `children` proc | arrows/Home/End | tree/treeitem |
|
|
88
88
|
| L5 | `Sparkline` | `(values, width:, height:, color:, label:)` | line + tooltip | image |
|
|
89
89
|
| L5 | `LineChart`, `BarChart` | `(series, width:, height:, colors:, label:)` | axes, legend, tooltip | image |
|
|
90
90
|
| L5 | `Validation` | `required`, `format`, `length`, `number`, `rule` | composable rules | n/a |
|
|
@@ -101,11 +101,18 @@ Table columns are hashes with `key`, and optional `label`, `width`, `sortable`,
|
|
|
101
101
|
`resizable`, `editable`, and `render`. Tree items accept hashes containing `id`,
|
|
102
102
|
`label`, and either an array or lazy proc in `children`. A lazy proc receives the
|
|
103
103
|
item value, runs on first expansion, and is cached after it succeeds; a raised
|
|
104
|
-
exception leaves it available for retry.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
exception leaves it available for retry. A loader can return a placeholder while
|
|
105
|
+
work runs elsewhere; call `replace_children(id, children)` on the UI thread to
|
|
106
|
+
apply the result without changing selection, focus, or expansion. It returns
|
|
107
|
+
`false` when the ID is missing or is not lazy. `invalidate(id)` likewise returns
|
|
108
|
+
`false` for missing or non-lazy items; otherwise it discards that lazy result and
|
|
109
|
+
its loaded descendants so the next expansion calls the loader again. Omit the ID
|
|
110
|
+
to invalidate all loaded results. `replace(items)` starts a
|
|
111
|
+
new source generation, so it never inherits loaded children from the old source.
|
|
112
|
+
Completed children must be an array with unique, non-nil stable IDs, at most 64
|
|
113
|
+
levels deep and 100,000 items total. Invalid results leave the previous children
|
|
114
|
+
unchanged. Tree rows and accessibility nodes are built only for the current
|
|
115
|
+
viewport.
|
|
109
116
|
|
|
110
117
|
In a table, Up/Down/Home/End/Page keys move and select rows, Shift+Up/Down extends
|
|
111
118
|
a range, and Cmd/Ctrl+A selects every row in multiple-selection mode. Sortable
|
data/lib/zaniah/ui/tree_view.rb
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
module Zaniah
|
|
4
4
|
module UI
|
|
5
5
|
class TreeView < Component
|
|
6
|
+
MAX_COMPLETED_ITEMS = 100_000
|
|
7
|
+
MAX_DEPTH = 64
|
|
8
|
+
private_constant :MAX_COMPLETED_ITEMS, :MAX_DEPTH
|
|
9
|
+
|
|
6
10
|
Item = Data.define(:id, :label, :value, :children, :loader, :parent, :depth)
|
|
7
11
|
Segment = Data.define(:items, :first, :length, :parent, :depth, :path)
|
|
8
12
|
Location = Data.define(:items, :index, :parent, :depth, :path)
|
|
@@ -25,15 +29,53 @@ module Zaniah
|
|
|
25
29
|
|
|
26
30
|
def replace(items)
|
|
27
31
|
wanted = @expanded.dup
|
|
28
|
-
@source
|
|
29
|
-
@
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
@selected_id = nil if !@selected_id.nil? && !visible_index(@selected_id)
|
|
32
|
+
@source = items.to_a
|
|
33
|
+
@loaded.clear
|
|
34
|
+
reindex(wanted)
|
|
35
|
+
clear_missing_selection
|
|
33
36
|
@cx&.window&.request_frame
|
|
34
37
|
self
|
|
35
38
|
end
|
|
36
39
|
|
|
40
|
+
def replace_children(id, children)
|
|
41
|
+
item = find_known_item(id)
|
|
42
|
+
return false unless item&.loader
|
|
43
|
+
|
|
44
|
+
values = validate_children(id, children)
|
|
45
|
+
discard_descendants(id)
|
|
46
|
+
@loaded[id] = values
|
|
47
|
+
rebuild_segments
|
|
48
|
+
clear_missing_selection
|
|
49
|
+
@cx&.window&.request_frame
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def invalidate(id = nil)
|
|
54
|
+
item = find_known_item(id) unless id.nil?
|
|
55
|
+
return false if !id.nil? && (!item&.loader || !@loaded.key?(id))
|
|
56
|
+
|
|
57
|
+
if id.nil?
|
|
58
|
+
return false if @loaded.empty?
|
|
59
|
+
|
|
60
|
+
@loaded.clear
|
|
61
|
+
reindex(@expanded.dup)
|
|
62
|
+
else
|
|
63
|
+
selected = @locations[@selected_id]
|
|
64
|
+
parent = @locations.fetch(id)
|
|
65
|
+
select_parent = selected && selected.path.length > parent.path.length &&
|
|
66
|
+
selected.path.first(parent.path.length) == parent.path
|
|
67
|
+
descendants = descendant_ids(id)
|
|
68
|
+
discard_descendants(id, descendants)
|
|
69
|
+
@loaded.delete(id)
|
|
70
|
+
@expanded.delete(id)
|
|
71
|
+
rebuild_segments
|
|
72
|
+
@selected_id = id if select_parent
|
|
73
|
+
end
|
|
74
|
+
clear_missing_selection
|
|
75
|
+
@cx&.window&.request_frame
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
37
79
|
def build(cx)
|
|
38
80
|
@cx = cx
|
|
39
81
|
rebuild_segments if @indexed_expanded != @expanded
|
|
@@ -93,6 +135,74 @@ module Zaniah
|
|
|
93
135
|
|
|
94
136
|
private
|
|
95
137
|
|
|
138
|
+
def reindex(wanted)
|
|
139
|
+
@locations = {}
|
|
140
|
+
@expanded.clear
|
|
141
|
+
reset_index
|
|
142
|
+
restore_expansions(wanted)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def clear_missing_selection
|
|
146
|
+
@selected_id = nil if !@selected_id.nil? && !visible_index(@selected_id)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate_children(id, children)
|
|
150
|
+
raise TypeError, "tree children must be an Array" unless children.is_a?(Array)
|
|
151
|
+
|
|
152
|
+
ids = {}
|
|
153
|
+
validate_collection(@source, [], ids, skip: id)
|
|
154
|
+
location = @locations.fetch(id)
|
|
155
|
+
validate_collection(children, location.path, ids, location.depth + 1, count: [0])
|
|
156
|
+
children
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def validate_collection(items, path, ids, depth = 0, active = {}, skip: nil, count: nil)
|
|
160
|
+
raise ArgumentError, "tree children are too deeply nested" if depth > MAX_DEPTH
|
|
161
|
+
raise ArgumentError, "tree children must not contain cycles" if active[items.object_id]
|
|
162
|
+
|
|
163
|
+
active[items.object_id] = true
|
|
164
|
+
items.each_with_index do |source, index|
|
|
165
|
+
if count
|
|
166
|
+
count[0] += 1
|
|
167
|
+
raise ArgumentError, "tree children exceed #{MAX_COMPLETED_ITEMS} items" if count[0] > MAX_COMPLETED_ITEMS
|
|
168
|
+
end
|
|
169
|
+
pair = source.is_a?(Array) && source.length == 2 && source.last.is_a?(Array)
|
|
170
|
+
explicit_id = source.is_a?(Hash) ? source.key?(:id) : !pair && source.respond_to?(:id)
|
|
171
|
+
item_path = path + [index] unless explicit_id
|
|
172
|
+
item_id = source_id(source, item_path)
|
|
173
|
+
raise ArgumentError, "duplicate tree item id #{item_id.inspect}" if ids.key?(item_id)
|
|
174
|
+
|
|
175
|
+
ids[item_id] = true
|
|
176
|
+
next if item_id == skip
|
|
177
|
+
next if source.is_a?(Hash) ? !source.key?(:children) && !source.key?(:load) :
|
|
178
|
+
!(source.respond_to?(:children) || pair)
|
|
179
|
+
|
|
180
|
+
nested, loader = source_children(source)
|
|
181
|
+
raise TypeError, "tree item loader must respond to call" if loader && !loader.respond_to?(:call)
|
|
182
|
+
|
|
183
|
+
nested = @loaded.fetch(item_id, nested)
|
|
184
|
+
unless nested.empty?
|
|
185
|
+
item_path ||= path + [index]
|
|
186
|
+
validate_collection(nested, item_path, ids, depth + 1, active, skip: skip, count: count)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
ensure
|
|
190
|
+
active.delete(items.object_id)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def descendant_ids(id)
|
|
194
|
+
location = @locations.fetch(id)
|
|
195
|
+
@locations.filter_map do |target, child|
|
|
196
|
+
target if child.path.length > location.path.length && child.path.first(location.path.length) == location.path
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def discard_descendants(id, descendants = descendant_ids(id))
|
|
201
|
+
descendants.each { |target| @locations.delete(target) }
|
|
202
|
+
descendants.each { |target| @loaded.delete(target) }
|
|
203
|
+
@expanded.subtract(descendants)
|
|
204
|
+
end
|
|
205
|
+
|
|
96
206
|
def reset_index
|
|
97
207
|
@segments, @visible_count = [], 0
|
|
98
208
|
append_segment(@source, 0, @source.length, nil, 0, [].freeze)
|
|
@@ -149,21 +259,18 @@ module Zaniah
|
|
|
149
259
|
|
|
150
260
|
def normalize_item(source, parent, depth, path, items)
|
|
151
261
|
path = path.freeze
|
|
262
|
+
children, loader = source_children(source)
|
|
152
263
|
if source.is_a?(Hash)
|
|
153
|
-
children = source[:children]
|
|
154
|
-
loader = children.respond_to?(:call) ? children : source[:load]
|
|
155
264
|
item = Item.new(id: source.fetch(:id, path), label: source.fetch(:label, source[:value]).to_s,
|
|
156
|
-
value: source.fetch(:value, source), children:
|
|
265
|
+
value: source.fetch(:value, source), children: children,
|
|
157
266
|
loader: loader, parent: parent, depth: depth)
|
|
158
267
|
elsif source.is_a?(Array) && source.length == 2 && source.last.is_a?(Array)
|
|
159
268
|
item = Item.new(id: path, label: source.first.to_s, value: source.first,
|
|
160
|
-
children:
|
|
269
|
+
children: children, loader: nil, parent: parent, depth: depth)
|
|
161
270
|
else
|
|
162
|
-
children = source.children if source.respond_to?(:children)
|
|
163
|
-
loader = children if children.respond_to?(:call)
|
|
164
271
|
item = Item.new(id: source.respond_to?(:id) ? source.id : path,
|
|
165
272
|
label: source.respond_to?(:label) ? source.label.to_s : source.to_s,
|
|
166
|
-
value: source, children:
|
|
273
|
+
value: source, children: children, loader: loader,
|
|
167
274
|
parent: parent, depth: depth)
|
|
168
275
|
end
|
|
169
276
|
raise ArgumentError, "tree item id must not be nil" if item.id.nil?
|
|
@@ -195,6 +302,19 @@ module Zaniah
|
|
|
195
302
|
id
|
|
196
303
|
end
|
|
197
304
|
|
|
305
|
+
def source_children(source)
|
|
306
|
+
if source.is_a?(Hash)
|
|
307
|
+
children = source[:children]
|
|
308
|
+
loader = children.respond_to?(:call) ? children : source[:load]
|
|
309
|
+
elsif source.is_a?(Array) && source.length == 2 && source.last.is_a?(Array)
|
|
310
|
+
return [source.last, nil]
|
|
311
|
+
else
|
|
312
|
+
children = source.children if source.respond_to?(:children)
|
|
313
|
+
loader = children if children.respond_to?(:call)
|
|
314
|
+
end
|
|
315
|
+
[loader.equal?(children) ? [] : Array(children), loader]
|
|
316
|
+
end
|
|
317
|
+
|
|
198
318
|
def children_for(item) = @loaded.fetch(item.id, item.children)
|
|
199
319
|
def expandable?(item) = !!(item.loader || !children_for(item).empty?)
|
|
200
320
|
def branch(item) = expandable?(item) ? (@expanded.include?(item.id) ? "▾" : "▸") : " "
|
|
@@ -244,13 +364,22 @@ module Zaniah
|
|
|
244
364
|
index && visible_item(index)
|
|
245
365
|
end
|
|
246
366
|
|
|
367
|
+
def find_known_item(id)
|
|
368
|
+
location = @locations[id]
|
|
369
|
+
return find_item(id) unless location_valid?(id, location)
|
|
370
|
+
|
|
371
|
+
normalize_item(location.items[location.index], location.parent, location.depth, location.path, location.items)
|
|
372
|
+
end
|
|
373
|
+
|
|
247
374
|
def toggle(id, value = nil)
|
|
248
375
|
item = find_item(id)
|
|
249
376
|
return false unless item && expandable?(item)
|
|
250
377
|
|
|
251
378
|
open = value.nil? ? !@expanded.include?(id) : !!value
|
|
252
379
|
return false if open == @expanded.include?(id)
|
|
253
|
-
|
|
380
|
+
if open && item.loader && !@loaded.key?(id)
|
|
381
|
+
@loaded[id] = Array(item.loader.call(item.value))
|
|
382
|
+
end
|
|
254
383
|
open ? @expanded.add(id) : @expanded.delete(id)
|
|
255
384
|
rebuild_segments
|
|
256
385
|
select_parent_after_collapse(item) unless open
|
data/lib/zaniah/version.rb
CHANGED
data/sig/ui.rbs
CHANGED
|
@@ -358,6 +358,8 @@ module Zaniah
|
|
|
358
358
|
def expand: (untyped id) -> self
|
|
359
359
|
def collapse: (untyped id) -> self
|
|
360
360
|
def replace: (Array[untyped] items) -> self
|
|
361
|
+
def replace_children: (untyped id, Array[untyped] children) -> bool
|
|
362
|
+
def invalidate: (?untyped id) -> bool
|
|
361
363
|
def accessibility_action: (Accessibility::Node node, Symbol action) -> bool?
|
|
362
364
|
end
|
|
363
365
|
class ListView < Component
|