ruflet_rails 0.0.15 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb81df616c3834d41ea9599f851ea1bc30457e7102ee1eb55e23d17b5afc5a79
4
- data.tar.gz: 8f4e10fb6d907f9095ebac09c2c4496b5267c5c4d0ae79d95d7a429b7e6f33ea
3
+ metadata.gz: 6868764b9b4a6920b449c8b46bc937558c97edcafc76b361a8e6873b93b3188b
4
+ data.tar.gz: 00ab29dcc01cb4e21dee34946c6a42dd7293f1053eb1abc71207f9dcffdf780e
5
5
  SHA512:
6
- metadata.gz: c5d92c8138d67b554a8cba1522a46135dc01188454b182fb242a59ec06472da1c0ccc6ed76064ee96432bb51833342b3236607b61ebb9e115fbcaa535ca94341
7
- data.tar.gz: ab2163eb77d3608ca0e933f9a3ca6eee1f369fc5e08e03927d3099c58de81e0da4df6e18519c87fbae0da1b93bf9bb1abfc6bc711bab03837bb15347ab05f6fe
6
+ metadata.gz: 2ed38b850ec571aadda2fc52f41309dc869e7484bee93a6b3ef2d0fce222aa11d872e63eb97e6beec9f2b6142eeebd244e183b434585d8daf6f66938603e3eb2
7
+ data.tar.gz: bab03d623d130b47169cb9fa098edba6f7f9cea739f50e8156e142fd1d033577d6ae4e28412003a6dd128ca706bba66ac5fe3608ef4b1ce9d8a00ae6811b5cb8
data/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  `ruflet_rails` is the Rails-first integration package for Ruflet.
4
4
 
5
5
  It mounts Ruby-driven Ruflet interfaces in a Rails application, makes Rails
6
- views able to opt into native WebView chrome, and connects web, mobile, and
7
- desktop clients to the same application entrypoint.
6
+ views able to opt into native WebView chrome, and connects Ruflet clients to
7
+ the same application entrypoint.
8
8
 
9
9
  ## Add The Gem
10
10
 
@@ -17,18 +17,15 @@ gem "ruflet_rails"
17
17
 
18
18
  ```bash
19
19
  bin/rails generate ruflet:install
20
- bin/rails generate ruflet:install --web
21
20
  bin/rails generate ruflet:install --desktop
22
- bin/rails generate ruflet:install --web --desktop
23
21
  ```
24
22
 
25
23
  This generator will:
26
24
  - create `app/views/ruflet/main.rb`
27
25
  - create `config/initializers/ruflet.rb`
28
26
  - add the Ruflet WebSocket route to `config/routes.rb`
29
- - add a `/ruflet` web mount when `--web` is used
30
- - download prebuilt clients from GitHub releases when `--web`, `--desktop`, or
31
- `--client=web|desktop|all` is used
27
+ - download the prebuilt desktop client when `--desktop` or
28
+ `--client=desktop` is used
32
29
 
33
30
  Generated `config/initializers/ruflet.rb`:
34
31
 
@@ -48,24 +45,17 @@ At build time `ruflet_rails` serializes this Rails config into the Ruflet CLI
48
45
  config shape, so the initializer remains the source of truth for app name,
49
46
  backend URL, services, assets, and build colors.
50
47
 
51
- ## Web client
48
+ ## Connect a Ruflet client
52
49
 
53
- Rails installs the prebuilt web client into `frontend/`; it does not need
54
- Flutter source or a Flutter web build:
55
-
56
- ```bash
57
- bundle exec rake ruflet:web
58
- ```
59
-
60
- Mount the installed client and a developer-owned Ruflet entrypoint:
50
+ The generator adds an explicit WebSocket endpoint backed by the application
51
+ entrypoint:
61
52
 
62
53
  ```ruby
63
- mount Ruflet::Rails.web_app(app_file: Rails.root.join("app/views/ruflet/main.rb")), at: "/app"
54
+ match "/ws", to: Ruflet::Rails.native(Rails.root.join("app/views/ruflet/main.rb")), via: :all
64
55
  ```
65
56
 
