ruflet_core 0.0.17 → 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 -30
  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 -50
  8. data/lib/ruflet_ui/ruflet/control.rb +69 -64
  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 +308 -526
  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 +7 -8
  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",
@@ -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
@@ -525,13 +590,15 @@ module Ruflet
525
590
  def dialog=(value)
526
591
  @dialog = value
527
592
  refresh_dialogs_container!
528
- push_dialogs_update! if @dialogs_container_mounted
529
593
  end
530
594
 
531
595
  def snack_bar=(value)
532
596
  @snack_bar = value
533
597
  refresh_dialogs_container!
534
- push_dialogs_update! if @dialogs_container_mounted
598
+ end
599
+
600
+ def snack_bar
601
+ @snack_bar
535
602
  end
536
603
 
537
604
  def snackbar=(value)
@@ -541,12 +608,10 @@ module Ruflet
541
608
  def bottom_sheet=(value)
542
609
  @bottom_sheet = value
543
610
  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
- push_dialogs_update! if @dialogs_container_mounted
611
+ end
612
+
613
+ def bottom_sheet
614
+ @bottom_sheet
550
615
  end
551
616
 
552
617
  def bottomsheet=(value)
@@ -559,7 +624,6 @@ module Ruflet
559
624
  return self if dialog_open?(dialog_control)
560
625
 
561
626
  dialog_control.props["open"] = true
562
- remove_existing_singleton_dialogs(dialog_control)
563
627
  @dialogs << dialog_control unless @dialogs.include?(dialog_control)
564
628
  refresh_dialogs_container!
565
629
  send_view_patch unless @dialogs_container.wire_id
@@ -567,6 +631,72 @@ module Ruflet
567
631
  self
568
632
  end
569
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
+
570
700
  def invoke(control_or_id, method_name, args: nil, timeout: 10, on_result: nil)
571
701
  control_id =
572
702
  if page_control_target?(control_or_id)
@@ -580,7 +710,7 @@ module Ruflet
580
710
  call_id = "call_#{Ruflet::Control.generate_id}"
581
711
  if on_result.respond_to?(:call)
582
712
  @invoke_waiters_mutex.synchronize { @invoke_callbacks[call_id] = on_result }
583
- if embedded_async_timeout_available? && !timeout.nil?
713
+ unless timeout.nil?
584
714
  Thread.new(call_id, timeout.to_f) do |pending_call_id, invoke_timeout|
585
715
  sleep([invoke_timeout, 0.0].max + 0.1)
586
716
  callback = @invoke_waiters_mutex.synchronize { @invoke_callbacks.delete(pending_call_id) }
@@ -729,167 +859,6 @@ module Ruflet
729
859
  upload(files, timeout: timeout, on_result: on_result)
730
860
  end
731
861
 
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
862
  def heavy_impact(timeout: 10, on_result: nil)
894
863
  invoke_haptic_feedback("heavy_impact", timeout: timeout, on_result: on_result)
895
864
  end
@@ -989,6 +958,18 @@ module Ruflet
989
958
  service(:barometer, **props)
990
959
  end
991
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
+
992
973
  def shake_detector(**props)
993
974
  service(:shake_detector, **props)
994
975
  end
@@ -998,7 +979,7 @@ module Ruflet
998
979
  end
999
980
 
1000
981
  def screenshot(**props)
1001
- service(:screenshot, **props)
982
+ Ruflet::UI::ControlFactory.build(:screenshot, **props)
1002
983
  end
1003
984
 
1004
985
  def battery(**props)
@@ -1037,18 +1018,6 @@ module Ruflet
1037
1018
  service(:haptic_feedback, **props)
1038
1019
  end
1039
1020
 
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
1021
  def get_application_cache_directory(timeout: nil, on_result: nil)
1053
1022
  invoke_storage_paths("get_application_cache_directory", timeout: timeout, on_result: on_result)
1054
1023
  end
@@ -1193,27 +1162,7 @@ module Ruflet
1193
1162
  end
1194
1163
 
1195
1164
  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
1165
+ close_dialog
1217
1166
  end
1218
1167
 
1219
1168
  def update(control_or_id = nil, **props)
@@ -1251,7 +1200,6 @@ module Ruflet
1251
1200
 
1252
1201
  visited = Set.new
1253
1202
  patch.each_value { |value| register_embedded_value(value, visited) }
