ruflet_core 0.0.9

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 (208) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +3 -0
  3. data/lib/ruflet/version.rb +5 -0
  4. data/lib/ruflet.rb +3 -0
  5. data/lib/ruflet_core.rb +40 -0
  6. data/lib/ruflet_protocol/ruflet/protocol.rb +62 -0
  7. data/lib/ruflet_protocol.rb +4 -0
  8. data/lib/ruflet_ui/ruflet/app.rb +27 -0
  9. data/lib/ruflet_ui/ruflet/colors.rb +234 -0
  10. data/lib/ruflet_ui/ruflet/control.rb +281 -0
  11. data/lib/ruflet_ui/ruflet/dsl.rb +291 -0
  12. data/lib/ruflet_ui/ruflet/event.rb +42 -0
  13. data/lib/ruflet_ui/ruflet/events/gesture_events.rb +552 -0
  14. data/lib/ruflet_ui/ruflet/icon_data.rb +62 -0
  15. data/lib/ruflet_ui/ruflet/icons/cupertino/cupertino_icons.rb +54 -0
  16. data/lib/ruflet_ui/ruflet/icons/cupertino_icon_lookup.rb +70 -0
  17. data/lib/ruflet_ui/ruflet/icons/material/material_icons.rb +55 -0
  18. data/lib/ruflet_ui/ruflet/icons/material_icon_lookup.rb +70 -0
  19. data/lib/ruflet_ui/ruflet/page.rb +1222 -0
  20. data/lib/ruflet_ui/ruflet/types/geometry.rb +65 -0
  21. data/lib/ruflet_ui/ruflet/types/text_style.rb +191 -0
  22. data/lib/ruflet_ui/ruflet/ui/control_factory.rb +53 -0
  23. data/lib/ruflet_ui/ruflet/ui/control_methods.rb +17 -0
  24. data/lib/ruflet_ui/ruflet/ui/control_registry.rb +33 -0
  25. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoactionsheet_control.rb +57 -0
  26. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoactionsheetaction_control.rb +58 -0
  27. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoactivityindicator_control.rb +57 -0
  28. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoalertdialog_control.rb +39 -0
  29. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoappbar_control.rb +44 -0
  30. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinobottomsheet_control.rb +38 -0
  31. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinobutton_control.rb +73 -0
  32. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinocheckbox_control.rb +70 -0
  33. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinocontextmenu_control.rb +34 -0
  34. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinocontextmenuaction_control.rb +36 -0
  35. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinodatepicker_control.rb +67 -0
  36. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinodialogaction_control.rb +35 -0
  37. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinofilledbutton_control.rb +73 -0
  38. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinolisttile_control.rb +67 -0
  39. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinonavigationbar_control.rb +61 -0
  40. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinopicker_control.rb +66 -0
  41. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoradio_control.rb +66 -0
  42. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinosegmentedbutton_control.rb +63 -0
  43. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoslider_control.rb +64 -0
  44. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoslidingsegmentedbutton_control.rb +60 -0
  45. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinoswitch_control.rb +73 -0
  46. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinotextfield_control.rb +159 -0
  47. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinotimerpicker_control.rb +61 -0
  48. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/cupertinotintedbutton_control.rb +73 -0
  49. data/lib/ruflet_ui/ruflet/ui/controls/cupertinos/ruflet_controls.rb +89 -0
  50. data/lib/ruflet_ui/ruflet/ui/controls/materials/alertdialog_control.rb +58 -0
  51. data/lib/ruflet_ui/ruflet/ui/controls/materials/appbar_control.rb +53 -0
  52. data/lib/ruflet_ui/ruflet/ui/controls/materials/audio_control.rb +36 -0
  53. data/lib/ruflet_ui/ruflet/ui/controls/materials/autocomplete_control.rb +58 -0
  54. data/lib/ruflet_ui/ruflet/ui/controls/materials/badge_control.rb +31 -0
  55. data/lib/ruflet_ui/ruflet/ui/controls/materials/banner_control.rb +47 -0
  56. data/lib/ruflet_ui/ruflet/ui/controls/materials/bottomappbar_control.rb +62 -0
  57. data/lib/ruflet_ui/ruflet/ui/controls/materials/bottomsheet_control.rb +48 -0
  58. data/lib/ruflet_ui/ruflet/ui/controls/materials/button_control.rb +69 -0
  59. data/lib/ruflet_ui/ruflet/ui/controls/materials/card_control.rb +63 -0
  60. data/lib/ruflet_ui/ruflet/ui/controls/materials/chart_controls.rb +321 -0
  61. data/lib/ruflet_ui/ruflet/ui/controls/materials/checkbox_control.rb +76 -0
  62. data/lib/ruflet_ui/ruflet/ui/controls/materials/chip_control.rb +88 -0
  63. data/lib/ruflet_ui/ruflet/ui/controls/materials/circleavatar_control.rb +62 -0
  64. data/lib/ruflet_ui/ruflet/ui/controls/materials/container_control.rb +81 -0
  65. data/lib/ruflet_ui/ruflet/ui/controls/materials/contextmenu_control.rb +63 -0
  66. data/lib/ruflet_ui/ruflet/ui/controls/materials/datacell_control.rb +38 -0
  67. data/lib/ruflet_ui/ruflet/ui/controls/materials/datacolumn_control.rb +34 -0
  68. data/lib/ruflet_ui/ruflet/ui/controls/materials/datarow_control.rb +35 -0
  69. data/lib/ruflet_ui/ruflet/ui/controls/materials/datatable_control.rb +78 -0
  70. data/lib/ruflet_ui/ruflet/ui/controls/materials/datepicker_control.rb +55 -0
  71. data/lib/ruflet_ui/ruflet/ui/controls/materials/daterangepicker_control.rb +57 -0
  72. data/lib/ruflet_ui/ruflet/ui/controls/materials/divider_control.rb +36 -0
  73. data/lib/ruflet_ui/ruflet/ui/controls/materials/dropdown_control.rb +99 -0
  74. data/lib/ruflet_ui/ruflet/ui/controls/materials/dropdownm2_control.rb +119 -0
  75. data/lib/ruflet_ui/ruflet/ui/controls/materials/dropdownoption_control.rb +35 -0
  76. data/lib/ruflet_ui/ruflet/ui/controls/materials/expansionpanel_control.rb +61 -0
  77. data/lib/ruflet_ui/ruflet/ui/controls/materials/expansionpanellist_control.rb +60 -0
  78. data/lib/ruflet_ui/ruflet/ui/controls/materials/expansiontile_control.rb +82 -0
  79. data/lib/ruflet_ui/ruflet/ui/controls/materials/filledbutton_control.rb +69 -0
  80. data/lib/ruflet_ui/ruflet/ui/controls/materials/fillediconbutton_control.rb +81 -0
  81. data/lib/ruflet_ui/ruflet/ui/controls/materials/filledtonalbutton_control.rb +69 -0
  82. data/lib/ruflet_ui/ruflet/ui/controls/materials/filledtonaliconbutton_control.rb +81 -0
  83. data/lib/ruflet_ui/ruflet/ui/controls/materials/floatingactionbutton_control.rb +73 -0
  84. data/lib/ruflet_ui/ruflet/ui/controls/materials/iconbutton_control.rb +81 -0
  85. data/lib/ruflet_ui/ruflet/ui/controls/materials/listtile_control.rb +87 -0
  86. data/lib/ruflet_ui/ruflet/ui/controls/materials/menubar_control.rb +33 -0
  87. data/lib/ruflet_ui/ruflet/ui/controls/materials/menuitembutton_control.rb +67 -0
  88. data/lib/ruflet_ui/ruflet/ui/controls/materials/navigationbar_control.rb +67 -0
  89. data/lib/ruflet_ui/ruflet/ui/controls/materials/navigationbardestination_control.rb +35 -0
  90. data/lib/ruflet_ui/ruflet/ui/controls/materials/navigationdrawer_control.rb +41 -0
  91. data/lib/ruflet_ui/ruflet/ui/controls/materials/navigationdrawerdestination_control.rb +34 -0
  92. data/lib/ruflet_ui/ruflet/ui/controls/materials/navigationrail_control.rb +70 -0
  93. data/lib/ruflet_ui/ruflet/ui/controls/materials/navigationraildestination_control.rb +36 -0
  94. data/lib/ruflet_ui/ruflet/ui/controls/materials/option_control.rb +35 -0
  95. data/lib/ruflet_ui/ruflet/ui/controls/materials/outlinedbutton_control.rb +66 -0
  96. data/lib/ruflet_ui/ruflet/ui/controls/materials/outlinediconbutton_control.rb +81 -0
  97. data/lib/ruflet_ui/ruflet/ui/controls/materials/popupmenubutton_control.rb +74 -0
  98. data/lib/ruflet_ui/ruflet/ui/controls/materials/popupmenuitem_control.rb +38 -0
  99. data/lib/ruflet_ui/ruflet/ui/controls/materials/progressbar_control.rb +64 -0
  100. data/lib/ruflet_ui/ruflet/ui/controls/materials/progressring_control.rb +65 -0
  101. data/lib/ruflet_ui/ruflet/ui/controls/materials/radio_control.rb +70 -0
  102. data/lib/ruflet_ui/ruflet/ui/controls/materials/radiogroup_control.rb +33 -0
  103. data/lib/ruflet_ui/ruflet/ui/controls/materials/rangeslider_control.rb +67 -0
  104. data/lib/ruflet_ui/ruflet/ui/controls/materials/reorderablelistview_control.rb +80 -0
  105. data/lib/ruflet_ui/ruflet/ui/controls/materials/ruflet_controls.rb +252 -0
  106. data/lib/ruflet_ui/ruflet/ui/controls/materials/searchbar_control.rb +94 -0
  107. data/lib/ruflet_ui/ruflet/ui/controls/materials/segment_control.rb +33 -0
  108. data/lib/ruflet_ui/ruflet/ui/controls/materials/segmentedbutton_control.rb +63 -0
  109. data/lib/ruflet_ui/ruflet/ui/controls/materials/selectionarea_control.rb +32 -0
  110. data/lib/ruflet_ui/ruflet/ui/controls/materials/slider_control.rb +76 -0
  111. data/lib/ruflet_ui/ruflet/ui/controls/materials/snackbar_control.rb +51 -0
  112. data/lib/ruflet_ui/ruflet/ui/controls/materials/submenubutton_control.rb +66 -0
  113. data/lib/ruflet_ui/ruflet/ui/controls/materials/switch_control.rb +77 -0
  114. data/lib/ruflet_ui/ruflet/ui/controls/materials/tab_control.rb +35 -0
  115. data/lib/ruflet_ui/ruflet/ui/controls/materials/tabbar_control.rb +77 -0
  116. data/lib/ruflet_ui/ruflet/ui/controls/materials/tabbarview_control.rb +57 -0
  117. data/lib/ruflet_ui/ruflet/ui/controls/materials/tabs_control.rb +59 -0
  118. data/lib/ruflet_ui/ruflet/ui/controls/materials/textbutton_control.rb +66 -0
  119. data/lib/ruflet_ui/ruflet/ui/controls/materials/textfield_control.rb +148 -0
  120. data/lib/ruflet_ui/ruflet/ui/controls/materials/timepicker_control.rb +50 -0
  121. data/lib/ruflet_ui/ruflet/ui/controls/materials/verticaldivider_control.rb +36 -0
  122. data/lib/ruflet_ui/ruflet/ui/controls/materials/video_control.rb +39 -0
  123. data/lib/ruflet_ui/ruflet/ui/controls/materials/webview_control.rb +35 -0
  124. data/lib/ruflet_ui/ruflet/ui/controls/ruflet_controls.rb +447 -0
  125. data/lib/ruflet_ui/ruflet/ui/controls/shared/animatedswitcher_control.rb +59 -0
  126. data/lib/ruflet_ui/ruflet/ui/controls/shared/arc_control.rb +29 -0
  127. data/lib/ruflet_ui/ruflet/ui/controls/shared/autofillgroup_control.rb +32 -0
  128. data/lib/ruflet_ui/ruflet/ui/controls/shared/basepage_control.rb +44 -0
  129. data/lib/ruflet_ui/ruflet/ui/controls/shared/browsercontextmenu_control.rb +21 -0
  130. data/lib/ruflet_ui/ruflet/ui/controls/shared/canvas_control.rb +57 -0
  131. data/lib/ruflet_ui/ruflet/ui/controls/shared/circle_control.rb +25 -0
  132. data/lib/ruflet_ui/ruflet/ui/controls/shared/color_control.rb +23 -0
  133. data/lib/ruflet_ui/ruflet/ui/controls/shared/column_control.rb +67 -0
  134. data/lib/ruflet_ui/ruflet/ui/controls/shared/dialogs_control.rb +22 -0
  135. data/lib/ruflet_ui/ruflet/ui/controls/shared/dismissible_control.rb +66 -0
  136. data/lib/ruflet_ui/ruflet/ui/controls/shared/draggable_control.rb +39 -0
  137. data/lib/ruflet_ui/ruflet/ui/controls/shared/dragtarget_control.rb +36 -0
  138. data/lib/ruflet_ui/ruflet/ui/controls/shared/fill_control.rb +22 -0
  139. data/lib/ruflet_ui/ruflet/ui/controls/shared/fletapp_control.rb +62 -0
  140. data/lib/ruflet_ui/ruflet/ui/controls/shared/gesturedetector_control.rb +129 -0
  141. data/lib/ruflet_ui/ruflet/ui/controls/shared/gridview_control.rb +71 -0
  142. data/lib/ruflet_ui/ruflet/ui/controls/shared/hero_control.rb +56 -0
  143. data/lib/ruflet_ui/ruflet/ui/controls/shared/icon_control.rb +64 -0
  144. data/lib/ruflet_ui/ruflet/ui/controls/shared/image_control.rb +27 -0
  145. data/lib/ruflet_ui/ruflet/ui/controls/shared/interactiveviewer_control.rb +69 -0
  146. data/lib/ruflet_ui/ruflet/ui/controls/shared/keyboardlistener_control.rb +36 -0
  147. data/lib/ruflet_ui/ruflet/ui/controls/shared/line_control.rb +26 -0
  148. data/lib/ruflet_ui/ruflet/ui/controls/shared/listview_control.rb +71 -0
  149. data/lib/ruflet_ui/ruflet/ui/controls/shared/markdown_control.rb +70 -0
  150. data/lib/ruflet_ui/ruflet/ui/controls/shared/mergesemantics_control.rb +31 -0
  151. data/lib/ruflet_ui/ruflet/ui/controls/shared/oval_control.rb +26 -0
  152. data/lib/ruflet_ui/ruflet/ui/controls/shared/overlay_control.rb +22 -0
  153. data/lib/ruflet_ui/ruflet/ui/controls/shared/page_control.rb +74 -0
  154. data/lib/ruflet_ui/ruflet/ui/controls/shared/pagelet_control.rb +64 -0
  155. data/lib/ruflet_ui/ruflet/ui/controls/shared/pageview_control.rb +64 -0
  156. data/lib/ruflet_ui/ruflet/ui/controls/shared/path_control.rb +23 -0
  157. data/lib/ruflet_ui/ruflet/ui/controls/shared/placeholder_control.rb +58 -0
  158. data/lib/ruflet_ui/ruflet/ui/controls/shared/points_control.rb +24 -0
  159. data/lib/ruflet_ui/ruflet/ui/controls/shared/rect_control.rb +27 -0
  160. data/lib/ruflet_ui/ruflet/ui/controls/shared/reorderabledraghandle_control.rb +56 -0
  161. data/lib/ruflet_ui/ruflet/ui/controls/shared/responsiverow_control.rb +61 -0
  162. data/lib/ruflet_ui/ruflet/ui/controls/shared/row_control.rb +67 -0
  163. data/lib/ruflet_ui/ruflet/ui/controls/shared/ruflet_controls.rb +140 -0
  164. data/lib/ruflet_ui/ruflet/ui/controls/shared/safearea_control.rb +61 -0
  165. data/lib/ruflet_ui/ruflet/ui/controls/shared/semantics_control.rb +79 -0
  166. data/lib/ruflet_ui/ruflet/ui/controls/shared/serviceregistry_control.rb +21 -0
  167. data/lib/ruflet_ui/ruflet/ui/controls/shared/shadermask_control.rb +57 -0
  168. data/lib/ruflet_ui/ruflet/ui/controls/shared/shadow_control.rb +25 -0
  169. data/lib/ruflet_ui/ruflet/ui/controls/shared/shimmer_control.rb +60 -0
  170. data/lib/ruflet_ui/ruflet/ui/controls/shared/stack_control.rb +58 -0
  171. data/lib/ruflet_ui/ruflet/ui/controls/shared/text_control.rb +32 -0
  172. data/lib/ruflet_ui/ruflet/ui/controls/shared/textspan_control.rb +39 -0
  173. data/lib/ruflet_ui/ruflet/ui/controls/shared/transparentpointer_control.rb +54 -0
  174. data/lib/ruflet_ui/ruflet/ui/controls/shared/view_control.rb +77 -0
  175. data/lib/ruflet_ui/ruflet/ui/controls/shared/window_control.rb +56 -0
  176. data/lib/ruflet_ui/ruflet/ui/controls/shared/windowdragarea_control.rb +58 -0
  177. data/lib/ruflet_ui/ruflet/ui/cupertino_control_factory.rb +13 -0
  178. data/lib/ruflet_ui/ruflet/ui/cupertino_control_methods.rb +26 -0
  179. data/lib/ruflet_ui/ruflet/ui/cupertino_control_registry.rb +49 -0
  180. data/lib/ruflet_ui/ruflet/ui/material_control_factory.rb +13 -0
  181. data/lib/ruflet_ui/ruflet/ui/material_control_methods.rb +170 -0
  182. data/lib/ruflet_ui/ruflet/ui/material_control_registry.rb +168 -0
  183. data/lib/ruflet_ui/ruflet/ui/services/ruflet/accelerometer_control.rb +26 -0
  184. data/lib/ruflet_ui/ruflet/ui/services/ruflet/barometer_control.rb +26 -0
  185. data/lib/ruflet_ui/ruflet/ui/services/ruflet/battery_control.rb +22 -0
  186. data/lib/ruflet_ui/ruflet/ui/services/ruflet/camera_control.rb +58 -0
  187. data/lib/ruflet_ui/ruflet/ui/services/ruflet/clipboard_control.rb +21 -0
  188. data/lib/ruflet_ui/ruflet/ui/services/ruflet/connectivity_control.rb +22 -0
  189. data/lib/ruflet_ui/ruflet/ui/services/ruflet/filepicker_control.rb +23 -0
  190. data/lib/ruflet_ui/ruflet/ui/services/ruflet/flashlight_control.rb +22 -0
  191. data/lib/ruflet_ui/ruflet/ui/services/ruflet/gyroscope_control.rb +26 -0
  192. data/lib/ruflet_ui/ruflet/ui/services/ruflet/hapticfeedback_control.rb +21 -0
  193. data/lib/ruflet_ui/ruflet/ui/services/ruflet/magnetometer_control.rb +26 -0
  194. data/lib/ruflet_ui/ruflet/ui/services/ruflet/screenbrightness_control.rb +23 -0
  195. data/lib/ruflet_ui/ruflet/ui/services/ruflet/screenshot_control.rb +31 -0
  196. data/lib/ruflet_ui/ruflet/ui/services/ruflet/semanticsservice_control.rb +21 -0
  197. data/lib/ruflet_ui/ruflet/ui/services/ruflet/shakedetector_control.rb +26 -0
  198. data/lib/ruflet_ui/ruflet/ui/services/ruflet/share_control.rb +21 -0
  199. data/lib/ruflet_ui/ruflet/ui/services/ruflet/sharedpreferences_control.rb +21 -0
  200. data/lib/ruflet_ui/ruflet/ui/services/ruflet/storagepaths_control.rb +21 -0
  201. data/lib/ruflet_ui/ruflet/ui/services/ruflet/urllauncher_control.rb +21 -0
  202. data/lib/ruflet_ui/ruflet/ui/services/ruflet/useraccelerometer_control.rb +26 -0
  203. data/lib/ruflet_ui/ruflet/ui/services/ruflet/wakelock_control.rb +21 -0
  204. data/lib/ruflet_ui/ruflet/ui/services/ruflet_services.rb +66 -0
  205. data/lib/ruflet_ui/ruflet/ui/shared_control_forwarders.rb +129 -0
  206. data/lib/ruflet_ui/ruflet/ui/widget_builder.rb +64 -0
  207. data/lib/ruflet_ui.rb +160 -0
  208. metadata +245 -0