66
- The install generator adds the same mount at `/ruflet` when `--web` is used.
67
- The mount serves the static client and its WebSocket endpoint together. The
68
- same `main.rb` also drives native clients through the generated `/ws` route.
57
+ Start Rails, then connect a Ruflet mobile or desktop client to that endpoint.
58
+ The Rails gem does not install or serve a second static web application.
69
59
 
70
60
  ## Build native clients from Rails
71
61
 
@@ -100,12 +90,11 @@ bin/rails server --desktop
100
90
  bin/rails s --desktop
101
91
  ```
102
92
 
103
- ## Update prebuilt clients
93
+ ## Update the prebuilt desktop client
104
94
 
105
- Reinstall web or update native desktop clients:
95
+ Update the native desktop client:
106
96
 
107
97
  ```bash
108
- bundle exec rake ruflet:web
109
98
  bundle exec rake ruflet:update[desktop]
110
99
  ```
111
100
 
@@ -120,11 +109,214 @@ bundle exec rake ruflet:install
120
109
  bundle exec rake ruflet:install[DEVICE_ID]
121
110
  ```
122
111
 
112
+ ## HTML as the UI DSL (no WebView)
113
+
114
+ Beyond the WebView shell, Ruflet can treat HTML itself as the UI language:
115
+ Rails views describe screens with markup, and `Ruflet::Rails.erb_to_native`
116
+ compiles each page into **real native Ruflet controls** — no WebView anywhere.
117
+ This is HTML-over-the-wire for native UI: state lives in Rails, every
118
+ interaction is a request, and the response markup re-renders the screen.
119
+
120
+ ```ruby
121
+ # app/views/ruflet/main.rb
122
+ Ruflet.run do |page|
123
+ Ruflet::Rails.erb_to_native(page, start_url: "/native")
124
+ end
125
+ ```
126
+
127
+ Screens are ordinary Rails views (ERB, controllers, sessions, and redirects
128
+ all work — requests carry the Rails session cookie and CSRF token, plus an
129
+ `X-Ruflet-Native: 1` header so a controller can render native markup and a
130
+ browser page from the same action):
131
+
132
+ ```erb
133
+ <%# app/views/counters/show.html.erb %>
134
+ <appbar title="Counter"></appbar>
135
+
136
+ <column class="p-6 gap-4 items-center justify-center flex-1">
137
+ <text class="text-5xl font-bold text-slate-900"><%= @count %></text>
138
+ <row class="gap-3">
139
+ <button variant="outlined" icon="remove" on-click="<%= counter_decrement_path %>">Down</button>
140
+ <button variant="filled" icon="add" on-click="<%= counter_increment_path %>">Up</button>
141
+ </row>
142
+ <a href="<%= settings_path %>">Settings</a>
143
+ </column>
144
+ ```
145
+
146
+ - **Layout** — `<column>`, `<row>`, `<stack>`, `<div>`/`<section>` (container),
147
+ `<card>`, `<center>`, `<spacer>`, `<list>`, `<grid>`.
148
+ - **Content** — `<text>`, `<h1>`–`<h6>`, `<p>`, `<markdown>`, `<img>`, `<icon>`,
149
+ `<hr>`, `<ul>`/`<li>`.
150
+ - **Styling** — a broad Tailwind-flavored `class` vocabulary that maps onto
151
+ each control's real props (only the ones a control accepts are applied):
152
+ - spacing & size: `p-4`, `px-6`, `m-2`, `-mt-2`, `gap-3`, `w-64`, `h-full`,
153
+ `flex-1`, `aspect-video`, `aspect-[4/3]`
154
+ - color: `bg-slate-100`, `text-emerald-600`, `bg-[#123456]`, theme tokens
155
+ (`bg-primary`, `text-on-surface`), gradients
156
+ (`bg-gradient-to-r from-blue-500 via-sky-400 to-cyan-300`)
157
+ - typography: `text-xl`, `text-[42]`, `font-bold`, `font-mono`, `italic`,
158
+ `text-center`, `tracking-wide`, `leading-relaxed`, `underline`,
159
+ `line-through`, `uppercase`/`lowercase`/`capitalize`, `truncate`,
160
+ `line-clamp-2`, `whitespace-nowrap`, `select-none`, `text-ellipsis`
161
+ - shape & borders: `rounded-2xl`, `rounded-t-lg`, `rounded-br-sm`, `border`,
162
+ `border-2`, `border-t`, `border-red-500`, `shadow-lg`, `opacity-75`, `blur-md`
163
+ - transforms & position: `rotate-45`, `-rotate-12`, `scale-95`, `top-4`,
164
+ `left-2`, `inset-0`/`inset-x-4` (Stack children)
165
+ - transitions: `transition`, `duration-300`, `ease-in-out` (implicit animation)
166
+ - layout & display: `items-center`, `justify-between`, `place-center`,
167
+ `flex-wrap`, `gap-x-3`/`gap-y-2`, `size-12`, `w-screen`, `min-h-screen`,
168
+ `scroll`, `overflow-hidden`, `object-cover` (image fit), `hidden`/`invisible`
169
+ Anything not mapped is ignored; the full native prop is always available as
170
+ an explicit attribute (`gradient='{…}'`, `blur="8"`, …).
171
+ - **Navigation** — `<a href>` pushes a native screen; `nav="replace|root|back"`
172
+ change the mode. The native back button/gesture pops.
173
+ - **Actions** — `on-click="/counter/increment"` posts to Rails and re-renders
174
+ the current screen in place with the response (`redirect_to` is followed).
175
+ Prefix a verb for other methods: `on-click="delete:/items/3"`.
176
+ - **Forms** — `<form action method>` with named `<input>`, `<textarea>`,
177
+ `<select>` fields tracks values natively and submits them like a normal
178
+ Rails form, then renders the response.
179
+ - **App chrome** — `<appbar title="Inbox" leading-icon="menu">` with
180
+ `<action icon="search" href="/search"/>` children becomes the native AppBar.
181
+ - **The whole widget catalog** — any other tag falls through to the ruflet_core
182
+ control registry with its attributes as props:
183
+ `<progress-bar value="0.4">`, `<switch label="Dark mode">`,
184
+ `<chip label="New">`… kebab-case maps to the control name.
185
+
186
+ **First-class components** — these have dedicated tags/helpers that map to the
187
+ right native shape (some Ruflet "components" are *props* on another control,
188
+ not controls of their own — the DSL handles that for you):
189
+
190
+ | Tag | Helper | Notes |
191
+ | --- | --- | --- |
192
+ | `<badge label="3">…</badge>` | `badge` | wraps its child; badge is a prop on it |
193
+ | `<tooltip message="…">…</tooltip>` | `tooltip` | prop on its child |
194
+ | `<chip label="Ruby" icon="star">` | `chip` | text → `label`; `on-click`/`href` supported |
195
+ | `<avatar src>` / `<avatar>AM</avatar>` | `avatar` | image or initials |
196
+ | `<list-tile title subtitle leading href>` | `list_tile` | taps navigate/act natively |
197
+ | `<expansion-tile title>…</expansion-tile>` | `expansion_tile` | collapsible section |
198
+ | `<switch>` `<checkbox>` `<slider>` `<radio>` | same | standalone; `name` makes them form fields |
199
+ | `<radio-group name value>…radios…</radio-group>` | `radio_group` | |
200
+ | `<segmented-button name value>…<segment>…</segmented-button>` | `segmented_button` | |
201
+ | `<tabs><tab label icon>…</tab></tabs>` | `tabs` / `tab` | native TabBar + panes |
202
+ | `<table><thead><tr><th>…` | — | plain HTML tables → native `DataTable` |
203
+ | `<fab icon href>` | `fab` | mounts as the screen's FloatingActionButton |
204
+ | `<bottom-nav><nav-item icon label href selected>…` | `bottom_nav` / `nav_item` | screen's NavigationBar; a tab resets to that URL as root |
205
+
206
+ **Every Ruflet control is reachable.** Any tag without a dedicated builder
207
+ maps straight onto the ruflet_core control registry — the entire catalog
208
+ (charts, canvas, cupertino_*, maps, sensors, list/menu/expansion controls, …)
209
+ works from markup. The passthrough is schema-aware: a control's text and child
210
+ elements are routed into whichever prop it actually accepts (`content`,
211
+ `controls`, `label`, `title`), so `<filled-button>Save</filled-button>`,
212
+ `<banner><text>…</text></banner>`, and `<responsive-row>…</responsive-row>`
213
+ all build correctly. Use the raw tag (kebab-case → control name) or the
214
+ `widget` helper:
215
+
216
+ ```erb
217
+ <%= widget "bar-chart", expand: true do %>
218
+ <%= widget "bar-chart-group", x: 0 do %>
219
+ <%= widget "bar-chart-rod", from_y: 0, to_y: 10 %>
220
+ <% end %>
221
+ <% end %>
222
+
223
+ <cupertino-activity-indicator animating="true"></cupertino-activity-indicator>
224
+ ```
225
+
226
+ **Services and extensions.** Non-visual platform services (`<geolocator>`,
227
+ `<battery>`, `<clipboard>`, `<flashlight>`, `<permission-handler>`,
228
+ `<secure-storage>`, `<shared-preferences>`, `<file-picker>`, `<wakelock>`,
229
+ `<share>`, `<url-launcher>`, the sensors, …) are non-visual: declaring one
230
+ mounts it on the screen's service registry rather than in the layout. Extension
231
+ controls that *do* render (`<camera>`, `<audio>`, `<video>`, `<lottie>`,
232
+ `<rive>`, `<map>`, `<code-editor>`, `<web-view>`, `<spinkit>`,
233
+ `<color-picker>`, the charts) render inline like any other control. Each has a
234
+ matching helper:
235
+
236
+ ```erb
237
+ <%= geolocator %>
238
+ <%= camera id: "camera-preview", preview_enabled: true %>
239
+ <%= video src: "clip.mp4" %>
240
+ <%= lottie src: "loader.json" %>
241
+ <%= map do %><%= widget "tile-layer", url_template: "…" %><% end %>
242
+ ```
243
+
244
+ Buttons can invoke native services without a request back to Rails. Common
245
+ operations have short names, and the open-ended `method`/`args` form exposes
246
+ new service methods without waiting for another `ruflet_rails` release:
247
+
248
+ ```erb
249
+ <%= button "Copy", service: "copy", text: "Hello from Rails" %>
250
+ <%= button "Locate", service: "location" %>
251
+ <%= button "Allow camera",
252
+ service: "permission-handler",
253
+ method: "request",
254
+ args: { permission: "camera" } %>
255
+ ```
256
+
257
+ An inline extension can also be controlled by its native ID:
258
+
259
+ ```erb
260
+ <%= audio id: "player", src: "https://example.com/song.mp3" %>
261
+ <%= button "Play", service: "control", target: "player", method: "play" %>
262
+ <%= button "Seek", service: "control", target: "player",
263
+ method: "seek", position: 2_000 %>
264
+ ```
265
+
266
+ Service results and errors are reported in a native dialog by default. For a
267
+ stream such as an accelerometer, pass `result_target: "status-control-id"` to
268
+ update an inline Text control instead.
269
+
270
+ ```erb
271
+ <%# a form, natively rendered %>
272
+ <form action="<%= session_path %>" method="post">
273
+ <column class="p-6 gap-4">
274
+ <h2>Sign in</h2>
275
+ <input type="email" name="email" label="Email" placeholder="you@example.com">
276
+ <input type="password" name="password" label="Password">
277
+ <input type="checkbox" name="remember" label="Remember me" checked>
278
+ <input type="submit" value="Sign in">
279
+ </column>
280
+ </form>
281
+ ```
282
+
283
+ ### Ruby helper DSL
284
+
285
+ The same markup can be authored with Ruby helpers (auto-included into
286
+ ActionView), and both styles mix freely in one template. Attribute keys are
287
+ snake_case and become kebab-case attributes (`on_click:` → `on-click`);
288
+ Hash/Array values serialize as JSON:
289
+
290
+ ```erb
291
+ <%= appbar "Counter" %>
292
+
293
+ <%= column class: "p-6 gap-6 items-center justify-center flex-1" do %>
294
+ <%= text @count, class: "text-[96] font-bold" %>
295
+ <%= row class: "gap-3" do %>
296
+ <%= button "Down", variant: "outlined", icon: "remove",
297
+ on_click: counter_decrement_path %>
298
+ <%= button "Up", variant: "filled", icon: "add",
299
+ on_click: counter_increment_path %>
300
+ <% end %>
301
+ <%= link "Settings", settings_path %>
302
+ <% end %>
303
+ ```
304
+
305
+ Available helpers: layout (`column`, `row`, `stack`, `card`, `center`,
306
+ `list`, `grid`, `spacer`, `divider`), content (`text`, `h1`–`h6`/`heading`,
307
+ `markdown`, `image`, `icon`), interaction (`button`, `link`), chrome
308
+ (`appbar`, `appbar_action`), forms (`form`, `input`, `textarea`, `dropdown`,
309
+ `submit`), and `widget("progress-bar", value: 0.4)` for anything else in the
310
+ control registry.
311
+
312
+ Pick the mode per app: `erb_to_native` when HTML should *become* native controls,
313
+ `native_shell` when you want the real web page in a WebView with native chrome.
314
+
123
315
  ## Native WebView shell
