ruflet_core 0.0.16 → 0.0.18

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.
Files changed (188) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -18
  3. data/lib/ruflet/version.rb +1 -1
  4. data/lib/ruflet_core.rb +9 -7
  5. data/lib/ruflet_protocol/ruflet/protocol.rb +28 -54
  6. data/lib/ruflet_ui/ruflet/app.rb +1 -1
  7. data/lib/ruflet_ui/ruflet/colors.rb +4 -18
  8. data/lib/ruflet_ui/ruflet/control.rb +65 -63
  9. data/lib/ruflet_ui/ruflet/dsl.rb +10 -3
  10. data/lib/ruflet_ui/ruflet/icons/cupertino/cupertino_icons.rb +2 -2
  11. data/lib/ruflet_ui/ruflet/icons/cupertino_icon_lookup.rb +1 -0
  12. data/lib/ruflet_ui/ruflet/icons/icon_constant_names.rb +29 -0
  13. data/lib/ruflet_ui/ruflet/icons/material/material_icons.rb +2 -2
  14. data/lib/ruflet_ui/ruflet/icons/material_icon_lookup.rb +1 -0
  15. data/lib/ruflet_ui/ruflet/page.rb +303 -460
  16. data/lib/ruflet_ui/ruflet/ui/control_factory.rb +68 -12
  17. data/lib/ruflet_ui/ruflet/ui/control_registry.rb +13 -6
  18. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoactionsheet_control.rb +8 -45
  19. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoactionsheetaction_control.rb +45 -2
  20. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoactivityindicator_control.rb +43 -2
  21. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoalertdialog_control.rb +0 -5
  22. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinobutton_control.rb +59 -4
  23. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinocheckbox_control.rb +56 -1
  24. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinocontextmenu_control.rb +1 -3
  25. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinocontextmenuaction_control.rb +1 -1
  26. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinodatepicker_control.rb +53 -8
  27. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinodialogaction_control.rb +1 -1
  28. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinofilledbutton_control.rb +59 -4
  29. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinolisttile_control.rb +54 -9
  30. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinonavigationbar_control.rb +47 -9
  31. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinopicker_control.rb +52 -10
  32. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoradio_control.rb +52 -1
  33. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinosegmentedbutton_control.rb +49 -1
  34. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoslider_control.rb +50 -5
  35. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoslidingsegmentedbutton_control.rb +46 -1
  36. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoswitch_control.rb +61 -1
  37. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinotextfield_control.rb +145 -1
  38. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinotimerpicker_control.rb +47 -12
  39. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinotintedbutton_control.rb +59 -4
  40. data/lib/ruflet_ui/ruflet/ui/controls/materials/alertdialog_control.rb +8 -45
  41. data/lib/ruflet_ui/ruflet/ui/controls/materials/appbar_control.rb +8 -40
  42. data/lib/ruflet_ui/ruflet/ui/controls/materials/audio_control.rb +16 -5
  43. data/lib/ruflet_ui/ruflet/ui/controls/materials/autocomplete_control.rb +44 -1
  44. data/lib/ruflet_ui/ruflet/ui/controls/materials/badge_control.rb +0 -7
  45. data/lib/ruflet_ui/ruflet/ui/controls/materials/banner_control.rb +0 -7
  46. data/lib/ruflet_ui/ruflet/ui/controls/materials/bottomappbar_control.rb +8 -47
  47. data/lib/ruflet_ui/ruflet/ui/controls/materials/bottomsheet_control.rb +0 -3
  48. data/lib/ruflet_ui/ruflet/ui/controls/materials/button_control.rb +8 -54
  49. data/lib/ruflet_ui/ruflet/ui/controls/materials/card_control.rb +8 -50
  50. data/lib/ruflet_ui/ruflet/ui/controls/materials/chart_controls.rb +19 -0
  51. data/lib/ruflet_ui/ruflet/ui/controls/materials/checkbox_control.rb +9 -60
  52. data/lib/ruflet_ui/ruflet/ui/controls/materials/chip_control.rb +7 -81
  53. data/lib/ruflet_ui/ruflet/ui/controls/materials/circleavatar_control.rb +7 -56
  54. data/lib/ruflet_ui/ruflet/ui/controls/materials/container_control.rb +9 -65
  55. data/lib/ruflet_ui/ruflet/ui/controls/materials/contextmenu_control.rb +50 -4
  56. data/lib/ruflet_ui/ruflet/ui/controls/materials/datacell_control.rb +1 -3
  57. data/lib/ruflet_ui/ruflet/ui/controls/materials/datacolumn_control.rb +1 -3
  58. data/lib/ruflet_ui/ruflet/ui/controls/materials/datatable_control.rb +7 -88
  59. data/lib/ruflet_ui/ruflet/ui/controls/materials/datepicker_control.rb +9 -55
  60. data/lib/ruflet_ui/ruflet/ui/controls/materials/daterangepicker_control.rb +8 -58
  61. data/lib/ruflet_ui/ruflet/ui/controls/materials/divider_control.rb +0 -9
  62. data/lib/ruflet_ui/ruflet/ui/controls/materials/dropdown_control.rb +8 -93
  63. data/lib/ruflet_ui/ruflet/ui/controls/materials/dropdownm2_control.rb +9 -103
  64. data/lib/ruflet_ui/ruflet/ui/controls/materials/dropdownoption_control.rb +0 -2
  65. data/lib/ruflet_ui/ruflet/ui/controls/materials/expansionpanel_control.rb +7 -51
  66. data/lib/ruflet_ui/ruflet/ui/controls/materials/expansionpanellist_control.rb +46 -5
  67. data/lib/ruflet_ui/ruflet/ui/controls/materials/expansiontile_control.rb +69 -3
  68. data/lib/ruflet_ui/ruflet/ui/controls/materials/filledbutton_control.rb +54 -2
  69. data/lib/ruflet_ui/ruflet/ui/controls/materials/fillediconbutton_control.rb +67 -5
  70. data/lib/ruflet_ui/ruflet/ui/controls/materials/filledtonalbutton_control.rb +54 -2
  71. data/lib/ruflet_ui/ruflet/ui/controls/materials/filledtonaliconbutton_control.rb +67 -5
  72. data/lib/ruflet_ui/ruflet/ui/controls/materials/floatingactionbutton_control.rb +7 -69
  73. data/lib/ruflet_ui/ruflet/ui/controls/materials/iconbutton_control.rb +8 -68
  74. data/lib/ruflet_ui/ruflet/ui/controls/materials/listtile_control.rb +8 -74
  75. data/lib/ruflet_ui/ruflet/ui/controls/materials/map_control.rb +99 -0
  76. data/lib/ruflet_ui/ruflet/ui/controls/materials/menuitembutton_control.rb +8 -56
  77. data/lib/ruflet_ui/ruflet/ui/controls/materials/navigationbar_control.rb +7 -59
  78. data/lib/ruflet_ui/ruflet/ui/controls/materials/navigationdrawer_control.rb +0 -7
  79. data/lib/ruflet_ui/ruflet/ui/controls/materials/navigationrail_control.rb +7 -73
  80. data/lib/ruflet_ui/ruflet/ui/controls/materials/outlinedbutton_control.rb +51 -2
  81. data/lib/ruflet_ui/ruflet/ui/controls/materials/outlinediconbutton_control.rb +67 -5
  82. data/lib/ruflet_ui/ruflet/ui/controls/materials/popupmenubutton_control.rb +8 -61
  83. data/lib/ruflet_ui/ruflet/ui/controls/materials/popupmenuitem_control.rb +0 -6
  84. data/lib/ruflet_ui/ruflet/ui/controls/materials/progressbar_control.rb +8 -56
  85. data/lib/ruflet_ui/ruflet/ui/controls/materials/progressring_control.rb +8 -56
  86. data/lib/ruflet_ui/ruflet/ui/controls/materials/radio_control.rb +9 -54
  87. data/lib/ruflet_ui/ruflet/ui/controls/materials/rangeslider_control.rb +7 -62
  88. data/lib/ruflet_ui/ruflet/ui/controls/materials/reorderablelistview_control.rb +9 -64
  89. data/lib/ruflet_ui/ruflet/ui/controls/materials/ruflet_controls.rb +5 -26
  90. data/lib/ruflet_ui/ruflet/ui/controls/materials/searchbar_control.rb +80 -11
  91. data/lib/ruflet_ui/ruflet/ui/controls/materials/segment_control.rb +0 -3
  92. data/lib/ruflet_ui/ruflet/ui/controls/materials/segmentedbutton_control.rb +7 -64
  93. data/lib/ruflet_ui/ruflet/ui/controls/materials/selectionarea_control.rb +1 -3
  94. data/lib/ruflet_ui/ruflet/ui/controls/materials/slider_control.rb +9 -60
  95. data/lib/ruflet_ui/ruflet/ui/controls/materials/snackbar_control.rb +37 -4
  96. data/lib/ruflet_ui/ruflet/ui/controls/materials/snackbaraction_control.rb +38 -0
  97. data/lib/ruflet_ui/ruflet/ui/controls/materials/spinkit_control.rb +25 -0
  98. data/lib/ruflet_ui/ruflet/ui/controls/materials/submenubutton_control.rb +52 -7
  99. data/lib/ruflet_ui/ruflet/ui/controls/materials/switch_control.rb +9 -61
  100. data/lib/ruflet_ui/ruflet/ui/controls/materials/tab_control.rb +0 -7
  101. data/lib/ruflet_ui/ruflet/ui/controls/materials/tabbar_control.rb +8 -64
  102. data/lib/ruflet_ui/ruflet/ui/controls/materials/tabbarview_control.rb +43 -5
  103. data/lib/ruflet_ui/ruflet/ui/controls/materials/tabs_control.rb +7 -47
  104. data/lib/ruflet_ui/ruflet/ui/controls/materials/textbutton_control.rb +51 -2
  105. data/lib/ruflet_ui/ruflet/ui/controls/materials/textfield_control.rb +9 -132
  106. data/lib/ruflet_ui/ruflet/ui/controls/materials/timepicker_control.rb +10 -34
  107. data/lib/ruflet_ui/ruflet/ui/controls/materials/verticaldivider_control.rb +0 -9
  108. data/lib/ruflet_ui/ruflet/ui/controls/materials/video_control.rb +9 -42
  109. data/lib/ruflet_ui/ruflet/ui/controls/materials/webview_control.rb +1 -114
  110. data/lib/ruflet_ui/ruflet/ui/controls/ruflet_controls.rb +13 -28
  111. data/lib/ruflet_ui/ruflet/ui/controls/shared/animatedswitcher_control.rb +46 -4
  112. data/lib/ruflet_ui/ruflet/ui/controls/shared/arc_control.rb +1 -0
  113. data/lib/ruflet_ui/ruflet/ui/controls/shared/browsercontextmenu_control.rb +1 -12
  114. data/lib/ruflet_ui/ruflet/ui/controls/shared/canvas_control.rb +8 -54
  115. data/lib/ruflet_ui/ruflet/ui/controls/shared/circle_control.rb +1 -0
  116. data/lib/ruflet_ui/ruflet/ui/controls/shared/column_control.rb +9 -51
  117. data/lib/ruflet_ui/ruflet/ui/controls/shared/dismissible_control.rb +52 -12
  118. data/lib/ruflet_ui/ruflet/ui/controls/shared/draggable_control.rb +1 -7
  119. data/lib/ruflet_ui/ruflet/ui/controls/shared/dragtarget_control.rb +1 -3
  120. data/lib/ruflet_ui/ruflet/ui/controls/shared/fletapp_control.rb +9 -46
  121. data/lib/ruflet_ui/ruflet/ui/controls/shared/gesturedetector_control.rb +9 -113
  122. data/lib/ruflet_ui/ruflet/ui/controls/shared/gridview_control.rb +57 -13
  123. data/lib/ruflet_ui/ruflet/ui/controls/shared/hero_control.rb +9 -40
  124. data/lib/ruflet_ui/ruflet/ui/controls/shared/icon_control.rb +9 -48
  125. data/lib/ruflet_ui/ruflet/ui/controls/shared/interactiveviewer_control.rb +9 -53
  126. data/lib/ruflet_ui/ruflet/ui/controls/shared/line_control.rb +1 -0
  127. data/lib/ruflet_ui/ruflet/ui/controls/shared/listview_control.rb +57 -11
  128. data/lib/ruflet_ui/ruflet/ui/controls/shared/markdown_control.rb +9 -54
  129. data/lib/ruflet_ui/ruflet/ui/controls/shared/oval_control.rb +1 -0
  130. data/lib/ruflet_ui/ruflet/ui/controls/shared/page_control.rb +9 -58
  131. data/lib/ruflet_ui/ruflet/ui/controls/shared/pagelet_control.rb +9 -48
  132. data/lib/ruflet_ui/ruflet/ui/controls/shared/pageview_control.rb +50 -4
  133. data/lib/ruflet_ui/ruflet/ui/controls/shared/path_control.rb +1 -0
  134. data/lib/ruflet_ui/ruflet/ui/controls/shared/placeholder_control.rb +44 -9
  135. data/lib/ruflet_ui/ruflet/ui/controls/shared/points_control.rb +1 -0
  136. data/lib/ruflet_ui/ruflet/ui/controls/shared/rect_control.rb +1 -0
  137. data/lib/ruflet_ui/ruflet/ui/controls/shared/reorderabledraghandle_control.rb +43 -4
  138. data/lib/ruflet_ui/ruflet/ui/controls/shared/responsiverow_control.rb +47 -15
  139. data/lib/ruflet_ui/ruflet/ui/controls/shared/rotatedbox_control.rb +97 -0
  140. data/lib/ruflet_ui/ruflet/ui/controls/shared/row_control.rb +9 -51
  141. data/lib/ruflet_ui/ruflet/ui/controls/shared/ruflet_controls.rb +5 -0
  142. data/lib/ruflet_ui/ruflet/ui/controls/shared/safearea_control.rb +48 -4
  143. data/lib/ruflet_ui/ruflet/ui/{services/ruflet → controls/shared}/screenshot_control.rb +18 -2
  144. data/lib/ruflet_ui/ruflet/ui/controls/shared/semantics_control.rb +65 -1
  145. data/lib/ruflet_ui/ruflet/ui/controls/shared/shadermask_control.rb +9 -41
  146. data/lib/ruflet_ui/ruflet/ui/controls/shared/shimmer_control.rb +9 -44
  147. data/lib/ruflet_ui/ruflet/ui/controls/shared/stack_control.rb +44 -1
  148. data/lib/ruflet_ui/ruflet/ui/controls/shared/text_control.rb +9 -35
  149. data/lib/ruflet_ui/ruflet/ui/controls/shared/transparentpointer_control.rb +9 -38
  150. data/lib/ruflet_ui/ruflet/ui/controls/shared/view_control.rb +63 -1
  151. data/lib/ruflet_ui/ruflet/ui/controls/shared/window_control.rb +40 -66
  152. data/lib/ruflet_ui/ruflet/ui/controls/shared/windowdragarea_control.rb +45 -4
  153. data/lib/ruflet_ui/ruflet/ui/material_control_methods.rb +61 -17
  154. data/lib/ruflet_ui/ruflet/ui/material_control_registry.rb +22 -0
  155. data/lib/ruflet_ui/ruflet/ui/services/ruflet/accelerometer_control.rb +1 -0
  156. data/lib/ruflet_ui/ruflet/ui/services/ruflet/audio_recorder_control.rb +14 -36
  157. data/lib/ruflet_ui/ruflet/ui/services/ruflet/barometer_control.rb +1 -0
  158. data/lib/ruflet_ui/ruflet/ui/services/ruflet/battery_control.rb +1 -0
  159. data/lib/ruflet_ui/ruflet/ui/services/ruflet/camera_control.rb +9 -42
  160. data/lib/ruflet_ui/ruflet/ui/services/ruflet/clipboard_control.rb +31 -0
  161. data/lib/ruflet_ui/ruflet/ui/services/ruflet/connectivity_control.rb +1 -0
  162. data/lib/ruflet_ui/ruflet/ui/services/ruflet/filepicker_control.rb +1 -0
  163. data/lib/ruflet_ui/ruflet/ui/services/ruflet/flashlight_control.rb +1 -0
  164. data/lib/ruflet_ui/ruflet/ui/services/ruflet/geolocator_control.rb +14 -30
  165. data/lib/ruflet_ui/ruflet/ui/services/ruflet/gyroscope_control.rb +1 -0
  166. data/lib/ruflet_ui/ruflet/ui/services/ruflet/hapticfeedback_control.rb +1 -0
  167. data/lib/ruflet_ui/ruflet/ui/services/ruflet/magnetometer_control.rb +1 -0
  168. data/lib/ruflet_ui/ruflet/ui/services/ruflet/permissionhandler_control.rb +5 -20
  169. data/lib/ruflet_ui/ruflet/ui/services/ruflet/screenbrightness_control.rb +1 -0
  170. data/lib/ruflet_ui/ruflet/ui/services/ruflet/securestorage_control.rb +22 -49
  171. data/lib/ruflet_ui/ruflet/ui/services/ruflet/semanticsservice_control.rb +20 -15
  172. data/lib/ruflet_ui/ruflet/ui/services/ruflet/shakedetector_control.rb +1 -0
  173. data/lib/ruflet_ui/ruflet/ui/services/ruflet/share_control.rb +1 -0
  174. data/lib/ruflet_ui/ruflet/ui/services/ruflet/sharedpreferences_control.rb +1 -0
  175. data/lib/ruflet_ui/ruflet/ui/services/ruflet/storagepaths_control.rb +1 -0
  176. data/lib/ruflet_ui/ruflet/ui/services/ruflet/urllauncher_control.rb +1 -0
  177. data/lib/ruflet_ui/ruflet/ui/services/ruflet/useraccelerometer_control.rb +1 -0
  178. data/lib/ruflet_ui/ruflet/ui/services/ruflet/wakelock_control.rb +1 -0
  179. data/lib/ruflet_ui/ruflet/ui/services/ruflet_services.rb +0 -7
  180. data/lib/ruflet_ui/ruflet/ui/shared_control_forwarders.rb +20 -1
  181. data/lib/ruflet_ui.rb +12 -10
  182. metadata +8 -9
  183. data/lib/ruflet_ui/ruflet/types/animation.rb +0 -110
  184. data/lib/ruflet_ui/ruflet/ui/controls/materials/codeeditor_control.rb +0 -93
  185. data/lib/ruflet_ui/ruflet/ui/controls/materials/map_controls.rb +0 -311
  186. data/lib/ruflet_ui/ruflet/ui/controls/materials/rive_control.rb +0 -87
  187. data/lib/ruflet_ui/ruflet/ui/controls/materials/spinkit_controls.rb +0 -109
  188. data/lib/ruflet_ui/ruflet/ui/services/ruflet/tester_control.rb +0 -126
