ruflet_core 0.0.17 → 0.0.19

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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ruflet/version.rb +1 -1
  3. data/lib/ruflet_core.rb +9 -7
  4. data/lib/ruflet_protocol/ruflet/protocol.rb +28 -54
  5. data/lib/ruflet_ui/ruflet/app.rb +1 -1
  6. data/lib/ruflet_ui/ruflet/colors.rb +4 -50
  7. data/lib/ruflet_ui/ruflet/control.rb +69 -64
  8. data/lib/ruflet_ui/ruflet/dsl.rb +10 -3
  9. data/lib/ruflet_ui/ruflet/icons/cupertino/cupertino_icons.rb +2 -2
  10. data/lib/ruflet_ui/ruflet/icons/cupertino_icon_lookup.rb +1 -0
  11. data/lib/ruflet_ui/ruflet/icons/icon_constant_names.rb +29 -0
  12. data/lib/ruflet_ui/ruflet/icons/material/material_icons.rb +2 -2
  13. data/lib/ruflet_ui/ruflet/icons/material_icon_lookup.rb +1 -0
  14. data/lib/ruflet_ui/ruflet/page.rb +313 -523
  15. data/lib/ruflet_ui/ruflet/ui/control_factory.rb +68 -12
  16. data/lib/ruflet_ui/ruflet/ui/control_registry.rb +13 -6
  17. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoactionsheet_control.rb +8 -45
  18. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoactionsheetaction_control.rb +45 -2
  19. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoactivityindicator_control.rb +43 -2
  20. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoalertdialog_control.rb +0 -5
  21. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinobutton_control.rb +59 -4
  22. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinocheckbox_control.rb +56 -1
  23. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinocontextmenu_control.rb +1 -3
  24. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinocontextmenuaction_control.rb +1 -1
  25. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinodatepicker_control.rb +53 -8
  26. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinodialogaction_control.rb +1 -1
  27. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinofilledbutton_control.rb +59 -4
  28. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinolisttile_control.rb +54 -9
  29. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinonavigationbar_control.rb +47 -9
  30. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinopicker_control.rb +52 -10
  31. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoradio_control.rb +52 -1
  32. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinosegmentedbutton_control.rb +49 -1
  33. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoslider_control.rb +50 -5
  34. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoslidingsegmentedbutton_control.rb +46 -1
  35. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoswitch_control.rb +61 -1
  36. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinotextfield_control.rb +145 -1
  37. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinotimerpicker_control.rb +47 -12
  38. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinotintedbutton_control.rb +59 -4
  39. data/lib/ruflet_ui/ruflet/ui/controls/materials/alertdialog_control.rb +8 -45
  40. data/lib/ruflet_ui/ruflet/ui/controls/materials/appbar_control.rb +8 -40
  41. data/lib/ruflet_ui/ruflet/ui/controls/materials/audio_control.rb +16 -5
  42. data/lib/ruflet_ui/ruflet/ui/controls/materials/autocomplete_control.rb +44 -1
  43. data/lib/ruflet_ui/ruflet/ui/controls/materials/badge_control.rb +0 -7
  44. data/lib/ruflet_ui/ruflet/ui/controls/materials/banner_control.rb +0 -7
  45. data/lib/ruflet_ui/ruflet/ui/controls/materials/bottomappbar_control.rb +8 -47
  46. data/lib/ruflet_ui/ruflet/ui/controls/materials/bottomsheet_control.rb +0 -3
  47. data/lib/ruflet_ui/ruflet/ui/controls/materials/button_control.rb +8 -54
  48. data/lib/ruflet_ui/ruflet/ui/controls/materials/card_control.rb +8 -50
  49. data/lib/ruflet_ui/ruflet/ui/controls/materials/chart_controls.rb +19 -0
  50. data/lib/ruflet_ui/ruflet/ui/controls/materials/checkbox_control.rb +9 -60
  51. data/lib/ruflet_ui/ruflet/ui/controls/materials/chip_control.rb +7 -81
  52. data/lib/ruflet_ui/ruflet/ui/controls/materials/circleavatar_control.rb +7 -56
  53. data/lib/ruflet_ui/ruflet/ui/controls/materials/container_control.rb +9 -65
  54. data/lib/ruflet_ui/ruflet/ui/controls/materials/contextmenu_control.rb +50 -4
  55. data/lib/ruflet_ui/ruflet/ui/controls/materials/datacell_control.rb +1 -3
  56. data/lib/ruflet_ui/ruflet/ui/controls/materials/datacolumn_control.rb +1 -3
  57. data/lib/ruflet_ui/ruflet/ui/controls/materials/datatable_control.rb +7 -88
  58. data/lib/ruflet_ui/ruflet/ui/controls/materials/datepicker_control.rb +9 -55
  59. data/lib/ruflet_ui/ruflet/ui/controls/materials/daterangepicker_control.rb +8 -58
  60. data/lib/ruflet_ui/ruflet/ui/controls/materials/divider_control.rb +0 -9
  61. data/lib/ruflet_ui/ruflet/ui/controls/materials/dropdown_control.rb +8 -93
  62. data/lib/ruflet_ui/ruflet/ui/controls/materials/dropdownm2_control.rb +9 -103
  63. data/lib/ruflet_ui/ruflet/ui/controls/materials/dropdownoption_control.rb +0 -2
  64. data/lib/ruflet_ui/ruflet/ui/controls/materials/expansionpanel_control.rb +7 -51
  65. data/lib/ruflet_ui/ruflet/ui/controls/materials/expansionpanellist_control.rb +46 -5
  66. data/lib/ruflet_ui/ruflet/ui/controls/materials/expansiontile_control.rb +69 -3
  67. data/lib/ruflet_ui/ruflet/ui/controls/materials/filledbutton_control.rb +54 -2
  68. data/lib/ruflet_ui/ruflet/ui/controls/materials/fillediconbutton_control.rb +67 -5
  69. data/lib/ruflet_ui/ruflet/ui/controls/materials/filledtonalbutton_control.rb +54 -2
  70. data/lib/ruflet_ui/ruflet/ui/controls/materials/filledtonaliconbutton_control.rb +67 -5
  71. data/lib/ruflet_ui/ruflet/ui/controls/materials/floatingactionbutton_control.rb +7 -69
  72. data/lib/ruflet_ui/ruflet/ui/controls/materials/iconbutton_control.rb +8 -68
  73. data/lib/ruflet_ui/ruflet/ui/controls/materials/listtile_control.rb +8 -74
  74. data/lib/ruflet_ui/ruflet/ui/controls/materials/map_control.rb +99 -0
  75. data/lib/ruflet_ui/ruflet/ui/controls/materials/menuitembutton_control.rb +8 -56
  76. data/lib/ruflet_ui/ruflet/ui/controls/materials/navigationbar_control.rb +7 -59
  77. data/lib/ruflet_ui/ruflet/ui/controls/materials/navigationdrawer_control.rb +0 -7
  78. data/lib/ruflet_ui/ruflet/ui/controls/materials/navigationrail_control.rb +7 -73
  79. data/lib/ruflet_ui/ruflet/ui/controls/materials/outlinedbutton_control.rb +51 -2
  80. data/lib/ruflet_ui/ruflet/ui/controls/materials/outlinediconbutton_control.rb +67 -5
  81. data/lib/ruflet_ui/ruflet/ui/controls/materials/popupmenubutton_control.rb +8 -61
  82. data/lib/ruflet_ui/ruflet/ui/controls/materials/popupmenuitem_control.rb +0 -6
  83. data/lib/ruflet_ui/ruflet/ui/controls/materials/progressbar_control.rb +8 -56
  84. data/lib/ruflet_ui/ruflet/ui/controls/materials/progressring_control.rb +8 -56
  85. data/lib/ruflet_ui/ruflet/ui/controls/materials/radio_control.rb +9 -54
  86. data/lib/ruflet_ui/ruflet/ui/controls/materials/rangeslider_control.rb +7 -62
  87. data/lib/ruflet_ui/ruflet/ui/controls/materials/reorderablelistview_control.rb +9 -64
  88. data/lib/ruflet_ui/ruflet/ui/controls/materials/ruflet_controls.rb +5 -26
  89. data/lib/ruflet_ui/ruflet/ui/controls/materials/searchbar_control.rb +80 -11
  90. data/lib/ruflet_ui/ruflet/ui/controls/materials/segment_control.rb +0 -3
  91. data/lib/ruflet_ui/ruflet/ui/controls/materials/segmentedbutton_control.rb +7 -64
  92. data/lib/ruflet_ui/ruflet/ui/controls/materials/selectionarea_control.rb +1 -3
  93. data/lib/ruflet_ui/ruflet/ui/controls/materials/slider_control.rb +9 -60
  94. data/lib/ruflet_ui/ruflet/ui/controls/materials/snackbar_control.rb +37 -4
  95. data/lib/ruflet_ui/ruflet/ui/controls/materials/snackbaraction_control.rb +38 -0
  96. data/lib/ruflet_ui/ruflet/ui/controls/materials/spinkit_control.rb +25 -0
  97. data/lib/ruflet_ui/ruflet/ui/controls/materials/submenubutton_control.rb +52 -7
  98. data/lib/ruflet_ui/ruflet/ui/controls/materials/switch_control.rb +9 -61
  99. data/lib/ruflet_ui/ruflet/ui/controls/materials/tab_control.rb +0 -7
  100. data/lib/ruflet_ui/ruflet/ui/controls/materials/tabbar_control.rb +8 -64
  101. data/lib/ruflet_ui/ruflet/ui/controls/materials/tabbarview_control.rb +43 -5
  102. data/lib/ruflet_ui/ruflet/ui/controls/materials/tabs_control.rb +7 -47
  103. data/lib/ruflet_ui/ruflet/ui/controls/materials/textbutton_control.rb +51 -2
  104. data/lib/ruflet_ui/ruflet/ui/controls/materials/textfield_control.rb +9 -132
  105. data/lib/ruflet_ui/ruflet/ui/controls/materials/timepicker_control.rb +10 -34
  106. data/lib/ruflet_ui/ruflet/ui/controls/materials/verticaldivider_control.rb +0 -9
  107. data/lib/ruflet_ui/ruflet/ui/controls/materials/video_control.rb +9 -42
  108. data/lib/ruflet_ui/ruflet/ui/controls/materials/webview_control.rb +3 -4
  109. data/lib/ruflet_ui/ruflet/ui/controls/ruflet_controls.rb +13 -28
  110. data/lib/ruflet_ui/ruflet/ui/controls/shared/animatedswitcher_control.rb +46 -4
  111. data/lib/ruflet_ui/ruflet/ui/controls/shared/arc_control.rb +1 -0
  112. data/lib/ruflet_ui/ruflet/ui/controls/shared/browsercontextmenu_control.rb +1 -12
  113. data/lib/ruflet_ui/ruflet/ui/controls/shared/canvas_control.rb +8 -54
  114. data/lib/ruflet_ui/ruflet/ui/controls/shared/circle_control.rb +1 -0
  115. data/lib/ruflet_ui/ruflet/ui/controls/shared/column_control.rb +9 -51
  116. data/lib/ruflet_ui/ruflet/ui/controls/shared/dismissible_control.rb +52 -12
  117. data/lib/ruflet_ui/ruflet/ui/controls/shared/draggable_control.rb +1 -7
  118. data/lib/ruflet_ui/ruflet/ui/controls/shared/dragtarget_control.rb +1 -3
  119. data/lib/ruflet_ui/ruflet/ui/controls/shared/fletapp_control.rb +9 -46
  120. data/lib/ruflet_ui/ruflet/ui/controls/shared/gesturedetector_control.rb +9 -113
  121. data/lib/ruflet_ui/ruflet/ui/controls/shared/gridview_control.rb +57 -13
  122. data/lib/ruflet_ui/ruflet/ui/controls/shared/hero_control.rb +9 -40
  123. data/lib/ruflet_ui/ruflet/ui/controls/shared/icon_control.rb +9 -48
  124. data/lib/ruflet_ui/ruflet/ui/controls/shared/interactiveviewer_control.rb +9 -53
  125. data/lib/ruflet_ui/ruflet/ui/controls/shared/line_control.rb +1 -0
  126. data/lib/ruflet_ui/ruflet/ui/controls/shared/listview_control.rb +57 -11
  127. data/lib/ruflet_ui/ruflet/ui/controls/shared/markdown_control.rb +9 -54
  128. data/lib/ruflet_ui/ruflet/ui/controls/shared/oval_control.rb +1 -0
  129. data/lib/ruflet_ui/ruflet/ui/controls/shared/page_control.rb +9 -58
  130. data/lib/ruflet_ui/ruflet/ui/controls/shared/pagelet_control.rb +9 -48
  131. data/lib/ruflet_ui/ruflet/ui/controls/shared/pageview_control.rb +50 -4
  132. data/lib/ruflet_ui/ruflet/ui/controls/shared/path_control.rb +1 -0
  133. data/lib/ruflet_ui/ruflet/ui/controls/shared/placeholder_control.rb +44 -9
  134. data/lib/ruflet_ui/ruflet/ui/controls/shared/points_control.rb +1 -0
  135. data/lib/ruflet_ui/ruflet/ui/controls/shared/rect_control.rb +1 -0
  136. data/lib/ruflet_ui/ruflet/ui/controls/shared/reorderabledraghandle_control.rb +43 -4
  137. data/lib/ruflet_ui/ruflet/ui/controls/shared/responsiverow_control.rb +47 -15
  138. data/lib/ruflet_ui/ruflet/ui/controls/shared/rotatedbox_control.rb +97 -0
  139. data/lib/ruflet_ui/ruflet/ui/controls/shared/row_control.rb +9 -51
  140. data/lib/ruflet_ui/ruflet/ui/controls/shared/ruflet_controls.rb +5 -0
  141. data/lib/ruflet_ui/ruflet/ui/controls/shared/safearea_control.rb +48 -4
  142. data/lib/ruflet_ui/ruflet/ui/{services/ruflet → controls/shared}/screenshot_control.rb +18 -2
  143. data/lib/ruflet_ui/ruflet/ui/controls/shared/semantics_control.rb +65 -1
  144. data/lib/ruflet_ui/ruflet/ui/controls/shared/shadermask_control.rb +9 -41
  145. data/lib/ruflet_ui/ruflet/ui/controls/shared/shimmer_control.rb +9 -44
  146. data/lib/ruflet_ui/ruflet/ui/controls/shared/stack_control.rb +44 -1
  147. data/lib/ruflet_ui/ruflet/ui/controls/shared/text_control.rb +9 -35
  148. data/lib/ruflet_ui/ruflet/ui/controls/shared/transparentpointer_control.rb +9 -38
  149. data/lib/ruflet_ui/ruflet/ui/controls/shared/view_control.rb +63 -1
  150. data/lib/ruflet_ui/ruflet/ui/controls/shared/window_control.rb +40 -66
  151. data/lib/ruflet_ui/ruflet/ui/controls/shared/windowdragarea_control.rb +45 -4
  152. data/lib/ruflet_ui/ruflet/ui/material_control_methods.rb +62 -18
  153. data/lib/ruflet_ui/ruflet/ui/material_control_registry.rb +22 -0
  154. data/lib/ruflet_ui/ruflet/ui/services/ruflet/accelerometer_control.rb +1 -0
  155. data/lib/ruflet_ui/ruflet/ui/services/ruflet/audio_recorder_control.rb +14 -36
  156. data/lib/ruflet_ui/ruflet/ui/services/ruflet/barometer_control.rb +1 -0
  157. data/lib/ruflet_ui/ruflet/ui/services/ruflet/battery_control.rb +1 -0
  158. data/lib/ruflet_ui/ruflet/ui/services/ruflet/camera_control.rb +9 -42
  159. data/lib/ruflet_ui/ruflet/ui/services/ruflet/clipboard_control.rb +31 -0
  160. data/lib/ruflet_ui/ruflet/ui/services/ruflet/connectivity_control.rb +1 -0
  161. data/lib/ruflet_ui/ruflet/ui/services/ruflet/filepicker_control.rb +1 -0
  162. data/lib/ruflet_ui/ruflet/ui/services/ruflet/flashlight_control.rb +1 -0
  163. data/lib/ruflet_ui/ruflet/ui/services/ruflet/geolocator_control.rb +14 -30
  164. data/lib/ruflet_ui/ruflet/ui/services/ruflet/gyroscope_control.rb +1 -0
  165. data/lib/ruflet_ui/ruflet/ui/services/ruflet/hapticfeedback_control.rb +1 -0
  166. data/lib/ruflet_ui/ruflet/ui/services/ruflet/magnetometer_control.rb +1 -0
  167. data/lib/ruflet_ui/ruflet/ui/services/ruflet/permissionhandler_control.rb +5 -20
  168. data/lib/ruflet_ui/ruflet/ui/services/ruflet/screenbrightness_control.rb +1 -0
  169. data/lib/ruflet_ui/ruflet/ui/services/ruflet/securestorage_control.rb +22 -49
  170. data/lib/ruflet_ui/ruflet/ui/services/ruflet/semanticsservice_control.rb +20 -15
  171. data/lib/ruflet_ui/ruflet/ui/services/ruflet/shakedetector_control.rb +1 -0
  172. data/lib/ruflet_ui/ruflet/ui/services/ruflet/share_control.rb +1 -0
  173. data/lib/ruflet_ui/ruflet/ui/services/ruflet/sharedpreferences_control.rb +1 -0
  174. data/lib/ruflet_ui/ruflet/ui/services/ruflet/storagepaths_control.rb +1 -0
  175. data/lib/ruflet_ui/ruflet/ui/services/ruflet/urllauncher_control.rb +1 -0
  176. data/lib/ruflet_ui/ruflet/ui/services/ruflet/useraccelerometer_control.rb +1 -0
  177. data/lib/ruflet_ui/ruflet/ui/services/ruflet/wakelock_control.rb +1 -0
  178. data/lib/ruflet_ui/ruflet/ui/services/ruflet_services.rb +0 -7
  179. data/lib/ruflet_ui/ruflet/ui/shared_control_forwarders.rb +25 -2
  180. data/lib/ruflet_ui.rb +12 -10
  181. metadata +7 -8
  182. data/lib/ruflet_ui/ruflet/types/animation.rb +0 -110
  183. data/lib/ruflet_ui/ruflet/ui/controls/materials/codeeditor_control.rb +0 -93
  184. data/lib/ruflet_ui/ruflet/ui/controls/materials/map_controls.rb +0 -311
  185. data/lib/ruflet_ui/ruflet/ui/controls/materials/rive_control.rb +0 -87
  186. data/lib/ruflet_ui/ruflet/ui/controls/materials/spinkit_controls.rb +0 -109
  187. 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",
