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.
@@ -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