@@ -9,11 +9,21 @@ require_relative "icons/material_icon_lookup"
9
9
  require_relative "icons/cupertino_icon_lookup"
10
10
  require "set"
11
11
  require "cgi"
12
+ require "uri"
12
13
  require "thread"
13
14
  require "timeout"
14
15
 
15
16
  module Ruflet
16
17
  class Page
18
+ # Older and embedded clients may omit this capability from registration.
19
+ # Ruflet applications should still be able to branch on `page.web` just as
20
+ # they do with a full desktop client.
21
+ def web
22
+ !!(@client_details && @client_details["web"])
23
+ end
24
+
25
+ alias web? web
26
+
17
27
  class SharedPreferencesService
18
28
  def initialize(page)
19
29
  @page = page
@@ -139,15 +149,16 @@ module Ruflet
139
149
  end
140
150
  end
141
151
 
142
- PAGE_PROP_KEYS = %w[dark_theme fonts route rtl show_semantics_debugger theme theme_mode title vertical_alignment horizontal_alignment scroll].freeze
152
+ PAGE_PROP_KEYS = %w[dark_theme fonts route rtl show_semantics_debugger theme theme_mode theme_animation_style title vertical_alignment horizontal_alignment scroll].freeze
143
153
  DIALOG_PROP_KEYS = %w[dialog snack_bar bottom_sheet].freeze