@@ -183,7 +196,6 @@ module Ruflet
183
196
  id: "_dialogs",
184
197
  controls: []
185
198
  )
186
- @mounted_services = []
187
199
  @invoke_waiters = {}
188
200
  @invoke_callbacks = {}
189
201
  @invoke_waiters_mutex = Mutex.new
@@ -245,64 +257,64 @@ module Ruflet
245
257
  @view_props["bgcolor"] = normalize_value("bgcolor", value)
246
258
  end
247
259
 
248
- # Client-reported page properties. The Flutter client sends these in its
249
- # register payload (see Protocol.normalize_register_payload), where they are
250
- # stored in @client_details; expose them as readers so apps can do
251
- # `page.width`, `page.platform`, etc. without reaching into client_details.
252
- def width
253
- client_reported_prop("width")
254
- 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
255
264
 
256
- def height
257
- client_reported_prop("height")
258
- end
265
+ raise ArgumentError, "Page#add accepts exactly one control" unless control.is_a?(Control)
259
266
 
260
- def platform
261
- client_reported_prop("platform")
267
+ replace_root_controls(@root_controls + [control])
268
+ self
262
269
  end
263
270
 
264
- def platform_brightness
265
- client_reported_prop("platform_brightness")
271
+ def controls
272
+ @root_controls
266
273
  end