124
316
 
125
317
  Beyond the server-driven UI, Ruflet can wrap an existing Rails HTML app in a
126
318
  managed native WebView. This is an opt-in shell: calling
127
- `Ruflet::Rails.native_app` wraps web pages in a native Ruflet shell whose body is
319
+ `Ruflet::Rails.native_shell` wraps web pages in a native Ruflet shell whose body is
128
320
  a WebView. Plain `Ruflet.run { |page| ... }` remains a normal Ruflet app with no
129
321
  WebView wrapper or HTML adapter.
130
322
 
@@ -136,7 +328,7 @@ promote page chrome.
136
328
  ```ruby
137
329
  # app/views/ruflet/main.rb
138
330
  Ruflet.run do |page|
139
- Ruflet::Rails.native_app(
331
+ Ruflet::Rails.native_shell(
140
332
  page,
141
333
  start_url: "https://myapp.com",
142
334
  title: "My App", # opt into a native AppBar (tracks <title>)
@@ -290,13 +482,13 @@ Add `data-ruflet-close="false"` when a sheet link should not dismiss:
290
482
  <%= ruflet_haptic_button "Tap", style: "light" %>
291
483
  ```
292
484
 
293
- For a Fizzy-style web app, keep `Ruflet::Rails.native_app` simple and opt in from
485
+ For a Fizzy-style web app, keep `Ruflet::Rails.native_shell` simple and opt in from
294
486
  the views that need native treatment:
295
487
 
296
488
  ```ruby
297
489
  # app/views/ruflet/main.rb
298
490
  Ruflet.run do |page|
299
- Ruflet::Rails.native_app(page, start_url: "#{Ruflet::Rails.backend_url}/")
491
+ Ruflet::Rails.native_shell(page, start_url: "#{Ruflet::Rails.backend_url}/")
300
492
  end
301
493
  ```
302
494
 
@@ -315,7 +507,7 @@ end
315
507
  Mount it in Rails:
316
508
 
317
509
  ```ruby
318
- match "/ws", to: Ruflet::Rails.app(Rails.root.join("app/views/ruflet/main.rb")), via: :all
510
+ match "/ws", to: Ruflet::Rails.native(Rails.root.join("app/views/ruflet/main.rb")), via: :all
319
511
  ```
320
512
 
321
513
  The same mounted Ruby entrypoint drives mobile, web, and desktop clients.
@@ -6,9 +6,8 @@ require "ruflet/rails/install_support"
6
6
  module Ruflet
7
7
  module Generators
8
8
  class InstallGenerator < ::Rails::Generators::Base
9
- class_option :web, type: :boolean, default: false, desc: "Install the prebuilt Ruflet web client"
10
9
  class_option :desktop, type: :boolean, default: false, desc: "Download the server-driven desktop Ruflet client"