154
+ PAGE_ADD_RESERVED_KEYS = %i[appbar bottom_appbar floating_action_button navigation_bar dialog snack_bar bottom_sheet].freeze
144
155
  WIDGET_HELPER_METHODS = (
145
156
  Ruflet::UI::MaterialControlMethods.instance_methods(false) +
146
157
  Ruflet::UI::CupertinoControlMethods.instance_methods(false) +
147
158
  %i[control widget]
148
- ).map(&:to_s).to_set.freeze
159
+ ).map(&:to_s).uniq.freeze
149
160
 
150
- attr_reader :session_id, :client_details, :views
161
+ attr_reader :session_id, :client_details, :views, :window
151
162
 
152
163
  def initialize(session_id:, client_details:, sender:)
153
164
  @session_id = session_id
@@ -167,6 +178,8 @@ module Ruflet
167
178
  @page_event_handlers = {}
168
179
  @view_props = {}
169
180
  @page_props = { "route" => (client_details["route"] || "/") }
181
+ @window = build_client_window(client_details["window"])
182
+ @page_props["window"] = @window
170
183
  @overlay_container = Ruflet::Control.new(
171
184
  type: "overlay",
172
185
  id: "_overlay",
@@ -244,64 +257,64 @@ module Ruflet
244
257
  @view_props["bgcolor"] = normalize_value("bgcolor", value)
245
258
  end
246
259
 
247
- # Client-reported page properties. The Flutter client sends these in its
248
- # register payload (see Protocol.normalize_register_payload), where they are
249
- # stored in @client_details; expose them as readers so apps can do
250
- # `page.width`, `page.platform`, etc. without reaching into client_details.
251
- def width
252
- client_reported_prop("width")
253
- end
260
+ def add(control)
261
+ if control.is_a?(Hash) && (control.keys.map(&:to_sym) & PAGE_ADD_RESERVED_KEYS).any?
262
+ raise ArgumentError, "Page#add accepts only controls; assign page.appbar, page.floating_action_button, dialogs, or other page properties before calling add"
263
+ end
254
264
 
255
- def height
256
- client_reported_prop("height")
257
- end
265
+ raise ArgumentError, "Page#add accepts exactly one control" unless control.is_a?(Control)
258
266
 
259
- def platform
260
- client_reported_prop("platform")
267
+ replace_root_controls(@root_controls + [control])
268
+ self
261
269
  end
262
270
 
263
- def platform_brightness
264
- client_reported_prop("platform_brightness")
271
+ def controls
272
+ @root_controls
265
273
  end
266
274
 
267
- def web
268
- client_reported_prop("web")
275
+ def controls=(value)
276
+ replace_root_controls(Array(value).flatten.compact)
277
+ self
269
278
  end
270
279
 
271
- def pwa
272
- client_reported_prop("pwa")
280
+ def insert(at, *controls)
281
+ @root_controls.insert(at.to_i, *controls.flatten.compact)
282
+ send_view_patch
283
+ self
273
284
  end
274
285
 
275
- def wasm
276
- client_reported_prop("wasm")
286
+ def remove(*controls)
287
+ controls.flatten.each { |control| @root_controls.delete(control) }
288
+ send_view_patch
289
+ self
277
290
  end
278
291
 
279
- def media
280
- client_reported_prop("media")
292
+ def remove_at(index)
293
+ @root_controls.delete_at(index.to_i)
294
+ send_view_patch
295
+ self
281
296
  end
282
297
 
283
- def add(*controls, appbar: nil, bottom_appbar: nil, floating_action_button: nil, navigation_bar: nil, dialog: nil, snack_bar: nil, bottom_sheet: nil)
284
- controls = controls.flatten
285
- visited = Set.new
286
- controls.each { |c| register_control_tree(c, visited) }
287
- @root_controls = controls
288
-
289
- update_view_slot("appbar", appbar)
290
- update_view_slot("bottom_appbar", bottom_appbar)
291
- update_view_slot("floating_action_button", floating_action_button)
292
- update_view_slot("navigation_bar", navigation_bar)
293
- @dialog = dialog if dialog
294
- @snack_bar = snack_bar if snack_bar
295
- @bottom_sheet = bottom_sheet if bottom_sheet
296
-
297
- refresh_dialogs_container!
298
- @view_props.each_value { |value| register_embedded_value(value, visited) }
298
+ def clean
299
+ replace_root_controls([])
300
+ self
301
+ end
299
302
 
300
- send_view_patch
303
+ def overlay
304
+ @overlay_container.children
305
+ end
301
306
 
307
+ def overlay=(value)
308
+ @overlay_container.children.replace(Array(value).flatten.compact)
309
+ push_overlay_update!
302
310
  self
303
311
  end
304
312
 
313
+ def get_control(id)
314
+ refresh_control_indexes!
315
+ resolve_control(id)
316
+ end
317
+
305
318
  def views=(value)
306
319
  @views = Array(value).compact
307
320
  self
@@ -350,18 +363,6 @@ module Ruflet
350
363
  service(:audio_recorder, **props)
351
364
  end
352
365
 
353
- def browser_context_menu(**props)
354
- service(:browser_context_menu, **props)
355
- end
356
-
357
- def window(**props)
358
- service(:window, **props)
359
- end
360
-
361
- def tester(**props)
362
- service(:tester, **props)
363
- end
364
-
365
366
  def add_service(*value)
366
367
  @services_container.props["_services"] = services + value.flatten.compact
367
368
  refresh_services_container!
@@ -406,6 +407,10 @@ module Ruflet
406
407
  return svc
407
408
  end
408
409
 
410
+ unless service_control_type?(normalized_type)
411
+ raise ArgumentError, "#{type} is a visual control, not a page service"
412
+ end
413
+
409
414
  existing =
410
415
  if id
411
416
  services.find { |s| s.is_a?(Control) && s.id.to_s == id.to_s }
@@ -414,7 +419,14 @@ module Ruflet
414
419
  s.is_a?(Control) && s.type.to_s.downcase.delete("_") == compact_type
415
420
  end
416
421
  end
417
- return existing if existing
422
+ if existing
423
+ unless mapped_props.empty?
424
+ existing.merge_props(mapped_props)
425
+ refresh_services_container!
426
+ push_services_update!
427
+ end
428
+ return existing
429
+ end
418
430
 
419
431
  svc = Ruflet::UI::ControlFactory.build(type.to_s, id: id&.to_s, **mapped_props)
420
432
  add_service(svc) unless services.include?(svc)
@@ -448,25 +460,35 @@ module Ruflet
448
460
  @page_event_handlers["view_pop"] = handler
449
461
  end
450
462
 
451
- def on_resize=(handler)
452
- @page_event_handlers["resize"] = handler
463
+ def on_views_pop_until=(handler)
464
+ @page_event_handlers["views_pop_until"] = handler
453
465
  end
454
466
 
455
467
  def on(event_name, &block)
456
- @page_event_handlers[event_name.to_s.sub(/\Aon_/, "")] = block
468
+ name = event_name.to_s
469
+ name = name[3..-1] if name.start_with?("on_")
470
+ @page_event_handlers[name] = block
457
471
  self
458
472
  end
459
473
 
460
474
  def mount(&block)
461
475
  builder = WidgetBuilder.new
462
476
  builder.instance_eval(&block)
463
- add(*builder.children)
477
+ builder.children.each { |control| add(control) }
478
+ end
479
+
480
+ def appbar
481
+ @view_props["appbar"]
464
482
  end
465
483
 
466
484
  def appbar=(value)
467
485
  @view_props["appbar"] = value
468
486
  end
469
487
 
488
+ def bottom_appbar
489
+ @view_props["bottom_appbar"]
490
+ end
491
+
470
492
  def bottom_appbar=(value)
471
493
  @view_props["bottom_appbar"] = value
472
494
  end
@@ -475,48 +497,92 @@ module Ruflet
475
497
  self.bottom_appbar = value
476
498
  end
477
499
 
500
+ def floating_action_button
501
+ @view_props["floating_action_button"]
502
+ end
503
+
478
504
  def floating_action_button=(value)
479
505
  @view_props["floating_action_button"] = value
480
506
  end
481
507
 
482
- def drawer
483
- @view_props["drawer"]
508
+ def navigation_bar
509
+ @view_props["navigation_bar"]
484
510
  end
485
511
 
486
- def drawer=(value)
487
- @view_props["drawer"] = value
512
+ def navigation_bar=(value)
513
+ @view_props["navigation_bar"] = value
488
514
  end
489
515
 
490
- def end_drawer
491
- @view_props["end_drawer"]
516
+ def auto_scroll
517
+ @view_props["auto_scroll"]
492
518
  end
493
519
 
494
- def end_drawer=(value)
495
- @view_props["end_drawer"] = value
520
+ def auto_scroll=(value)
521
+ @view_props["auto_scroll"] = value
496
522
  end
497
523
 
498
- def show_drawer(timeout: 10, on_result: nil)
499
- raise ArgumentError, "show_drawer requires drawer" unless drawer
524
+ def browser_context_menu
525
+ @view_props["browser_context_menu"]
526
+ end
500
527
 
501
- invoke_current_view("show_drawer", timeout: timeout, on_result: on_result)
502
- self
528
+ def browser_context_menu=(value)
529
+ @view_props["browser_context_menu"] = value
503
530
  end
504
531
 
505
- def close_drawer(timeout: 10, on_result: nil)
506
- invoke_current_view("close_drawer", timeout: timeout, on_result: on_result)
507
- self
532
+ def decoration
533
+ @view_props["decoration"]
508
534
  end
509
535
 
510
- def show_end_drawer(timeout: 10, on_result: nil)
511
- raise ArgumentError, "show_end_drawer requires end_drawer" unless end_drawer
536
+ def decoration=(value)
537
+ @view_props["decoration"] = value
538
+ end
512
539
 
513
- invoke_current_view("show_end_drawer", timeout: timeout, on_result: on_result)
514
- self
540
+ def floating_action_button_location
541
+ @view_props["floating_action_button_location"]
515
542
  end
516
543
 
517
- def close_end_drawer(timeout: 10, on_result: nil)
518
- invoke_current_view("close_end_drawer", timeout: timeout, on_result: on_result)
519
- self
544
+ def floating_action_button_location=(value)
545
+ @view_props["floating_action_button_location"] = value
546
+ end
547
+
548
+ def foreground_decoration
549
+ @view_props["foreground_decoration"]
550
+ end
551
+
552
+ def foreground_decoration=(value)
553
+ @view_props["foreground_decoration"] = value
554
+ end
555
+
556
+ def padding
557
+ @view_props["padding"]
558
+ end
559
+
560
+ def padding=(value)
561
+ @view_props["padding"] = value
562
+ end
563
+
564
+ def spacing
565
+ @view_props["spacing"]
566
+ end
567
+
568
+ def spacing=(value)
569
+ @view_props["spacing"] = value
570
+ end
571
+
572
+ def drawer
573
+ @view_props["drawer"]
574
+ end
575
+
576
+ def drawer=(value)
577
+ @view_props["drawer"] = value
578
+ end
579
+
580
+ def end_drawer
581
+ @view_props["end_drawer"]
582
+ end
583
+
584
+ def end_drawer=(value)
585
+ @view_props["end_drawer"] = value
520
586
  end
521
587
 
522
588
  def dialog = @dialog
@@ -524,13 +590,15 @@ module Ruflet
524
590
  def dialog=(value)
525
591
  @dialog = value
526
592
  refresh_dialogs_container!
527
- push_dialogs_update! if @dialogs_container_mounted
528
593
  end
529
594
 
530
595
  def snack_bar=(value)
531
596
  @snack_bar = value
532
597
  refresh_dialogs_container!
533
- push_dialogs_update! if @dialogs_container_mounted
598
+ end
599
+
600
+ def snack_bar
601
+ @snack_bar
534
602
  end
535
603
 
536
604
  def snackbar=(value)
@@ -542,6 +610,10 @@ module Ruflet
542
610
  refresh_dialogs_container!
543
611
  end
544
612
 
613
+ def bottom_sheet
614
+ @bottom_sheet
615
+ end
616
+
545
617
  def bottomsheet=(value)
546
618
  self.bottom_sheet = value
547
619
  end
@@ -552,7 +624,6 @@ module Ruflet
552
624
  return self if dialog_open?(dialog_control)
553
625
 
554
626
  dialog_control.props["open"] = true
555
- remove_existing_singleton_dialogs(dialog_control)
556
627
  @dialogs << dialog_control unless @dialogs.include?(dialog_control)
557
628
  refresh_dialogs_container!
558
629
  send_view_patch unless @dialogs_container.wire_id
@@ -560,6 +631,72 @@ module Ruflet
560
631
  self
561
632
  end
562
633
 
634
+ def show_snack_bar(snack_bar_control)
635
+ show_dialog(snack_bar_control)
636
+ end
637
+
638
+ def show_snackbar(snack_bar_control)
639
+ show_dialog(snack_bar_control)
640
+ end
641
+
642
+ def show_bottom_sheet(bottom_sheet_control)
643
+ show_dialog(bottom_sheet_control)
644
+ end
645
+
646
+ def show_banner(banner_control)
647
+ show_dialog(banner_control)
648
+ end
649
+
650
+ def close_banner(banner_control = nil)
651
+ close_dialog(banner_control)
652
+ end
653
+
654
+ def close_dialog(dialog_control = nil)
655
+ target = dialog_control || latest_open_dialog
656
+ return nil unless target
657
+
658
+ target.props["open"] = false
659
+ refresh_dialogs_container!
660
+ push_dialogs_update!
661
+ target
662
+ end
663
+
664
+ def show_drawer(timeout: 10, on_result: nil)
665
+ raise ArgumentError, "No drawer defined" unless drawer
666
+
667
+ invoke(:page, "show_drawer", timeout: timeout, on_result: on_result)
668
+ end
669
+
670
+ def close_drawer(timeout: 10, on_result: nil)
671
+ invoke(:page, "close_drawer", timeout: timeout, on_result: on_result)
672
+ end
673
+
674
+ def show_end_drawer(timeout: 10, on_result: nil)
675
+ raise ArgumentError, "No end_drawer defined" unless end_drawer
676
+
677
+ invoke(:page, "show_end_drawer", timeout: timeout, on_result: on_result)
678
+ end
679
+
680
+ def close_end_drawer(timeout: 10, on_result: nil)
681
+ invoke(:page, "close_end_drawer", timeout: timeout, on_result: on_result)
682
+ end
683
+
684
+ def scroll_to(offset: nil, delta: nil, scroll_key: nil, duration: nil, curve: nil, timeout: 10, on_result: nil)
685
+ invoke(
686
+ :page,
687
+ "scroll_to",
688
+ args: {
689
+ "offset" => offset,
690
+ "delta" => delta,
691
+ "scroll_key" => scroll_key,
692
+ "duration" => duration,
693
+ "curve" => curve
694
+ },
695
+ timeout: timeout,
696
+ on_result: on_result
697
+ )
698
+ end
699
+
563
700
  def invoke(control_or_id, method_name, args: nil, timeout: 10, on_result: nil)
564
701
  control_id =
565
702
  if page_control_target?(control_or_id)
@@ -573,7 +710,7 @@ module Ruflet
573
710
  call_id = "call_#{Ruflet::Control.generate_id}"
574
711
  if on_result.respond_to?(:call)
575
712
  @invoke_waiters_mutex.synchronize { @invoke_callbacks[call_id] = on_result }
576
- if embedded_async_timeout_available? && !timeout.nil?
713
+ unless timeout.nil?
577
714
  Thread.new(call_id, timeout.to_f) do |pending_call_id, invoke_timeout|
578
715
  sleep([invoke_timeout, 0.0].max + 0.1)
579
716
  callback = @invoke_waiters_mutex.synchronize { @invoke_callbacks.delete(pending_call_id) }
@@ -722,167 +859,6 @@ module Ruflet
722
859
  upload(files, timeout: timeout, on_result: on_result)
723
860
  end
724
861
 
725
- def disable_browser_context_menu(timeout: 10, on_result: nil)
726
- invoke_browser_context_menu("disable_menu", timeout: timeout, on_result: on_result)
727
- end
728
-
729
- def enable_browser_context_menu(timeout: 10, on_result: nil)
730
- invoke_browser_context_menu("enable_menu", timeout: timeout, on_result: on_result)
731
- end
732
-
733
- def wait_until_ready_to_show(timeout: 10, on_result: nil)
734
- invoke_window("wait_until_ready_to_show", timeout: timeout, on_result: on_result)
735
- end
736
-
737
- def window_to_front(timeout: 10, on_result: nil)
738
- invoke_window("to_front", timeout: timeout, on_result: on_result)
739
- end
740
-
741
- def center_window(timeout: 10, on_result: nil)
742
- invoke_window("center", timeout: timeout, on_result: on_result)
743
- end
744
-
745
- def close_window(timeout: 10, on_result: nil)
746
- invoke_window("close", timeout: timeout, on_result: on_result)
747
- end
748
-
749
- def destroy_window(timeout: 10, on_result: nil)
750
- invoke_window("destroy", timeout: timeout, on_result: on_result)
751
- end
752
-
753
- def start_window_dragging(timeout: 10, on_result: nil)
754
- invoke_window("start_dragging", timeout: timeout, on_result: on_result)
755
- end
756
-
757
- def start_window_resizing(edge, timeout: 10, on_result: nil)
758
- invoke_window(
759
- "start_resizing",
760
- args: { "edge" => normalize_service_value(edge) },
761
- timeout: timeout,
762
- on_result: on_result
763
- )
764
- end
765
-
766
- def tester_pump(options = nil, duration: nil, timeout: 10, on_result: nil)
767
- duration = options[:duration] || options["duration"] if options.is_a?(Hash) && duration.nil?
768
- invoke_tester("pump", args: compact_service_args("duration" => duration), timeout: timeout, on_result: on_result)
769
- end
770
-
771
- def tester_pump_and_settle(options = nil, duration: nil, timeout: 10, on_result: nil)
772
- duration = options[:duration] || options["duration"] if options.is_a?(Hash) && duration.nil?
773
- invoke_tester("pump_and_settle", args: compact_service_args("duration" => duration), timeout: timeout, on_result: on_result)
774
- end
775
-
776
- def find_by_text(text, timeout: 10, on_result: nil)
777
- invoke_tester("find_by_text", args: { "text" => text }, timeout: timeout, on_result: on_result)
778
- end
779
-
780
- def find_by_text_containing(pattern, timeout: 10, on_result: nil)
781
- invoke_tester("find_by_text_containing", args: { "pattern" => pattern }, timeout: timeout, on_result: on_result)
782
- end
783
-
784
- def find_by_key(key, timeout: 10, on_result: nil)
785
- invoke_tester("find_by_key", args: { "key" => key }, timeout: timeout, on_result: on_result)
786
- end
787
-
788
- def find_by_tooltip(value, timeout: 10, on_result: nil)
789
- invoke_tester("find_by_tooltip", args: { "value" => value }, timeout: timeout, on_result: on_result)
790
- end
791
-
792
- def find_by_icon(icon, timeout: 10, on_result: nil)
793
- invoke_tester("find_by_icon", args: { "icon" => normalize_service_value(icon) }, timeout: timeout, on_result: on_result)
794
- end
795
-
796
- def take_screenshot(name, timeout: 10, on_result: nil)
797
- invoke_tester("take_screenshot", args: { "name" => name }, timeout: timeout, on_result: on_result)
798
- end
799
-
800
- def tap(finder_id = nil, options = nil, finder_index: nil, timeout: 10, on_result: nil)
801
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
802
- invoke_tester_finder("tap", finder_id, finder_index: finder_index, timeout: timeout, on_result: on_result)
803
- end
804
-
805
- def mouse_click(finder_id = nil, options = nil, finder_index: nil, timeout: 10, on_result: nil)
806
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
807
- invoke_tester_finder("mouse_click", finder_id, finder_index: finder_index, timeout: timeout, on_result: on_result)
808
- end
809
-
810
- def mouse_double_click(finder_id = nil, options = nil, finder_index: nil, timeout: 10, on_result: nil)
811
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
812
- invoke_tester_finder("mouse_double_click", finder_id, finder_index: finder_index, timeout: timeout, on_result: on_result)
813
- end
814
-
815
- def right_mouse_click(finder_id = nil, options = nil, finder_index: nil, timeout: 10, on_result: nil)
816
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
817
- invoke_tester_finder("right_mouse_click", finder_id, finder_index: finder_index, timeout: timeout, on_result: on_result)
818
- end
819
-
820
- def tap_at(offset = nil, timeout: 10, on_result: nil)
821
- invoke_tester_at("tap_at", offset, timeout: timeout, on_result: on_result)
822
- end
823
-
824
- def mouse_click_at(offset = nil, timeout: 10, on_result: nil)
825
- invoke_tester_at("mouse_click_at", offset, timeout: timeout, on_result: on_result)
826
- end
827
-
828
- def mouse_double_click_at(offset = nil, timeout: 10, on_result: nil)
829
- invoke_tester_at("mouse_double_click_at", offset, timeout: timeout, on_result: on_result)
830
- end
831
-
832
- def right_mouse_click_at(offset = nil, timeout: 10, on_result: nil)
833
- invoke_tester_at("right_mouse_click_at", offset, timeout: timeout, on_result: on_result)
834
- end
835
-
836
- def drag(finder_id, offset, finder_index: nil, timeout: 10, on_result: nil)
837
- invoke_tester(
838
- "drag",
839
- args: compact_service_args(
840
- "finder_id" => finder_id,
841
- "finder_index" => finder_index,
842
- "offset" => offset
843
- ),
844
- timeout: timeout,
845
- on_result: on_result
846
- )
847
- end
848
-
849
- def drag_from(start, offset, timeout: 10, on_result: nil)
850
- invoke_tester(
851
- "drag_from",
852
- args: compact_service_args("start" => start, "offset" => offset),
853
- timeout: timeout,
854
- on_result: on_result
855
- )
856
- end
857
-
858
- def long_press(finder_id = nil, options = nil, finder_index: nil, timeout: 10, on_result: nil)
859
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
860
- invoke_tester_finder("long_press", finder_id, finder_index: finder_index, timeout: timeout, on_result: on_result)
861
- end
862
-
863
- def enter_text(finder_id, text, options = nil, finder_index: nil, timeout: 10, on_result: nil)
864
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
865
- invoke_tester(
866
- "enter_text",
867
- args: compact_service_args(
868
- "finder_id" => finder_id,
869
- "finder_index" => finder_index,
870
- "text" => text
871
- ),
872
- timeout: timeout,
873
- on_result: on_result
874
- )
875
- end
876
-
877
- def mouse_hover(finder_id = nil, options = nil, finder_index: nil, timeout: 10, on_result: nil)
878
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
879
- invoke_tester_finder("mouse_hover", finder_id, finder_index: finder_index, timeout: timeout, on_result: on_result)
880
- end
881
-
882
- def tester_teardown(timeout: 10, on_result: nil)
883
- invoke_tester("teardown", timeout: timeout, on_result: on_result)
884
- end
885
-
886
862
  def heavy_impact(timeout: 10, on_result: nil)
887
863
  invoke_haptic_feedback("heavy_impact", timeout: timeout, on_result: on_result)
888
864
  end
@@ -982,6 +958,18 @@ module Ruflet
982
958
  service(:barometer, **props)
983
959
  end
984
960
 
961
+ def geolocator(**props)
962
+ service(:geolocator, **props)
963
+ end
964
+
965
+ def permission_handler(**props)
966
+ service(:permission_handler, **props)
967
+ end
968
+
969
+ def secure_storage(**props)
970
+ service(:secure_storage, **props)
971
+ end
972
+
985
973
  def shake_detector(**props)
986
974
  service(:shake_detector, **props)
987
975
  end
@@ -991,7 +979,7 @@ module Ruflet
991
979
  end
992
980
 
993
981
  def screenshot(**props)
994
- service(:screenshot, **props)
982
+ Ruflet::UI::ControlFactory.build(:screenshot, **props)
995
983
  end
996
984
 
997
985
  def battery(**props)
@@ -1030,18 +1018,6 @@ module Ruflet
1030
1018
  service(:haptic_feedback, **props)
1031
1019
  end
1032
1020
 
1033
- def geolocator(**props)
1034
- service(:geolocator, **props)
1035
- end
1036
-
1037
- def permission_handler(**props)
1038
- service(:permission_handler, **props)
1039
- end
1040
-
1041
- def secure_storage(**props)
1042
- service(:secure_storage, **props)
1043
- end
1044
-
1045
1021
  def get_application_cache_directory(timeout: nil, on_result: nil)
1046
1022
  invoke_storage_paths("get_application_cache_directory", timeout: timeout, on_result: on_result)
1047
1023
  end
@@ -1186,27 +1162,7 @@ module Ruflet
1186
1162
  end
1187
1163
 
1188
1164
  def pop_dialog
1189
- dialog_control = latest_open_dialog
1190
- return nil unless dialog_control
1191
-
1192
- dialog_control.props["open"] = false
1193
- update(dialog_control, open: false)
1194
- dialog_control
1195
- end
1196
-
1197
- def close_dialog(dialog_control)
1198
- return self unless dialog_control
1199
-
1200
- dialog_control.props["open"] = false
1201
- @dialog = nil if @dialog.equal?(dialog_control)
1202
- remove_dialog_tracking(dialog_control)
1203
- refresh_dialogs_container!
1204
- # Patch the dialogs container in place. Forcing a full view re-render
1205
- # here would remount the whole overlay — fatal while another dialog
1206
- # (e.g. the form behind a nested picker) is still open. The empty case
1207
- # is handled inside push_dialogs_update!.
1208
- push_dialogs_update!
1209
- self
1165
+ close_dialog
1210
1166
  end
1211
1167
 
1212
1168
  def update(control_or_id = nil, **props)
@@ -1244,7 +1200,6 @@ module Ruflet
1244
1200
 
1245
1201
  visited = Set.new
1246
1202
  patch.each_value { |value| register_embedded_value(value, visited) }
1247
- patch.each { |k, v| control.props[k] = v }
1248
1203
 
1249
1204
  patch_ops = patch.map { |k, v| [0, 0, k, serialize_patch_value(v)] }
1250
1205
 
@@ -1256,6 +1211,10 @@ module Ruflet
1256
1211
  self
1257
1212
  end
1258
1213
 
1214
+ def schedule_update
1215
+ update
1216
+ end
1217
+
1259
1218
  def patch_page(control_id, **props)
1260
1219
  update(control_id, **props)
1261
1220
  end
@@ -1267,6 +1226,8 @@ module Ruflet
1267
1226
  patch = normalize_props(props || {})
1268
1227
  patch.each { |k, v| control.props[k] = v }
1269
1228
 
1229
+ remove_dialog_tracking(control) if patch.key?("open") && patch["open"] == false
1230
+
1270
1231
  self
1271
1232
  end
1272
1233
 
@@ -1274,17 +1235,8 @@ module Ruflet
1274
1235
  if page_control_target?(target)
1275
1236
  if name.to_s == "route_change"
1276
1237
  route_from_event = extract_route(data)
1277
- # Dialogs (including pickers) belong to the view that opened them.
1278
- # Navigating away must dismiss them, or they ghost onto the next
1279
- # view — the picker that "reappears after going home".
1280
- dismiss_tracked_dialogs! if route_from_event && route_from_event != @page_props["route"]
1238
+ return if route_from_event && route_from_event == @page_props["route"]
1281
1239
  @page_props["route"] = route_from_event if route_from_event
1282
- elsif name.to_s == "resize"
1283
- # The client reports the live page size via the "resize" event. Store
1284
- # it so `page.width`/`page.height` reflect the real viewport — without
1285
- # this, responsive layouts collapse on clients (e.g. embedded/iOS)
1286
- # that don't know their size at the initial handshake.
1287
- store_reported_page_size(data)
1288
1240
  end
1289
1241
  dispatch_page_event(name: name, data: data)
1290
1242
  return
@@ -1293,19 +1245,13 @@ module Ruflet
1293
1245
  control = @wire_index[target.to_i] || @control_index[target.to_s]
1294
1246
  return unless control
1295
1247
 
1296
- event = Ruflet::Event.new(name: name, target: target, raw_data: data, page: self, control: control)
1248
+ event = Event.new(name: name, target: target, raw_data: data, page: self, control: control)
1297
1249
  apply_event_value_to_control(control, event) if %w[change select select_change].include?(name.to_s)
1298
- # Material/Cupertino pickers dismiss themselves on the client once a
1299
- # value is confirmed, but only send a value event — never a close. Mark
1300
- # the dialog closed here so show_dialog can reopen it next time.
1301
- mark_picker_dialog_closed(control, name)
1302
- if dialog_close_event?(control, name) && remove_dialog_tracking(control)
1303
- # Patch the container in place; never force a full view re-render that
1304
- # would remount a still-open parent dialog (the nested-picker case).
1250
+ control.emit(name, event)
1251
+
1252
+ if name.to_s == "dismiss" && remove_dialog_tracking(control)
1305
1253
  push_dialogs_update!
1306
1254
  end
1307
-
1308
- control.emit(name, event)
1309
1255
  end
1310
1256
 
1311
1257
  def method_missing(name, *args, &block)
@@ -1323,6 +1269,9 @@ module Ruflet
1323
1269
  if args.empty? && !block
1324
1270
  return @page_props[method_name] if @page_props.key?(method_name)
1325
1271
  return @view_props[method_name] if @view_props.key?(method_name)
1272
+ # Client-reported page properties (width, height, platform,
1273
+ # platform_brightness, media) arrive in the register payload.
1274
+ return @client_details[method_name] if @client_details.respond_to?(:key?) && @client_details.key?(method_name)
1326
1275
  return instance_variable_get("@#{method_name}") if DIALOG_PROP_KEYS.include?(method_name)
1327
1276
  end
1328
1277
 
@@ -1347,14 +1296,20 @@ module Ruflet
1347
1296
 
1348
1297
  private
1349
1298
 
1350
- def client_reported_prop(name)
1351
- return @page_props[name] if @page_props.key?(name)
1352
-
1353
- @client_details[name]
1354
- end
1355
-
1356
- def embedded_async_timeout_available?
1357
- !Object.const_defined?(:RUFLET_EMBEDDED_FAKE_THREAD)
1299
+ def build_client_window(snapshot)
1300
+ properties = snapshot.is_a?(Hash) ? snapshot : {}
1301
+ allowed = Ruflet::UI::Controls::RufletComponents::WindowControl::KEYWORDS
1302
+ props = properties.each_with_object({}) do |(key, value), result|
1303
+ name = key.to_s
1304
+ result[name.to_sym] = value if allowed.include?(name.to_sym)
1305
+ end
1306
+ control = Ruflet::UI::Controls::RufletComponents::WindowControl.new(
1307
+ id: "_window",
1308
+ **props
1309
+ )
1310
+ control.wire_id = (properties["_i"] || properties[:_i] || 2).to_i
1311
+ control.runtime_page = self
1312
+ control
1358
1313
  end
1359
1314
 
1360
1315
  def invoke_and_wait(control_or_id, method_name, args: nil, timeout: 10)
@@ -1440,6 +1395,14 @@ module Ruflet
1440
1395
  type.to_s.delete("_") == "camera"
1441
1396
  end
1442
1397
 
1398
+ def service_control_type?(type)
1399
+ normalized = type.to_s.downcase
1400
+ compact = normalized.delete("_")
1401
+ compact == "audio" ||
1402
+ Ruflet::UI::Services::RufletServices::CLASS_MAP.key?(normalized) ||
1403
+ Ruflet::UI::Services::RufletServices::CLASS_MAP.key?(compact)
1404
+ end
1405
+
1443
1406
  def text_maps_to_content?(control, patch)
1444
1407
  patch.key?("text") && control.type.end_with?("button")
1445
1408
  end
@@ -1454,12 +1417,14 @@ module Ruflet
1454
1417
  @sender.call(action, payload)
1455
1418
  end
1456
1419
 
1457
- def update_view_slot(name, value)
1458
- if value.nil?
1459
- @view_props.delete(name)
1460
- else
1461
- @view_props[name] = value
1462
- end
1420
+ def replace_root_controls(controls)
1421
+ visited = Set.new
1422
+ controls.each { |control| register_control_tree(control, visited) }
1423
+ @root_controls = controls
1424
+
1425
+ refresh_dialogs_container!
1426
+ @view_props.each_value { |value| register_embedded_value(value, visited) }
1427
+ send_view_patch
1463
1428
  end
1464
1429
 
1465
1430
  def send_view_patch
@@ -1471,8 +1436,8 @@ module Ruflet
1471
1436
  "id" => 1,
1472
1437
  "patch" => [
1473
1438
  [0],
1474
- *page_patch_ops,
1475
- [0, 0, "views", view_patches]
1439
+ [0, 0, "views", view_patches],
1440
+ *page_patch_ops
1476
1441
  ]
1477
1442
  })
