zaniah 0.5.3 → 0.6.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 +6 -0
- data/README.md +4 -2
- data/docs/describe.md +53 -0
- data/lib/zaniah/describe.rb +385 -0
- data/lib/zaniah/version.rb +1 -1
- data/lib/zaniah.rb +1 -0
- data/sig/describe.rbs +44 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 905d6e52f6013be2a7b8e0223ece509526a9cde3a6198c8386e8b59903bf2dde
|
|
4
|
+
data.tar.gz: 57db74ac492c60d4d0f310c25596005c89faa706e2c8750cb6322b8091ac3671
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 27209b822cd741f79110228e9ddefc63eccf3eee8ffe64f2467570c4dd9fbc126176cf1e39289d8afa3b144577335809a1aa7b77d21deba013050e32eb374fcf
|
|
7
|
+
data.tar.gz: 739e4c67fd1de82b7fce3e0682aa9ecb1380cdf629f315f61387989f64d89379247840e6d691a36e0d17bb148a30a7eb5fa82cd5d749857b6592c864e2880340
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Add validated declarative element descriptions with event routing, tree diffs, and keyed surface reuse.
|
|
6
|
+
|
|
7
|
+
## 0.6.0 — 2026-09-19
|
|
8
|
+
|
|
9
|
+
- Add the validated declarative Describe layer for remote UI trees.
|
|
10
|
+
|
|
5
11
|
## 0.5.3 — 2026-09-17
|
|
6
12
|
|
|
7
13
|
- Restore Ruby 4.0/json 3 compatibility for process messages and atlas caches while keeping JSON object additions disabled.
|
data/README.md
CHANGED
|
@@ -28,14 +28,15 @@ Zaniah is a pure Ruby UI toolkit for building desktop and terminal interfaces.
|
|
|
28
28
|
It renders through native GPU APIs, a deterministic headless backend, or an ANSI
|
|
29
29
|
terminal without extension compilation or a rendering subprocess.
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
the
|
|
31
|
+
Zaniah is named for a star in Virgo. The toolkit is the drawing surface beneath
|
|
32
|
+
the editor, providing the native rendering layer for its UI.
|
|
33
33
|
|
|
34
34
|
## Features
|
|
35
35
|
|
|
36
36
|
- Flex, Grid, sticky, absolute, wrapping, and baseline-aware layouts
|
|
37
37
|
- Gradients, transforms, paths, SVG, shadows, clipping, and GPU glyph atlases
|
|
38
38
|
- Retained element state, subscriptions, and non-blocking background tasks
|
|
39
|
+
- Validated declarative element trees with event IDs, minimal diffs, and keyed reuse
|
|
39
40
|
- Scroll views, inertial input, and uniform or variable-height virtual lists
|
|
40
41
|
- Stable-ID pointer and keyboard reordering without materializing virtual collections
|
|
41
42
|
- Stable-ID split-pane grids with fixed, fractional, and minmax tracks
|
|
@@ -124,6 +125,7 @@ Native backends use the operating system libraries through Ruby's Fiddle. See
|
|
|
124
125
|
- [Accessibility](docs/accessibility.md) — semantic trees, diffs, and native bridges
|
|
125
126
|
- [Terminal UI](docs/tui.md) — deterministic component degradation
|
|
126
127
|
- [CPU process workers](docs/process_pool.md) — portable background CPU work
|
|
128
|
+
- [Declarative elements](docs/describe.md) — validated vocabularies, event routing, diffs, and surfaces
|
|
127
129
|
- [Architecture decisions](docs/adr) — design records and rationale
|
|
128
130
|
- [RBS declarations](sig) — public API signatures
|
|
129
131
|
|
data/docs/describe.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Declarative elements
|
|
2
|
+
|
|
3
|
+
`Zaniah::Describe` rebuilds element trees from plain data. The application owns
|
|
4
|
+
the vocabulary, so it decides which node types and properties untrusted or
|
|
5
|
+
remote producers may use.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
vocabulary = Zaniah::Describe::Vocabulary.build do
|
|
9
|
+
node :column, props: {gap: :integer} do |props, children|
|
|
10
|
+
Zaniah::Div.new.flex_col.gap(props.fetch(:gap)).children(children)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
node :button,
|
|
14
|
+
props: {label: :string, on_click: :handler}, children: :none do |props|
|
|
15
|
+
Zaniah::UI::Button.new(props.fetch(:label)).on_click(&props.fetch(:on_click))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
tree = {
|
|
20
|
+
"type" => "column",
|
|
21
|
+
"props" => {"gap" => 8},
|
|
22
|
+
"children" => [{
|
|
23
|
+
"type" => "button",
|
|
24
|
+
"props" => {"label" => "Save", "on_click" => ["save", 1]},
|
|
25
|
+
"children" => [],
|
|
26
|
+
"key" => "save"
|
|
27
|
+
}]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
surface = Zaniah::Describe::Surface.new(
|
|
31
|
+
vocabulary: vocabulary,
|
|
32
|
+
on_event: ->(id, payload) { send_event(id, payload) }
|
|
33
|
+
)
|
|
34
|
+
surface.replace(tree)
|
|
35
|
+
window.draw { surface.element }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Property validators include `:string`, `:integer`, `:number`, `:boolean`,
|
|
39
|
+
`:array`, `:hash`, `:symbol`, `:handler`, `:any`, classes, callables, and arrays
|
|
40
|
+
of allowed values. Children may be `:none`, `:one`, `:many`, an exact count,
|
|
41
|
+
or a range. JSON string keys are normalized to symbols, and string values are
|
|
42
|
+
normalized for symbol enums.
|
|
43
|
+
|
|
44
|
+
Handler properties contain an opaque string or array ID. The factory receives
|
|
45
|
+
a callable in its place; invoking it sends the ID and the first payload argument
|
|
46
|
+
to `on_event`.
|
|
47
|
+
|
|
48
|
+
`Zaniah::Describe.diff(previous, current)` returns `Patch` records with
|
|
49
|
+
`:replace`, `:insert`, `:remove`, or `:update` operations. Paths are arrays of
|
|
50
|
+
child indexes from the root, and `:update` contains the complete new property
|
|
51
|
+
map. `Surface#apply` validates and applies a patch set atomically. Its `element`
|
|
52
|
+
reader performs no building or I/O, and unchanged keyed subtrees retain their
|
|
53
|
+
element instances across reordering.
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module Describe
|
|
5
|
+
Node = Data.define(:type, :props, :children, :key)
|
|
6
|
+
Patch = Data.define(:op, :path, :node, :props)
|
|
7
|
+
MISSING = Object.new.freeze
|
|
8
|
+
private_constant :MISSING
|
|
9
|
+
|
|
10
|
+
class Vocabulary
|
|
11
|
+
Definition = Data.define(:props, :children, :factory)
|
|
12
|
+
|
|
13
|
+
def self.build(&block)
|
|
14
|
+
raise ArgumentError, "a vocabulary block is required" unless block
|
|
15
|
+
new.tap { |vocabulary| vocabulary.instance_eval(&block) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@definitions = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def node(type, props: {}, children: :many, &factory)
|
|
23
|
+
type = Describe.send(:normalize_type, type)
|
|
24
|
+
raise ArgumentError, "node #{type.inspect} is already registered" if @definitions.key?(type)
|
|
25
|
+
raise ArgumentError, "node #{type.inspect} needs a factory" unless factory
|
|
26
|
+
|
|
27
|
+
properties = Describe.send(:normalize_props, props)
|
|
28
|
+
properties.each_value { |validator| validate_validator!(validator) }
|
|
29
|
+
validate_children_rule!(children)
|
|
30
|
+
@definitions[type] = Definition.new(properties.freeze, children, factory)
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def allow?(type)
|
|
35
|
+
@definitions.key?(Describe.send(:normalize_type, type))
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate!(value)
|
|
41
|
+
node = Describe.send(:normalize_node, value)
|
|
42
|
+
validate_node!(node)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def definition(type)
|
|
48
|
+
@definitions.fetch(type) { raise ArgumentError, "node type #{type.inspect} is not allowed" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_node!(node)
|
|
52
|
+
entry = definition(node.type)
|
|
53
|
+
unknown = node.props.keys - entry.props.keys
|
|
54
|
+
raise ArgumentError, "unknown properties for #{node.type.inspect}: #{unknown.join(", ")}" unless unknown.empty?
|
|
55
|
+
|
|
56
|
+
props = node.props.each_with_object({}) do |(name, value), validated|
|
|
57
|
+
validator = entry.props.fetch(name)
|
|
58
|
+
value = normalize_property(validator, value)
|
|
59
|
+
raise ArgumentError, "invalid #{name.inspect} property for #{node.type.inspect}" unless valid_property?(validator, value)
|
|
60
|
+
validated[name] = value
|
|
61
|
+
end
|
|
62
|
+
children = node.children.map { |child| validate_node!(child) }.freeze
|
|
63
|
+
validated = Node.new(node.type, props.freeze, children, node.key)
|
|
64
|
+
validate_children!(entry.children, validated)
|
|
65
|
+
duplicate_keys = children.map(&:key).reject(&:nil?).tally.select { |_key, count| count > 1 }.keys
|
|
66
|
+
raise ArgumentError, "duplicate child keys for #{node.type.inspect}: #{duplicate_keys.inspect}" unless duplicate_keys.empty?
|
|
67
|
+
validated
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_children!(rule, node)
|
|
71
|
+
count = node.children.length
|
|
72
|
+
valid = case rule
|
|
73
|
+
when :none then count.zero?
|
|
74
|
+
when :one then count == 1
|
|
75
|
+
when :many then true
|
|
76
|
+
when Integer then count == rule
|
|
77
|
+
when Range then rule.cover?(count)
|
|
78
|
+
end
|
|
79
|
+
raise ArgumentError, "invalid child count for #{node.type.inspect}" unless valid
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_children_rule!(rule)
|
|
83
|
+
return if %i[none one many].include?(rule) || rule.is_a?(Integer) || rule.is_a?(Range)
|
|
84
|
+
raise ArgumentError, "children must be :none, :one, :many, an Integer, or a Range"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validate_validator!(validator)
|
|
88
|
+
valid = validator.is_a?(Array) || validator.is_a?(Module) || validator.respond_to?(:call) ||
|
|
89
|
+
%i[any string integer number boolean array hash symbol handler].include?(validator)
|
|
90
|
+
raise ArgumentError, "unsupported property validator #{validator.inspect}" unless valid
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def valid_property?(validator, value)
|
|
94
|
+
case validator
|
|
95
|
+
when :any then true
|
|
96
|
+
when :string then value.is_a?(String)
|
|
97
|
+
when :integer then value.is_a?(Integer)
|
|
98
|
+
when :number then value.is_a?(Numeric)
|
|
99
|
+
when :boolean then value == true || value == false
|
|
100
|
+
when :array then value.is_a?(Array)
|
|
101
|
+
when :hash then value.is_a?(Hash)
|
|
102
|
+
when :symbol then value.is_a?(Symbol)
|
|
103
|
+
when :handler then value.is_a?(String) || value.is_a?(Array)
|
|
104
|
+
when Array then validator.include?(value)
|
|
105
|
+
when Module then value.is_a?(validator)
|
|
106
|
+
else validator.call(value)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalize_property(validator, value)
|
|
111
|
+
return value.to_sym if validator == :symbol && value.is_a?(String)
|
|
112
|
+
if validator.is_a?(Array) && value.is_a?(String) && validator.any? { |item| item.is_a?(Symbol) }
|
|
113
|
+
symbol = value.to_sym
|
|
114
|
+
return symbol if validator.include?(symbol)
|
|
115
|
+
end
|
|
116
|
+
value
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_node(node, children, on_event)
|
|
120
|
+
entry = definition(node.type)
|
|
121
|
+
props = node.props.each_with_object({}) do |(name, value), converted|
|
|
122
|
+
converted[name] = entry.props[name] == :handler ? event_handler(value, on_event) : value
|
|
123
|
+
end
|
|
124
|
+
element = entry.factory.call(props.freeze, children.freeze)
|
|
125
|
+
unless element.respond_to?(:request_layout) && element.respond_to?(:prepaint) && element.respond_to?(:paint)
|
|
126
|
+
raise TypeError, "factory for #{node.type.inspect} must return a renderable element"
|
|
127
|
+
end
|
|
128
|
+
element.key(node.key) if !node.key.nil? && element.respond_to?(:key)
|
|
129
|
+
element
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def event_handler(id, on_event)
|
|
133
|
+
->(payload = nil, *) { on_event.call(id, payload) }
|
|
134
|
+
end
|
|
135
|
+
private_constant :Definition
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
Mounted = Struct.new(:node, :element, :children)
|
|
139
|
+
private_constant :Mounted
|
|
140
|
+
|
|
141
|
+
class Surface
|
|
142
|
+
def initialize(vocabulary:, on_event:)
|
|
143
|
+
raise ArgumentError, "vocabulary must be a Vocabulary" unless vocabulary.is_a?(Vocabulary)
|
|
144
|
+
raise ArgumentError, "on_event must be callable" unless on_event.respond_to?(:call)
|
|
145
|
+
@vocabulary, @on_event = vocabulary, on_event
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def replace(value)
|
|
149
|
+
if value.nil?
|
|
150
|
+
@node = @mounted = @element = nil
|
|
151
|
+
else
|
|
152
|
+
node = @vocabulary.validate!(value)
|
|
153
|
+
mounted = Describe.send(:mount, node, @vocabulary, @on_event, nil)
|
|
154
|
+
@node, @mounted, @element = node, mounted, mounted.element
|
|
155
|
+
end
|
|
156
|
+
self
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def apply(values)
|
|
160
|
+
patches = Array(values).map { |value| Describe.send(:normalize_patch, value) }
|
|
161
|
+
candidate = patches.reduce(@node) { |node, patch| Describe.send(:apply_patch, node, patch) }
|
|
162
|
+
candidate = @vocabulary.validate!(candidate) if candidate
|
|
163
|
+
mounted = candidate && Describe.send(:mount, candidate, @vocabulary, @on_event, @mounted)
|
|
164
|
+
@node, @mounted, @element = candidate, mounted, mounted&.element
|
|
165
|
+
self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def element = @element
|
|
169
|
+
def empty? = @element.nil?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class << self
|
|
173
|
+
def build(value, vocabulary:, on_event:)
|
|
174
|
+
raise ArgumentError, "vocabulary must be a Vocabulary" unless vocabulary.is_a?(Vocabulary)
|
|
175
|
+
raise ArgumentError, "on_event must be callable" unless on_event.respond_to?(:call)
|
|
176
|
+
node = vocabulary.validate!(value)
|
|
177
|
+
mount(node, vocabulary, on_event, nil).element
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def diff(previous, current)
|
|
181
|
+
previous = normalize_node(previous) if previous
|
|
182
|
+
current = normalize_node(current) if current
|
|
183
|
+
return [] if previous == current
|
|
184
|
+
return [Patch.new(:insert, [].freeze, current, nil)] unless previous
|
|
185
|
+
return [Patch.new(:remove, [].freeze, nil, nil)] unless current
|
|
186
|
+
|
|
187
|
+
[].tap { |patches| diff_node(previous, current, [], patches) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def normalize_type(type)
|
|
193
|
+
raise ArgumentError, "node type must be a Symbol or String" unless type.is_a?(Symbol) || type.is_a?(String)
|
|
194
|
+
type.to_sym
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def normalize_props(value)
|
|
198
|
+
raise ArgumentError, "props must be a Hash" unless value.is_a?(Hash)
|
|
199
|
+
value.each_with_object({}) do |(key, item), props|
|
|
200
|
+
raise ArgumentError, "property names must be Symbols or Strings" unless key.is_a?(Symbol) || key.is_a?(String)
|
|
201
|
+
name = key.to_sym
|
|
202
|
+
raise ArgumentError, "duplicate property #{name.inspect}" if props.key?(name)
|
|
203
|
+
props[name] = item
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def normalize_node(value)
|
|
208
|
+
return Node.new(value.type, normalize_props(value.props).freeze,
|
|
209
|
+
normalize_children(value.children), value.key) if value.is_a?(Node)
|
|
210
|
+
raise ArgumentError, "node must be a Describe::Node or Hash" unless value.is_a?(Hash)
|
|
211
|
+
|
|
212
|
+
keys = value.keys.map { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
|
|
213
|
+
unknown = keys - %i[type props children key]
|
|
214
|
+
raise ArgumentError, "unknown node fields: #{unknown.inspect}" unless unknown.empty?
|
|
215
|
+
type = fetch_field(value, :type)
|
|
216
|
+
props = fetch_field(value, :props, {})
|
|
217
|
+
children = fetch_field(value, :children, [])
|
|
218
|
+
key = fetch_field(value, :key, nil)
|
|
219
|
+
Node.new(normalize_type(type), normalize_props(props).freeze, normalize_children(children), key)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def normalize_children(value)
|
|
223
|
+
raise ArgumentError, "children must be an Array" unless value.is_a?(Array)
|
|
224
|
+
value.map { |child| normalize_node(child) }.freeze
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def fetch_field(hash, name, default = MISSING)
|
|
228
|
+
return hash[name] if hash.key?(name)
|
|
229
|
+
string = name.to_s
|
|
230
|
+
return hash[string] if hash.key?(string)
|
|
231
|
+
raise ArgumentError, "node is missing #{name.inspect}" if default.equal?(MISSING)
|
|
232
|
+
default
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def mount(node, vocabulary, on_event, previous)
|
|
236
|
+
return previous if previous&.node == node
|
|
237
|
+
|
|
238
|
+
previous_children = previous&.children || []
|
|
239
|
+
keyed = previous_children.each_with_object({}) do |mounted, by_key|
|
|
240
|
+
key = mounted.node.key
|
|
241
|
+
by_key[[mounted.node.type, key]] = mounted unless key.nil?
|
|
242
|
+
end
|
|
243
|
+
children = node.children.each_with_index.map do |child, index|
|
|
244
|
+
match = if child.key.nil?
|
|
245
|
+
previous_children[index] if previous_children[index]&.node&.key.nil?
|
|
246
|
+
else
|
|
247
|
+
keyed[[child.type, child.key]]
|
|
248
|
+
end
|
|
249
|
+
mount(child, vocabulary, on_event, match)
|
|
250
|
+
end
|
|
251
|
+
element = vocabulary.send(:build_node, node, children.map(&:element), on_event)
|
|
252
|
+
Mounted.new(node, element, children)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def diff_node(previous, current, path, patches)
|
|
256
|
+
unless previous.type == current.type && previous.key == current.key
|
|
257
|
+
patches << Patch.new(:replace, path.freeze, current, nil)
|
|
258
|
+
return
|
|
259
|
+
end
|
|
260
|
+
patches << Patch.new(:update, path.freeze, nil, current.props) unless previous.props == current.props
|
|
261
|
+
diff_children(previous.children, current.children, path, patches)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def diff_children(previous, current, path, patches)
|
|
265
|
+
working = previous.dup
|
|
266
|
+
index = 0
|
|
267
|
+
while index < current.length
|
|
268
|
+
wanted = current[index]
|
|
269
|
+
present = working[index]
|
|
270
|
+
if present && same_slot?(present, wanted)
|
|
271
|
+
diff_node(present, wanted, path + [index], patches)
|
|
272
|
+
working[index] = wanted
|
|
273
|
+
index += 1
|
|
274
|
+
elsif present&.key && !current[index..].any? { |node| same_slot?(present, node) }
|
|
275
|
+
patches << Patch.new(:remove, (path + [index]).freeze, nil, nil)
|
|
276
|
+
working.delete_at(index)
|
|
277
|
+
elsif wanted.key && (from = working.each_index.find { |at| at > index && same_slot?(working[at], wanted) })
|
|
278
|
+
patches << Patch.new(:remove, (path + [from]).freeze, nil, nil)
|
|
279
|
+
working.delete_at(from)
|
|
280
|
+
patches << Patch.new(:insert, (path + [index]).freeze, wanted, nil)
|
|
281
|
+
working.insert(index, wanted)
|
|
282
|
+
index += 1
|
|
283
|
+
elsif wanted.key || present.nil?
|
|
284
|
+
patches << Patch.new(:insert, (path + [index]).freeze, wanted, nil)
|
|
285
|
+
working.insert(index, wanted)
|
|
286
|
+
index += 1
|
|
287
|
+
else
|
|
288
|
+
diff_node(present, wanted, path + [index], patches)
|
|
289
|
+
working[index] = wanted
|
|
290
|
+
index += 1
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
(working.length - 1).downto(current.length) do |at|
|
|
294
|
+
patches << Patch.new(:remove, (path + [at]).freeze, nil, nil)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def same_slot?(left, right)
|
|
299
|
+
if !left.key.nil? || !right.key.nil?
|
|
300
|
+
!left.key.nil? && left.key == right.key
|
|
301
|
+
else
|
|
302
|
+
left.type == right.type
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def normalize_patch(value)
|
|
307
|
+
return Patch.new(normalize_operation(value.op), normalize_path(value.path),
|
|
308
|
+
value.node && normalize_node(value.node), value.props && normalize_props(value.props).freeze) if value.is_a?(Patch)
|
|
309
|
+
raise ArgumentError, "patch must be a Describe::Patch or Hash" unless value.is_a?(Hash)
|
|
310
|
+
op = normalize_operation(fetch_patch_field(value, :op))
|
|
311
|
+
path = normalize_path(fetch_patch_field(value, :path))
|
|
312
|
+
node = fetch_patch_field(value, :node, nil)
|
|
313
|
+
props = fetch_patch_field(value, :props, nil)
|
|
314
|
+
Patch.new(op, path, node && normalize_node(node), props && normalize_props(props).freeze)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def fetch_patch_field(hash, name, default = MISSING)
|
|
318
|
+
return hash[name] if hash.key?(name)
|
|
319
|
+
return hash[name.to_s] if hash.key?(name.to_s)
|
|
320
|
+
raise ArgumentError, "patch is missing #{name.inspect}" if default.equal?(MISSING)
|
|
321
|
+
default
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def normalize_operation(value)
|
|
325
|
+
raise ArgumentError, "patch operation must be a Symbol or String" unless value.is_a?(Symbol) || value.is_a?(String)
|
|
326
|
+
value.to_sym
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def normalize_path(value)
|
|
330
|
+
raise ArgumentError, "patch path must be an Array" unless value.is_a?(Array)
|
|
331
|
+
value.map do |index|
|
|
332
|
+
index = Integer(index)
|
|
333
|
+
raise ArgumentError, "patch path indexes must be non-negative" if index.negative?
|
|
334
|
+
index
|
|
335
|
+
end.freeze
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def apply_patch(root, patch)
|
|
339
|
+
raise ArgumentError, "unknown patch operation #{patch.op.inspect}" unless %i[replace insert remove update].include?(patch.op)
|
|
340
|
+
case patch.op
|
|
341
|
+
when :replace
|
|
342
|
+
raise ArgumentError, "replace patch needs a node" unless patch.node
|
|
343
|
+
return patch.node if patch.path.empty?
|
|
344
|
+
rewrite_at(root, patch.path) { |_node| patch.node }
|
|
345
|
+
when :insert
|
|
346
|
+
raise ArgumentError, "insert patch needs a node" unless patch.node
|
|
347
|
+
return patch.node if patch.path.empty? && root.nil?
|
|
348
|
+
change_children(root, patch.path, insert: patch.node)
|
|
349
|
+
when :remove
|
|
350
|
+
return nil if patch.path.empty?
|
|
351
|
+
change_children(root, patch.path, remove: true)
|
|
352
|
+
when :update
|
|
353
|
+
raise ArgumentError, "update patch needs props" unless patch.props
|
|
354
|
+
rewrite_at(root, patch.path) { |node| node.with(props: patch.props) }
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def rewrite_at(node, path, &block)
|
|
359
|
+
raise ArgumentError, "patch path does not exist" unless node
|
|
360
|
+
return yield(node) if path.empty?
|
|
361
|
+
index = path.first
|
|
362
|
+
raise ArgumentError, "patch path does not exist" unless index < node.children.length
|
|
363
|
+
children = node.children.dup
|
|
364
|
+
children[index] = rewrite_at(children[index], path.drop(1), &block)
|
|
365
|
+
node.with(children: children.freeze)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def change_children(root, path, insert: nil, remove: false)
|
|
369
|
+
raise ArgumentError, "insert and remove require a child path" if path.empty?
|
|
370
|
+
index = path.last
|
|
371
|
+
rewrite_at(root, path[0...-1]) do |parent|
|
|
372
|
+
children = parent.children.dup
|
|
373
|
+
if remove
|
|
374
|
+
raise ArgumentError, "patch path does not exist" unless index < children.length
|
|
375
|
+
children.delete_at(index)
|
|
376
|
+
else
|
|
377
|
+
raise ArgumentError, "patch insertion is outside the children" unless index <= children.length
|
|
378
|
+
children.insert(index, insert)
|
|
379
|
+
end
|
|
380
|
+
parent.with(children: children.freeze)
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
data/lib/zaniah/version.rb
CHANGED
data/lib/zaniah.rb
CHANGED
|
@@ -37,6 +37,7 @@ require_relative "zaniah/process_pool"
|
|
|
37
37
|
require_relative "zaniah/input"
|
|
38
38
|
require_relative "zaniah/drag_drop"
|
|
39
39
|
require_relative "zaniah/element"
|
|
40
|
+
require_relative "zaniah/describe"
|
|
40
41
|
Zaniah.autoload(:SVG, File.expand_path("zaniah/svg", __dir__))
|
|
41
42
|
require_relative "zaniah/scroll_state"
|
|
42
43
|
require_relative "zaniah/scroll_view"
|
data/sig/describe.rbs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Zaniah
|
|
2
|
+
module Describe
|
|
3
|
+
type node_input = Node | Hash[Symbol | String, untyped]
|
|
4
|
+
type patch_input = Patch | Hash[Symbol | String, untyped]
|
|
5
|
+
type event_callback = ^(untyped id, untyped payload) -> untyped
|
|
6
|
+
|
|
7
|
+
class Node < Data
|
|
8
|
+
attr_reader type: Symbol
|
|
9
|
+
attr_reader props: Hash[Symbol, untyped]
|
|
10
|
+
attr_reader children: Array[Node]
|
|
11
|
+
attr_reader key: untyped
|
|
12
|
+
def self.new: (Symbol type, Hash[Symbol, untyped] props, Array[Node] children, untyped key) -> Node
|
|
13
|
+
| (type: Symbol, props: Hash[Symbol, untyped], children: Array[Node], key: untyped) -> Node
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class Patch < Data
|
|
17
|
+
attr_reader op: :replace | :insert | :remove | :update
|
|
18
|
+
attr_reader path: Array[Integer]
|
|
19
|
+
attr_reader node: Node?
|
|
20
|
+
attr_reader props: Hash[Symbol, untyped]?
|
|
21
|
+
def self.new: (:replace | :insert | :remove | :update op, Array[Integer] path, Node? node, Hash[Symbol, untyped]? props) -> Patch
|
|
22
|
+
| (op: :replace | :insert | :remove | :update, path: Array[Integer], node: Node?, props: Hash[Symbol, untyped]?) -> Patch
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Vocabulary
|
|
26
|
+
def self.build: () { (Vocabulary) -> void } -> Vocabulary
|
|
27
|
+
def initialize: () -> void
|
|
28
|
+
def node: (Symbol | String type, ?props: Hash[Symbol | String, untyped], ?children: :none | :one | :many | Integer | Range[Integer]) { (Hash[Symbol, untyped] props, Array[_Renderable] children) -> _Renderable } -> self
|
|
29
|
+
def allow?: (Symbol | String type) -> bool
|
|
30
|
+
def validate!: (node_input value) -> Node
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.build: (node_input value, vocabulary: Vocabulary, on_event: event_callback) -> _Renderable
|
|
34
|
+
def self.diff: (node_input? previous, node_input? current) -> Array[Patch]
|
|
35
|
+
|
|
36
|
+
class Surface
|
|
37
|
+
def initialize: (vocabulary: Vocabulary, on_event: event_callback) -> void
|
|
38
|
+
def replace: (node_input? value) -> self
|
|
39
|
+
def apply: (Array[patch_input] patches) -> self
|
|
40
|
+
def element: () -> _Renderable?
|
|
41
|
+
def empty?: () -> bool
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
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.
|
|
4
|
+
version: 0.6.0
|
|
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-
|
|
11
|
+
date: 2026-09-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: alhena
|
|
@@ -87,6 +87,7 @@ files:
|
|
|
87
87
|
- docs/adr/README.md
|
|
88
88
|
- docs/animation.md
|
|
89
89
|
- docs/components.md
|
|
90
|
+
- docs/describe.md
|
|
90
91
|
- docs/drag_drop.md
|
|
91
92
|
- docs/gallery/command-palette-dark.png
|
|
92
93
|
- docs/gallery/command-palette-high_contrast.png
|
|
@@ -195,6 +196,7 @@ files:
|
|
|
195
196
|
- lib/zaniah/configuration.rb
|
|
196
197
|
- lib/zaniah/corners.rb
|
|
197
198
|
- lib/zaniah/data_compat.rb
|
|
199
|
+
- lib/zaniah/describe.rb
|
|
198
200
|
- lib/zaniah/device_pixels.rb
|
|
199
201
|
- lib/zaniah/devtools.rb
|
|
200
202
|
- lib/zaniah/devtools/hot_reload.rb
|
|
@@ -360,6 +362,7 @@ files:
|
|
|
360
362
|
- sig/accessibility.rbs
|
|
361
363
|
- sig/animation.rbs
|
|
362
364
|
- sig/app.rbs
|
|
365
|
+
- sig/describe.rbs
|
|
363
366
|
- sig/devtools.rbs
|
|
364
367
|
- sig/drag_drop.rbs
|
|
365
368
|
- sig/elements.rbs
|