1254
- patch.each { |k, v| control.props[k] = v }
1255
1203
 
1256
1204
  patch_ops = patch.map { |k, v| [0, 0, k, serialize_patch_value(v)] }
1257
1205
 
@@ -1263,6 +1211,10 @@ module Ruflet
1263
1211
  self
1264
1212
  end
1265
1213
 
1214
+ def schedule_update
1215
+ update
1216
+ end
1217
+
1266
1218
  def patch_page(control_id, **props)
1267
1219
  update(control_id, **props)
1268
1220
  end
@@ -1274,6 +1226,8 @@ module Ruflet
1274
1226
  patch = normalize_props(props || {})
1275
1227
  patch.each { |k, v| control.props[k] = v }
1276
1228
 
1229
+ remove_dialog_tracking(control) if patch.key?("open") && patch["open"] == false
1230
+
1277
1231
  self
1278
1232
  end
1279
1233
 
@@ -1281,17 +1235,8 @@ module Ruflet
1281
1235
  if page_control_target?(target)
1282
1236
  if name.to_s == "route_change"
1283
1237
  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"]
1238
+ return if route_from_event && route_from_event == @page_props["route"]
1288
1239
  @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
1240
  end
1296
1241
  dispatch_page_event(name: name, data: data)
1297
1242
  return
@@ -1300,19 +1245,13 @@ module Ruflet
1300
1245
  control = @wire_index[target.to_i] || @control_index[target.to_s]
1301
1246
  return unless control
1302
1247
 
1303
- 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)
1304
1249
  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).
1250
+ control.emit(name, event)
1251
+
1252
+ if name.to_s == "dismiss" && remove_dialog_tracking(control)
1312
1253
  push_dialogs_update!
1313
1254
  end
1314
-
1315
- control.emit(name, event)
1316
1255
  end
1317
1256
 
1318
1257
  def method_missing(name, *args, &block)
@@ -1330,6 +1269,9 @@ module Ruflet
1330
1269
  if args.empty? && !block
1331
1270
  return @page_props[method_name] if @page_props.key?(method_name)
1332
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)
1333
1275
  return instance_variable_get("@#{method_name}") if DIALOG_PROP_KEYS.include?(method_name)
1334
1276
  end
1335
1277
 
@@ -1354,14 +1296,20 @@ module Ruflet
1354
1296
 
1355
1297
  private
1356
1298
 
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)
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
1365
1313
  end
1366
1314
 
1367
1315
  def invoke_and_wait(control_or_id, method_name, args: nil, timeout: 10)
@@ -1447,6 +1395,14 @@ module Ruflet
1447
1395
  type.to_s.delete("_") == "camera"
1448
1396
  end
1449
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
+
1450
1406
  def text_maps_to_content?(control, patch)
1451
1407
  patch.key?("text") && control.type.end_with?("button")
1452
1408
  end
@@ -1461,12 +1417,14 @@ module Ruflet
1461
1417
  @sender.call(action, payload)
1462
1418
  end
1463
1419
 
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
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
1470
1428
  end
1471
1429
 
1472
1430
  def send_view_patch
@@ -1478,8 +1436,8 @@ module Ruflet
1478
1436
  "id" => 1,
1479
1437
  "patch" => [
1480
1438
  [0],
1481
- *page_patch_ops,
1482
- [0, 0, "views", view_patches]
1439
+ [0, 0, "views", view_patches],
1440
+ *page_patch_ops
1483
1441
  ]
1484
1442
  })
1485
1443
  @overlay_container_mounted = true if @overlay_container.wire_id
@@ -1574,7 +1532,6 @@ module Ruflet
1574
1532
  raise ArgumentError, "page #{key} must use an icon name string, not #{value.inspect}"
1575
1533
  end
1576
1534
 
1577
- value = Ruflet::Colors.normalize_property(key, value)
1578
1535
  return value.value if value.is_a?(Ruflet::IconData)
1579
1536
  value.is_a?(Symbol) ? value.to_s : value
1580
1537
  end
@@ -1600,14 +1557,9 @@ module Ruflet
1600
1557
  query_string = route_value.to_s.split("?", 2)[1].to_s
1601
1558
  return {} if query_string.empty?
