ruflet_rails 0.0.13 → 0.0.14

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,645 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruflet
4
+ module Rails
5
+ class NativeApp
6
+ private
7
+
8
+ # Screen navigation modes:
9
+ # push - add Ruby-side history, then patch the native shell body
10
+ # replace - swap the current history entry for a new body URL
11
+ # root - reset history to the root shell body
12
+ # sheet - present the URL as a bottom-sheet web modal
13
+ # back - restore the previous history entry
14
+ def navigate_screen(url, mode, spec = nil)
15
+ url = absolute_url(url)
16
+ mode = mode.to_s
17
+ mode = "push" if mode.empty?
18
+ if bottomnav_item_for(url) && !%w[back sheet].include?(mode)
19
+ mode = "root"
20
+ spec = bottomnav_appbar_spec(url, spec)
21
+ end
22
+
23
+ # A bottom sheet corrupts the shell Scaffold on the client: afterwards it
24
+ # silently drops BOTH targeted control patches (so a navigation's new
25
+ # WebView never mounts) AND method invokes (so the drawer won't
26
+ # open/close). Reusing that Scaffold never recovers. So when a sheet was
27
+ # active, rebuild a fresh root view — a brand-new Scaffold the client
28
+ # wires up from scratch, which restores navigation and the drawer.
29
+ resume_from_sheet = mode == "sheet" ? false : consume_pending_sheet
30
+ if resume_from_sheet && !%w[back sheet].include?(mode)
31
+ close_drawer
32
+ @screens.clear
33
+ push_webview(url, root: true, spec: spec)
34
+ return
35
+ end
36
+
37
+ case mode
38
+ when "back" then pop
39
+ when "root" then go_webview(url, spec: spec)
40
+ when "replace" then replace_webview(url, spec: spec)
41
+ when "sheet" then present_sheet((spec || {}).merge("url" => url))
42
+ else push_webview(url, spec: spec)
43
+ end
44
+ end
45
+
46
+ # Drop any open/lingering bottom sheet from both the native_app reference
47
+ # and the ruflet page overlay. Returns whether a sheet was present, so the
48
+ # caller can force a full re-render to recover the client view tree.
49
+ def consume_pending_sheet
50
+ active = !@sheet.nil? || !@page.instance_variable_get(:@bottom_sheet).nil?
51
+ @sheet = nil
52
+ @page.bottom_sheet = nil if active && @page.respond_to?(:bottom_sheet=)
53
+ active
54
+ end
55
+
56
+ # Root navigation keeps the mounted Ruflet shell (AppBar / drawer / bottom
57
+ # chrome) alive and swaps only the WebView body.
58
+ def go_webview(url, spec: nil)
59
+ url = absolute_url(url)
60
+ return if url.empty?
61
+
62
+ root = @screens.first
63
+ if root&.webview&.wire_id
64
+ @screens = [root] if @screens.size > 1
65
+ navigate_root_body(root, url, spec)
66
+ else
67
+ @screens.clear
68
+ push_webview(url, root: true, spec: spec)
69
+ end
70
+ end
71
+
72
+ # Swap only the mounted shell body. A fresh WebView gets fresh client-side
73
+ # listeners while the AppBar, drawer, and bottom chrome stay mounted.
74
+ def navigate_root_body(screen, url, spec)
75
+ screen.url = url
76
+ screen.webview = build_webview(screen)
77
+ screen.loading = build_loading_state
78
+ screen.body = build_webview_body(screen)
79
+ apply_spec_chrome(screen, spec)
80
+ sync_drawer_selection(screen)
81
+ update_screen_controls(screen)
82
+ sync_root_navigation_bar_selection
83
+ end
84
+
85
+ # Apply declared chrome onto the reused shell in place. A title-only hint
86
+ # just repaints the AppBar text; richer chrome replaces the AppBar control.
87
+ def apply_spec_chrome(screen, spec)
88
+ new_spec = appbar_spec_from(spec)
89
+ return unless new_spec
90
+
91
+ if new_spec[:leading].nil? && Array(new_spec[:actions]).empty? && new_spec[:props].empty? && new_spec[:title_props].empty?
92
+ update_screen_title(screen, new_spec[:title].to_s)
93
+ return
94
+ end
95
+
96
+ screen.appbar_spec = new_spec
97
+ screen.appbar_signature = nil
98
+ screen.title_text = build_title_text(screen)
99
+ update_screen_appbar(screen)
100
+ end
101
+
102
+ # Repaint just the AppBar's title text in place (no AppBar rebuild).
103
+ def update_screen_title(screen, title)
104
+ return if title.empty?
105
+
106
+ text = screen.title_text
107
+ return unless text
108
+
109
+ if text.wire_id
110
+ return if text.props["value"] == title
111
+
112
+ @page.update(text, value: title)
113
+ else
114
+ text.props["value"] = title
115
+ end
116
+ end
117
+
118
+ # Replace the current top screen with a new one (no extra back step).
119
+ def replace_webview(url, spec: nil)
120
+ url = absolute_url(url)
121
+ return if url.empty?
122
+
123
+ root = @screens.size <= 1
124
+ @screens.pop unless @screens.empty?
125
+ push_webview(url, root: root, spec: spec)
126
+ end
127
+
128
+ # --- HTML-declared native chrome ---------------------------------------
129
+
130
+ # The page declared a `ruflet-appbar`: give the reporting screen a native
131
+ # AppBar with that title and any action buttons (each navigates on tap).
132
+ def apply_appbar(screen, spec)
133
+ return unless screen
134
+ return clear_appbar(screen) if chrome_absent?(spec)
135
+
136
+ spec = bottomnav_appbar_spec(screen.url, spec) if screen.equal?(@screens.first) && bottomnav_item_for(screen.url)
137
+ remember_appbar_spec(screen.url, spec)
138
+ signature = JSON.generate(canonicalize(spec))
139
+ return if screen.appbar_signature == signature
140
+
141
+ screen.appbar_spec = appbar_spec_from(spec) || { title: spec["title"].to_s, leading: nil, actions: [], props: {}, title_props: {} }
142
+ screen.title_text = build_title_text(screen)
143
+ screen.appbar_signature = signature
144
+ update_screen_appbar(screen)
145
+ end
146
+
147
+ # The page declared a `ruflet-bottomnav`: build a native NavigationBar on
148
+ # the root view. Selecting a destination switches to that URL as a fresh
149
+ # root (tab semantics).
150
+ def apply_bottomnav(spec)
151
+ return if chrome_absent?(spec)
152
+
153
+ @bottomnav_spec = spec
154
+ return unless buildable_bottomnav?(spec)
155
+
156
+ # Like the drawer, the bottom bar is a persistent client-owned control:
157
+ # the signature ignores selection, so re-reporting it on each page load
158
+ # only re-points the selected tab instead of rebuilding (and churning)
159
+ # the control.
160
+ signature = structural_signature(spec)
161
+ if @bottomnav_signature == signature
162
+ sync_root_navigation_bar_selection
163
+ return
164
+ end
165
+
166
+ @navigation_bar = build_bottomnav(spec)
167
+ @bottomnav_signature = signature
168
+ update_root_navigation_bar
169
+ end
170
+
171
+ # The page declared a `ruflet-drawer`: build a native NavigationDrawer on
172
+ # the reporting screen. Selecting a destination closes the drawer first,
173
+ # then applies that item's requested navigation mode.
174
+ def apply_drawer(screen, spec)
175
+ return unless screen
176
+ return clear_drawer(screen) if chrome_absent?(spec)
177
+
178
+ @drawer_spec = spec
179
+ items = Array(spec["items"])
180
+ urls = items.map { |item| absolute_url(item["url"]) }
181
+ modes = items.map { |item| (item["action"] || item["mode"] || "root").to_s }
182
+ static_controls = drawer_static_controls(spec)
183
+ item_offset = static_controls.length
184
+ selected_index = drawer_selected_index(items)
185
+ destinations = items.each_with_index.filter_map do |item, index|
186
+ build_drawer_destination(item, selected: index == selected_index, on_click: lambda do |_event|
187
+ select_drawer_item(screen, items, urls, modes, index)
188
+ end)
189
+ end
190
+ return if destinations.empty?
191
+
192
+ # The drawer is a persistent, client-owned control. Each page re-reports it
193
+ # on load; when the structure is unchanged we keep the same control and
194
+ # only re-point its selection, so its open/close state survives navigation.
195
+ signature = structural_signature(spec)
196
+ if screen.drawer_signature == signature
197
+ sync_drawer_selection(screen)
198
+ if @drawer_open_requested
199
+ @drawer_open_requested = false
200
+ show_drawer
201
+ end
202
+ return
203
+ end
204
+
205
+ drawer = navigation_drawer(
206
+ static_controls + destinations,
207
+ **ruflet_props_for(:navigation_drawer, spec),
208
+ selected_index: item_offset + selected_index,
209
+ on_change: lambda do |event|
210
+ index = navigation_index(event) - item_offset
211
+ next if index.negative? || index >= items.length
212
+
213
+ select_drawer_item(screen, items, urls, modes, index)
214
+ end
215
+ )
216
+ screen.drawer_signature = signature
217
+ update_screen_drawer(screen, drawer: drawer)
218
+ update_screen_appbar(screen) if screen.appbar_spec
219
+ if @drawer_open_requested
220
+ @drawer_open_requested = false
221
+ show_drawer
222
+ end
223
+ end
224
+
225
+ # The page declared a `ruflet-rail`: desktop/tablet native navigation.
226
+ # Unlike drawer/bottom nav, NavigationRail is a control, so the WebView
227
+ # body is wrapped in a Row with the rail on the left.
228
+ def apply_rail(screen, spec)
229
+ return unless screen
230
+ return clear_rail(screen) if chrome_absent?(spec)
231
+
232
+ items = Array(spec["items"])
233
+ destinations = items.filter_map { |item| build_rail_destination(item) }
234
+ return if destinations.length < 2
235
+
236
+ urls = items.map { |item| absolute_url(item["url"]) }
237
+
238
+ # Persistent client-owned control: same structure → only re-point the
239
+ # selection, never rebuild.
240
+ signature = structural_signature(spec)
241
+ if screen.rail_signature == signature
242
+ sync_rail_selection(screen, urls)
243
+ return
244
+ end
245
+
246
+ modes = items.map { |item| (item["action"] || item["mode"] || "root").to_s }
247
+ rail_args = {
248
+ destinations: destinations,
249
+ selected_index: items.index { |item| item["selected"] } || 0,
250
+ **ruflet_props_for(:navigation_rail, spec),
251
+ label_type: spec["label_type"] || spec["labelType"] || "all",
252
+ on_change: lambda do |event|
253
+ index = navigation_index(event)
254
+ target = urls[index].to_s
255
+ next if target.empty?
256
+
257
+ mode = modes[index]
258
+ spec = mode == "root" ? { "title" => items[index]["label"].to_s } : nil
259
+ navigate_screen(target, mode, spec)
260
+ end
261
+ }
262
+ rail_args[:extended] = !!spec["extended"] unless spec["extended"].nil?
263
+ rail_args[:min_width] = spec["min_width"].to_i if spec["min_width"]
264
+ rail_args[:min_extended_width] = spec["min_extended_width"].to_i if spec["min_extended_width"]
265
+ screen.rail = navigation_rail(**rail_args)
266
+ screen.rail_signature = signature
267
+ update_screen_controls(screen)
268
+ end
269
+
270
+ # Signature of a chrome spec's STRUCTURE, ignoring which item is currently
271
+ # selected. Selection is client-owned state we patch via `selected_index`,
272
+ # so a navigation that only moves the highlight keeps the same controls.
273
+ def structural_signature(spec)
274
+ JSON.generate(canonicalize(strip_selection(spec)))
275
+ end
276
+
277
+ def chrome_absent?(spec)
278
+ spec.is_a?(Hash) && spec["absent"] == true
279
+ end
280
+
281
+ def clear_appbar(screen)
282
+ screen.appbar_spec = nil
283
+ screen.appbar_signature = nil
284
+ screen.title_text = build_title_text(screen)
285
+ update_screen_appbar(screen)
286
+ end
287
+
288
+ def clear_drawer(screen)
289
+ @drawer_spec = nil
290
+ @drawer_open_requested = false
291
+ screen.drawer_signature = nil
292
+ update_screen_drawer(screen, drawer: nil, end_drawer: nil)
293
+ end
294
+
295
+ def clear_rail(screen)
296
+ screen.rail = nil
297
+ screen.rail_signature = nil
298
+ update_screen_controls(screen)
299
+ end
300
+
301
+ def strip_selection(value)
302
+ case value
303
+ when Hash
304
+ value.each_with_object({}) do |(key, nested), out|
305
+ next if %w[selected selected_index].include?(key.to_s)
306
+
307
+ out[key] = strip_selection(nested)
308
+ end
309
+ when Array
310
+ value.map { |item| strip_selection(item) }
311
+ else
312
+ value
313
+ end
314
+ end
315
+
316
+ def canonicalize(value)
317
+ case value
318
+ when Hash
319
+ value.keys.map(&:to_s).sort.each_with_object({}) do |key, out|
320
+ original_key = value.key?(key) ? key : key.to_sym
321
+ out[key] = canonicalize(value[original_key])
322
+ end
323
+ when Array
324
+ value.map { |item| canonicalize(item) }
325
+ else
326
+ value
327
+ end
328
+ end
329
+
330
+ def build_destination(item)
331
+ icon = item["icon"].to_s
332
+ return nil if icon.empty?
333
+
334
+ navigation_bar_destination(icon: icon, label: item["label"].to_s,
335
+ **ruflet_props_for(:navigation_bar_destination, item))
336
+ end
337
+
338
+ def buildable_bottomnav?(spec)
339
+ Array(spec["items"]).filter_map { |item| build_destination(item) }.length >= 2
340
+ end
341
+
342
+ def build_bottomnav(spec)
343
+ items = Array(spec["items"])
344
+ destinations = items.filter_map { |item| build_destination(item) }
345
+ urls = items.map { |item| absolute_url(item["url"]) }
346
+
347
+ navigation_bar(
348
+ **ruflet_props_for(:navigation_bar, spec),
349
+ destinations: destinations,
350
+ selected_index: bottomnav_selected_index(items),
351
+ on_change: lambda do |event|
352
+ index = navigation_index(event)
353
+ target = urls[index].to_s
354
+ next if target.empty? || same_url?(target, current_url)
355
+
356
+ # Route through navigate_screen (not go_webview directly) so a sheet
357
+ # opened/closed before this tap is torn down and the view re-rendered
358
+ # in full. The tab label is carried so the AppBar shows the
359
+ # destination's name immediately.
360
+ navigate_screen(target, "root", { "title" => items[index]["label"].to_s })
361
+ end
362
+ )
363
+ end
364
+
365
+ def bottomnav_selected_index(items)
366
+ urls = items.map { |item| absolute_url(item["url"]) }
367
+ urls.index { |candidate| same_url?(candidate, current_url) } ||
368
+ items.index { |item| item["selected"] } || 0
369
+ end
370
+
371
+ def bottomnav_appbar_spec(url, spec)
372
+ declared = appbar_spec_for_url(url)
373
+ current = @screens.first&.appbar_spec
374
+ label = bottomnav_item_for(url)&.fetch("label", nil).to_s
375
+ title =
376
+ if declared && !declared["title"].to_s.empty?
377
+ declared["title"].to_s
378
+ elsif current && !current[:title].to_s.empty?
379
+ current[:title].to_s
380
+ elsif spec.is_a?(Hash) && !spec["title"].to_s.empty?
381
+ spec["title"].to_s
382
+ else
383
+ label
384
+ end
385
+ actions =
386
+ if declared
387
+ declared["actions"]
388
+ else
389
+ spec.is_a?(Hash) ? spec["actions"] : nil
390
+ end
391
+ { "title" => title, "leading" => { "action" => "drawer" }, "actions" => actions }.compact
392
+ end
393
+
394
+ def bottomnav_item_for(url)
395
+ items = Array(@bottomnav_spec && @bottomnav_spec["items"])
396
+ items.find { |item| same_url?(absolute_url(item["url"]), url) }
397
+ end
398
+
399
+ def remember_appbar_spec(url, spec)
400
+ return unless spec.is_a?(Hash)
401
+
402
+ key = appbar_spec_key(url)
403
+ @appbar_specs_by_url[key] = canonicalize(spec) if key
404
+ end
405
+
406
+ def appbar_spec_for_url(url)
407
+ key = appbar_spec_key(url)
408
+ key && @appbar_specs_by_url[key]
409
+ end
410
+
411
+ def appbar_spec_key(url)
412
+ uri = URI.parse(absolute_url(url))
413
+ path = uri.path.to_s.empty? ? "/" : uri.path.to_s
414
+ "#{uri.scheme}://#{uri.host}:#{uri.port}#{path}"
415
+ rescue URI::InvalidURIError
416
+ nil
417
+ end
418
+
419
+ def sync_root_navigation_bar_selection
420
+ return unless @navigation_bar&.wire_id && @bottomnav_spec
421
+
422
+ index = bottomnav_selected_index(Array(@bottomnav_spec["items"]))
423
+ return if @navigation_bar.props["selected_index"] == index
424
+
425
+ @navigation_bar.props["selected_index"] = index
426
+ @page.update(@navigation_bar, selected_index: index)
427
+ end
428
+
429
+ def build_drawer_destination(item, selected: false, on_click: nil)
430
+ icon = item["icon"].to_s
431
+ return nil if icon.empty?
432
+
433
+ list_tile(
434
+ **ruflet_props_for(:list_tile, item),
435
+ leading: icon(icon, **nested_ruflet_props(item, "icon_props", control: :icon)),
436
+ title: text(item["label"].to_s, **nested_ruflet_props(item, "label_props", "title_props", control: :text)),
437
+ selected: selected,
438
+ on_click: on_click
439
+ )
440
+ end
441
+
442
+ def drawer_static_controls(spec)
443
+ header = spec["header"]
444
+ return [] unless header.is_a?(Hash)
445
+
446
+ title = header["title"].to_s
447
+ subtitle = header["subtitle"].to_s
448
+ avatar = header["avatar"].to_s
449
+ avatar = title[0].to_s.upcase if avatar.empty? && !title.empty?
450
+ [
451
+ container(
452
+ padding: { left: 18, right: 18, top: 24, bottom: 18 },
453
+ content: row(
454
+ controls: [
455
+ circle_avatar(
456
+ text(avatar, color: "#047857", weight: "bold", size: 18),
457
+ bgcolor: "#D1FAE5",
458
+ radius: 28
459
+ ),
460
+ column(
461
+ controls: [
462
+ text(title, weight: "bold", color: "#0F172A", size: 16, max_lines: 1, no_wrap: true),
463
+ text(subtitle, color: "#64748B", size: 12, max_lines: 1, no_wrap: true)
464
+ ],
465
+ spacing: 2,
466
+ tight: true,
467
+ expand: true
468
+ )
469
+ ],
470
+ spacing: 12,
471
+ vertical_alignment: "center"
472
+ )
473
+ ),
474
+ divider(height: 1, color: "#E2E8F0")
475
+ ]
476
+ end
477
+
478
+ def select_drawer_item(screen, items, urls, modes, index)
479
+ close_drawer(screen)
480
+ target = urls[index].to_s
481
+ return if target.empty?
482
+
483
+ if same_url?(target, current_url)
484
+ sync_drawer_selection(screen)
485
+ return
486
+ end
487
+
488
+ mode = modes[index]
489
+ if %w[delete post patch put].include?(mode)
490
+ submit_webview_request(target, mode)
491
+ return
492
+ end
493
+
494
+ # Only a tab (root) switch carries a title hint to repaint the AppBar
495
+ # title in place. A push keeps its own declared chrome (notably the
496
+ # back button), so we must NOT force a title-only AppBar on it.
497
+ spec = mode == "root" ? { "title" => items[index]["label"].to_s } : nil
498
+ navigate_screen(target, mode, spec)
499
+ close_drawer(@screens.last)
500
+ sync_drawer_selection(screen)
501
+ end
502
+
503
+ def build_rail_destination(item)
504
+ icon = item["icon"].to_s
505
+ return nil if icon.empty?
506
+
507
+ navigation_rail_destination(icon: icon, label: item["label"].to_s,
508
+ **ruflet_props_for(:navigation_rail_destination, item))
509
+ end
510
+
511
+ def navigation_index(event)
512
+ data = event.respond_to?(:data) ? event.data : event
513
+ return (data["selected_index"] || data["value"] || data["i"] || 0).to_i if data.is_a?(Hash)
514
+
515
+ data.to_i
516
+ end
517
+
518
+ # Keep the drawer's highlighted destination in sync with the route we are
519
+ # actually on. Selecting a drawer item (or tapping a tab) leaves the drawer
520
+ # marking that item, and the screen's WebView is not re-reported on a tab
521
+ # switch or a back navigation — so without this the drawer keeps
522
+ # highlighting a route we already left. Re-point the selection to the item
523
+ # matching the current URL (or the declared default). Called after every
524
+ # root navigation and on back.
525
+ def sync_drawer_selection(screen)
526
+ drawer = screen&.drawer
527
+ return unless drawer&.wire_id
528
+
529
+ index = drawer_selected_index(Array(@drawer_spec && @drawer_spec["items"]))
530
+ item_offset = drawer_static_controls(@drawer_spec || {}).length
531
+ selected_control_index = item_offset + index
532
+ if drawer.props["selected_index"] != selected_control_index
533
+ drawer.props["selected_index"] = selected_control_index
534
+ @page.update(drawer, selected_index: selected_control_index)
535
+ end
536
+ sync_drawer_tile_selection(drawer, index, item_offset)
537
+ end
538
+
539
+ def sync_drawer_tile_selection(drawer, selected_index, item_offset = 0)
540
+ drawer.children.each_with_index do |control, control_index|
541
+ next if control_index < item_offset
542
+ next unless control.respond_to?(:props)
543
+
544
+ selected = (control_index - item_offset) == selected_index
545
+ next if control.props["selected"] == selected
546
+
547
+ control.props["selected"] = selected
548
+ @page.update(control, selected: selected) if control.wire_id
549
+ end
550
+ end
551
+
552
+ def sync_rail_selection(screen, urls)
553
+ rail = screen&.rail
554
+ return unless rail&.wire_id
555
+
556
+ index = urls.index { |candidate| same_url?(candidate, screen.url) } || 0
557
+ return if rail.props["selected_index"] == index
558
+
559
+ rail.props["selected_index"] = index
560
+ @page.update(rail, selected_index: index)
561
+ end
562
+
563
+ def drawer_selected_index(items)
564
+ urls = items.map { |item| absolute_url(item["url"]) }
565
+ urls.index { |candidate| same_url?(candidate, current_url) } ||
566
+ items.index { |item| item["selected"] } || 0
567
+ end
568
+
569
+ def show_drawer
570
+ screen = @screens.last
571
+ return unless screen&.view&.wire_id
572
+ unless screen.drawer || screen.view.props["drawer"]
573
+ if @drawer_spec
574
+ @drawer_open_requested = true
575
+ apply_drawer(screen, @drawer_spec)
576
+ return
577
+ end
578
+ @drawer_open_requested = true
579
+ return
580
+ end
581
+
582
+ invoke_drawer_when_ready(screen, "show_drawer")
583
+ rescue StandardError
584
+ nil
585
+ end
586
+
587
+ # Close the native drawer on the view that owns it (and the current top
588
+ # screen, if different). The drawer is a persistent, client-owned control,
589
+ # so a single ordered invoke is enough — no rebuild races it anymore.
590
+ def close_drawer(screen = nil)
591
+ @drawer_open_requested = false
592
+ targets = [screen, @screens.last].compact.uniq
593
+ targets.each { |target| invoke_close_drawer(target) }
594
+ rescue StandardError
595
+ nil
596
+ end
597
+
598
+ def show_end_drawer
599
+ screen = @screens.last
600
+ return unless screen&.view&.wire_id
601
+
602
+ invoke_drawer_when_ready(screen, "show_end_drawer")
603
+ rescue StandardError
604
+ nil
605
+ end
606
+
607
+ # The drawer is a persistent control mounted on the View well before the
608
+ # user can open it, so a single ordered invoke opens it. (The previous
609
+ # sleep/retry loop relied on real background threads; on the embedded
610
+ # runtime Thread runs inline, so it only blocked the UI without deferring.)
611
+ def invoke_drawer_when_ready(screen, method_name)
612
+ return unless screen&.view&.wire_id
613
+
614
+ @page.invoke(screen.view, method_name)
615
+ end
616
+
617
+ def invoke_close_drawer(screen)
618
+ return unless screen&.view&.wire_id
619
+
620
+ @page.invoke(screen.view, "close_drawer")
621
+ end
622
+
623
+ def absolute_url(url)
624
+ url = url.to_s
625
+ return "" if url.empty?
626
+ return url if URI.parse(url).absolute?
627
+
628
+ URI.join(current_url, url).to_s
629
+ rescue URI::InvalidURIError
630
+ url
631
+ end
632
+
633
+ def current_url
634
+ @screens.last&.url || @start_url
635
+ end
636
+
637
+ def message_of(event)
638
+ data = event.respond_to?(:data) ? event.data : event
639
+ return data["message"] || data[:message] if data.is_a?(Hash)
640
+
641
+ data
642
+ end
643
+ end
644
+ end
645
+ end