267
274
 
268
- def web
269
- client_reported_prop("web")
275
+ def controls=(value)
276
+ replace_root_controls(Array(value).flatten.compact)
277
+ self
270
278
  end
271
279
 
272
- def pwa
273
- 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
274
284
  end
275
285
 
276
- def wasm
277
- client_reported_prop("wasm")
286
+ def remove(*controls)
287
+ controls.flatten.each { |control| @root_controls.delete(control) }
288
+ send_view_patch
289
+ self
278
290
  end
279
291
 
280
- def media
281
- client_reported_prop("media")
292
+ def remove_at(index)
293
+ @root_controls.delete_at(index.to_i)
294
+ send_view_patch
295
+ self
282
296
  end
283
297
 
284
- def add(*controls, appbar: nil, bottom_appbar: nil, floating_action_button: nil, navigation_bar: nil, dialog: nil, snack_bar: nil, bottom_sheet: nil)
285
- controls = controls.flatten
286
- visited = Set.new
287
- controls.each { |c| register_control_tree(c, visited) }
288
- @root_controls = controls
289
-
290
- update_view_slot("appbar", appbar)
291
- update_view_slot("bottom_appbar", bottom_appbar)
292
- update_view_slot("floating_action_button", floating_action_button)
293
- update_view_slot("navigation_bar", navigation_bar)
294
- @dialog = dialog if dialog
295
- @snack_bar = snack_bar if snack_bar
296
- @bottom_sheet = bottom_sheet if bottom_sheet
297
-
298
- refresh_dialogs_container!
299
- @view_props.each_value { |value| register_embedded_value(value, visited) }
298
+ def clean
299
+ replace_root_controls([])
300
+ self
301
+ end
300
302
 
301
- send_view_patch
303
+ def overlay
304
+ @overlay_container.children
305
+ end
302
306
 
307
+ def overlay=(value)
308
+ @overlay_container.children.replace(Array(value).flatten.compact)
309
+ push_overlay_update!
303
310
  self
304
311
  end
305
312
 
313
+ def get_control(id)
314
+ refresh_control_indexes!
315
+ resolve_control(id)
316
+ end
317
+
306
318
  def views=(value)
307
319
  @views = Array(value).compact
308
320
  self
@@ -351,18 +363,6 @@ module Ruflet
351
363
  service(:audio_recorder, **props)
352
364
  end
353
365
 
354
- def browser_context_menu(**props)
355
- service(:browser_context_menu, **props)
356
- end
357
-
358
- def window(**props)
359
- service(:window, **props)
360
- end
361
-
362
- def tester(**props)
363
- service(:tester, **props)
364
- end
365
-
366
366
  def add_service(*value)
367
367
  @services_container.props["_services"] = services + value.flatten.compact
368
368
  refresh_services_container!
@@ -407,6 +407,10 @@ module Ruflet
407
407
  return svc
408
408
  end
409
409
 
410
+ unless service_control_type?(normalized_type)
411
+ raise ArgumentError, "#{type} is a visual control, not a page service"
412
+ end
413
+
410
414
  existing =
411
415
  if id
412
416
  services.find { |s| s.is_a?(Control) && s.id.to_s == id.to_s }
@@ -415,7 +419,14 @@ module Ruflet
415
419
  s.is_a?(Control) && s.type.to_s.downcase.delete("_") == compact_type
416
420
  end
417
421
  end
418
- 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
419
430
 
420
431
  svc = Ruflet::UI::ControlFactory.build(type.to_s, id: id&.to_s, **mapped_props)
421
432
  add_service(svc) unless services.include?(svc)
@@ -449,25 +460,35 @@ module Ruflet
449
460
  @page_event_handlers["view_pop"] = handler
450
461
  end
451
462
 
452
- def on_resize=(handler)
453
- @page_event_handlers["resize"] = handler
463
+ def on_views_pop_until=(handler)
464
+ @page_event_handlers["views_pop_until"] = handler
454
465
  end
455
466
 
456
467
  def on(event_name, &block)
457
- @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
458
471
  self
459
472
  end
460
473
 
461
474
  def mount(&block)
462
475
  builder = WidgetBuilder.new
463
476
  builder.instance_eval(&block)
