eui-ruby 0.1.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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE +21 -0
- data/README.md +192 -0
- data/examples/counter.rb +65 -0
- data/lib/eui/app.rb +83 -0
- data/lib/eui/assets.rb +76 -0
- data/lib/eui/blake3.rb +216 -0
- data/lib/eui/component.rb +105 -0
- data/lib/eui/dsl.rb +166 -0
- data/lib/eui/errors.rb +19 -0
- data/lib/eui/manifest.rb +109 -0
- data/lib/eui/proto/frame.rb +243 -0
- data/lib/eui/proto/limits.rb +47 -0
- data/lib/eui/proto/node.rb +385 -0
- data/lib/eui/proto/op.rb +196 -0
- data/lib/eui/proto/reader.rb +125 -0
- data/lib/eui/proto/style.rb +250 -0
- data/lib/eui/proto/writer.rb +89 -0
- data/lib/eui/proto.rb +22 -0
- data/lib/eui/server.rb +175 -0
- data/lib/eui/session.rb +289 -0
- data/lib/eui/theme.rb +73 -0
- data/lib/eui/version.rb +5 -0
- data/lib/eui/view/diff.rb +212 -0
- data/lib/eui/view/style.rb +198 -0
- data/lib/eui/view/tree.rb +378 -0
- data/lib/eui/websocket.rb +195 -0
- data/lib/eui-ruby.rb +4 -0
- data/lib/eui.rb +32 -0
- metadata +80 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../theme'
|
|
4
|
+
require_relative '../proto/style'
|
|
5
|
+
|
|
6
|
+
module EUI
|
|
7
|
+
module View
|
|
8
|
+
# A style hash, in the spec's own vocabulary, compiled into the 64-byte
|
|
9
|
+
# record the wire carries.
|
|
10
|
+
#
|
|
11
|
+
# {"display" => "column", "gap" => 4, "bg" => "surface.base",
|
|
12
|
+
# "pad" => [4, 6, 4, 6], "size" => "lg", "fg" => "text.muted"}
|
|
13
|
+
#
|
|
14
|
+
# An unknown key is an error rather than a key that does nothing: a
|
|
15
|
+
# style that is silently dropped is a page that is wrong for a day.
|
|
16
|
+
class Compiler
|
|
17
|
+
# `colors` is asked for a literal `#RRGGBB` and answers the session
|
|
18
|
+
# table index it interned it at. Roles never reach it. `fonts` is
|
|
19
|
+
# asked for a family the application bound and answers its role.
|
|
20
|
+
def initialize(colors: nil, fonts: nil)
|
|
21
|
+
@colors = colors
|
|
22
|
+
@fonts = fonts
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record(style)
|
|
26
|
+
r = Proto::StyleRecord.new
|
|
27
|
+
return r if style.nil?
|
|
28
|
+
raise ViewError, 'a style is a hash' unless style.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
style.each do |key, value|
|
|
31
|
+
apply(r, key.to_s, value)
|
|
32
|
+
end
|
|
33
|
+
r.validate!
|
|
34
|
+
r
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A colour as the wire carries it: a role, a literal the session
|
|
38
|
+
# interned, or nothing.
|
|
39
|
+
def color(value)
|
|
40
|
+
name = value.is_a?(Proto::ColorRef) ? nil : value.to_s
|
|
41
|
+
return value if value.is_a?(Proto::ColorRef)
|
|
42
|
+
return Proto::ColorRef::NONE if name == 'none'
|
|
43
|
+
|
|
44
|
+
if name.start_with?('#')
|
|
45
|
+
raise ViewError, "no colour table to intern '#{name}' into" unless @colors
|
|
46
|
+
|
|
47
|
+
return Proto::ColorRef.literal(@colors.call(self.class.rgba(name)))
|
|
48
|
+
end
|
|
49
|
+
Proto::ColorRef.role(Theme.role(name))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# `#RRGGBB` or `#RRGGBBAA` as `0xRRGGBBAA`.
|
|
53
|
+
def self.rgba(hex)
|
|
54
|
+
digits = hex.delete_prefix('#')
|
|
55
|
+
case digits.length
|
|
56
|
+
when 6 then (Integer(digits, 16) << 8) | 0xFF
|
|
57
|
+
when 8 then Integer(digits, 16)
|
|
58
|
+
else raise ViewError, "a hex colour is #RRGGBB or #RRGGBBAA, got '#{hex}'"
|
|
59
|
+
end
|
|
60
|
+
rescue ArgumentError
|
|
61
|
+
raise ViewError, "bad hex colour '#{hex}'"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def apply(r, key, v)
|
|
67
|
+
case key
|
|
68
|
+
when 'display' then r.display = enum(Proto::Enum::DISPLAY, v, key)
|
|
69
|
+
when 'wrap' then r.wrap = enum(Proto::Enum::WRAP, v, key)
|
|
70
|
+
when 'justify' then r.justify = enum(Proto::Enum::JUSTIFY, v, key)
|
|
71
|
+
when 'align' then r.align_items = enum(Proto::Enum::ALIGN_ITEMS, v, key)
|
|
72
|
+
when 'self' then r.align_self = enum(Proto::Enum::ALIGN_SELF, v, key)
|
|
73
|
+
when 'grow' then r.grow = byte(v, key)
|
|
74
|
+
when 'shrink' then r.shrink = byte(v, key)
|
|
75
|
+
when 'gap' then r.gap = byte(v, key)
|
|
76
|
+
when 'basis' then r.basis = dim(v)
|
|
77
|
+
when 'width' then r.width = dim(v)
|
|
78
|
+
when 'height' then r.height = dim(v)
|
|
79
|
+
when 'min_width' then r.min_width = dim(v)
|
|
80
|
+
when 'min_height' then r.min_height = dim(v)
|
|
81
|
+
when 'max_width' then r.max_width = dim(v)
|
|
82
|
+
when 'max_height' then r.max_height = dim(v)
|
|
83
|
+
when 'pad' then r.padding = edges(v)
|
|
84
|
+
when 'margin' then r.margin = edges(v)
|
|
85
|
+
when 'bg' then r.bg = color(v)
|
|
86
|
+
when 'fg' then r.fg = color(v)
|
|
87
|
+
when 'border_color' then r.border_color = color(v)
|
|
88
|
+
when 'border' then r.border_width = edges(v)
|
|
89
|
+
when 'radius' then r.radius = scale(Theme::RADIUS, v, key)
|
|
90
|
+
when 'shadow' then r.shadow = scale(Theme::SHADOW, v, key)
|
|
91
|
+
when 'opacity' then r.opacity = byte(v, key)
|
|
92
|
+
when 'blur' then r.blur = byte(v, key)
|
|
93
|
+
when 'font' then r.font_family = font(v)
|
|
94
|
+
when 'size' then r.font_size = scale(Theme::TEXT, v, key)
|
|
95
|
+
when 'weight' then r.font_weight = enum(Proto::Enum::FONT_WEIGHT, v, key)
|
|
96
|
+
when 'text_align' then r.text_align = enum(Proto::Enum::TEXT_ALIGN, v, key)
|
|
97
|
+
when 'clamp' then r.line_clamp = byte(v, key)
|
|
98
|
+
when 'underline' then r.text_decoration |= (v ? 1 : 0)
|
|
99
|
+
when 'strike' then r.text_decoration |= (v ? 2 : 0)
|
|
100
|
+
when 'overflow' then r.overflow = enum(Proto::Enum::OVERFLOW, v, key)
|
|
101
|
+
when 'transition' then r.transition = enum(Proto::Enum::TRANSITION, v, key)
|
|
102
|
+
when 'animation' then r.animation = animation(v)
|
|
103
|
+
when 'motion' then r.motion = enum(Proto::Enum::MOTION, v, key)
|
|
104
|
+
when 'position' then r.position = enum(Proto::Enum::POSITION, v, key)
|
|
105
|
+
when 'z' then r.z = byte(v, key)
|
|
106
|
+
when 'cursor' then r.cursor = enum(Proto::Enum::CURSOR, v, key)
|
|
107
|
+
else raise ViewError, "unknown style key '#{key}'"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def enum(table, value, key)
|
|
112
|
+
table.fetch(value.to_s) do
|
|
113
|
+
raise ViewError, "unknown #{key} '#{value}'; it is one of #{table.keys.join(', ')}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A scale index, written as the index or as the name the spec gives it.
|
|
118
|
+
def scale(names, value, key)
|
|
119
|
+
return byte(value, key) if value.is_a?(Integer)
|
|
120
|
+
|
|
121
|
+
names.fetch(value.to_s) do
|
|
122
|
+
raise ViewError, "unknown #{key} '#{value}'; it is an index or one of #{names.keys.join(', ')}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def byte(value, key)
|
|
127
|
+
n = Integer(value)
|
|
128
|
+
raise ViewError, "#{key} is 0–255, got #{n}" unless n.between?(0, 255)
|
|
129
|
+
|
|
130
|
+
n
|
|
131
|
+
rescue TypeError, ArgumentError
|
|
132
|
+
raise ViewError, "#{key} is a number, got #{value.inspect}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# `12` (px), `"auto"`, `"50%"`, `"1fr"`, `"sp:4"` (a space index).
|
|
136
|
+
def dim(v)
|
|
137
|
+
case v
|
|
138
|
+
when Integer
|
|
139
|
+
raise ViewError, "px is 0–65535, got #{v}" unless v.between?(0, 65_535)
|
|
140
|
+
|
|
141
|
+
Proto::Dim.px(v)
|
|
142
|
+
when Float then Proto::Dim.px(v.round.clamp(0, 65_535))
|
|
143
|
+
when String, Symbol
|
|
144
|
+
s = v.to_s
|
|
145
|
+
if s == 'auto' then Proto::Dim.auto
|
|
146
|
+
elsif s.end_with?('%') then Proto::Dim.percent((Float(s.chomp('%')) * 100).round.clamp(0, 65_535))
|
|
147
|
+
elsif s.end_with?('fr') then Proto::Dim.fr((Float(s.delete_suffix('fr')) * 100).round.clamp(0, 65_535))
|
|
148
|
+
elsif s.start_with?('sp:') then Proto::Dim.space(Integer(s.delete_prefix('sp:')))
|
|
149
|
+
else raise ViewError, "cannot read a length from '#{s}'"
|
|
150
|
+
end
|
|
151
|
+
else raise ViewError, "cannot read a length from #{v.inspect}"
|
|
152
|
+
end
|
|
153
|
+
rescue ArgumentError
|
|
154
|
+
raise ViewError, "cannot read a length from #{v.inspect}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# One index for every side, `[y, x]`, or `[t, r, b, l]`.
|
|
158
|
+
def edges(v)
|
|
159
|
+
case v
|
|
160
|
+
when Integer then [byte(v, 'edge')] * 4
|
|
161
|
+
when Array
|
|
162
|
+
case v.length
|
|
163
|
+
when 4 then v.map { |n| byte(n, 'edge') }
|
|
164
|
+
when 2
|
|
165
|
+
y = byte(v[0], 'edge')
|
|
166
|
+
x = byte(v[1], 'edge')
|
|
167
|
+
[y, x, y, x]
|
|
168
|
+
else raise ViewError, 'edges are one index, [y, x] or [t, r, b, l]'
|
|
169
|
+
end
|
|
170
|
+
else raise ViewError, "edges are one index, [y, x] or [t, r, b, l], got #{v.inspect}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# `"sans"`, `"mono"`, or a role 2..9 an application bound with a font.
|
|
175
|
+
def font(v)
|
|
176
|
+
case v
|
|
177
|
+
when Integer
|
|
178
|
+
raise ViewError, "font role #{v} is above #{Proto::Limits::MAX_FONT_ROLE}" if v > Proto::Limits::MAX_FONT_ROLE
|
|
179
|
+
|
|
180
|
+
v
|
|
181
|
+
when 'sans', :sans then 0
|
|
182
|
+
when 'mono', :mono then 1
|
|
183
|
+
when String, Symbol
|
|
184
|
+
raise ViewError, "a font is \"sans\", \"mono\" or a family the application bound, got #{v.inspect}" unless @fonts
|
|
185
|
+
|
|
186
|
+
@fonts.call(v.to_s)
|
|
187
|
+
else raise ViewError, "a font is \"sans\", \"mono\" or a family the application bound, got #{v.inspect}"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# One name, or several: a node has to say how it arrives *and* how it
|
|
192
|
+
# leaves while it is still there to say it.
|
|
193
|
+
def animation(v)
|
|
194
|
+
Array(v).reduce(0) { |acc, name| acc | enum(Proto::Enum::ANIMATION, name, 'animation') }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'style'
|
|
4
|
+
require_relative '../proto/op'
|
|
5
|
+
require_relative '../proto/node'
|
|
6
|
+
|
|
7
|
+
module EUI
|
|
8
|
+
module View
|
|
9
|
+
# A node on its way to the wire: the view's hash with every string
|
|
10
|
+
# interned, every style resolved to a table id, and an id of its own
|
|
11
|
+
# once the diff has settled one.
|
|
12
|
+
TNode = Struct.new(
|
|
13
|
+
:id, :kind, :style, :key, :key_atom, :text, :props, :handlers,
|
|
14
|
+
:children, :scroll_to, :focus_to, keyword_init: true
|
|
15
|
+
) do
|
|
16
|
+
def leaf? = Proto::NodeKind.leaf?(kind)
|
|
17
|
+
|
|
18
|
+
# Everything in the subtree, this node included.
|
|
19
|
+
def size = 1 + children.sum(&:size)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The session's tables and the tree it last sent.
|
|
23
|
+
#
|
|
24
|
+
# Tables are append-only and session-scoped, exactly as the wire format
|
|
25
|
+
# requires: an atom interned for the first page is still there on the
|
|
26
|
+
# tenth, which is why a second page costs no second `DefAtom`.
|
|
27
|
+
class Encoder
|
|
28
|
+
attr_reader :previous, :protocol
|
|
29
|
+
|
|
30
|
+
def initialize(protocol: Proto::PROTOCOL_VERSION, assets: nil)
|
|
31
|
+
@protocol = protocol
|
|
32
|
+
@assets = assets
|
|
33
|
+
@atoms = {}
|
|
34
|
+
@atoms_by_id = ['']
|
|
35
|
+
@styles = {}
|
|
36
|
+
@colors = {}
|
|
37
|
+
@chunks = {}
|
|
38
|
+
@fonts = {}
|
|
39
|
+
@style_cache = {}
|
|
40
|
+
@pending = []
|
|
41
|
+
@previous = nil
|
|
42
|
+
@next_id = 1
|
|
43
|
+
@nodes_by_id = {}
|
|
44
|
+
@compiler = Compiler.new(colors: method(:color_literal), fonts: method(:font_role!))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def protocol=(version)
|
|
48
|
+
@protocol = version
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------- tables
|
|
52
|
+
|
|
53
|
+
def atom(string)
|
|
54
|
+
string = string.to_s
|
|
55
|
+
found = @atoms[string]
|
|
56
|
+
return found if found
|
|
57
|
+
|
|
58
|
+
id = @atoms.length + 1
|
|
59
|
+
raise ViewError, "more than #{Proto::Limits::MAX_ATOMS} atoms in one session" if id > Proto::Limits::MAX_ATOMS
|
|
60
|
+
|
|
61
|
+
@atoms[string] = id
|
|
62
|
+
@atoms_by_id << string
|
|
63
|
+
@pending << Proto::Op.def_atom(id, string)
|
|
64
|
+
id
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def atom_value(id) = @atoms_by_id[id]
|
|
68
|
+
|
|
69
|
+
# Style id 0 is the default record, which every session already has.
|
|
70
|
+
def style(record)
|
|
71
|
+
bytes = record.to_bytes
|
|
72
|
+
return 0 if bytes == Proto::StyleRecord.new.to_bytes
|
|
73
|
+
|
|
74
|
+
found = @styles[bytes]
|
|
75
|
+
return found if found
|
|
76
|
+
|
|
77
|
+
id = @styles.length + 1
|
|
78
|
+
raise ViewError, "more than #{Proto::Limits::MAX_STYLES} styles in one session" if id > Proto::Limits::MAX_STYLES
|
|
79
|
+
|
|
80
|
+
@styles[bytes] = id
|
|
81
|
+
@pending << Proto::Op.def_style(id, record)
|
|
82
|
+
id
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def color_literal(rgba)
|
|
86
|
+
found = @colors[rgba]
|
|
87
|
+
return found if found
|
|
88
|
+
|
|
89
|
+
id = @colors.length + 1
|
|
90
|
+
raise ViewError, "more than #{Proto::Limits::MAX_COLORS} literal colours" if id > Proto::Limits::MAX_COLORS
|
|
91
|
+
|
|
92
|
+
@colors[rgba] = id
|
|
93
|
+
@pending << Proto::Op.def_color(id, rgba)
|
|
94
|
+
id
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Bind a font role to its faces. Roles 0 and 1 are the client's own
|
|
98
|
+
# sans and mono; binding one replaces it for this session only.
|
|
99
|
+
def font(family, hashes)
|
|
100
|
+
found = @fonts[family]
|
|
101
|
+
return found if found
|
|
102
|
+
|
|
103
|
+
role = @fonts.length + 2
|
|
104
|
+
raise ViewError, "a session binds at most #{Proto::Limits::MAX_FONT_ROLE - 1} font families" if role > Proto::Limits::MAX_FONT_ROLE
|
|
105
|
+
|
|
106
|
+
@fonts[family] = role
|
|
107
|
+
@pending << Proto::Op.def_font(role, hashes)
|
|
108
|
+
role
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def font_role(family) = @fonts[family]
|
|
112
|
+
|
|
113
|
+
# The role a view means when it names a family. A family nobody bound
|
|
114
|
+
# is an error here rather than a silent fall back to `sans`: the view
|
|
115
|
+
# asked for a typeface, and drawing another one quietly is how a page
|
|
116
|
+
# is wrong for a week.
|
|
117
|
+
def font_role!(family)
|
|
118
|
+
@fonts.fetch(family) do
|
|
119
|
+
raise ViewError, "no font bound for '#{family}'; call app.font(#{family.inspect}, [paths]) at boot"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def next_id
|
|
124
|
+
id = @next_id
|
|
125
|
+
@next_id += 1
|
|
126
|
+
id
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------- rendering
|
|
130
|
+
|
|
131
|
+
# The ops this view costs: the definitions it needed, then the patch
|
|
132
|
+
# that takes the client's tree to it. Definitions come first because
|
|
133
|
+
# the wire format requires it — a decoder rejects a forward reference.
|
|
134
|
+
def render(view, full: false)
|
|
135
|
+
tree = build(view)
|
|
136
|
+
ops =
|
|
137
|
+
if full || @previous.nil?
|
|
138
|
+
assign_ids(tree)
|
|
139
|
+
[Proto::Op.mount(subtree_of(tree))]
|
|
140
|
+
else
|
|
141
|
+
Diff.new(self).ops(@previous, tree)
|
|
142
|
+
end
|
|
143
|
+
@previous = tree
|
|
144
|
+
index(tree)
|
|
145
|
+
flush + ops
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# What the session owes the client before it can read anything else.
|
|
149
|
+
def flush
|
|
150
|
+
pending = @pending
|
|
151
|
+
@pending = []
|
|
152
|
+
pending
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The node the client named, and what the server last rendered on it:
|
|
156
|
+
# the handler's own event name, and the node's props.
|
|
157
|
+
#
|
|
158
|
+
# An event on a node that carries no handler for it *now* is dropped.
|
|
159
|
+
# Usually that is a race rather than an attack — a handler a render
|
|
160
|
+
# removed is still in the client's tree for the one round trip it
|
|
161
|
+
# takes the new one to arrive.
|
|
162
|
+
def event_target(node_id, event)
|
|
163
|
+
node = @nodes_by_id[node_id]
|
|
164
|
+
return nil unless node
|
|
165
|
+
|
|
166
|
+
handler = node.handlers.find { |(kind, _)| kind == event }
|
|
167
|
+
return nil unless handler
|
|
168
|
+
|
|
169
|
+
name = handler[1].name
|
|
170
|
+
return nil unless name
|
|
171
|
+
|
|
172
|
+
[@atoms_by_id[name], props_of(node)]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def node(node_id) = @nodes_by_id[node_id]
|
|
176
|
+
|
|
177
|
+
# Everything a session forgets when its client asks for a resync.
|
|
178
|
+
def forget_tree!
|
|
179
|
+
@previous = nil
|
|
180
|
+
@nodes_by_id = {}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------- build
|
|
184
|
+
|
|
185
|
+
def build(view, depth = 1)
|
|
186
|
+
raise ViewError, 'a view is a hash' unless view.is_a?(Hash)
|
|
187
|
+
raise ViewError, "the tree is nested more than #{Proto::Limits::MAX_TREE_DEPTH} deep" if depth > Proto::Limits::MAX_TREE_DEPTH
|
|
188
|
+
|
|
189
|
+
kind_name = fetch(view, 'k') || 'box'
|
|
190
|
+
kind = Proto::NodeKind.code(kind_name)
|
|
191
|
+
style_id = style_for(fetch(view, 's'))
|
|
192
|
+
|
|
193
|
+
key = fetch(view, 'key')
|
|
194
|
+
key = key.to_s if key
|
|
195
|
+
text = build_text(view, kind)
|
|
196
|
+
props, scroll_to, focus_to = build_props(fetch(view, 'p'), kind)
|
|
197
|
+
handlers = build_handlers(fetch(view, 'on'))
|
|
198
|
+
|
|
199
|
+
if Proto::NodeKind.inert?(kind) && (text || !props.empty? || !handlers.empty?)
|
|
200
|
+
raise ViewError, "a #{kind_name} carries nothing: no text, no props, no handlers"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
children = Array(fetch(view, 'c')).compact.map { |child| build(child, depth + 1) }
|
|
204
|
+
if Proto::NodeKind.leaf?(kind) && !children.empty?
|
|
205
|
+
raise ViewError, "a #{kind_name} is a leaf and cannot have children"
|
|
206
|
+
end
|
|
207
|
+
raise ViewError, "more than #{Proto::Limits::MAX_CHILDREN} children on one node" if children.length > Proto::Limits::MAX_CHILDREN
|
|
208
|
+
|
|
209
|
+
TNode.new(
|
|
210
|
+
id: 0, kind: kind, style: style_id, key: key,
|
|
211
|
+
key_atom: key ? atom(key) : 0, text: text, props: props,
|
|
212
|
+
handlers: handlers, children: children, scroll_to: scroll_to, focus_to: focus_to
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# A subtree as the wire carries it, pre-order.
|
|
217
|
+
def subtree_of(node)
|
|
218
|
+
out = Proto::Subtree.new
|
|
219
|
+
stack = [node]
|
|
220
|
+
walk = lambda do |n|
|
|
221
|
+
out.push(kind: n.kind, id: n.id, style: n.style, key: n.key_atom, text: n.text,
|
|
222
|
+
props: n.props, handlers: n.handlers, child_count: n.children.length)
|
|
223
|
+
n.children.each { |c| walk.call(c) }
|
|
224
|
+
end
|
|
225
|
+
walk.call(stack.first)
|
|
226
|
+
out
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def assign_ids(node)
|
|
230
|
+
node.id = next_id if node.id.zero?
|
|
231
|
+
node.children.each { |child| assign_ids(child) }
|
|
232
|
+
node
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# What a handler is handed: the node's props, by name, as plain Ruby.
|
|
236
|
+
def props_of(node)
|
|
237
|
+
node.props.each_with_object({}) do |(atom_id, value), out|
|
|
238
|
+
out[@atoms_by_id[atom_id]] = value.to_ruby
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
# Two style hashes with the same contents are the same style, and a
|
|
245
|
+
# table of ten thousand rows has three of them. Ruby hashes a small
|
|
246
|
+
# hash by its contents, so this is one lookup instead of compiling and
|
|
247
|
+
# encoding a 64-byte record per node — which is most of what a render
|
|
248
|
+
# of fifty thousand nodes used to cost.
|
|
249
|
+
def style_for(style)
|
|
250
|
+
return 0 if style.nil? || style.empty?
|
|
251
|
+
|
|
252
|
+
cached = @style_cache[style]
|
|
253
|
+
return cached if cached
|
|
254
|
+
|
|
255
|
+
@style_cache[style.dup.freeze] = style(@compiler.record(style))
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def fetch(hash, key)
|
|
259
|
+
return hash[key] if hash.key?(key)
|
|
260
|
+
|
|
261
|
+
hash[key.to_sym]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def build_text(view, kind)
|
|
265
|
+
raw = fetch(view, 't')
|
|
266
|
+
return nil if raw.nil?
|
|
267
|
+
|
|
268
|
+
string = raw.to_s
|
|
269
|
+
if string.bytesize > Proto::Limits::MAX_INLINE_STR
|
|
270
|
+
raise ViewError, "a text of #{string.bytesize} bytes; the client takes at most #{Proto::Limits::MAX_INLINE_STR} — split it into nodes"
|
|
271
|
+
end
|
|
272
|
+
raise ViewError, "a #{Proto::NodeKind.name(kind)} carries no text" if Proto::NodeKind.inert?(kind)
|
|
273
|
+
|
|
274
|
+
# Interning is for what repeats. A unique cell value would be a
|
|
275
|
+
# permanent entry in a table that is never cleared, so only what
|
|
276
|
+
# the view asked for goes in it.
|
|
277
|
+
if fetch(view, 'intern') && string.bytesize <= 24
|
|
278
|
+
Proto::TextRef.atom_ref(atom(string))
|
|
279
|
+
else
|
|
280
|
+
Proto::TextRef.inline_ref(string)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# `scroll_to` and `focus_to` never reach the client as props: they are
|
|
285
|
+
# instructions, done to a node once, and the diff turns a change of
|
|
286
|
+
# one into its own op.
|
|
287
|
+
def build_props(props, kind)
|
|
288
|
+
return [[], nil, false] if props.nil?
|
|
289
|
+
raise ViewError, 'a node\'s props are a hash' unless props.is_a?(Hash)
|
|
290
|
+
|
|
291
|
+
scroll_to = nil
|
|
292
|
+
focus_to = false
|
|
293
|
+
out = []
|
|
294
|
+
props.each do |name, value|
|
|
295
|
+
name = name.to_s
|
|
296
|
+
case name
|
|
297
|
+
when 'scroll_to'
|
|
298
|
+
unless %w[scroll list].include?(Proto::NodeKind.name(kind))
|
|
299
|
+
raise ViewError, "scroll_to is for a scroll or a list, not a #{Proto::NodeKind.name(kind)}"
|
|
300
|
+
end
|
|
301
|
+
unless value.is_a?(Array) && value.length == 2
|
|
302
|
+
raise ViewError, 'a scroll_to is [x, y] in pixels'
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
scroll_to = value.map { |n| Integer(n.round) }
|
|
306
|
+
when 'focus_to'
|
|
307
|
+
focus_to = value == true
|
|
308
|
+
else
|
|
309
|
+
out << [atom(name), prop_value(name, value, kind)]
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
raise ViewError, "more than #{Proto::Limits::MAX_PROPS} props on one node" if out.length > Proto::Limits::MAX_PROPS
|
|
313
|
+
|
|
314
|
+
[out, scroll_to, focus_to]
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# An image's `src` is a file in the application: it goes on the wire
|
|
318
|
+
# as the hash of its bytes, served from `/_eui/asset`.
|
|
319
|
+
def prop_value(name, value, kind)
|
|
320
|
+
kind_name = Proto::NodeKind.name(kind)
|
|
321
|
+
asset = (%w[image audio video].include?(kind_name) && name == 'src') ||
|
|
322
|
+
(kind_name == 'scene' && %w[shader mesh].include?(name))
|
|
323
|
+
return Proto::Value.asset(asset_hash(name, value)) if asset
|
|
324
|
+
|
|
325
|
+
Proto::Value.from(value)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def asset_hash(name, value)
|
|
329
|
+
case value
|
|
330
|
+
when String
|
|
331
|
+
raise ViewError, "no asset store to resolve #{name} '#{value}'" unless @assets
|
|
332
|
+
|
|
333
|
+
@assets.add_file(value)
|
|
334
|
+
when Hash
|
|
335
|
+
hex = value['asset'] || value[:asset]
|
|
336
|
+
raise ViewError, "a #{name} is a path or {\"asset\" => \"<hash>\"}" unless hex
|
|
337
|
+
raise ViewError, "an asset is 64 hex characters, got '#{hex}'" unless /\A[0-9a-f]{64}\z/.match?(hex)
|
|
338
|
+
|
|
339
|
+
[hex].pack('H*')
|
|
340
|
+
else raise ViewError, "a #{name} is a path or {\"asset\" => \"<hash>\"}"
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def build_handlers(on)
|
|
345
|
+
return [] if on.nil?
|
|
346
|
+
raise ViewError, 'a node\'s handlers are a hash' unless on.is_a?(Hash)
|
|
347
|
+
|
|
348
|
+
out = []
|
|
349
|
+
on.each do |event, target|
|
|
350
|
+
code = Proto::EventKind.code(event)
|
|
351
|
+
# An event the other end cannot decode is left out rather than
|
|
352
|
+
# sent: the view still renders, the widget just never hears from
|
|
353
|
+
# it. `level` arrived in version 3.
|
|
354
|
+
next if Proto::EventKind.since(code) > @protocol
|
|
355
|
+
|
|
356
|
+
unless target.is_a?(String) || target.is_a?(Symbol)
|
|
357
|
+
raise ViewError, 'a handler is a server event name; local handlers are not compiled yet'
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
out << [code, Proto::Handler.server(atom(target.to_s))]
|
|
361
|
+
end
|
|
362
|
+
raise ViewError, "more than #{Proto::Limits::MAX_HANDLERS} handlers on one node" if out.length > Proto::Limits::MAX_HANDLERS
|
|
363
|
+
|
|
364
|
+
out
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def index(tree)
|
|
368
|
+
@nodes_by_id = {}
|
|
369
|
+
stack = [tree]
|
|
370
|
+
until stack.empty?
|
|
371
|
+
node = stack.pop
|
|
372
|
+
@nodes_by_id[node.id] = node
|
|
373
|
+
stack.concat(node.children)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|