11
- class_option :client, type: :string, default: nil, desc: "Install prebuilt clients: web, desktop, all, or none"
10
+ class_option :client, type: :string, default: nil, desc: "Install prebuilt clients: desktop or none"
12
11
 
13
12
  desc "Install Ruflet into a Rails app."
14
13
 
@@ -31,21 +30,11 @@ module Ruflet
31
30
  def mount_websocket
32
31
  routes = File.join(destination_root, "config", "routes.rb")
33
32
  return unless File.file?(routes)
34
- return if File.read(routes).include?("Ruflet::Rails.app(")
33
+ return if File.read(routes).include?("Ruflet::Rails.native(")
35
34
 
36
35
  route Ruflet::Rails::InstallSupport.route_snippet(entrypoint: entrypoint_path)
37
36
  end
38
37
 
39
- def mount_web_app
40
- return unless web_requested?
41
-
42
- routes = File.join(destination_root, "config", "routes.rb")
43
- return unless File.file?(routes)
44
- return if File.read(routes).include?("Ruflet::Rails.web_app(")
45
-
46
- route Ruflet::Rails::InstallSupport.web_route_snippet(entrypoint: entrypoint_path)
47
- end
48
-
49
38
  def add_desktop_flag_to_binstubs
50
39
  return unless desktop_requested?