464
- add(*builder.children)
477
+ builder.children.each { |control| add(control) }
478
+ end
479
+
480
+ def appbar
481
+ @view_props["appbar"]
465
482
  end
466
483
 
467
484
  def appbar=(value)
468
485
  @view_props["appbar"] = value
469
486
  end
470
487
 
488
+ def bottom_appbar
489
+ @view_props["bottom_appbar"]
490
+ end
491
+
471
492
  def bottom_appbar=(value)
472
493
  @view_props["bottom_appbar"] = value
473
494
  end
@@ -476,48 +497,92 @@ module Ruflet
476
497
  self.bottom_appbar = value
477
498
  end
478
499
 
500
+ def floating_action_button
501
+ @view_props["floating_action_button"]
502
+ end
503
+
479
504
  def floating_action_button=(value)
480
505
  @view_props["floating_action_button"] = value
481
506
  end
482
507
 
483
- def drawer
484
- @view_props["drawer"]
508
+ def navigation_bar
509
+ @view_props["navigation_bar"]
485
510
  end
486
511
 
487
- def drawer=(value)
488
- @view_props["drawer"] = value
512
+ def navigation_bar=(value)
513
+ @view_props["navigation_bar"] = value
489
514
  end
490
515
 
491
- def end_drawer
492
- @view_props["end_drawer"]
516
+ def auto_scroll
517
+ @view_props["auto_scroll"]
493
518
  end
494
519
 
495
- def end_drawer=(value)
496
- @view_props["end_drawer"] = value
520
+ def auto_scroll=(value)
521
+ @view_props["auto_scroll"] = value
497
522
  end
498
523
 
499
- def show_drawer(timeout: 10, on_result: nil)
500
- raise ArgumentError, "show_drawer requires drawer" unless drawer
524
+ def browser_context_menu
525
+ @view_props["browser_context_menu"]
526
+ end
501
527
 
502
- invoke_current_view("show_drawer", timeout: timeout, on_result: on_result)
503
- self
528
+ def browser_context_menu=(value)
529
+ @view_props["browser_context_menu"] = value
504
530
  end
505
531
 
506
- def close_drawer(timeout: 10, on_result: nil)
507
- invoke_current_view("close_drawer", timeout: timeout, on_result: on_result)
508
- self
532
+ def decoration
533
+ @view_props["decoration"]
509
534
  end
510
535
 
511
- def show_end_drawer(timeout: 10, on_result: nil)
512
- raise ArgumentError, "show_end_drawer requires end_drawer" unless end_drawer
536
+ def decoration=(value)
537
+ @view_props["decoration"] = value
538
+ end
513
539
 
514
- invoke_current_view("show_end_drawer", timeout: timeout, on_result: on_result)
515
- self
540
+ def floating_action_button_location
541
+ @view_props["floating_action_button_location"]
516
542
  end
517
543
 
518
- def close_end_drawer(timeout: 10, on_result: nil)
519
- invoke_current_view("close_end_drawer", timeout: timeout, on_result: on_result)
520
- 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
521
586
  end
522
587
 
523
588
  def dialog = @dialog
@@ -534,6 +599,10 @@ module Ruflet
534
599
  push_dialogs_update! if @dialogs_container_mounted
535
600
  end
536
601
 
602
+ def snack_bar
603
+ @snack_bar
604
+ end
605
+
537
606
  def snackbar=(value)
538
607
  self.snack_bar = value
539
608
  end
@@ -541,14 +610,13 @@ module Ruflet
541
610
  def bottom_sheet=(value)
542
611
  @bottom_sheet = value
543
612
  refresh_dialogs_container!
544
- # Mirror dialog= / snack_bar=: once the dialogs container is mounted, push
545
- # the change as an in-place patch. Without this, opening a bottom sheet
546
- # after the container is already mounted (e.g. a prior dialog/snackbar)
547
- # never reaches the client, since send_view_patch skips _dialogs once
548
- # mounted. This is what made NativeApp's modal: sheets fail to open.
549
613
  push_dialogs_update! if @dialogs_container_mounted
550
614
  end
551
615
 
616
+ def bottom_sheet
617
+ @bottom_sheet
618
+ end
619
+
552
620
  def bottomsheet=(value)
553
621
  self.bottom_sheet = value
554
622
  end
@@ -559,7 +627,6 @@ module Ruflet
559
627
  return self if dialog_open?(dialog_control)
560
628
 
561
629
  dialog_control.props["open"] = true
562
- remove_existing_singleton_dialogs(dialog_control)
563
630
  @dialogs << dialog_control unless @dialogs.include?(dialog_control)
564
631
  refresh_dialogs_container!
565
632
  send_view_patch unless @dialogs_container.wire_id
@@ -567,6 +634,75 @@ module Ruflet
567
634
  self
568
635
  end
569
636
 
637
+ def show_snack_bar(snack_bar_control)
638
+ show_dialog(snack_bar_control)
639
+ end
640
+
641
+ def show_snackbar(snack_bar_control)
642
+ show_dialog(snack_bar_control)
643
+ end
644
+
645
+ def show_bottom_sheet(bottom_sheet_control)
646
+ show_dialog(bottom_sheet_control)
647
+ end
648
+
649
+ def show_banner(banner_control)
650
+ show_dialog(banner_control)
651
+ end
652
+
653
+ def close_banner(banner_control = nil)
654
+ close_dialog(banner_control)
655
+ end
656
+
657
+ def close_dialog(dialog_control = nil)
658
+ target = dialog_control || latest_open_dialog
659
+ return nil unless target
660
+
661
+ target.props["open"] = false
662
+ @dialog = nil if @dialog.equal?(target)
663
+ @snack_bar = nil if @snack_bar.equal?(target)
664
+ @bottom_sheet = nil if @bottom_sheet.equal?(target)
665
+ refresh_dialogs_container!
666
+ push_dialogs_update!
667
+ target
668
+ end
669
+
670
+ def show_drawer(timeout: 10, on_result: nil)
671
+ raise ArgumentError, "No drawer defined" unless drawer
672
+
673
+ invoke(:page, "show_drawer", timeout: timeout, on_result: on_result)
674
+ end
675
+
676
+ def close_drawer(timeout: 10, on_result: nil)
677
+ invoke(:page, "close_drawer", timeout: timeout, on_result: on_result)
678
+ end
679
+
680
+ def show_end_drawer(timeout: 10, on_result: nil)
681
+ raise ArgumentError, "No end_drawer defined" unless end_drawer
682
+
683
+ invoke(:page, "show_end_drawer", timeout: timeout, on_result: on_result)
684
+ end
685
+
686
+ def close_end_drawer(timeout: 10, on_result: nil)
687
+ invoke(:page, "close_end_drawer", timeout: timeout, on_result: on_result)
688
+ end
689
+
690
+ def scroll_to(offset: nil, delta: nil, scroll_key: nil, duration: nil, curve: nil, timeout: 10, on_result: nil)
691
+ invoke(
692
+ :page,
693
+ "scroll_to",
694
+ args: {
695
+ "offset" => offset,
696
+ "delta" => delta,
697
+ "scroll_key" => scroll_key,
698
+ "duration" => duration,
699
+ "curve" => curve
700
+ },
701
+ timeout: timeout,
702
+ on_result: on_result
703
+ )
704
+ end
705
+
570
706
  def invoke(control_or_id, method_name, args: nil, timeout: 10, on_result: nil)
571
707
  control_id =
572
708
  if page_control_target?(control_or_id)
@@ -580,7 +716,7 @@ module Ruflet
580
716
  call_id = "call_#{Ruflet::Control.generate_id}"
581
717
  if on_result.respond_to?(:call)
582
718
  @invoke_waiters_mutex.synchronize { @invoke_callbacks[call_id] = on_result }
583
- if embedded_async_timeout_available? && !timeout.nil?
719
+ unless timeout.nil?
584
720
  Thread.new(call_id, timeout.to_f) do |pending_call_id, invoke_timeout|
585
721
  sleep([invoke_timeout, 0.0].max + 0.1)
586
722
  callback = @invoke_waiters_mutex.synchronize { @invoke_callbacks.delete(pending_call_id) }