@@ -0,0 +1,1222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "event"
4
+ require "ruflet_protocol"
5
+ require_relative "control"
6
+ require_relative "ui/widget_builder"
7
+ require_relative "ui/control_factory"
8
+ require_relative "icons/material_icon_lookup"
9
+ require_relative "icons/cupertino_icon_lookup"
10
+ require "set"
11
+ require "cgi"
12
+ require "thread"
13
+ require "timeout"
14
+
15
+ module Ruflet
16
+ class Page
17
+ PAGE_PROP_KEYS = %w[route title vertical_alignment horizontal_alignment scroll].freeze
18
+ DIALOG_PROP_KEYS = %w[dialog snack_bar bottom_sheet].freeze
19
+ WIDGET_HELPER_METHODS = (
20
+ Ruflet::UI::MaterialControlMethods.instance_methods(false) +
21
+ Ruflet::UI::CupertinoControlMethods.instance_methods(false) +
22
+ %i[control widget]
23
+ ).map(&:to_s).to_set.freeze
24
+
25
+ attr_reader :session_id, :client_details, :views
26
+
27
+ def initialize(session_id:, client_details:, sender:)
28
+ @session_id = session_id
29
+ @client_details = client_details
30
+ @sender = sender
31
+ @control_index = {}
32
+ @wire_index = {}
33
+ @next_wire_id = 100
34
+ @view_id = 20
35
+ @root_controls = []
36
+ @views = []
37
+ @dialogs = []
38
+ @page_event_handlers = {}
39
+ @view_props = {}
40
+ @page_props = { "route" => (client_details["route"] || "/") }
41
+ @overlay_container = Ruflet::Control.new(
42
+ type: "overlay",
43
+ id: "_overlay",
44
+ controls: []
45
+ )
46
+ @services_container = Ruflet::Control.new(
47
+ type: "service_registry",
48
+ id: "_services",
49
+ "_services": [],
50
+ "_internals": { "uid" => Ruflet::Control.generate_id }
51
+ )
52
+ @dialogs_container = Ruflet::Control.new(
53
+ type: "dialogs",
54
+ id: "_dialogs",
55
+ controls: []
56
+ )
57
+ @invoke_waiters = {}
58
+ @invoke_callbacks = {}
59
+ @invoke_waiters_mutex = Mutex.new
60
+ refresh_overlay_container!
61
+ refresh_services_container!
62
+ refresh_dialogs_container!
63
+ end
64
+
65
+ def set_view_props(props)
66
+ split_props(normalize_props(props || {}))
67
+ self
68
+ end
69
+
70
+ def title
71
+ @page_props["title"]
72
+ end
73
+
74
+ def title=(value)
75
+ @page_props["title"] = value
76
+ end
77
+
78
+ def route
79
+ @page_props["route"]
80
+ end
81
+
82
+ def route=(value)
83
+ @page_props["route"] = value
84
+ end
85
+
86
+ def vertical_alignment
87
+ @page_props["vertical_alignment"] || @view_props["vertical_alignment"]
88
+ end
89
+
90
+ def vertical_alignment=(value)
91
+ v = normalize_value("vertical_alignment", value)
92
+ @page_props["vertical_alignment"] = v
93
+ @view_props["vertical_alignment"] = v
94
+ end
95
+
96
+ def horizontal_alignment
97
+ @page_props["horizontal_alignment"] || @view_props["horizontal_alignment"]
98
+ end
99
+
100
+ def horizontal_alignment=(value)
101
+ v = normalize_value("horizontal_alignment", value)
102
+ @page_props["horizontal_alignment"] = v
103
+ @view_props["horizontal_alignment"] = v
104
+ end
105
+
106
+ def bgcolor
107
+ @view_props["bgcolor"]
108
+ end
109
+
110
+ def bgcolor=(value)
111
+ @view_props["bgcolor"] = normalize_value("bgcolor", value)
112
+ end
113
+
114
+ def add(*controls, appbar: nil, floating_action_button: nil, navigation_bar: nil, dialog: nil, snack_bar: nil, bottom_sheet: nil)
115
+ controls = controls.flatten
116
+ visited = Set.new
117
+ controls.each { |c| register_control_tree(c, visited) }
118
+ @root_controls = controls
119
+
120
+ @view_props["appbar"] = appbar if appbar
121
+ @view_props["floating_action_button"] = floating_action_button if floating_action_button
122
+ @view_props["navigation_bar"] = navigation_bar if navigation_bar
123
+ @dialog = dialog if dialog
124
+ @snack_bar = snack_bar if snack_bar
125
+ @bottom_sheet = bottom_sheet if bottom_sheet
126
+
127
+ refresh_dialogs_container!
128
+ @view_props.each_value { |value| register_embedded_value(value, visited) }
129
+
130
+ send_view_patch
131
+
132
+ self
133
+ end
134
+
135
+ def views=(value)
136
+ @views = Array(value).compact
137
+ self
138
+ end
139
+
140
+ def services
141
+ @services_container.props["_services"] ||= []
142
+ end
143
+
144
+ def services=(value)
145
+ @services_container.props["_services"] = Array(value).compact
146
+ refresh_services_container!
147
+ push_services_update!
148
+ self
149
+ end
150
+
151
+ def add_service(*value)
152
+ @services_container.props["_services"] = services + value.flatten.compact
153
+ refresh_services_container!
154
+ push_services_update!
155
+ self
156
+ end
157
+
158
+ def remove_service(*value)
159
+ targets = value.flatten.compact
160
+ return self if targets.empty?
161
+
162
+ @services_container.props["_services"] = services.reject do |service|
163
+ targets.any? do |target|
164
+ case target
165
+ when Control
166
+ service.equal?(target) || (!target.id.nil? && service.id.to_s == target.id.to_s)
167
+ else
168
+ needle = target.to_s
169
+ service.id.to_s == needle || service.type.to_s.downcase == needle.downcase
170
+ end
171
+ end
172
+ end
173
+
174
+ refresh_services_container!
175
+ push_services_update!
176
+ self
177
+ end
178
+
179
+ def service(type, **props)
180
+ mapped_props = normalize_props(props || {})
181
+ id = mapped_props.delete("id")
182
+ normalized_type = type.to_s.downcase
183
+
184
+ existing =
185
+ if id
186
+ services.find { |s| s.is_a?(Control) && s.id.to_s == id.to_s }
187
+ else
188
+ services.find { |s| s.is_a?(Control) && s.type.to_s.downcase == normalized_type }
189
+ end
190
+ return existing if existing
191
+
192
+ svc = Ruflet::UI::ControlFactory.build(type.to_s, id: id&.to_s, **mapped_props)
193
+ add_service(svc) unless services.include?(svc)
194
+ svc
195
+ end
196
+
197
+ def go(route, **query_params)
198
+ @page_props["route"] = build_route(route, query_params)
199
+ dispatch_page_event(name: "route_change", data: @page_props["route"])
200
+ send_view_patch
201
+ self
202
+ end
203
+
204
+ def on_route_change=(handler)
205
+ @page_event_handlers["route_change"] = handler
206
+ end
207
+
208
+ def on_view_pop=(handler)
209
+ @page_event_handlers["view_pop"] = handler
210
+ end
211
+
212
+ def on(event_name, &block)
213
+ @page_event_handlers[event_name.to_s.sub(/\Aon_/, "")] = block
214
+ self
215
+ end
216
+
217
+ def mount(&block)
218
+ builder = WidgetBuilder.new
219
+ builder.instance_eval(&block)
220
+ add(*builder.children)
221
+ end
222
+
223
+ def appbar=(value)
224
+ @view_props["appbar"] = value
225
+ end
226
+
227
+ def floating_action_button=(value)
228
+ @view_props["floating_action_button"] = value
229
+ end
230
+
231
+ def dialog = @dialog
232
+
233
+ def dialog=(value)
234
+ @dialog = value
235
+ refresh_dialogs_container!
236
+ end
237
+
238
+ def snack_bar=(value)
239
+ @snack_bar = value
240
+ refresh_dialogs_container!
241
+ end
242
+
243
+ def snackbar=(value)
244
+ self.snack_bar = value
245
+ end
246
+
247
+ def bottom_sheet=(value)
248
+ @bottom_sheet = value
249
+ refresh_dialogs_container!
250
+ end
251
+
252
+ def bottomsheet=(value)
253
+ self.bottom_sheet = value
254
+ end
255
+
256
+ def show_dialog(dialog_control)
257
+ return self unless dialog_control
258
+
259
+ return self if dialog_open?(dialog_control)
260
+
261
+ dialog_control.props["open"] = true
262
+ @dialogs << dialog_control unless @dialogs.include?(dialog_control)
263
+ refresh_dialogs_container!
264
+ send_view_patch unless @dialogs_container.wire_id
265
+ push_dialogs_update!
266
+ self
267
+ end
268
+
269
+ def invoke(control_or_id, method_name, args: nil, timeout: 10, on_result: nil)
270
+ control_id =
271
+ if page_control_target?(control_or_id)
272
+ 1
273
+ else
274
+ control = resolve_control(control_or_id)
275
+ return nil unless control
276
+ control.wire_id
277
+ end
278
+
279
+ call_id = "call_#{Ruflet::Control.generate_id}"
280
+ if on_result.respond_to?(:call)
281
+ @invoke_waiters_mutex.synchronize { @invoke_callbacks[call_id] = on_result }
282
+ unless timeout.nil?
283
+ Thread.new(call_id, timeout.to_f) do |pending_call_id, invoke_timeout|
284
+ sleep([invoke_timeout, 0.0].max + 0.1)
285
+ callback = @invoke_waiters_mutex.synchronize { @invoke_callbacks.delete(pending_call_id) }
286
+ callback&.call(nil, "execution expired")
287
+ rescue StandardError => e
288
+ Kernel.warn("invoke timeout callback error: #{e.class}: #{e.message}")
289
+ end
290
+ end
291
+ end
292
+ payload = {
293
+ "control_id" => control_id,
294
+ "call_id" => call_id,
295
+ "name" => method_name.to_s,
296
+ "args" => args
297
+ }
298
+ payload["timeout"] = timeout unless timeout.nil?
299
+ send_message(Protocol::ACTIONS[:invoke_control_method], payload)
300
+
301
+ call_id
302
+ end
303
+
304
+ # Synchronous invoke for controls/services that must return a value
305
+ # before continuing (e.g. picker selection, camera discovery/init).
306
+ def invoke_sync(control_or_id, method_name, args: nil, timeout: 10)
307
+ invoke_and_wait(control_or_id, method_name, args: args, timeout: timeout)
308
+ end
309
+
310
+ def launch_url(url, mode: nil, web_view_configuration: nil, browser_configuration: nil, web_only_window_name: nil, timeout: 10, on_result: nil)
311
+ url_launcher = ensure_url_launcher_service
312
+ args = { "url" => url }
313
+ args["mode"] = mode unless mode.nil?
314
+ args["web_view_configuration"] = web_view_configuration unless web_view_configuration.nil?
315
+ args["browser_configuration"] = browser_configuration unless browser_configuration.nil?
316
+ args["web_only_window_name"] = web_only_window_name unless web_only_window_name.nil?
317
+ invoke(
318
+ url_launcher,
319
+ "launch_url",
320
+ args: args,
321
+ timeout: timeout,
322
+ on_result: on_result
323
+ )
324
+ end
325
+
326
+ def can_launch_url(url, timeout: 10)
327
+ url_launcher = ensure_url_launcher_service
328
+ invoke(url_launcher, "can_launch_url", args: { "url" => url }, timeout: timeout)
329
+ end
330
+
331
+ # File picker helpers: create an ephemeral service, invoke method, and dispose it.
332
+ def pick_files(
333
+ dialog_title: nil,
334
+ initial_directory: nil,
335
+ file_type: "any",
336
+ allowed_extensions: nil,
337
+ allow_multiple: false,
338
+ with_data: false,
339
+ timeout: nil,
340
+ on_result: nil
341
+ )
342
+ invoke_file_picker(
343
+ "pick_files",
344
+ {
345
+ "dialog_title" => dialog_title,
346
+ "initial_directory" => initial_directory,
347
+ "file_type" => file_type,
348
+ "allowed_extensions" => allowed_extensions,
349
+ "allow_multiple" => allow_multiple,
350
+ "with_data" => with_data
351
+ },
352
+ timeout: timeout,
353
+ on_result: on_result
354
+ )
355
+ end
356
+
357
+ def save_file(
358
+ dialog_title: nil,
359
+ file_name: nil,
360
+ initial_directory: nil,
361
+ file_type: "any",
362
+ allowed_extensions: nil,
363
+ src_bytes: nil,
364
+ timeout: nil,
365
+ on_result: nil
366
+ )
367
+ invoke_file_picker(
368
+ "save_file",
369
+ {
370
+ "dialog_title" => dialog_title,
371
+ "file_name" => file_name,
372
+ "initial_directory" => initial_directory,
373
+ "file_type" => file_type,
374
+ "allowed_extensions" => allowed_extensions,
375
+ "src_bytes" => src_bytes
376
+ },
377
+ timeout: timeout,
378
+ on_result: on_result
379
+ )
380
+ end
381
+
382
+ def get_directory_path(dialog_title: nil, initial_directory: nil, timeout: nil, on_result: nil)
383
+ invoke_file_picker(
384
+ "get_directory_path",
385
+ {
386
+ "dialog_title" => dialog_title,
387
+ "initial_directory" => initial_directory
388
+ },
389
+ timeout: timeout,
390
+ on_result: on_result
391
+ )
392
+ end
393
+
394
+ def set_clipboard(value, timeout: nil, on_result: nil)
395
+ invoke_clipboard_method(
396
+ "set_data",
397
+ args: { "data" => value.to_s },
398
+ timeout: timeout,
399
+ on_result: on_result
400
+ )
401
+ end
402
+
403
+ def get_clipboard(timeout: nil, on_result: nil)
404
+ invoke_clipboard_method("get_data", timeout: timeout, on_result: on_result)
405
+ end
406
+
407
+ def set_clipboard_files(files, timeout: nil, on_result: nil)
408
+ invoke_clipboard_method(
409
+ "set_files",
410
+ args: { "files" => Array(files).map(&:to_s) },
411
+ timeout: timeout,
412
+ on_result: on_result
413
+ )
414
+ end
415
+
416
+ def get_clipboard_files(timeout: nil, on_result: nil)
417
+ invoke_clipboard_method("get_files", timeout: timeout, on_result: on_result)
418
+ end
419
+
420
+ def set_clipboard_image(value, timeout: nil, on_result: nil)
421
+ invoke_clipboard_method(
422
+ "set_image",
423
+ args: { "data" => value },
424
+ timeout: timeout,
425
+ on_result: on_result
426
+ )
427
+ end
428
+
429
+ def get_clipboard_image(timeout: nil, on_result: nil)
430
+ invoke_clipboard_method("get_image", timeout: timeout, on_result: on_result)
431
+ end
432
+
433
+ def get_connectivity(timeout: nil, on_result: nil)
434
+ invoke_connectivity_method("get_connectivity", timeout: timeout, on_result: on_result)
435
+ end
436
+
437
+ def get_battery_level(timeout: nil, on_result: nil)
438
+ invoke_battery_method("get_battery_level", timeout: timeout, on_result: on_result)
439
+ end
440
+
441
+ def get_battery_state(timeout: nil, on_result: nil)
442
+ invoke_battery_method("get_battery_state", timeout: timeout, on_result: on_result)
443
+ end
444
+
445
+ def battery_save_mode?(timeout: nil, on_result: nil)
446
+ invoke_battery_method("is_in_battery_save_mode", timeout: timeout, on_result: on_result)
447
+ end
448
+
449
+ def get_application_cache_directory(timeout: nil, on_result: nil)
450
+ invoke_storage_paths("get_application_cache_directory", timeout: timeout, on_result: on_result)
451
+ end
452
+
453
+ def get_application_documents_directory(timeout: nil, on_result: nil)
454
+ invoke_storage_paths("get_application_documents_directory", timeout: timeout, on_result: on_result)
455
+ end
456
+
457
+ def get_application_support_directory(timeout: nil, on_result: nil)
458
+ invoke_storage_paths("get_application_support_directory", timeout: timeout, on_result: on_result)
459
+ end
460
+
461
+ def get_downloads_directory(timeout: nil, on_result: nil)
462
+ invoke_storage_paths("get_downloads_directory", timeout: timeout, on_result: on_result)
463
+ end
464
+
465
+ def get_external_cache_directories(timeout: nil, on_result: nil)
466
+ invoke_storage_paths("get_external_cache_directories", timeout: timeout, on_result: on_result)
467
+ end
468
+
469
+ def get_external_storage_directories(timeout: nil, on_result: nil)
470
+ invoke_storage_paths("get_external_storage_directories", timeout: timeout, on_result: on_result)
471
+ end
472
+
473
+ def get_library_directory(timeout: nil, on_result: nil)
474
+ invoke_storage_paths("get_library_directory", timeout: timeout, on_result: on_result)
475
+ end
476
+
477
+ def get_external_storage_directory(timeout: nil, on_result: nil)
478
+ invoke_storage_paths("get_external_storage_directory", timeout: timeout, on_result: on_result)
479
+ end
480
+
481
+ def get_temporary_directory(timeout: nil, on_result: nil)
482
+ invoke_storage_paths("get_temporary_directory", timeout: timeout, on_result: on_result)
483
+ end
484
+
485
+ def get_console_log_filename(timeout: nil, on_result: nil)
486
+ invoke_storage_paths("get_console_log_filename", timeout: timeout, on_result: on_result)
487
+ end
488
+
489
+ def share_text(
490
+ text:,
491
+ title: nil,
492
+ subject: nil,
493
+ preview_thumbnail: nil,
494
+ share_position_origin: nil,
495
+ download_fallback_enabled: true,
496
+ mail_to_fallback_enabled: true,
497
+ excluded_cupertino_activities: nil,
498
+ timeout: nil,
499
+ on_result: nil
500
+ )
501
+ share = ensure_share_service
502
+ invoke(
503
+ share,
504
+ "share_text",
505
+ args: {
506
+ "text" => text,
507
+ "title" => title,
508
+ "subject" => subject,
509
+ "preview_thumbnail" => preview_thumbnail,
510
+ "share_position_origin" => share_position_origin,
511
+ "download_fallback_enabled" => download_fallback_enabled,
512
+ "mail_to_fallback_enabled" => mail_to_fallback_enabled,
513
+ "excluded_cupertino_activities" => excluded_cupertino_activities
514
+ },
515
+ timeout: timeout,
516
+ on_result: on_result
517
+ )
518
+ end
519
+
520
+ def share_uri(
521
+ uri:,
522
+ share_position_origin: nil,
523
+ excluded_cupertino_activities: nil,
524
+ timeout: nil,
525
+ on_result: nil
526
+ )
527
+ share = ensure_share_service
528
+ invoke(
529
+ share,
530
+ "share_uri",
531
+ args: {
532
+ "uri" => uri,
533
+ "share_position_origin" => share_position_origin,
534
+ "excluded_cupertino_activities" => excluded_cupertino_activities
535
+ },
536
+ timeout: timeout,
537
+ on_result: on_result
538
+ )
539
+ end
540
+
541
+ def share_files(
542
+ files:,
543
+ text: nil,
544
+ title: nil,
545
+ subject: nil,
546
+ preview_thumbnail: nil,
547
+ share_position_origin: nil,
548
+ download_fallback_enabled: true,
549
+ mail_to_fallback_enabled: true,
550
+ excluded_cupertino_activities: nil,
551
+ timeout: nil,
552
+ on_result: nil
553
+ )
554
+ share = ensure_share_service
555
+ invoke(
556
+ share,
557
+ "share_files",
558
+ args: {
559
+ "files" => files,
560
+ "text" => text,
561
+ "title" => title,
562
+ "subject" => subject,
563
+ "preview_thumbnail" => preview_thumbnail,
564
+ "share_position_origin" => share_position_origin,
565
+ "download_fallback_enabled" => download_fallback_enabled,
566
+ "mail_to_fallback_enabled" => mail_to_fallback_enabled,
567
+ "excluded_cupertino_activities" => excluded_cupertino_activities
568
+ },
569
+ timeout: timeout,
570
+ on_result: on_result
571
+ )
572
+ end
573
+
574
+ def handle_invoke_method_result(payload)
575
+ call_id = payload["call_id"].to_s
576
+ waiter = @invoke_waiters_mutex.synchronize { @invoke_waiters[call_id] }
577
+ if waiter
578
+ waiter << payload
579
+ return true
580
+ end
581
+
582
+ callback = @invoke_waiters_mutex.synchronize { @invoke_callbacks.delete(call_id) }
583
+ return false unless callback
584
+
585
+ callback.call(payload["result"], payload["error"])
586
+ true
587
+ rescue StandardError => e
588
+ Kernel.warn("invoke callback error: #{e.class}: #{e.message}")
589
+ false
590
+ end
591
+
592
+ def pop_dialog
593
+ dialog_control = latest_open_dialog
594
+ return nil unless dialog_control
595
+
596
+ dialog_control.props["open"] = false
597
+ refresh_dialogs_container!
598
+ push_dialogs_update!
599
+ dialog_control
600
+ end
601
+
602
+ def update(control_or_id = nil, **props)
603
+ if control_or_id.nil? && props.empty?
604
+ send_view_patch
605
+ return self
606
+ end
607
+
608
+ if page_control_target?(control_or_id)
609
+ split_props(normalize_props(props))
610
+ send_view_patch
611
+ return self
612
+ end
613
+
614
+ control = resolve_control(control_or_id)
615
+ return self unless control
616
+ wire_id = control.wire_id
617
+ if wire_id.nil?
618
+ # Events can race with navigation/disposal; never emit patch_control with nil id.
619
+ refresh_control_indexes!
620
+ wire_id = control.wire_id
621
+ end
622
+ return self if wire_id.nil?
623
+
624
+ patch = normalize_props(props)
625
+ if text_maps_to_content?(control, patch)
626
+ patch["content"] = patch.delete("text")
627
+ end
628
+
629
+ # Keep runtime control tree aligned with incremental patches.
630
+ if patch.key?("controls")
631
+ control.children.clear
632
+ Array(patch["controls"]).each { |child| control.children << child if child.is_a?(Control) }
633
+ end
634
+
635
+ visited = Set.new
636
+ patch.each_value { |value| register_embedded_value(value, visited) }
637
+
638
+ patch_ops = patch.map { |k, v| [0, 0, k, serialize_patch_value(v)] }
639
+
640
+ send_message(Protocol::ACTIONS[:patch_control], {
641
+ "id" => wire_id,
642
+ "patch" => [[0], *patch_ops]
643
+ })
644
+
645
+ self
646
+ end
647
+
648
+ def patch_page(control_id, **props)
649
+ update(control_id, **props)
650
+ end
651
+
652
+ def apply_client_update(control_or_id, props)
653
+ control = resolve_control(control_or_id)
654
+ return self unless control
655
+
656
+ patch = normalize_props(props || {})
657
+ patch.each { |k, v| control.props[k] = v }
658
+
659
+ remove_dialog_tracking(control) if patch.key?("open") && patch["open"] == false
660
+
661
+ self
662
+ end
663
+
664
+ def dispatch_event(target:, name:, data:)
665
+ if page_control_target?(target)
666
+ if name.to_s == "route_change"
667
+ route_from_event = extract_route(data)
668
+ @page_props["route"] = route_from_event if route_from_event
669
+ end
670
+ dispatch_page_event(name: name, data: data)
671
+ return
672
+ end
673
+
674
+ control = @wire_index[target.to_i] || @control_index[target.to_s]
675
+ return unless control
676
+
677
+ event = Event.new(name: name, target: target, raw_data: data, page: self, control: control)
678
+ control.emit(name, event)
679
+
680
+ if name.to_s == "dismiss" && remove_dialog_tracking(control)
681
+ push_dialogs_update!
682
+ end
683
+ end
684
+
685
+ def method_missing(name, *args, &block)
686
+ method_name = name.to_s
687
+ prop_name = method_name.delete_suffix("=")
688
+
689
+ if method_name.end_with?("=")
690
+ if widget_helper_method?(prop_name)
691
+ raise NoMethodError, "Use `#{prop_name}(...)` as a free widget helper, then attach with `page.add(...)`."
692
+ end
693
+ assign_split_prop(prop_name, normalize_value(prop_name, args.first))
694
+ return args.first
695
+ end
696
+
697
+ if args.empty? && !block
698
+ return @page_props[method_name] if @page_props.key?(method_name)
699
+ return @view_props[method_name] if @view_props.key?(method_name)
700
+ return instance_variable_get("@#{method_name}") if DIALOG_PROP_KEYS.include?(method_name)
701
+ end
702
+
703
+ if widget_helper_method?(name)
704
+ raise NoMethodError, "Use `#{name}(...)` as a free widget helper, then attach with `page.add(...)`."
705
+ end
706
+
707
+ super
708
+ end
709
+
710
+ def respond_to_missing?(name, include_private = false)
711
+ method_name = name.to_s
712
+ prop_name = method_name.delete_suffix("=")
713
+ widget_helper_method?(name) ||
714
+ widget_helper_method?(prop_name) ||
715
+ method_name.end_with?("=") ||
716
+ @page_props.key?(method_name) ||
717
+ @view_props.key?(method_name) ||
718
+ DIALOG_PROP_KEYS.include?(method_name) ||
719
+ super
720
+ end
721
+
722
+ private
723
+
724
+ def invoke_and_wait(control_or_id, method_name, args: nil, timeout: 10)
725
+ control_id =
726
+ if page_control_target?(control_or_id)
727
+ 1
728
+ else
729
+ control = resolve_control(control_or_id)
730
+ return nil unless control
731
+ control.wire_id
732
+ end
733
+
734
+ call_id = "call_#{Ruflet::Control.generate_id}"
735
+ waiter = Queue.new
736
+ @invoke_waiters_mutex.synchronize { @invoke_waiters[call_id] = waiter }
737
+
738
+ send_message(Protocol::ACTIONS[:invoke_control_method], {
739
+ "control_id" => control_id,
740
+ "call_id" => call_id,
741
+ "name" => method_name.to_s,
742
+ "args" => args,
743
+ "timeout" => timeout
744
+ })
745
+
746
+ response = Timeout.timeout(timeout.to_f) { waiter.pop }
747
+ error = response["error"]
748
+ raise RuntimeError, error if error && !error.to_s.empty?
749
+
750
+ response["result"]
751
+ ensure
752
+ @invoke_waiters_mutex.synchronize { @invoke_waiters.delete(call_id) } if call_id
753
+ end
754
+
755
+ def build_widget(type, **props, &block) = WidgetBuilder.new.control(type, **props, &block)
756
+
757
+ def widget_helper_method?(name)
758
+ WIDGET_HELPER_METHODS.include?(name.to_s)
759
+ end
760
+
761
+ def text_maps_to_content?(control, patch)
762
+ patch.key?("text") && control.type.end_with?("button")
763
+ end
764
+
765
+ def split_props(props)
766
+ props.each do |k, v|
767
+ assign_split_prop(k, v)
768
+ end
769
+ end
770
+
771
+ def send_message(action, payload)
772
+ @sender.call(action, payload)
773
+ end
774
+
775
+ def send_view_patch
776
+ refresh_control_indexes!
777
+ view_patches = build_view_patches
778
+ page_patch_ops = build_page_patch_ops
779
+
780
+ send_message(Protocol::ACTIONS[:patch_control], {
781
+ "id" => 1,
782
+ "patch" => [
783
+ [0],
784
+ [0, 0, "views", view_patches],
785
+ *page_patch_ops
786
+ ]
787
+ })
788
+ end
789
+
790
+ def register_control_tree(control, visited = Set.new)
791
+ return unless control
792
+ return if visited.include?(control.object_id)
793
+
794
+ visited << control.object_id
795
+ assign_wire_id(control)
796
+ control.runtime_page = self if control.respond_to?(:runtime_page=)
797
+ @control_index[control.id.to_s] = control
798
+ @wire_index[control.wire_id] = control
799
+ control.children.each { |child| register_control_tree(child, visited) }
800
+ control.props.each_value { |value| register_embedded_value(value, visited) }
801
+ end
802
+
803
+ def implicit_view_patch
804
+ view_patch = {
805
+ "_c" => "View",
806
+ "_i" => @view_id,
807
+ "route" => (@page_props["route"] || @client_details["route"] || "/"),
808
+ # Required by Flet layout engine so children with `expand` inside View
809
+ # are wrapped with Expanded/Flexible on the Flutter side.
810
+ "_internals" => { "host_expanded" => true }
811
+ }
812
+ @view_props.each { |k, v| view_patch[k] = serialize_patch_value(v) }
813
+ view_patch["controls"] = @root_controls.map(&:to_patch)
814
+ view_patch
815
+ end
816
+
817
+ def refresh_control_indexes!
818
+ @control_index.clear
819
+ @wire_index.clear
820
+ visited = Set.new
821
+
822
+ if @views.any?
823
+ @views.each { |view| register_control_tree(view, visited) }
824
+ else
825
+ @root_controls.each { |control| register_control_tree(control, visited) }
826
+ @view_props.each_value { |value| register_embedded_value(value, visited) }
827
+ end
828
+ @page_props.each_value { |value| register_embedded_value(value, visited) }
829
+ end
830
+
831
+ def register_embedded_value(value, visited)
832
+ case value
833
+ when Control
834
+ register_control_tree(value, visited)
835
+ when Array
836
+ value.each { |v| register_embedded_value(v, visited) }
837
+ when Hash
838
+ value.each_value { |v| register_embedded_value(v, visited) }
839
+ end
840
+ end
841
+
842
+ def assign_wire_id(control)
843
+ return if control.wire_id
844
+
845
+ control.wire_id = @next_wire_id
846
+ @next_wire_id += 1
847
+ end
848
+
849
+ def resolve_control(control_or_id)
850
+ if control_or_id.respond_to?(:wire_id)
851
+ control_or_id
852
+ elsif control_or_id.to_s.match?(/^\d+$/)
853
+ @wire_index[control_or_id.to_i]
854
+ else
855
+ @control_index[control_or_id.to_s]
856
+ end
857
+ end
858
+
859
+ def normalize_props(hash)
860
+ hash.each_with_object({}) do |(k, v), result|
861
+ key = k.to_s
862
+ key = "controls" if key == "children"
863
+ result[key] = normalize_value(key, v)
864
+ end
865
+ end
866
+
867
+ def normalize_value(key, value)
868
+ if icon_prop_key?(key) && (value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Integer))
869
+ codepoint = resolve_icon_codepoint(value)
870
+ return codepoint unless codepoint.nil?
871
+ end
872
+
873
+ return value.value if value.is_a?(Ruflet::IconData)
874
+ value.is_a?(Symbol) ? value.to_s : value
875
+ end
876
+
877
+ def build_route(route, query_params = {})
878
+ base = route.to_s
879
+ return base if query_params.nil? || query_params.empty?
880
+
881
+ query = query_params.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join("&")
882
+ separator = base.include?("?") ? "&" : "?"
883
+ "#{base}#{separator}#{query}"
884
+ end
885
+
886
+ def extract_route(data)
887
+ case data
888
+ when String
889
+ data
890
+ when Hash
891
+ data["route"] || data[:route]
892
+ else
893
+ nil
894
+ end
895
+ end
896
+
897
+ def dispatch_page_event(name:, data:)
898
+ handler = @page_event_handlers[name.to_s.sub(/\Aon_/, "")]
899
+ return unless handler.respond_to?(:call)
900
+
901
+ event = Event.new(name: name.to_s, target: 1, raw_data: data, page: self, control: nil)
902
+ handler.call(event)
903
+ end
904
+
905
+ def page_control_target?(control_or_id)
906
+ control_or_id == 1 || control_or_id.to_s == "1" || control_or_id.to_s == "page"
907
+ end
908
+
909
+ def serialize_patch_value(value)
910
+ case value
911
+ when Control
912
+ value.to_patch
913
+ when Ruflet::IconData
914
+ value.value
915
+ when Array
916
+ value.map { |v| serialize_patch_value(v) }
917
+ when Hash
918
+ value.transform_values { |v| serialize_patch_value(v) }
919
+ else
920
+ value
921
+ end
922
+ end
923
+
924
+ def icon_prop_key?(key)
925
+ key == "icon" || key.end_with?("_icon")
926
+ end
927
+
928
+ def refresh_dialogs_container!
929
+ dialog_controls = (@dialogs + dialog_slots).uniq
930
+ @dialogs_container.props["controls"] = dialog_controls
931
+ @page_props["_dialogs"] = @dialogs_container
932
+ end
933
+
934
+ def refresh_overlay_container!
935
+ @page_props["_overlay"] = @overlay_container
936
+ end
937
+
938
+ def refresh_services_container!
939
+ @page_props["_services"] = @services_container
940
+ end
941
+
942
+ def push_services_update!
943
+ refresh_control_indexes!
944
+
945
+ if @services_container.wire_id
946
+ send_message(Protocol::ACTIONS[:patch_control], {
947
+ "id" => @services_container.wire_id,
948
+ "patch" => [[0], [0, 0, "_services", serialize_patch_value(@services_container.props["_services"])]]
949
+ })
950
+ else
951
+ send_view_patch
952
+ end
953
+ end
954
+
955
+ def push_dialogs_update!
956
+ refresh_control_indexes!
957
+
958
+ if @dialogs_container.wire_id
959
+ send_message(Protocol::ACTIONS[:patch_control], {
960
+ "id" => @dialogs_container.wire_id,
961
+ "patch" => [[0], [0, 0, "controls", serialize_patch_value(@dialogs_container.props["controls"])]]
962
+ })
963
+ else
964
+ send_view_patch
965
+ end
966
+ end
967
+
968
+ def dialog_slots
969
+ [@dialog, @snack_bar, @bottom_sheet].compact
970
+ end
971
+
972
+ def latest_open_dialog
973
+ @dialogs.reverse.find { |d| d.props["open"] != false }
974
+ end
975
+
976
+ def dialog_open?(dialog_control)
977
+ @dialogs.include?(dialog_control) && dialog_control.props["open"] == true
978
+ end
979
+
980
+ def remove_dialog_tracking(control)
981
+ return false unless @dialogs.include?(control)
982
+
983
+ @dialogs.delete(control)
984
+ refresh_dialogs_container!
985
+ true
986
+ end
987
+
988
+ def assign_split_prop(key, value)
989
+ if key == "vertical_alignment" || key == "horizontal_alignment"
990
+ @page_props[key] = value
991
+ @view_props[key] = value
992
+ elsif DIALOG_PROP_KEYS.include?(key)
993
+ instance_variable_set("@#{key}", value)
994
+ refresh_dialogs_container!
995
+ elsif PAGE_PROP_KEYS.include?(key)
996
+ @page_props[key] = value
997
+ else
998
+ @view_props[key] = value
999
+ end
1000
+ end
1001
+
1002
+ def build_view_patches
1003
+ if @views.any?
1004
+ @views.map(&:to_patch)
1005
+ else
1006
+ [implicit_view_patch]
1007
+ end
1008
+ end
1009
+
1010
+ def build_page_patch_ops
1011
+ @page_props.filter_map do |k, v|
1012
+ # Keep internal containers stable after initial mount.
1013
+ # Re-sending them as full objects can replace Control instances with
1014
+ # same IDs and detach service invoke listeners on the Flutter side.
1015
+ next nil if k == "_overlay" && @overlay_container.wire_id
1016
+ next nil if k == "_dialogs" && @dialogs_container.wire_id
1017
+
1018
+ [0, 0, k, serialize_patch_value(v)]
1019
+ end
1020
+ end
1021
+
1022
+ def resolve_icon_codepoint(value)
1023
+ codepoint = Ruflet::MaterialIconLookup.codepoint_for(value)
1024
+ if codepoint.nil? || codepoint == value
1025
+ codepoint = Ruflet::CupertinoIconLookup.codepoint_for(value)
1026
+ end
1027
+ codepoint
1028
+ end
1029
+
1030
+ def ensure_clipboard_service
1031
+ clipboard = services.find { |service| service.is_a?(Control) && service.type == "clipboard" }
1032
+ return [clipboard, false] if clipboard
1033
+
1034
+ clipboard = build_widget(:clipboard)
1035
+ add_service(clipboard)
1036
+ [clipboard, true]
1037
+ end
1038
+
1039
+ def invoke_clipboard_method(method_name, args: nil, timeout:, on_result:)
1040
+ clipboard, created = ensure_clipboard_service
1041
+ send_view_patch if created
1042
+ sleep(0.05) if created
1043
+ invoke(
1044
+ clipboard,
1045
+ method_name,
1046
+ args: args,
1047
+ timeout: timeout,
1048
+ on_result: lambda { |result, error|
1049
+ message = error.to_s
1050
+ if message.include?("inexistent control")
1051
+ remove_service(clipboard)
1052
+ fresh_clipboard, = ensure_clipboard_service
1053
+ sleep(0.08)
1054
+ invoke(
1055
+ fresh_clipboard,
1056
+ method_name,
1057
+ args: args,
1058
+ timeout: timeout,
1059
+ on_result: on_result
1060
+ )
1061
+ else
1062
+ on_result&.call(result, error)
1063
+ end
1064
+ }
1065
+ )
1066
+ rescue StandardError => e
1067
+ on_result&.call(nil, e.message)
1068
+ end
1069
+
1070
+ def ensure_url_launcher_service
1071
+ url_launcher = services.find { |service| service.is_a?(Control) && %w[urllauncher url_launcher].include?(service.type) }
1072
+ return url_launcher if url_launcher
1073
+
1074
+ url_launcher = build_widget(:url_launcher)
1075
+ add_service(url_launcher)
1076
+ url_launcher
1077
+ end
1078
+
1079
+ def ensure_connectivity_service
1080
+ connectivity = services.find { |service| service.is_a?(Control) && service.type == "connectivity" }
1081
+ return [connectivity, false] if connectivity
1082
+
1083
+ connectivity = build_widget(:connectivity)
1084
+ add_service(connectivity)
1085
+ [connectivity, true]
1086
+ end
1087
+
1088
+ def invoke_connectivity_method(method_name, timeout:, on_result:)
1089
+ connectivity, created = ensure_connectivity_service
1090
+ send_view_patch if created
1091
+ sleep(0.05) if created
1092
+ invoke(
1093
+ connectivity,
1094
+ method_name,
1095
+ timeout: timeout,
1096
+ on_result: lambda { |result, error|
1097
+ message = error.to_s
1098
+ if message.include?("inexistent control")
1099
+ remove_service(connectivity)
1100
+ fresh_connectivity, = ensure_connectivity_service
1101
+ sleep(0.08)
1102
+ invoke(
1103
+ fresh_connectivity,
1104
+ method_name,
1105
+ timeout: timeout,
1106
+ on_result: on_result
1107
+ )
1108
+ else
1109
+ on_result&.call(result, error)
1110
+ end
1111
+ }
1112
+ )
1113
+ rescue StandardError => e
1114
+ on_result&.call(nil, e.message)
1115
+ end
1116
+
1117
+ def ensure_battery_service
1118
+ battery = services.find { |service| service.is_a?(Control) && service.type == "battery" }
1119
+ return [battery, false] if battery
1120
+
1121
+ battery = build_widget(:battery)
1122
+ add_service(battery)
1123
+ [battery, true]
1124
+ end
1125
+
1126
+ def ensure_share_service
1127
+ share = services.find { |service| service.is_a?(Control) && service.type == "share" }
1128
+ return share if share
1129
+
1130
+ share = build_widget(:share)
1131
+ add_service(share)
1132
+ share
1133
+ end
1134
+
1135
+ def invoke_battery_method(method_name, timeout:, on_result:)
1136
+ battery, created = ensure_battery_service
1137
+ send_view_patch if created
1138
+ sleep(0.05) if created
1139
+ invoke(
1140
+ battery,
1141
+ method_name,
1142
+ timeout: timeout,
1143
+ on_result: lambda { |result, error|
1144
+ message = error.to_s
1145
+ if message.include?("inexistent control")
1146
+ remove_service(battery)
1147
+ fresh_battery, = ensure_battery_service
1148
+ sleep(0.08)
1149
+ invoke(
1150
+ fresh_battery,
1151
+ method_name,
1152
+ timeout: timeout,
1153
+ on_result: on_result
1154
+ )
1155
+ else
1156
+ on_result&.call(result, error)
1157
+ end
1158
+ }
1159
+ )
1160
+ rescue StandardError => e
1161
+ on_result&.call(nil, e.message)
1162
+ end
1163
+
1164
+ def ensure_storage_paths_service
1165
+ storage_paths = services.find do |service|
1166
+ service.is_a?(Control) && %w[storage_paths storagepaths].include?(service.type.to_s)
1167
+ end
1168
+ return [storage_paths, false] if storage_paths
1169
+
1170
+ storage_paths = build_widget(:storage_paths)
1171
+ add_service(storage_paths)
1172
+ [storage_paths, true]
1173
+ end
1174
+
1175
+ def invoke_storage_paths(method_name, timeout:, on_result:)
1176
+ storage_paths, created = ensure_storage_paths_service
1177
+ send_view_patch if created
1178
+ sleep(0.05) if created
1179
+ invoke(
1180
+ storage_paths,
1181
+ method_name,
1182
+ timeout: timeout,
1183
+ on_result: lambda { |result, error|
1184
+ message = error.to_s
1185
+ if message.include?("inexistent control")
1186
+ remove_service(storage_paths)
1187
+ fresh_storage_paths, = ensure_storage_paths_service
1188
+ sleep(0.08)
1189
+ invoke(
1190
+ fresh_storage_paths,
1191
+ method_name,
1192
+ timeout: timeout,
1193
+ on_result: on_result
1194
+ )
1195
+ else
1196
+ on_result&.call(result, error)
1197
+ end
1198
+ }
1199
+ )
1200
+ rescue StandardError => e
1201
+ on_result&.call(nil, e.message)
1202
+ end
1203
+
1204
+ def invoke_file_picker(method_name, args, timeout:, on_result:)
1205
+ picker = build_widget(:file_picker)
1206
+ add_service(picker)
1207
+ invoke(
1208
+ picker,
1209
+ method_name,
1210
+ args: args,
1211
+ timeout: timeout,
1212
+ on_result: lambda { |result, error|
1213
+ remove_service(picker)
1214
+ on_result&.call(result, error)
1215
+ }
1216
+ )
1217
+ rescue StandardError => e
1218
+ remove_service(picker) if picker
1219
+ on_result&.call(nil, e.message)
1220
+ end
1221
+ end
1222
+ end