1478
1443
  @overlay_container_mounted = true if @overlay_container.wire_id
@@ -1592,7 +1557,8 @@ module Ruflet
1592
1557
  query_string = route_value.to_s.split("?", 2)[1].to_s
1593
1558
  return {} if query_string.empty?
1594
1559
 
1595
- CGI.parse(query_string).each_with_object({}) do |(key, values), result|
1560
+ URI.decode_www_form(query_string).group_by(&:first).each_with_object({}) do |(key, pairs), result|
1561
+ values = pairs.map(&:last)
1596
1562
  result[key] = values.size == 1 ? values.first : values
1597
1563
  end
1598
1564
  end
@@ -1608,20 +1574,13 @@ module Ruflet
1608
1574
  end
1609
1575
  end
1610
1576
 
1611
- def store_reported_page_size(data)
1612
- return unless data.is_a?(Hash)
1613
-
1614
- width = data["width"] || data[:width]
1615
- height = data["height"] || data[:height]
1616
- @page_props["width"] = width unless width.nil?
1617
- @page_props["height"] = height unless height.nil?
1618
- end
1619
-
1620
1577
  def dispatch_page_event(name:, data:)
1621
- handler = @page_event_handlers[name.to_s.sub(/\Aon_/, "")]
1578
+ event_name = name.to_s
1579
+ event_name = event_name[3..-1] if event_name.start_with?("on_")
1580
+ handler = @page_event_handlers[event_name]
1622
1581
  return unless handler.respond_to?(:call)