@@ -729,167 +865,6 @@ module Ruflet
729
865
  upload(files, timeout: timeout, on_result: on_result)
730
866
  end
731
867
 
732
- def disable_browser_context_menu(timeout: 10, on_result: nil)
733
- invoke_browser_context_menu("disable_menu", timeout: timeout, on_result: on_result)
734
- end
735
-
736
- def enable_browser_context_menu(timeout: 10, on_result: nil)
737
- invoke_browser_context_menu("enable_menu", timeout: timeout, on_result: on_result)
738
- end
739
-
740
- def wait_until_ready_to_show(timeout: 10, on_result: nil)
741
- invoke_window("wait_until_ready_to_show", timeout: timeout, on_result: on_result)
742
- end
743
-
744
- def window_to_front(timeout: 10, on_result: nil)
745
- invoke_window("to_front", timeout: timeout, on_result: on_result)
746
- end
747
-
748
- def center_window(timeout: 10, on_result: nil)
749
- invoke_window("center", timeout: timeout, on_result: on_result)
750
- end
751
-
752
- def close_window(timeout: 10, on_result: nil)
753
- invoke_window("close", timeout: timeout, on_result: on_result)
754
- end
755
-
756
- def destroy_window(timeout: 10, on_result: nil)
757
- invoke_window("destroy", timeout: timeout, on_result: on_result)
758
- end
759
-
760
- def start_window_dragging(timeout: 10, on_result: nil)
761
- invoke_window("start_dragging", timeout: timeout, on_result: on_result)
762
- end
763
-
764
- def start_window_resizing(edge, timeout: 10, on_result: nil)
765
- invoke_window(
766
- "start_resizing",
767
- args: { "edge" => normalize_service_value(edge) },
768
- timeout: timeout,
769
- on_result: on_result
770
- )
771
- end
772
-
773
- def tester_pump(options = nil, duration: nil, timeout: 10, on_result: nil)
774
- duration = options[:duration] || options["duration"] if options.is_a?(Hash) && duration.nil?
775
- invoke_tester("pump", args: compact_service_args("duration" => duration), timeout: timeout, on_result: on_result)
776
- end
777
-
778
- def tester_pump_and_settle(options = nil, duration: nil, timeout: 10, on_result: nil)
779
- duration = options[:duration] || options["duration"] if options.is_a?(Hash) && duration.nil?
780
- invoke_tester("pump_and_settle", args: compact_service_args("duration" => duration), timeout: timeout, on_result: on_result)
781
- end
782
-
783
- def find_by_text(text, timeout: 10, on_result: nil)
784
- invoke_tester("find_by_text", args: { "text" => text }, timeout: timeout, on_result: on_result)
785
- end
786
-
787
- def find_by_text_containing(pattern, timeout: 10, on_result: nil)
788
- invoke_tester("find_by_text_containing", args: { "pattern" => pattern }, timeout: timeout, on_result: on_result)
789
- end
790
-
791
- def find_by_key(key, timeout: 10, on_result: nil)
792
- invoke_tester("find_by_key", args: { "key" => key }, timeout: timeout, on_result: on_result)
793
- end
794
-
795
- def find_by_tooltip(value, timeout: 10, on_result: nil)
796
- invoke_tester("find_by_tooltip", args: { "value" => value }, timeout: timeout, on_result: on_result)
797
- end
798
-
799
- def find_by_icon(icon, timeout: 10, on_result: nil)
800
- invoke_tester("find_by_icon", args: { "icon" => normalize_service_value(icon) }, timeout: timeout, on_result: on_result)
801
- end
802
-
803
- def take_screenshot(name, timeout: 10, on_result: nil)
804
- invoke_tester("take_screenshot", args: { "name" => name }, timeout: timeout, on_result: on_result)
805
- end
806
-
807
- def tap(finder_id = nil, options = nil, finder_index: nil, timeout: 10, on_result: nil)
808
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
809
- invoke_tester_finder("tap", finder_id, finder_index: finder_index, timeout: timeout, on_result: on_result)
810
- end
811
-
812
- def mouse_click(finder_id = nil, options = nil, finder_index: nil, timeout: 10, on_result: nil)
813
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
814
- invoke_tester_finder("mouse_click", finder_id, finder_index: finder_index, timeout: timeout, on_result: on_result)
815
- end
816
-
817
- def mouse_double_click(finder_id = nil, options = nil, finder_index: nil, timeout: 10, on_result: nil)
818
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
819
- invoke_tester_finder("mouse_double_click", finder_id, finder_index: finder_index, timeout: timeout, on_result: on_result)
820
- end
821
-
822
- def right_mouse_click(finder_id = nil, options = nil, finder_index: nil, timeout: 10, on_result: nil)
823
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
824
- invoke_tester_finder("right_mouse_click", finder_id, finder_index: finder_index, timeout: timeout, on_result: on_result)
825
- end
826
-
827
- def tap_at(offset = nil, timeout: 10, on_result: nil)
828
- invoke_tester_at("tap_at", offset, timeout: timeout, on_result: on_result)
829
- end
830
-
831
- def mouse_click_at(offset = nil, timeout: 10, on_result: nil)
832
- invoke_tester_at("mouse_click_at", offset, timeout: timeout, on_result: on_result)
833
- end
834
-
835
- def mouse_double_click_at(offset = nil, timeout: 10, on_result: nil)
836
- invoke_tester_at("mouse_double_click_at", offset, timeout: timeout, on_result: on_result)
837
- end
838
-
839
- def right_mouse_click_at(offset = nil, timeout: 10, on_result: nil)
840
- invoke_tester_at("right_mouse_click_at", offset, timeout: timeout, on_result: on_result)
841
- end
842
-
843
- def drag(finder_id, offset, finder_index: nil, timeout: 10, on_result: nil)
844
- invoke_tester(
845
- "drag",
846
- args: compact_service_args(
847
- "finder_id" => finder_id,
848
- "finder_index" => finder_index,
849
- "offset" => offset
850
- ),
851
- timeout: timeout,
852
- on_result: on_result
853
- )
854
- end
855
-
856
- def drag_from(start, offset, timeout: 10, on_result: nil)
857
- invoke_tester(
858
- "drag_from",
859
- args: compact_service_args("start" => start, "offset" => offset),
860
- timeout: timeout,
861
- on_result: on_result
862
- )
863
- end
864
-
865
- def long_press(finder_id = nil, options = nil, finder_index: nil, timeout: 10, on_result: nil)
866
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
867
- invoke_tester_finder("long_press", finder_id, finder_index: finder_index, timeout: timeout, on_result: on_result)
868
- end
869
-
870
- def enter_text(finder_id, text, options = nil, finder_index: nil, timeout: 10, on_result: nil)
871
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
872
- invoke_tester(
873
- "enter_text",
874
- args: compact_service_args(
875
- "finder_id" => finder_id,
876
- "finder_index" => finder_index,
877
- "text" => text
878
- ),
879
- timeout: timeout,
880
- on_result: on_result
881
- )
882
- end
883
-
884
- def mouse_hover(finder_id = nil, options = nil, finder_index: nil, timeout: 10, on_result: nil)
885
- finder_index = options[:finder_index] || options["finder_index"] if options.is_a?(Hash) && finder_index.nil?
886
- invoke_tester_finder("mouse_hover", finder_id, finder_index: finder_index, timeout: timeout, on_result: on_result)
887
- end
888
-
889
- def tester_teardown(timeout: 10, on_result: nil)
890
- invoke_tester("teardown", timeout: timeout, on_result: on_result)
891
- end
892
-
893
868
  def heavy_impact(timeout: 10, on_result: nil)
894
869
  invoke_haptic_feedback("heavy_impact", timeout: timeout, on_result: on_result)
895
870
  end
@@ -989,6 +964,18 @@ module Ruflet
989
964
  service(:barometer, **props)
990
965
  end
991
966
 
967
+ def geolocator(**props)
968
+ service(:geolocator, **props)
969
+ end
970
+
971
+ def permission_handler(**props)
972
+ service(:permission_handler, **props)
973
+ end
974
+
975
+ def secure_storage(**props)
976
+ service(:secure_storage, **props)
977
+ end
978
+
992
979
  def shake_detector(**props)