51
40
 
@@ -57,7 +46,6 @@ module Ruflet
57
46
  client = requested_client
58
47
  return if client == "none"
59
48
 
60
- install_web_client if %w[web all].include?(client)
61
49
  install_desktop_client if %w[desktop all].include?(client)
62
50
  rescue StandardError => e
63
51
  @client_download_failed = true
@@ -66,7 +54,6 @@ module Ruflet
66
54
 
67
55
  def print_install_status
68
56
  Ruflet::Rails::InstallSupport.install_next_steps(
69
- target: install_target,
70
57
  entrypoint: entrypoint_path,
71
58
  client: requested_client
72
59
  ).each { |line| say line }
@@ -85,13 +72,11 @@ module Ruflet
85
72
  def requested_client
86
73
  explicit = options[:client].to_s.strip.downcase
87
74
  unless explicit.empty?
88
- raise Thor::Error, "--client must be web, desktop, all, or none" unless %w[web desktop all none].include?(explicit)
75
+ raise Thor::Error, "--client must be desktop or none" unless %w[desktop none].include?(explicit)
89
76
 
90
77
  return explicit
91
78
  end
92
79
 
93
- return "all" if options[:web] && options[:desktop]
94
- return "web" if options[:web]
95
80
  return "desktop" if options[:desktop]
96
81
 
97
82
  "none"
@@ -101,19 +86,6 @@ module Ruflet
101
86
  %w[desktop all].include?(requested_client)
102
87
  end
103
88
 
104
- def web_requested?
105
- %w[web all].include?(requested_client)
106
- end
107
-
108
- def install_target
109
- "ruflet"
110
- end
111
-
112
- def install_web_client
113
- return if Ruflet::Rails::WebInstaller.install!(root: destination_root)
114
-
115
- client_download_failed("web")
116
- end
117
89
 
118
90
  def install_desktop_client
119
91
  require "ruflet/cli"
@@ -8,7 +8,7 @@ module Ruflet
8
8
  module_function
9
9
 
10
10
  def launch_once(root:, argv: ARGV, env: ENV, wait: 1.0)
11
- return false unless desktop_enabled?(root: root, argv: argv, env: env)
11
+ return false unless desktop_enabled?(argv: argv, env: env)
12
12
  return false unless env["RUFLET_RAILS_DESKTOP_SERVER"].to_s.downcase == "true" || rails_server_command?(argv)
13
13
  return false if env["RUFLET_RAILS_DESKTOP"].to_s.downcase == "false"
14
14
  return false if launched?
@@ -51,7 +51,7 @@ module Ruflet
51
51
  "http://localhost:#{env["PORT"].to_s.empty? ? 3000 : env["PORT"]}"
52
52
  end
53
53
 
54
- def desktop_enabled?(root:, argv: ARGV, env: ENV)
54
+ def desktop_enabled?(argv: ARGV, env: ENV)
55
55
  return true if env["RUFLET_RAILS_DESKTOP_SERVER"].to_s.downcase == "true"
56
56
  return true if env["RUFLET_RAILS_DESKTOP"].to_s.downcase == "true"