1623
1582
 
1624
- event = Ruflet::Event.new(name: name.to_s, target: 1, raw_data: data, page: self, control: nil)
1583
+ event = Event.new(name: name.to_s, target: 1, raw_data: data, page: self, control: nil)
1625
1584
  handler.call(event)
1626
1585
  end
1627
1586
 
@@ -1637,8 +1596,8 @@ module Ruflet
1637
1596
  end_value = range_value["end_value"] || range_value[:end_value]
1638
1597
  control.props["start_value"] = start_value unless start_value.nil?
1639
1598
  control.props["end_value"] = end_value unless end_value.nil?
1599
+ return
1640
1600
  end
1641
- return
1642
1601
  end
1643
1602
 
1644
1603
  return if value.nil?
@@ -1685,7 +1644,7 @@ module Ruflet
1685
1644
  end
1686
1645
 
1687
1646
  def refresh_dialogs_container!
1688
- dialog_controls = (dialog_slots + @dialogs).uniq
1647
+ dialog_controls = (@dialogs + dialog_slots).uniq
1689
1648
  @dialogs_container.props["controls"] = dialog_controls
1690
1649
  @page_props["_dialogs"] = @dialogs_container
1691
1650
  end
@@ -1694,6 +1653,19 @@ module Ruflet
1694
1653
  @page_props["_overlay"] = @overlay_container