993
980
  service(:shake_detector, **props)
994
981
  end
@@ -998,7 +985,7 @@ module Ruflet
998
985
  end
999
986
 
1000
987
  def screenshot(**props)
1001
- service(:screenshot, **props)
988
+ Ruflet::UI::ControlFactory.build(:screenshot, **props)
1002
989
  end
1003
990
 
1004
991
  def battery(**props)
@@ -1037,18 +1024,6 @@ module Ruflet
1037
1024
  service(:haptic_feedback, **props)
1038
1025
  end
1039
1026
 
1040
- def geolocator(**props)
1041
- service(:geolocator, **props)
1042
- end
1043
-
1044
- def permission_handler(**props)
1045
- service(:permission_handler, **props)
1046
- end
1047
-
1048
- def secure_storage(**props)
1049
- service(:secure_storage, **props)
1050
- end
1051
-
1052
1027
  def get_application_cache_directory(timeout: nil, on_result: nil)
1053
1028
  invoke_storage_paths("get_application_cache_directory", timeout: timeout, on_result: on_result)
1054
1029
  end
@@ -1193,27 +1168,7 @@ module Ruflet
1193
1168
  end
1194
1169
 
1195
1170
  def pop_dialog
1196
- dialog_control = latest_open_dialog
1197
- return nil unless dialog_control
1198
-
1199
- dialog_control.props["open"] = false
1200
- update(dialog_control, open: false)
1201
- dialog_control
1202
- end
1203
-
1204
- def close_dialog(dialog_control)
1205
- return self unless dialog_control
1206
-
1207
- dialog_control.props["open"] = false
1208
- @dialog = nil if @dialog.equal?(dialog_control)
1209
- remove_dialog_tracking(dialog_control)
1210
- refresh_dialogs_container!
1211
- # Patch the dialogs container in place. Forcing a full view re-render
1212
- # here would remount the whole overlay — fatal while another dialog
1213
- # (e.g. the form behind a nested picker) is still open. The empty case
1214
- # is handled inside push_dialogs_update!.
1215
- push_dialogs_update!
1216
- self
1171
+ close_dialog
1217
1172
  end
1218
1173
 
1219
1174
  def update(control_or_id = nil, **props)
@@ -1243,6 +1198,8 @@ module Ruflet
1243
1198
  patch["content"] = patch.delete("text")
1244
1199
  end
1245
1200
 
1201
+ patch.each { |key, value| control.props[key] = value }
1202
+
1246
1203
  # Keep runtime control tree aligned with incremental patches.
1247
1204
  if patch.key?("controls")
1248
1205
  control.children.clear
@@ -1251,7 +1208,6 @@ module Ruflet
1251
1208
 
1252
1209
  visited = Set.new
1253
1210
  patch.each_value { |value| register_embedded_value(value, visited) }
1254
- patch.each { |k, v| control.props[k] = v }
1255
1211
 
1256
1212
  patch_ops = patch.map { |k, v| [0, 0, k, serialize_patch_value(v)] }
1257
1213
 
@@ -1263,6 +1219,10 @@ module Ruflet
1263
1219
  self
1264
1220
  end
1265
1221
 
1222
+ def schedule_update
1223
+ update
1224
+ end
1225
+
1266
1226
  def patch_page(control_id, **props)
1267
1227
  update(control_id, **props)
1268
1228
  end
@@ -1274,6 +1234,8 @@ module Ruflet
1274
1234
  patch = normalize_props(props || {})
1275
1235
  patch.each { |k, v| control.props[k] = v }
1276
1236
 
1237
+ remove_dialog_tracking(control) if patch.key?("open") && patch["open"] == false
1238
+
1277
1239
  self
1278
1240
  end
1279
1241
 
@@ -1281,17 +1243,8 @@ module Ruflet
1281
1243
  if page_control_target?(target)
1282
1244
  if name.to_s == "route_change"
1283
1245
  route_from_event = extract_route(data)
1284
- # Dialogs (including pickers) belong to the view that opened them.
1285
- # Navigating away must dismiss them, or they ghost onto the next
1286
- # view — the picker that "reappears after going home".
1287
- dismiss_tracked_dialogs! if route_from_event && route_from_event != @page_props["route"]
1246
+ return if route_from_event && route_from_event == @page_props["route"]
1288
1247
  @page_props["route"] = route_from_event if route_from_event
1289
- elsif name.to_s == "resize"
1290
- # The client reports the live page size via the "resize" event. Store
1291
- # it so `page.width`/`page.height` reflect the real viewport — without
1292
- # this, responsive layouts collapse on clients (e.g. embedded/iOS)
1293
- # that don't know their size at the initial handshake.
1294
- store_reported_page_size(data)
1295
1248
  end
1296
1249
  dispatch_page_event(name: name, data: data)
1297
1250
  return
@@ -1300,19 +1253,13 @@ module Ruflet
1300
1253
  control = @wire_index[target.to_i] || @control_index[target.to_s]
1301
1254
  return unless control
1302
1255
 
1303
- event = Ruflet::Event.new(name: name, target: target, raw_data: data, page: self, control: control)
1256
+ event = Event.new(name: name, target: target, raw_data: data, page: self, control: control)
1304
1257
  apply_event_value_to_control(control, event) if %w[change select select_change].include?(name.to_s)
1305
- # Material/Cupertino pickers dismiss themselves on the client once a
1306
- # value is confirmed, but only send a value event — never a close. Mark
1307
- # the dialog closed here so show_dialog can reopen it next time.
1308
- mark_picker_dialog_closed(control, name)
1309
- if dialog_close_event?(control, name) && remove_dialog_tracking(control)
1310
- # Patch the container in place; never force a full view re-render that
1311
- # would remount a still-open parent dialog (the nested-picker case).
1258
+ control.emit(name, event)
1259
+
1260
+ if name.to_s == "dismiss" && remove_dialog_tracking(control)
1312
1261
  push_dialogs_update!
1313
1262
  end
1314
-
1315
- control.emit(name, event)
1316
1263
  end
1317
1264
 
1318
1265
  def method_missing(name, *args, &block)
@@ -1330,6 +1277,9 @@ module Ruflet
1330
1277
  if args.empty? && !block
1331
1278
  return @page_props[method_name] if @page_props.key?(method_name)
1332
1279
  return @view_props[method_name] if @view_props.key?(method_name)
1280
+ # Client-reported page properties (width, height, platform,
1281
+ # platform_brightness, media) arrive in the register payload.
1282
+ return @client_details[method_name] if @client_details.respond_to?(:key?) && @client_details.key?(method_name)
1333
1283
  return instance_variable_get("@#{method_name}") if DIALOG_PROP_KEYS.include?(method_name)
1334
1284
  end
1335
1285
 
@@ -1354,14 +1304,20 @@ module Ruflet
1354
1304
 
1355
1305
  private
1356
1306
 
1357
- def client_reported_prop(name)
1358
- return @page_props[name] if @page_props.key?(name)
1359
-
1360
- @client_details[name]
1361
- end
1362
-
1363
- def embedded_async_timeout_available?
1364
- !Object.const_defined?(:RUFLET_EMBEDDED_FAKE_THREAD)
1307
+ def build_client_window(snapshot)
1308
+ properties = snapshot.is_a?(Hash) ? snapshot : {}
1309
+ allowed = Ruflet::UI::Controls::RufletComponents::WindowControl::KEYWORDS
1310
+ props = properties.each_with_object({}) do |(key, value), result|
1311
+ name = key.to_s
1312
+ result[name.to_sym] = value if allowed.include?(name.to_sym)
1313
+ end
1314
+ control = Ruflet::UI::Controls::RufletComponents::WindowControl.new(
1315
+ id: "_window",
1316
+ **props
1317
+ )
1318
+ control.wire_id = (properties["_i"] || properties[:_i] || 2).to_i
1319
+ control.runtime_page = self
1320
+ control
1365
1321
  end
1366
1322
 
1367
1323
  def invoke_and_wait(control_or_id, method_name, args: nil, timeout: 10)