57
57
  return true if Array(argv).map(&:to_s).include?("--desktop")
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruflet
4
+ module Rails
5
+ module HtmlDsl
6
+ # Compares the control tree already mounted on a screen with the one just
7
+ # built from fresh markup, and reports the minimum set of prop updates
8
+ # that turns the first into the second.
9
+ #
10
+ # Re-rendering a screen in place otherwise means shipping every control
11
+ # on it to change one value — a counter tick re-sends the heading, both
12
+ # buttons and the caption to move a single digit, and the client rebuilds
13
+ # that subtree (losing scroll position and any transient widget state).
14
+ #
15
+ # The diff is deliberately conservative: it only reports updates when the
16
+ # two trees have exactly the same shape (same control types, same number
17
+ # of children, same prop-held controls, in the same order). Any structural
18
+ # difference returns nil, and the caller replaces the body wholesale as
19
+ # before. Shape changes are where a diff would be subtly wrong, and they
20
+ # are also the case where a full replace costs what it should.
21
+ class ControlDiff
22
+ # Props whose values are server-side plumbing, never wire state.
23
+ SKIPPED_PROPS = %w[key].freeze
24
+
25
+ def initialize(old_tree, new_tree)
26
+ @old_tree = old_tree
27
+ @new_tree = new_tree
28
+ end
29
+
30
+ # [[mounted_control, {prop => value}], …], or nil when the shape moved.
31
+ def updates
32
+ @updates = []
33
+ return nil unless walk(@old_tree, @new_tree)
34
+
35
+ @updates
36
+ end
37
+
38
+ private
39
+
40
+ def walk(old_node, new_node)
41
+ return true if old_node.nil? && new_node.nil?
42
+ return false if old_node.nil? || new_node.nil?
43
+
44
+ if old_node.is_a?(Array) || new_node.is_a?(Array)
45
+ return false unless old_node.is_a?(Array) && new_node.is_a?(Array)
46
+ return false unless old_node.length == new_node.length
47
+
48
+ return old_node.each_with_index.all? { |item, i| walk(item, new_node[i]) }
49
+ end
50
+
51
+ return old_node == new_node unless control?(old_node) || control?(new_node)
52
+ return false unless control?(old_node) && control?(new_node)
53
+ return false unless old_node.type == new_node.type
54
+ return false unless old_node.children.length == new_node.children.length
55
+
56
+ return false unless compare_props(old_node, new_node)
57
+
58
+ old_node.children.each_with_index.all? { |child, i| walk(child, new_node.children[i]) }
59
+ end
60
+
61
+ def compare_props(old_node, new_node)
62
+ keys = (old_node.props.keys | new_node.props.keys) - SKIPPED_PROPS
63
+ changed = {}
64
+
65
+ keys.each do |key|
66
+ old_value = old_node.props[key]
67
+ new_value = new_node.props[key]
68
+
69
+ # A prop holding controls is structure: recurse instead of comparing.
70
+ if holds_controls?(old_value) || holds_controls?(new_value)
71
+ return false unless walk(old_value, new_value)
72
+
73
+ next
74
+ end
75
+
76
+ changed[key.to_sym] = new_value unless old_value == new_value
77
+ end
78
+
79
+ # The fresh tree carries handlers bound to this render (a button's
80
+ # on-click closes over the screen it was built for). Move them onto
81
+ # the mounted control so taps keep working after a patch; handlers
82
+ # live server-side, so this costs nothing on the wire.
83
+ rebind_handlers(old_node, new_node)
84
+
85
+ @updates << [old_node, changed] unless changed.empty?
86
+ true
87
+ end
88
+
89
+ def rebind_handlers(old_node, new_node)
90
+ handlers = new_node.instance_variable_get(:@handlers)
91
+ return if handlers.nil? || handlers.empty?
92
+
93
+ old_node.instance_variable_set(:@handlers, handlers)
94
+ end
95
+
96
+ def control?(node)
97
+ node.is_a?(Ruflet::Control)
98
+ end
99
+
100
+ def holds_controls?(value)
101
+ return true if control?(value)
102
+ return value.any? { |item| control?(item) } if value.is_a?(Array)
103
+
104
+ false
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end