1695
1654
  end
1696
1655
 
1656
+ def push_overlay_update!
1657
+ refresh_control_indexes!
1658
+
1659
+ if @overlay_container.wire_id
1660
+ send_message(Protocol::ACTIONS[:patch_control], {
1661
+ "id" => @overlay_container.wire_id,
1662
+ "patch" => [[0], [0, 0, "controls", serialize_patch_value(@overlay_container.children)]]
1663
+ })
1664
+ else
1665
+ send_view_patch
1666
+ end
1667
+ end
1668
+
1697
1669
  def refresh_services_container!
1698
1670
  @page_props["_services"] = @services_container
1699
1671
  end
@@ -1714,12 +1686,6 @@ module Ruflet
1714
1686
  def push_dialogs_update!
1715
1687
  refresh_control_indexes!
1716
1688
 
1717
- # Once the dialogs container is mounted, every change — opening, closing,
1718
- # even down to no dialogs at all — is an in-place patch of its controls
1719
- # list. Re-sending the view (or the container as a whole object) would
1720
- # replace the live container instance on the Flutter side, detaching its
1721
- # listeners and breaking any other dialog still open. Only the very first
1722
- # dialog, before the container has a wire id, needs a view patch to mount.
1723
1689
  if @dialogs_container.wire_id
