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
data/lib/eui/session.rb
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require_relative 'proto'
|
|
5
|
+
require_relative 'view/tree'
|
|
6
|
+
require_relative 'view/diff'
|
|
7
|
+
require_relative 'websocket'
|
|
8
|
+
|
|
9
|
+
module EUI
|
|
10
|
+
# One socket, one component instance, one tree.
|
|
11
|
+
#
|
|
12
|
+
# The shape is the whole protocol in twenty lines: the client says Hello,
|
|
13
|
+
# the server answers Welcome and mounts a tree, and from then on every
|
|
14
|
+
# event is a handler, a render, and the *difference* between what the
|
|
15
|
+
# client holds and what the view now says.
|
|
16
|
+
class Session
|
|
17
|
+
# A client that connects and says nothing is not a client.
|
|
18
|
+
HELLO_TIMEOUT = 10
|
|
19
|
+
# Whichever side has been silent for this long sends a Ping. Nothing
|
|
20
|
+
# else wakes: the zero-wakeup idle budget is a property of that rule.
|
|
21
|
+
IDLE_PING = 30
|
|
22
|
+
# Two unanswered pings and the socket is gone, whatever it still says.
|
|
23
|
+
MAX_UNANSWERED_PINGS = 2
|
|
24
|
+
# The code on the `Error` that ends a session the application itself
|
|
25
|
+
# closed. Codes 1–8 are the decoder's and 100–104 the client's; this is
|
|
26
|
+
# a server's, and it says the session ended on purpose.
|
|
27
|
+
CLOSED_BY_APPLICATION = 200
|
|
28
|
+
|
|
29
|
+
attr_reader :id, :component, :viewport, :granted, :protocol
|
|
30
|
+
|
|
31
|
+
def initialize(socket, component_class:, app:, logger: nil)
|
|
32
|
+
@ws = socket
|
|
33
|
+
@component_class = component_class
|
|
34
|
+
@app = app
|
|
35
|
+
@logger = logger
|
|
36
|
+
@id = SecureRandom.bytes(16)
|
|
37
|
+
@encoder = View::Encoder.new(assets: app&.assets)
|
|
38
|
+
@inbox = Thread::Queue.new
|
|
39
|
+
@seq = 0
|
|
40
|
+
@acked = 0
|
|
41
|
+
@protocol = Proto::PROTOCOL_VERSION
|
|
42
|
+
@granted = 0
|
|
43
|
+
@viewport = Proto::Viewport.default
|
|
44
|
+
@open = true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def run
|
|
48
|
+
hello = handshake or return
|
|
49
|
+
|
|
50
|
+
@protocol = [hello.version, Proto::PROTOCOL_VERSION].min
|
|
51
|
+
@granted = hello.granted
|
|
52
|
+
@viewport = hello.viewport
|
|
53
|
+
@encoder.protocol = @protocol
|
|
54
|
+
# A session that starts empty, which is every first Hello's answer.
|
|
55
|
+
# Resuming one whose socket broke is the server's to offer, and this
|
|
56
|
+
# one does not yet: a client that reconnects gets a fresh Mount.
|
|
57
|
+
send_frame(Proto::Frame.welcome(Proto::Welcome.new(@protocol, @id, false)))
|
|
58
|
+
|
|
59
|
+
# The faces this application draws in, bound to their roles before
|
|
60
|
+
# any view names one.
|
|
61
|
+
@app&.fonts&.each { |family, hashes| @encoder.font(family, hashes) }
|
|
62
|
+
|
|
63
|
+
@component = @component_class.new(session: self)
|
|
64
|
+
@component.mount({ 'viewport' => @viewport.to_h })
|
|
65
|
+
render!
|
|
66
|
+
|
|
67
|
+
reader = Thread.new { read_loop }
|
|
68
|
+
pump
|
|
69
|
+
reader.kill
|
|
70
|
+
@component.unmount
|
|
71
|
+
rescue WebSocket::ClosedError, WebSocket::ProtocolError => e
|
|
72
|
+
log("session ended: #{e.message}")
|
|
73
|
+
ensure
|
|
74
|
+
@open = false
|
|
75
|
+
@ws.close
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Render again although nothing arrived: a timer in the application, a
|
|
79
|
+
# message from another session, anything this process knows and the
|
|
80
|
+
# client does not.
|
|
81
|
+
def refresh!
|
|
82
|
+
@inbox << [:render] if @open
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Say one line to the person through the machine they are using. Shown
|
|
86
|
+
# only if they granted `notifications`, and nothing comes back either
|
|
87
|
+
# way — not that it was shown, not that it was not.
|
|
88
|
+
def notify(title, body: '', tag: '')
|
|
89
|
+
@inbox << [:notify, [title.to_s, body.to_s, tag.to_s]] if @open
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def close(reason = 'the application closed the session')
|
|
93
|
+
@inbox << [:close, reason] if @open
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def granted?(capability) = (@granted & Proto::Caps.bit(capability)) != 0
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def handshake
|
|
101
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + HELLO_TIMEOUT
|
|
102
|
+
kind, bytes = @ws.recv
|
|
103
|
+
return nil if kind.nil?
|
|
104
|
+
if kind == :text
|
|
105
|
+
# A text frame is not an extension point; it is something that is
|
|
106
|
+
# not an EUI client.
|
|
107
|
+
@ws.send_close(1003, 'binary frames only')
|
|
108
|
+
return nil
|
|
109
|
+
end
|
|
110
|
+
return nil if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
111
|
+
|
|
112
|
+
frame = Proto::Frame.decode(bytes)
|
|
113
|
+
unless frame.kind == Proto::Frame::HELLO && frame.body.version >= 1
|
|
114
|
+
fail_session(400, 'the first frame is a Hello')
|
|
115
|
+
return nil
|
|
116
|
+
end
|
|
117
|
+
frame.body
|
|
118
|
+
rescue DecodeError => e
|
|
119
|
+
fail_session(400, e.message)
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def read_loop
|
|
124
|
+
while (message = @ws.recv)
|
|
125
|
+
kind, bytes = message
|
|
126
|
+
if kind == :text
|
|
127
|
+
@inbox << [:close, 'binary frames only']
|
|
128
|
+
break
|
|
129
|
+
end
|
|
130
|
+
@inbox << [:frame, bytes]
|
|
131
|
+
end
|
|
132
|
+
@inbox << [:eof]
|
|
133
|
+
rescue WebSocket::ProtocolError => e
|
|
134
|
+
@inbox << [:close, e.message]
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
log("read: #{e.class}: #{e.message}")
|
|
137
|
+
@inbox << [:eof]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The one thread that writes. Everything that changes the tree comes
|
|
141
|
+
# through here, in the order it arrived, so two events never render on
|
|
142
|
+
# top of each other.
|
|
143
|
+
def pump
|
|
144
|
+
unanswered = 0
|
|
145
|
+
loop do
|
|
146
|
+
item = @inbox.pop(timeout: IDLE_PING)
|
|
147
|
+
if item.nil?
|
|
148
|
+
unanswered += 1
|
|
149
|
+
return if unanswered > MAX_UNANSWERED_PINGS
|
|
150
|
+
|
|
151
|
+
send_frame(Proto::Frame.ping(SecureRandom.bytes(8)))
|
|
152
|
+
next
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
what, payload = item
|
|
156
|
+
case what
|
|
157
|
+
when :eof then return
|
|
158
|
+
when :close
|
|
159
|
+
fail_session(CLOSED_BY_APPLICATION, payload.to_s)
|
|
160
|
+
return
|
|
161
|
+
when :render then render!
|
|
162
|
+
when :notify
|
|
163
|
+
send_batch([Proto::Op.notify(*payload)])
|
|
164
|
+
when :frame
|
|
165
|
+
unanswered = 0
|
|
166
|
+
return unless handle(payload)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def handle(bytes)
|
|
172
|
+
frame = Proto::Frame.decode(bytes)
|
|
173
|
+
trace { "frame kind 0x#{frame.kind.to_s(16)}" }
|
|
174
|
+
case frame.kind
|
|
175
|
+
when Proto::Frame::EVENT then dispatch(frame.body)
|
|
176
|
+
when Proto::Frame::ACK then @acked = frame.body
|
|
177
|
+
when Proto::Frame::PING then send_frame(Proto::Frame.pong(frame.body))
|
|
178
|
+
when Proto::Frame::PONG then nil
|
|
179
|
+
when Proto::Frame::VIEWPORT
|
|
180
|
+
@viewport = frame.body
|
|
181
|
+
post('viewport', { 'viewport' => @viewport.to_h })
|
|
182
|
+
when Proto::Frame::RESYNC
|
|
183
|
+
# Not an error, and never answered with one: the client's tree is
|
|
184
|
+
# unrecoverable and it wants the document again. The tables it
|
|
185
|
+
# already holds are not repeated — they were never cleared.
|
|
186
|
+
@encoder.forget_tree!
|
|
187
|
+
render!
|
|
188
|
+
when Proto::Frame::ERROR
|
|
189
|
+
log("client error #{frame.body[0]}: #{frame.body[1]}")
|
|
190
|
+
return false
|
|
191
|
+
when Proto::Frame::UPLOAD, Proto::Frame::BLOB
|
|
192
|
+
log('file transfers are not implemented yet; the chunk was dropped')
|
|
193
|
+
else
|
|
194
|
+
fail_session(400, 'that frame is the server\'s to send')
|
|
195
|
+
return false
|
|
196
|
+
end
|
|
197
|
+
true
|
|
198
|
+
rescue DecodeError => e
|
|
199
|
+
fail_session(400, e.message)
|
|
200
|
+
false
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# An event on a node that carries no handler for it *now* is dropped.
|
|
204
|
+
# Usually that is a race rather than an attack — a handler a render
|
|
205
|
+
# removed is still in the client's tree for the one round trip it takes
|
|
206
|
+
# the new one to arrive — and nothing is looked up for it either way.
|
|
207
|
+
def dispatch(event)
|
|
208
|
+
target = @encoder.event_target(event.node, event.event)
|
|
209
|
+
unless target
|
|
210
|
+
trace { "event on node #{event.node} (#{Proto::EventKind.name(event.event)}) names no handler in the tree we last sent" }
|
|
211
|
+
return
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
name, props = target
|
|
215
|
+
trace { "event #{Proto::EventKind.name(event.event)} on node #{event.node} -> #{name}" }
|
|
216
|
+
post(name, {
|
|
217
|
+
'node' => event.node,
|
|
218
|
+
'kind' => Proto::EventKind.name(event.event),
|
|
219
|
+
'payload' => resolve(event.payload),
|
|
220
|
+
'props' => props
|
|
221
|
+
})
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def post(name, params)
|
|
225
|
+
@component.handle(name, params)
|
|
226
|
+
render!
|
|
227
|
+
rescue ViewError
|
|
228
|
+
raise
|
|
229
|
+
rescue StandardError => e
|
|
230
|
+
# A handler that raised leaves the state unchanged and the screen
|
|
231
|
+
# right; the next click still works. It is a log line, not the end of
|
|
232
|
+
# somebody's session.
|
|
233
|
+
log("#{name}: #{e.class}: #{e.message}")
|
|
234
|
+
log(e.backtrace.first(3).join("\n")) if e.backtrace
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def render!
|
|
238
|
+
view = @component.render
|
|
239
|
+
ops = @encoder.render(view)
|
|
240
|
+
send_batch(ops) unless ops.empty?
|
|
241
|
+
rescue ViewError => e
|
|
242
|
+
# A view that cannot be encoded fails the same way on every later
|
|
243
|
+
# render, and a server that only logged it would leave a window that
|
|
244
|
+
# looks alive and answers nothing.
|
|
245
|
+
log("view: #{e.message}")
|
|
246
|
+
fail_session(400, e.message)
|
|
247
|
+
@open = false
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def send_batch(ops)
|
|
251
|
+
trace { "batch of #{ops.length}: #{ops.map { |o| format('0x%02X', o.opcode) }.join(' ')}" }
|
|
252
|
+
@seq += 1
|
|
253
|
+
send_frame(Proto::Frame.batch(Proto::Batch.new(@seq, ops)))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def send_frame(frame)
|
|
257
|
+
@ws.send_binary(frame.encode)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def fail_session(code, message)
|
|
261
|
+
send_frame(Proto::Frame.error(code, message))
|
|
262
|
+
@ws.send_close(1000, 'session ended')
|
|
263
|
+
rescue WebSocket::ClosedError
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Atoms the client sent back are ids in *this* session's table, so they
|
|
268
|
+
# are resolved here rather than handed to a view as numbers.
|
|
269
|
+
def resolve(value)
|
|
270
|
+
case value.tag
|
|
271
|
+
when Proto::Value::ATOM then @encoder.atom_value(value.value)
|
|
272
|
+
when Proto::Value::LIST then value.value.map { |v| resolve(v) }
|
|
273
|
+
else value.to_ruby
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def log(message)
|
|
278
|
+
@logger&.call("[EUI] #{message}")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# `EUI_TRACE=1` prints every frame and every event this session sees.
|
|
282
|
+
# The one thing worth watching when a click does nothing.
|
|
283
|
+
def trace
|
|
284
|
+
return unless ENV['EUI_TRACE']
|
|
285
|
+
|
|
286
|
+
log("trace: #{yield}")
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
data/lib/eui/theme.rb
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
|
|
5
|
+
module EUI
|
|
6
|
+
# What a server is allowed to say about colour and size
|
|
7
|
+
# (`spec/05-theme.md`).
|
|
8
|
+
#
|
|
9
|
+
# The server never sends a colour. It sends a *role* and a *scale index*,
|
|
10
|
+
# and the client resolves both against the active theme and the viewer's
|
|
11
|
+
# own mode, density and font scale. Dark mode costs zero bytes, and this
|
|
12
|
+
# process never learns which one somebody is in.
|
|
13
|
+
module Theme
|
|
14
|
+
# The 33 colour roles, numbered once and for good. Ids 34.. are reserved
|
|
15
|
+
# and a client rejects them.
|
|
16
|
+
ROLES = {
|
|
17
|
+
'surface.base' => 1, 'surface.raised' => 2, 'surface.sunken' => 3, 'surface.overlay' => 4,
|
|
18
|
+
'text.default' => 5, 'text.muted' => 6, 'text.inverted' => 7, 'text.disabled' => 8,
|
|
19
|
+
'accent.base' => 9, 'accent.hover' => 10, 'accent.active' => 11, 'accent.on' => 12,
|
|
20
|
+
'success.base' => 13, 'success.subtle' => 14, 'success.on' => 15,
|
|
21
|
+
'warning.base' => 16, 'warning.subtle' => 17, 'warning.on' => 18,
|
|
22
|
+
'danger.base' => 19, 'danger.subtle' => 20, 'danger.on' => 21,
|
|
23
|
+
'info.base' => 22, 'info.subtle' => 23, 'info.on' => 24,
|
|
24
|
+
'border.subtle' => 25, 'border.default' => 26, 'border.strong' => 27,
|
|
25
|
+
'focus.ring' => 28,
|
|
26
|
+
'series.1' => 29, 'series.2' => 30, 'series.3' => 31, 'series.4' => 32, 'series.5' => 33
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# `space`, in device-independent pixels at cozy density. What a style
|
|
30
|
+
# carries is the index; these are here so a view can reason about a
|
|
31
|
+
# measure it is computing itself.
|
|
32
|
+
SPACE = [0, 2, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 96].freeze
|
|
33
|
+
|
|
34
|
+
# `text`, as `[size, line height]`. The scale stops at index 7, 38 px,
|
|
35
|
+
# and the theme cannot move it: there is no hero type in this protocol.
|
|
36
|
+
TEXT = {
|
|
37
|
+
'xs' => 0, 'sm' => 1, 'base' => 2, 'lg' => 3,
|
|
38
|
+
'xl' => 4, '2xl' => 5, '3xl' => 6, '4xl' => 7
|
|
39
|
+
}.freeze
|
|
40
|
+
TEXT_PX = [[11, 16], [13, 18], [15, 22], [17, 24], [20, 28], [24, 32], [30, 38], [38, 46]].freeze
|
|
41
|
+
|
|
42
|
+
RADIUS = { 'none' => 0, 'sm' => 1, 'md' => 2, 'lg' => 3, 'full' => 4 }.freeze
|
|
43
|
+
SHADOW = { 'none' => 0, 'sm' => 1, 'md' => 2, 'lg' => 3 }.freeze
|
|
44
|
+
|
|
45
|
+
# `motion`, in milliseconds. `transition` carries the index + 1, which
|
|
46
|
+
# is why `none` is a name here rather than a hole in the scale.
|
|
47
|
+
MOTION_MS = { 'fast' => 100, 'base' => 180, 'slow' => 320, 'slower' => 560, 'slowest' => 1000 }.freeze
|
|
48
|
+
|
|
49
|
+
# Control heights, for the widgets composed on top of the primitives.
|
|
50
|
+
CONTROL = { 'sm' => 28, 'md' => 36, 'lg' => 44 }.freeze
|
|
51
|
+
|
|
52
|
+
def self.role(name)
|
|
53
|
+
ROLES.fetch(name.to_s) { raise ViewError, "unknown colour role '#{name}'" }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.role?(name) = ROLES.key?(name.to_s)
|
|
57
|
+
|
|
58
|
+
# The pixel value of a `space` index, for a view doing its own
|
|
59
|
+
# arithmetic — a measure derived from the viewport, say.
|
|
60
|
+
def self.space(index)
|
|
61
|
+
SPACE.fetch(index) { raise ViewError, "space index #{index} is past the end of the scale" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.text_size(name_or_index)
|
|
65
|
+
index = name_or_index.is_a?(Integer) ? name_or_index : TEXT.fetch(name_or_index.to_s) do
|
|
66
|
+
raise ViewError, "unknown text scale '#{name_or_index}'"
|
|
67
|
+
end
|
|
68
|
+
raise ViewError, "text index #{index} is past the end of the scale" if index > 7
|
|
69
|
+
|
|
70
|
+
index
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/eui/version.rb
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../proto/op'
|
|
4
|
+
|
|
5
|
+
module EUI
|
|
6
|
+
module View
|
|
7
|
+
# What it costs to go from the tree the client holds to the one the view
|
|
8
|
+
# just returned.
|
|
9
|
+
#
|
|
10
|
+
# The point of the whole exercise: a click that changes one number is
|
|
11
|
+
# one `SetText`, not a page. Keyed children reconcile by `MoveChild`, so
|
|
12
|
+
# reordering a thousand-row table is *n* moves rather than a rebuild.
|
|
13
|
+
class Diff
|
|
14
|
+
def initialize(encoder)
|
|
15
|
+
@encoder = encoder
|
|
16
|
+
@ops = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ops(old_tree, new_tree)
|
|
20
|
+
@ops = []
|
|
21
|
+
if replaceable?(old_tree, new_tree)
|
|
22
|
+
node(old_tree, new_tree)
|
|
23
|
+
else
|
|
24
|
+
# The root changed kind: there is no parent to patch it in, so the
|
|
25
|
+
# whole document is replaced. Tables are not cleared with it.
|
|
26
|
+
@encoder.assign_ids(new_tree)
|
|
27
|
+
@ops << Proto::Op.mount(@encoder.subtree_of(new_tree))
|
|
28
|
+
end
|
|
29
|
+
@ops
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def replaceable?(old, new)
|
|
35
|
+
old.kind == new.kind && old.key == new.key
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# One node against its counterpart, then its children.
|
|
39
|
+
def node(old, new)
|
|
40
|
+
new.id = old.id
|
|
41
|
+
@ops << Proto::Op.set_style(new.id, new.style) if old.style != new.style
|
|
42
|
+
@ops << Proto::Op.set_text(new.id, new.text) if old.text != new.text
|
|
43
|
+
props(old, new)
|
|
44
|
+
handlers(old, new)
|
|
45
|
+
children(old, new)
|
|
46
|
+
# Instructions rather than state: a node is scrolled, or focused,
|
|
47
|
+
# once — so what triggers the op is the view asking again, not the
|
|
48
|
+
# client's own offset, which this server never learns.
|
|
49
|
+
@ops << Proto::Op.scroll_to(new.id, new.scroll_to[0], new.scroll_to[1]) if new.scroll_to && new.scroll_to != old.scroll_to
|
|
50
|
+
@ops << Proto::Op.focus(new.id) if new.focus_to && !old.focus_to
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def props(old, new)
|
|
54
|
+
before = old.props.to_h
|
|
55
|
+
after = new.props.to_h
|
|
56
|
+
after.each do |atom, value|
|
|
57
|
+
@ops << Proto::Op.set_prop(new.id, atom, value) if before[atom] != value
|
|
58
|
+
end
|
|
59
|
+
# There is no op that removes a property, and a client that kept one
|
|
60
|
+
# the view stopped sending would answer for a state nothing holds.
|
|
61
|
+
# Null is how a prop goes away.
|
|
62
|
+
(before.keys - after.keys).each do |atom|
|
|
63
|
+
@ops << Proto::Op.set_prop(new.id, atom, Proto::Value.null)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def handlers(old, new)
|
|
68
|
+
before = old.handlers.to_h
|
|
69
|
+
after = new.handlers.to_h
|
|
70
|
+
after.each do |event, handler|
|
|
71
|
+
@ops << Proto::Op.set_handler(new.id, event, handler) if before[event] != handler
|
|
72
|
+
end
|
|
73
|
+
(before.keys - after.keys).each do |event|
|
|
74
|
+
@ops << Proto::Op.clear_handler(new.id, event)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def children(old, new)
|
|
79
|
+
return if old.children.empty? && new.children.empty?
|
|
80
|
+
|
|
81
|
+
if keyed?(old.children) && keyed?(new.children)
|
|
82
|
+
keyed_children(old, new)
|
|
83
|
+
else
|
|
84
|
+
positional_children(old, new)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def keyed?(children)
|
|
89
|
+
!children.empty? && children.all?(&:key)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Position is identity: child *i* on one side is child *i* on the
|
|
93
|
+
# other. Right for a view whose shape is fixed, wrong for a list —
|
|
94
|
+
# which is what keys are for.
|
|
95
|
+
def positional_children(old, new)
|
|
96
|
+
shared = [old.children.length, new.children.length].min
|
|
97
|
+
shared.times do |i|
|
|
98
|
+
before = old.children[i]
|
|
99
|
+
after = new.children[i]
|
|
100
|
+
if replaceable?(before, after)
|
|
101
|
+
node(before, after)
|
|
102
|
+
else
|
|
103
|
+
@encoder.assign_ids(after)
|
|
104
|
+
@ops << Proto::Op.replace(before.id, @encoder.subtree_of(after))
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if old.children.length > shared
|
|
109
|
+
@ops << Proto::Op.remove_child(new.id, shared, old.children.length - shared)
|
|
110
|
+
elsif new.children.length > shared
|
|
111
|
+
new.children[shared..].each_with_index do |child, offset|
|
|
112
|
+
@encoder.assign_ids(child)
|
|
113
|
+
@ops << Proto::Op.insert_child(new.id, shared + offset, @encoder.subtree_of(child))
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Identity is the key, so a row that moved is a row that moved rather
|
|
119
|
+
# than every row below it having changed.
|
|
120
|
+
#
|
|
121
|
+
# The obvious way to write this is quadratic — scan the old children
|
|
122
|
+
# for each new one — and a ten-thousand-row sort then costs fifty
|
|
123
|
+
# million comparisons before a single byte is sent. What makes it
|
|
124
|
+
# `n log n` instead is the observation that a `MoveChild` only ever
|
|
125
|
+
# pulls a row *forward*: everything before `index` is already final,
|
|
126
|
+
# and the rest keep their relative order. So a row's current position
|
|
127
|
+
# is `index` plus however many rows ahead of it are still waiting,
|
|
128
|
+
# and a Fenwick tree answers that in fourteen steps rather than ten
|
|
129
|
+
# thousand.
|
|
130
|
+
def keyed_children(old, new)
|
|
131
|
+
parent = new.id
|
|
132
|
+
wanted = new.children.map(&:key)
|
|
133
|
+
wanted_set = {}
|
|
134
|
+
wanted.each { |k| wanted_set[k] = true }
|
|
135
|
+
|
|
136
|
+
cur = old.children.dup
|
|
137
|
+
i = 0
|
|
138
|
+
while i < cur.length
|
|
139
|
+
if wanted_set[cur[i].key]
|
|
140
|
+
i += 1
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
run = 1
|
|
144
|
+
run += 1 while i + run < cur.length && !wanted_set[cur[i + run].key]
|
|
145
|
+
@ops << Proto::Op.remove_child(parent, i, run)
|
|
146
|
+
cur.slice!(i, run)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
at = {}
|
|
150
|
+
cur.each_with_index { |child, slot| at[child.key] = slot }
|
|
151
|
+
waiting = Fenwick.new(cur.length)
|
|
152
|
+
|
|
153
|
+
new.children.each_with_index do |after, index|
|
|
154
|
+
slot = at[after.key]
|
|
155
|
+
if slot.nil?
|
|
156
|
+
@encoder.assign_ids(after)
|
|
157
|
+
@ops << Proto::Op.insert_child(parent, index, @encoder.subtree_of(after))
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
from = index + waiting.count_before(slot)
|
|
162
|
+
@ops << Proto::Op.move_child(parent, from, index) if from != index
|
|
163
|
+
waiting.place(slot)
|
|
164
|
+
reconcile_kept(cur[slot], after)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# How many of the rows still waiting sit ahead of this one. A plain
|
|
169
|
+
# array of counts would answer it in a scan; this answers it, and
|
|
170
|
+
# takes a row out of the running, in log n.
|
|
171
|
+
class Fenwick
|
|
172
|
+
def initialize(size)
|
|
173
|
+
@size = size
|
|
174
|
+
@tree = Array.new(size + 1, 0)
|
|
175
|
+
(1..size).each do |i|
|
|
176
|
+
@tree[i] += 1
|
|
177
|
+
parent = i + (i & -i)
|
|
178
|
+
@tree[parent] += @tree[i] if parent <= size
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Rows still waiting at slots `0...slot`.
|
|
183
|
+
def count_before(slot)
|
|
184
|
+
total = 0
|
|
185
|
+
i = slot
|
|
186
|
+
while i.positive?
|
|
187
|
+
total += @tree[i]
|
|
188
|
+
i -= i & -i
|
|
189
|
+
end
|
|
190
|
+
total
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def place(slot)
|
|
194
|
+
i = slot + 1
|
|
195
|
+
while i <= @size
|
|
196
|
+
@tree[i] -= 1
|
|
197
|
+
i += i & -i
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def reconcile_kept(before, after)
|
|
203
|
+
if before.kind == after.kind
|
|
204
|
+
node(before, after)
|
|
205
|
+
else
|
|
206
|
+
@encoder.assign_ids(after)
|
|
207
|
+
@ops << Proto::Op.replace(before.id, @encoder.subtree_of(after))
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|