1602
1559
 
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
1560
+ URI.decode_www_form(query_string).group_by(&:first).each_with_object({}) do |(key, pairs), result|
1561
+ values = pairs.map(&:last)
1562
+ result[key] = values.size == 1 ? values.first : values
1611
1563
  end
1612
1564
  end
1613
1565
 
@@ -1622,20 +1574,13 @@ module Ruflet
1622
1574
  end
1623
1575
  end
1624
1576
 
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
1577
  def dispatch_page_event(name:, data:)
1635
- 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]
1636
1581
  return unless handler.respond_to?(:call)
1637
1582
 
1638
- 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)
1639
1584
  handler.call(event)
1640
1585
  end
1641
1586
 
@@ -1651,8 +1596,8 @@ module Ruflet
1651
1596
  end_value = range_value["end_value"] || range_value[:end_value]
1652
1597
  control.props["start_value"] = start_value unless start_value.nil?
1653
1598
  control.props["end_value"] = end_value unless end_value.nil?
1599
+ return
1654
1600
  end
1655
- return
1656
1601
  end
1657
1602
 
1658
1603
  return if value.nil?
@@ -1699,7 +1644,7 @@ module Ruflet
1699
1644
  end
1700
1645
 
1701
1646
  def refresh_dialogs_container!
1702
- dialog_controls = (dialog_slots + @dialogs).uniq
1647
+ dialog_controls = (@dialogs + dialog_slots).uniq
1703
1648
  @dialogs_container.props["controls"] = dialog_controls
1704
1649
  @page_props["_dialogs"] = @dialogs_container
1705
1650
  end
@@ -1708,6 +1653,19 @@ module Ruflet
1708
1653
  @page_props["_overlay"] = @overlay_container
1709
1654
  end
1710
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
+
1711
1669
  def refresh_services_container!
1712
1670
  @page_props["_services"] = @services_container
1713
1671
  end
@@ -1715,72 +1673,19 @@ module Ruflet
1715
1673
  def push_services_update!
1716
1674
  refresh_control_indexes!
1717
1675
 
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.
1676
+ if @services_container.wire_id
1736
1677
  send_message(Protocol::ACTIONS[:patch_control], {
1737
1678
  "id" => @services_container.wire_id,
1738
- "patch" => [[0, { "_services" => [1] }], *ops]
1679
+ "patch" => [[0], [0, 0, "_services", serialize_patch_value(@services_container.props["_services"])]]
1739
1680
  })
1681
+ else
1682
+ send_view_patch
1740
1683
  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
1684
  end
1774
1685
 
1775
1686
  def push_dialogs_update!
1776
1687
  refresh_control_indexes!
1777
1688
 
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
1689
  if @dialogs_container.wire_id
1785
1690
  send_message(Protocol::ACTIONS[:patch_control], {
1786
1691
  "id" => @dialogs_container.wire_id,
@@ -1803,41 +1708,6 @@ module Ruflet
1803
1708
  @dialogs.include?(dialog_control) && dialog_control.props["open"] == true
1804
1709
  end
1805
1710
 
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
1711
  def remove_dialog_tracking(control)
1842
1712
  return false unless @dialogs.include?(control)
1843
1713
 
@@ -1846,16 +1716,6 @@ module Ruflet
1846
1716
  true
1847
1717
  end
1848
1718
 
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
1719
  def assign_split_prop(key, value)
1860
1720
  if key == "vertical_alignment" || key == "horizontal_alignment"
1861
1721
  @page_props[key] = value
@@ -1902,7 +1762,6 @@ module Ruflet
1902
1762
  existing = service_by_type(type)
1903
1763
  return [existing, false] if existing
1904
1764
 
1905
- # `service` already syncs via add_service -> push_services_update!.
1906
1765
  [service(type), true]
1907
1766
  end
1908
1767
 
@@ -1927,51 +1786,6 @@ module Ruflet
1927
1786
  service(:url_launcher)
1928
1787
  end
1929
1788
 
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
1789
  def ensure_haptic_feedback_service
1976
1790
  service(:haptic_feedback)
1977
1791
  end
@@ -1981,38 +1795,6 @@ module Ruflet
1981
1795
  invoke(haptic_feedback, method_name, timeout: timeout, on_result: on_result)
1982
1796
  end
1983
1797
 
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
1798
  def ensure_connectivity_service
2017
1799
  service_with_created(:connectivity)
2018
1800
  end