zaniah 0.5.0 → 0.5.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 615b3b2c2a4cd7f0cfc43138163f57ce0cab7fa5af420d24b69ce7b0ebaf62a7
4
- data.tar.gz: f65f5cf0ec59f48c3320031e3ed1a4fc5bf60d97c2592810c74ca1537b866e91
3
+ metadata.gz: eb69d39d2a9c465f5229a512df0e94725862830b69c7349049fd9cf41ac101f4
4
+ data.tar.gz: d43e0a824e59324923fb0bd9b3a9379540370f22cb52db8cce86d48ff8fbbc8e
5
5
  SHA512:
6
- metadata.gz: f3511cb3869208deb5870b407af6403da397e1776cb95ff44b6baedaee00248b05aa86e947bf5b9e78ed7b4eec98a79767e14e476195e6f1b928cbcbff5fb335
7
- data.tar.gz: aabc636e94b51f64cf224473b1fd1214b791f029988c198246a81bd6d533487b65e27b5b960d19fc4b796c9502c311078e59482c83e7cf566d2942ccc60cbf7d
6
+ metadata.gz: 8f148a417c388934dbd199e55958cafc8edcb957b91a134d9597d81496629d20b4300aea9583145c549748d37c365421eaad1dbe4ad7f477359fbfb82779ba62
7
+ data.tar.gz: 15e46659712297ad8318608ff3353e5852d63619306af3983a3bfde36b343a664c42441c9392ad9a46752b02e4dab8e7429dfd62b7a268e9a0e6a889bc632698
data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.2 — 2026-09-16
6
+
7
+ - Preserve insertion order for inline overlays that share an offset and alignment.
8
+
9
+ ## 0.5.1 — 2026-09-16
10
+
11
+ - Let lazy tree views accept externally loaded children and invalidate cached subtrees without blocking the UI thread.
12
+ - Prevent replacement tree sources from inheriting stale lazy children.
13
+
5
14
  ## 0.5.0 — 2026-09-15
6
15
 
7
16
  - 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 viewport-virtualized trees, charts, forms, and terminal fallbacks
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. IDs must be unique. Supply explicit,
105
- stable IDs and call `replace(items)` after changing the hierarchy to preserve
106
- selection, expansion, and successfully loaded children across a rebuilt source.
107
- Changing a loader while retaining its ID intentionally retains its cached children.
108
- Tree rows and accessibility nodes are built only for the current viewport.
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
@@ -73,14 +73,15 @@ module Zaniah
73
73
 
74
74
  def validate_overlays(overlays)
75
75
  raise ArgumentError, "inline overlays must be an array" unless overlays.is_a?(Array)
76
- overlays.map do |overlay|
76
+ overlays.map.with_index do |overlay, index|
77
77
  unless overlay.is_a?(InlineOverlay) && overlay.offset.is_a?(Integer) && overlay.offset.between?(0, @text.bytesize) &&
78
78
  Unicode.grapheme_boundary?(@text, overlay.offset) && %i[before after].include?(overlay.align) &&
79
79
  [overlay.width, overlay.height].all? { |value| value.is_a?(Numeric) && value.finite? && !value.negative? }
80
80
  raise ArgumentError, "invalid inline overlay"
81
81
  end
82
- overlay
83
- end.sort_by { |overlay| [overlay.offset, overlay.align == :before ? 0 : 1] }.freeze
82
+ [overlay, index]
83
+ end.sort_by { |overlay, index| [overlay.offset, overlay.align == :before ? 0 : 1, index] }
84
+ .map!(&:first).freeze
84
85
  end
85
86
 
86
87
  def overlays_for(first, finish)
@@ -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, @locations = items.to_a, {}
29
- @expanded.clear
30
- reset_index
31
- restore_expansions(wanted)
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: loader.equal?(children) ? [] : Array(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: source.last, loader: nil, parent: parent, depth: depth)
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: loader ? [] : Array(children), loader: loader,
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
- @loaded[id] = Array(item.loader.call(item.value)) if open && item.loader && !@loaded.key?(id)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zaniah
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.2"
5
5
  end
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
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zaniah
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-15 00:00:00.000000000 Z
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: alhena