@@ -1447,6 +1403,14 @@ module Ruflet
1447
1403
  type.to_s.delete("_") == "camera"
1448
1404
  end
1449
1405
 
1406
+ def service_control_type?(type)
1407
+ normalized = type.to_s.downcase
1408
+ compact = normalized.delete("_")
1409
+ compact == "audio" ||
1410
+ Ruflet::UI::Services::RufletServices::CLASS_MAP.key?(normalized) ||
1411
+ Ruflet::UI::Services::RufletServices::CLASS_MAP.key?(compact)
1412
+ end
1413
+
1450
1414
  def text_maps_to_content?(control, patch)
1451
1415
  patch.key?("text") && control.type.end_with?("button")
1452
1416
  end
@@ -1461,12 +1425,14 @@ module Ruflet
1461
1425
  @sender.call(action, payload)
1462
1426
  end
1463
1427
 
1464
- def update_view_slot(name, value)
1465
- if value.nil?
1466
- @view_props.delete(name)
1467
- else
1468
- @view_props[name] = value
1469
- end
1428
+ def replace_root_controls(controls)
1429
+ visited = Set.new
1430
+ controls.each { |control| register_control_tree(control, visited) }
1431
+ @root_controls = controls
1432
+
1433
+ refresh_dialogs_container!
1434
+ @view_props.each_value { |value| register_embedded_value(value, visited) }
1435
+ send_view_patch
1470
1436
  end
1471
1437
 
1472
1438
  def send_view_patch
@@ -1478,8 +1444,8 @@ module Ruflet
1478
1444
  "id" => 1,
1479
1445
  "patch" => [
1480
1446
  [0],
1481
- *page_patch_ops,
1482
- [0, 0, "views", view_patches]
1447
+ [0, 0, "views", view_patches],
1448
+ *page_patch_ops
1483
1449
  ]
1484
1450
  })
1485
1451
  @overlay_container_mounted = true if @overlay_container.wire_id
@@ -1574,7 +1540,6 @@ module Ruflet
1574
1540
  raise ArgumentError, "page #{key} must use an icon name string, not #{value.inspect}"
1575
1541
  end
1576
1542
 
1577
- value = Ruflet::Colors.normalize_property(key, value)
1578
1543
  return value.value if value.is_a?(Ruflet::IconData)
1579
1544
  value.is_a?(Symbol) ? value.to_s : value
1580
1545
  end
@@ -1600,14 +1565,9 @@ module Ruflet
1600
1565
  query_string = route_value.to_s.split("?", 2)[1].to_s
1601
1566
  return {} if query_string.empty?
1602
1567
 
1603
- query_string.split("&").each_with_object({}) do |pair, result|
1604
- next if pair.empty?
1605
-
1606
- key, value = pair.split("=", 2)
1607
- key = CGI.unescape(key.to_s.tr("+", " "))
1608
- value = CGI.unescape(value.to_s.tr("+", " "))
1609
- previous = result[key]
1610
- result[key] = previous.nil? ? value : Array(previous) << value
1568
+ URI.decode_www_form(query_string).group_by(&:first).each_with_object({}) do |(key, pairs), result|
1569
+ values = pairs.map(&:last)
1570
+ result[key] = values.size == 1 ? values.first : values
1611
1571
  end
1612
1572
  end
1613
1573
 
@@ -1622,20 +1582,13 @@ module Ruflet
1622
1582
  end
1623
1583
  end
1624
1584
 
1625
- def store_reported_page_size(data)
1626
- return unless data.is_a?(Hash)
1627
-
1628
- width = data["width"] || data[:width]
1629
- height = data["height"] || data[:height]
1630
- @page_props["width"] = width unless width.nil?
1631
- @page_props["height"] = height unless height.nil?
1632
- end
1633
-
1634
1585
  def dispatch_page_event(name:, data:)
1635
- handler = @page_event_handlers[name.to_s.sub(/\Aon_/, "")]
1586
+ event_name = name.to_s
1587
+ event_name = event_name[3..-1] if event_name.start_with?("on_")
1588
+ handler = @page_event_handlers[event_name]
1636
1589
  return unless handler.respond_to?(:call)
1637
1590
 
1638
- event = Ruflet::Event.new(name: name.to_s, target: 1, raw_data: data, page: self, control: nil)
1591
+ event = Event.new(name: name.to_s, target: 1, raw_data: data, page: self, control: nil)
1639
1592
  handler.call(event)
1640
1593
  end
1641
1594
 
@@ -1651,8 +1604,8 @@ module Ruflet
1651
1604
  end_value = range_value["end_value"] || range_value[:end_value]
1652
1605
  control.props["start_value"] = start_value unless start_value.nil?
1653
1606
  control.props["end_value"] = end_value unless end_value.nil?
1607
+ return
1654
1608
  end
1655
- return
1656
1609
  end
1657
1610
 
1658
1611
  return if value.nil?
@@ -1699,7 +1652,7 @@ module Ruflet
1699
1652
  end
1700
1653
 
1701
1654
  def refresh_dialogs_container!
1702
- dialog_controls = (dialog_slots + @dialogs).uniq
1655
+ dialog_controls = (@dialogs + dialog_slots).uniq
1703
1656
  @dialogs_container.props["controls"] = dialog_controls
1704
1657
  @page_props["_dialogs"] = @dialogs_container
1705
1658
  end
@@ -1708,6 +1661,19 @@ module Ruflet
1708
1661
  @page_props["_overlay"] = @overlay_container
1709
1662
  end
1710
1663
 
1664
+ def push_overlay_update!
1665
+ refresh_control_indexes!
1666
+
1667
+ if @overlay_container.wire_id
1668
+ send_message(Protocol::ACTIONS[:patch_control], {
1669
+ "id" => @overlay_container.wire_id,
1670
+ "patch" => [[0], [0, 0, "controls", serialize_patch_value(@overlay_container.children)]]
1671
+ })
1672
+ else
1673
+ send_view_patch
1674
+ end
1675
+ end
1676
+
1711
1677
  def refresh_services_container!
1712
1678
  @page_props["_services"] = @services_container
1713
1679
  end
@@ -1715,72 +1681,19 @@ module Ruflet
1715
1681
  def push_services_update!
1716
1682
  refresh_control_indexes!
1717
1683
 
1718
- unless @services_container.wire_id
1719
- # First mount: the whole list ships inside the page/view patch.
1720
- send_view_patch
1721
- @mounted_services = Array(@services_container.props["_services"]).dup
1722
- return
1723
- end
1724
-
1725
- current = Array(@services_container.props["_services"])
1726
- ops = services_patch_ops(@mounted_services, current)
1727
- unless ops.empty?
1728
- # Incrementally add/remove individual services. Re-sending the whole
1729
- # `_services` list as full objects (a single replace op) makes the
1730
- # client run Control.fromMap for EVERY service, replacing each Control
1731
- # instance while ServiceRegistry only binds ids it hasn't seen — so an
1732
- # already-mounted service (share, clipboard, haptic) keeps its old
1733
- # binding while controlsIndex points at the fresh, listener-less
1734
- # instance, and its next invoke hangs. That was the "after Copy, Share
1735
- # stops working" bug. Add/remove ops leave untouched services intact.
1684
+ if @services_container.wire_id
1736
1685
  send_message(Protocol::ACTIONS[:patch_control], {
1737
1686
  "id" => @services_container.wire_id,
1738
- "patch" => [[0, { "_services" => [1] }], *ops]
1687
+ "patch" => [[0], [0, 0, "_services", serialize_patch_value(@services_container.props["_services"])]]
1739
1688
  })
1689
+ else
1690
+ send_view_patch
1740
1691
  end