1724
1690
  send_message(Protocol::ACTIONS[:patch_control], {
1725
1691
  "id" => @dialogs_container.wire_id,
@@ -1742,41 +1708,6 @@ module Ruflet
1742
1708
  @dialogs.include?(dialog_control) && dialog_control.props["open"] == true
1743
1709
  end
1744
1710
 
1745
- def dialog_close_event?(control, name)
1746
- name = name.to_s
1747
- name == "dismiss" || (%w[change select select_change].include?(name) && @dialogs.include?(control) && control.props["open"] == false)
1748
- end
1749
-
1750
- # Picker dialogs that auto-dismiss on the client after a selection. Their
1751
- # confirm sends a value event (change/select), not a close, so the server
1752
- # must flip `open` to false or show_dialog's open-guard blocks reopening.
1753
- PICKER_DIALOG_TYPES = %w[
1754
- datepicker daterangepicker timepicker
1755
- cupertinodatepicker cupertinotimerpicker
1756
- ].freeze
1757
-
1758
- def picker_dialog?(control)
1759
- PICKER_DIALOG_TYPES.include?(control.type.to_s.tr("_", "").downcase)
1760
- end
1761
-
1762
- def mark_picker_dialog_closed(control, name)
1763
- return unless picker_dialog?(control)
1764
- return unless %w[change select select_change dismiss].include?(name.to_s)
1765
-
1766
- control.props["open"] = false
1767
- end
1768
-
1769
- # Close and untrack every dialog currently shown. Called on navigation so
1770
- # a dialog opened in one view does not linger as an overlay on the next.
1771
- def dismiss_tracked_dialogs!
1772
- return if @dialogs.empty?
1773
-
1774
- @dialogs.each { |dialog| dialog.props["open"] = false }
1775
- @dialogs.clear
1776
- refresh_dialogs_container!
1777
- push_dialogs_update! if @dialogs_container_mounted
1778
- end
1779
-
1780
1711
  def remove_dialog_tracking(control)
1781
1712
  return false unless @dialogs.include?(control)
1782
1713
 
@@ -1785,16 +1716,6 @@ module Ruflet
1785
1716
  true
1786
1717
  end
1787
1718
 
1788
- def remove_existing_singleton_dialogs(control)
1789
- return unless singleton_dialog_control?(control)
1790
-
1791
- @dialogs.delete_if { |dialog| dialog != control && singleton_dialog_control?(dialog) }
1792
- end
1793
-
1794
- def singleton_dialog_control?(control)
1795
- control.type.to_s.tr("_", "").downcase == "snackbar"
1796
- end
1797
-
1798
1719
  def assign_split_prop(key, value)
1799
1720
  if key == "vertical_alignment" || key == "horizontal_alignment"
1800
1721
  @page_props[key] = value
@@ -1841,7 +1762,6 @@ module Ruflet
1841
1762
  existing = service_by_type(type)
1842
1763
  return [existing, false] if existing
1843
1764
 
1844
- # `service` already syncs via add_service -> push_services_update!.
1845
1765
  [service(type), true]
1846
1766
  end
1847
1767
 
@@ -1866,51 +1786,6 @@ module Ruflet
1866
1786
  service(:url_launcher)
1867
1787
  end
1868
1788
 
1869
- def ensure_browser_context_menu_service
1870
- service(:browser_context_menu)
1871
- end
1872
-
1873
- def invoke_browser_context_menu(method_name, timeout:, on_result:)
1874
- browser_context_menu = ensure_browser_context_menu_service
1875
- invoke(browser_context_menu, method_name, timeout: timeout, on_result: on_result)
1876
- end
1877
-
1878
- def ensure_window_service
1879
- service(:window)
1880
- end
1881
-
1882
- def invoke_window(method_name, args: nil, timeout:, on_result:)
1883
- window = ensure_window_service
1884
- invoke(window, method_name, args: args, timeout: timeout, on_result: on_result)
1885
- end
1886
-
1887
- def ensure_tester_service
1888
- service(:tester)
1889
- end
1890
-
1891
- def invoke_tester(method_name, args: nil, timeout:, on_result:)
1892
- tester = ensure_tester_service
1893
- invoke(tester, method_name, args: args, timeout: timeout, on_result: on_result)
1894
- end
1895
-
1896
- def invoke_tester_finder(method_name, finder_id, finder_index:, timeout:, on_result:)
1897
- invoke_tester(
1898
- method_name,
1899
- args: compact_service_args("finder_id" => finder_id, "finder_index" => finder_index),
1900
- timeout: timeout,
1901
- on_result: on_result
1902
- )
1903
- end
1904
-
1905
- def invoke_tester_at(method_name, offset, timeout:, on_result:)
1906
- invoke_tester(
1907
- method_name,
1908
- args: compact_service_args("offset" => offset),
1909
- timeout: timeout,
1910
- on_result: on_result
1911
- )
1912
- end
1913
-
1914
1789
  def ensure_haptic_feedback_service
1915
1790
  service(:haptic_feedback)
1916
1791
  end
@@ -1920,38 +1795,6 @@ module Ruflet
1920
1795
  invoke(haptic_feedback, method_name, timeout: timeout, on_result: on_result)
1921
1796
  end
1922
1797
 
1923
- def invoke_current_view(method_name, timeout:, on_result:)
1924
- target_id = @views.last&.wire_id || @view_id
1925
- invoke_control_id(target_id, method_name, timeout: timeout, on_result: on_result)
1926
- end
1927
-
1928
- def invoke_control_id(control_id, method_name, args: nil, timeout: 10, on_result: nil)
1929
- call_id = "call_#{Ruflet::Control.generate_id}"
1930
- if on_result.respond_to?(:call)
1931
- @invoke_waiters_mutex.synchronize { @invoke_callbacks[call_id] = on_result }
1932
- if embedded_async_timeout_available? && !timeout.nil?
1933
- Thread.new(call_id, timeout.to_f) do |pending_call_id, invoke_timeout|
1934
- sleep([invoke_timeout, 0.0].max + 0.1)
1935
- callback = @invoke_waiters_mutex.synchronize { @invoke_callbacks.delete(pending_call_id) }
1936
- callback&.call(nil, "execution expired")
1937
- rescue StandardError => e
1938
- Kernel.warn("invoke timeout callback error: #{e.class}: #{e.message}")
1939
- end
1940
- end
1941
- end
1942
-
1943
- payload = {
1944
- "control_id" => control_id,
1945
- "call_id" => call_id,
1946
- "name" => method_name.to_s,
1947
- "args" => args
1948
- }
1949
- payload["timeout"] = timeout unless timeout.nil?
1950
- send_message(Protocol::ACTIONS[:invoke_control_method], payload)
1951
-
1952
- call_id
1953
- end
1954
-
1955
1798
  def ensure_connectivity_service
1956
1799
  service_with_created(:connectivity)
1957
1800
  end