1741
- @mounted_services = current.dup
1742
- end
1743
-
1744
- # Diff the previously-mounted services list against the current one and emit
1745
- # add (1) / remove (2) ops against the `_services` list (patch node 1).
1746
- # Services are normally append-only; a moved entry is expressed as
1747
- # remove + add. Existing entries are never re-serialized, so their client
1748
- # Control instances (and invoke listeners) survive.
1749
- def services_patch_ops(previous, current)
1750
- ops = []
1751
- working = previous.dup
1752
-
1753
- (working.length - 1).downto(0) do |index|
1754
- next if current.any? { |service| service.equal?(working[index]) }
1755
-
1756
- ops << [2, 1, index]
1757
- working.delete_at(index)
1758
- end
1759
-
1760
- current.each_with_index do |service, index|
1761
- next if working[index]&.equal?(service)
1762
-
1763
- existing = working.index { |candidate| candidate.equal?(service) }
1764
- if existing
1765
- ops << [2, 1, existing]
1766
- working.delete_at(existing)
1767
- end
1768
- ops << [1, 1, index, serialize_patch_value(service)]
1769
- working.insert(index, service)
1770
- end
1771
-
1772
- ops
1773
1692
  end
1774
1693
 
1775
1694
  def push_dialogs_update!
1776
1695
  refresh_control_indexes!
1777
1696
 
1778
- # Once the dialogs container is mounted, every change — opening, closing,
1779
- # even down to no dialogs at all — is an in-place patch of its controls
1780
- # list. Re-sending the view (or the container as a whole object) would
1781
- # replace the live container instance on the Flutter side, detaching its
1782
- # listeners and breaking any other dialog still open. Only the very first
1783
- # dialog, before the container has a wire id, needs a view patch to mount.
1784
1697
  if @dialogs_container.wire_id
1785
1698
  send_message(Protocol::ACTIONS[:patch_control], {
1786
1699
  "id" => @dialogs_container.wire_id,
@@ -1803,41 +1716,6 @@ module Ruflet
1803
1716
  @dialogs.include?(dialog_control) && dialog_control.props["open"] == true
1804
1717
  end
1805
1718
 
1806
- def dialog_close_event?(control, name)
1807
- name = name.to_s
1808
- name == "dismiss" || (%w[change select select_change].include?(name) && @dialogs.include?(control) && control.props["open"] == false)
1809
- end
1810
-
1811
- # Picker dialogs that auto-dismiss on the client after a selection. Their
1812
- # confirm sends a value event (change/select), not a close, so the server
1813
- # must flip `open` to false or show_dialog's open-guard blocks reopening.
1814
- PICKER_DIALOG_TYPES = %w[
1815
- datepicker daterangepicker timepicker
1816
- cupertinodatepicker cupertinotimerpicker
1817
- ].freeze
1818
-
1819
- def picker_dialog?(control)
1820
- PICKER_DIALOG_TYPES.include?(control.type.to_s.tr("_", "").downcase)
1821
- end
1822
-
1823
- def mark_picker_dialog_closed(control, name)
1824
- return unless picker_dialog?(control)
1825
- return unless %w[change select select_change dismiss].include?(name.to_s)
1826
-
1827
- control.props["open"] = false
1828
- end
1829
-
1830
- # Close and untrack every dialog currently shown. Called on navigation so
1831
- # a dialog opened in one view does not linger as an overlay on the next.
1832
- def dismiss_tracked_dialogs!
1833
- return if @dialogs.empty?
1834
-
1835
- @dialogs.each { |dialog| dialog.props["open"] = false }
1836
- @dialogs.clear
1837
- refresh_dialogs_container!
1838
- push_dialogs_update! if @dialogs_container_mounted
1839
- end
1840
-
1841
1719
  def remove_dialog_tracking(control)
1842
1720
  return false unless @dialogs.include?(control)
1843
1721
 
@@ -1846,16 +1724,6 @@ module Ruflet
1846
1724
  true
1847
1725
  end
1848
1726
 
1849
- def remove_existing_singleton_dialogs(control)
1850
- return unless singleton_dialog_control?(control)
1851
-
1852
- @dialogs.delete_if { |dialog| dialog != control && singleton_dialog_control?(dialog) }
1853
- end
1854
-
1855
- def singleton_dialog_control?(control)
1856
- control.type.to_s.tr("_", "").downcase == "snackbar"
1857
- end
1858
-
1859
1727
  def assign_split_prop(key, value)
1860
1728
  if key == "vertical_alignment" || key == "horizontal_alignment"
1861
1729
  @page_props[key] = value
@@ -1902,7 +1770,6 @@ module Ruflet
1902
1770
  existing = service_by_type(type)
1903
1771
  return [existing, false] if existing
1904
1772
 
1905
- # `service` already syncs via add_service -> push_services_update!.
1906
1773
  [service(type), true]
1907
1774
  end
1908
1775
 
@@ -1927,51 +1794,6 @@ module Ruflet
1927
1794
  service(:url_launcher)
1928
1795
  end
1929
1796
 
1930
- def ensure_browser_context_menu_service
1931
- service(:browser_context_menu)
1932
- end
1933
-
1934
- def invoke_browser_context_menu(method_name, timeout:, on_result:)
1935
- browser_context_menu = ensure_browser_context_menu_service
1936
- invoke(browser_context_menu, method_name, timeout: timeout, on_result: on_result)
1937
- end
1938
-
1939
- def ensure_window_service
1940
- service(:window)
1941
- end
1942
-
1943
- def invoke_window(method_name, args: nil, timeout:, on_result:)
1944
- window = ensure_window_service
1945
- invoke(window, method_name, args: args, timeout: timeout, on_result: on_result)
1946
- end
1947
-
1948
- def ensure_tester_service
1949
- service(:tester)
1950
- end
1951
-
1952
- def invoke_tester(method_name, args: nil, timeout:, on_result:)
1953
- tester = ensure_tester_service
1954
- invoke(tester, method_name, args: args, timeout: timeout, on_result: on_result)
1955
- end
1956
-
1957
- def invoke_tester_finder(method_name, finder_id, finder_index:, timeout:, on_result:)
1958
- invoke_tester(
1959
- method_name,
1960
- args: compact_service_args("finder_id" => finder_id, "finder_index" => finder_index),
1961
- timeout: timeout,
1962
- on_result: on_result
1963
- )
1964
- end
1965
-
1966
- def invoke_tester_at(method_name, offset, timeout:, on_result:)
1967
- invoke_tester(
1968
- method_name,
1969
- args: compact_service_args("offset" => offset),
1970
- timeout: timeout,
1971
- on_result: on_result
1972
- )
1973
- end
1974
-
1975
1797
  def ensure_haptic_feedback_service
1976
1798
  service(:haptic_feedback)
1977
1799
  end
@@ -1981,38 +1803,6 @@ module Ruflet
1981
1803
  invoke(haptic_feedback, method_name, timeout: timeout, on_result: on_result)
1982
1804
  end
1983
1805
 
1984
- def invoke_current_view(method_name, timeout:, on_result:)
1985
- target_id = @views.last&.wire_id || @view_id
1986
- invoke_control_id(target_id, method_name, timeout: timeout, on_result: on_result)
1987
- end
1988
-
1989
- def invoke_control_id(control_id, method_name, args: nil, timeout: 10, on_result: nil)
1990
- call_id = "call_#{Ruflet::Control.generate_id}"
1991
- if on_result.respond_to?(:call)
1992
- @invoke_waiters_mutex.synchronize { @invoke_callbacks[call_id] = on_result }
1993
- if embedded_async_timeout_available? && !timeout.nil?
1994
- Thread.new(call_id, timeout.to_f) do |pending_call_id, invoke_timeout|
1995
- sleep([invoke_timeout, 0.0].max + 0.1)
1996
- callback = @invoke_waiters_mutex.synchronize { @invoke_callbacks.delete(pending_call_id) }
1997
- callback&.call(nil, "execution expired")
1998
- rescue StandardError => e
1999
- Kernel.warn("invoke timeout callback error: #{e.class}: #{e.message}")
2000
- end
2001
- end
2002
- end
2003
-
2004
- payload = {
2005
- "control_id" => control_id,
2006
- "call_id" => call_id,
2007
- "name" => method_name.to_s,
2008
- "args" => args
2009
- }
2010
- payload["timeout"] = timeout unless timeout.nil?
2011
- send_message(Protocol::ACTIONS[:invoke_control_method], payload)
2012
-
2013
- call_id
2014
- end
2015
-
2016
1806
  def ensure_connectivity_service
2017
1807
  service_with_created(:connectivity)
2018
1808
  end