ruflet_core 0.0.13 → 0.0.15

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f384022aa476f52d7aa7e1315e1a5da2e71071cab839f04f7a0331cdb7da21f
4
- data.tar.gz: f21fb7e4b2a9f401ef521d278128ac5b436d912182f2d1ddbcbb4b7597f4dfca
3
+ metadata.gz: c1823b100c3e97ee941849763509b25179554464310eb640d7954bb48a30d821
4
+ data.tar.gz: 80d3f6891f3418bf0b2b3aa2007e08e6578ca336b72bb48b9d84c54cac2397b5
5
5
  SHA512:
6
- metadata.gz: f5ffaad8c7cee45c8b756d66d567e3a9e04c13dbb672dedb3c18545f7ecd6374af89b7267fc715711d73f8dc4b1fe3b9f597f5b62ca2fd9181f7dabb336178da
7
- data.tar.gz: b52faf7ed1f6aa8980ba4c03e64c34c6b13760375261ef1e6121cf52eb774c035d8940d5d9cbb74f86de5174a768d758bba724fafa2f48a77aa1bc6a6d726524
6
+ metadata.gz: e37e04ea218fbc50da5d3a456ecdfe376f12d00fc802c0869da7dff977bafb6d0abab8eecff92e223b42002f94652e40f7d91d2eb7a62f04fbec72bf1c7dd600
7
+ data.tar.gz: 42458a85edccff3f66fd88cdc71724a7a94dbf1853fbf080d0751da7b57e783694e0ba65091c908bb4b1f203d6a8e8725e9e594df762f7deb1996e29e1bc88ed
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruflet
4
- VERSION = "0.0.13" unless const_defined?(:VERSION)
4
+ VERSION = "0.0.15" unless const_defined?(:VERSION)
5
5
  end
@@ -39,6 +39,12 @@ module Ruflet
39
39
  "height" => page["height"],
40
40
  "platform" => page["platform"],
41
41
  "platform_brightness" => page["platform_brightness"],
42
+ # The Flutter client reports the host OS in "platform" even inside a
43
+ # browser, so "web" is the only reliable way to tell a web client from a
44
+ # native one. (pwa/wasm passed through for completeness.)
45
+ "web" => page["web"],
46
+ "pwa" => page["pwa"],
47
+ "wasm" => page["wasm"],
42
48
  "media" => page["media"] || {}
43
49
  }
44
50
  end
@@ -97,7 +103,8 @@ module Ruflet
97
103
  }
98
104
  end
99
105
 
100
- def register_response(session_id:, page_patch: {}, error: nil)
106
+ def register_response(session_id:, page_patch: nil, error: nil)
107
+ page_patch ||= {}
101
108
  {
102
109
  "session_id" => session_id,
103
110
  "page_patch" => page_patch,
@@ -47,6 +47,37 @@ module Ruflet
47
47
  @handlers.key?(normalized_event_name(event_name))
48
48
  end
49
49
 
50
+ # Read a prop by name: control["value"] or control[:value].
51
+ def [](key)
52
+ @props[key.to_s]
53
+ end
54
+
55
+ # Write a prop by name: control["value"] = "x".
56
+ def []=(key, value)
57
+ @props[key.to_s] = value
58
+ end
59
+
60
+ # Convenience dot access to props, mirroring Flet-style controls:
61
+ # control.value # => @props["value"] (reads an existing prop)
62
+ # control.value = "hello" # => sets @props["value"]
63
+ # Reads only resolve props that exist, so typos still raise NoMethodError
64
+ # instead of silently returning nil. Defined methods (type, id, props,
65
+ # children, on, emit, to_patch, …) are never shadowed.
66
+ def method_missing(name, *args, &block)
67
+ key = name.to_s
68
+ if key.end_with?("=")
69
+ return @props[key[0..-2]] = args.first
70
+ end
71
+ return @props[key] if args.empty? && @props.key?(key)
72
+
73
+ super
74
+ end
75
+
76
+ def respond_to_missing?(name, include_private = false)
77
+ key = name.to_s
78
+ key.end_with?("=") || @props.key?(key) || super
79
+ end
80
+
50
81
  def to_patch
51
82
  wire_type = schema_wire_type_for_class
52
83
  if wire_type.nil?
@@ -160,6 +160,8 @@ module Ruflet
160
160
  @root_controls = []
161
161
  @views = []
162
162
  @dialogs = []
163
+ @overlay_container_mounted = false
164
+ @dialogs_container_mounted = false
163
165
  @services_container_mounted = false
164
166
  @visual_service_controls = {}
165
167
  @page_event_handlers = {}
@@ -248,10 +250,10 @@ module Ruflet
248
250
  controls.each { |c| register_control_tree(c, visited) }
249
251
  @root_controls = controls
250
252
 
251
- @view_props["appbar"] = appbar if appbar
252
- @view_props["bottom_appbar"] = bottom_appbar if bottom_appbar
253
- @view_props["floating_action_button"] = floating_action_button if floating_action_button
254
- @view_props["navigation_bar"] = navigation_bar if navigation_bar
253
+ update_view_slot("appbar", appbar)
254
+ update_view_slot("bottom_appbar", bottom_appbar)
255
+ update_view_slot("floating_action_button", floating_action_button)
256
+ update_view_slot("navigation_bar", navigation_bar)
255
257
  @dialog = dialog if dialog
256
258
  @snack_bar = snack_bar if snack_bar
257
259
  @bottom_sheet = bottom_sheet if bottom_sheet
@@ -482,11 +484,13 @@ module Ruflet
482
484
  def dialog=(value)
483
485
  @dialog = value
484
486
  refresh_dialogs_container!
487
+ push_dialogs_update! if @dialogs_container_mounted
485
488
  end
486
489
 
487
490
  def snack_bar=(value)
488
491
  @snack_bar = value
489
492
  refresh_dialogs_container!
493
+ push_dialogs_update! if @dialogs_container_mounted
490
494
  end
491
495
 
492
496
  def snackbar=(value)
@@ -508,6 +512,7 @@ module Ruflet
508
512
  return self if dialog_open?(dialog_control)
509
513
 
510
514
  dialog_control.props["open"] = true
515
+ remove_existing_singleton_dialogs(dialog_control)
511
516
  @dialogs << dialog_control unless @dialogs.include?(dialog_control)
512
517
  refresh_dialogs_container!
513
518
  send_view_patch unless @dialogs_container.wire_id
@@ -528,7 +533,7 @@ module Ruflet
528
533
  call_id = "call_#{Ruflet::Control.generate_id}"
529
534
  if on_result.respond_to?(:call)
530
535
  @invoke_waiters_mutex.synchronize { @invoke_callbacks[call_id] = on_result }
531
- unless timeout.nil?
536
+ if embedded_async_timeout_available? && !timeout.nil?
532
537
  Thread.new(call_id, timeout.to_f) do |pending_call_id, invoke_timeout|
533
538
  sleep([invoke_timeout, 0.0].max + 0.1)
534
539
  callback = @invoke_waiters_mutex.synchronize { @invoke_callbacks.delete(pending_call_id) }
@@ -1145,9 +1150,23 @@ module Ruflet
1145
1150
  return nil unless dialog_control
1146
1151
 
1147
1152
  dialog_control.props["open"] = false
1153
+ update(dialog_control, open: false)
1154
+ dialog_control
1155
+ end
1156
+
1157
+ def close_dialog(dialog_control)
1158
+ return self unless dialog_control
1159
+
1160
+ dialog_control.props["open"] = false
1161
+ @dialog = nil if @dialog.equal?(dialog_control)
1162
+ remove_dialog_tracking(dialog_control)
1148
1163
  refresh_dialogs_container!
1164
+ # Patch the dialogs container in place. Forcing a full view re-render
1165
+ # here would remount the whole overlay — fatal while another dialog
1166
+ # (e.g. the form behind a nested picker) is still open. The empty case
1167
+ # is handled inside push_dialogs_update!.
1149
1168
  push_dialogs_update!
1150
- dialog_control
1169
+ self
1151
1170
  end
1152
1171
 
1153
1172
  def update(control_or_id = nil, **props)
@@ -1185,6 +1204,7 @@ module Ruflet
1185
1204
 
1186
1205
  visited = Set.new
1187
1206
  patch.each_value { |value| register_embedded_value(value, visited) }
1207
+ patch.each { |k, v| control.props[k] = v }
1188
1208
 
1189
1209
  patch_ops = patch.map { |k, v| [0, 0, k, serialize_patch_value(v)] }
1190
1210
 
@@ -1207,8 +1227,6 @@ module Ruflet
1207
1227
  patch = normalize_props(props || {})
1208
1228
  patch.each { |k, v| control.props[k] = v }
1209
1229
 
1210
- remove_dialog_tracking(control) if patch.key?("open") && patch["open"] == false
1211
-
1212
1230
  self
1213
1231
  end
1214
1232
 
@@ -1216,6 +1234,10 @@ module Ruflet
1216
1234
  if page_control_target?(target)
1217
1235
  if name.to_s == "route_change"
1218
1236
  route_from_event = extract_route(data)
1237
+ # Dialogs (including pickers) belong to the view that opened them.
1238
+ # Navigating away must dismiss them, or they ghost onto the next
1239
+ # view — the picker that "reappears after going home".
1240
+ dismiss_tracked_dialogs! if route_from_event && route_from_event != @page_props["route"]
1219
1241
  @page_props["route"] = route_from_event if route_from_event
1220
1242
  end
1221
1243
  dispatch_page_event(name: name, data: data)
@@ -1225,13 +1247,19 @@ module Ruflet
1225
1247
  control = @wire_index[target.to_i] || @control_index[target.to_s]
1226
1248
  return unless control
1227
1249
 
1228
- event = Event.new(name: name, target: target, raw_data: data, page: self, control: control)
1250
+ event = Ruflet::Event.new(name: name, target: target, raw_data: data, page: self, control: control)
1229
1251
  apply_event_value_to_control(control, event) if %w[change select select_change].include?(name.to_s)
1230
- control.emit(name, event)
1231
-
1232
- if name.to_s == "dismiss" && remove_dialog_tracking(control)
1252
+ # Material/Cupertino pickers dismiss themselves on the client once a
1253
+ # value is confirmed, but only send a value event — never a close. Mark
1254
+ # the dialog closed here so show_dialog can reopen it next time.
1255
+ mark_picker_dialog_closed(control, name)
1256
+ if dialog_close_event?(control, name) && remove_dialog_tracking(control)
1257
+ # Patch the container in place; never force a full view re-render that
1258
+ # would remount a still-open parent dialog (the nested-picker case).
1233
1259
  push_dialogs_update!
1234
1260
  end
1261
+
1262
+ control.emit(name, event)
1235
1263
  end
1236
1264
 
1237
1265
  def method_missing(name, *args, &block)
@@ -1273,6 +1301,10 @@ module Ruflet
1273
1301
 
1274
1302
  private
1275
1303
 
1304
+ def embedded_async_timeout_available?
1305
+ !Object.const_defined?(:RUFLET_EMBEDDED_FAKE_THREAD)
1306
+ end
1307
+
1276
1308
  def invoke_and_wait(control_or_id, method_name, args: nil, timeout: 10)
1277
1309
  control_id =
1278
1310
  if page_control_target?(control_or_id)
@@ -1370,6 +1402,14 @@ module Ruflet
1370
1402
  @sender.call(action, payload)
1371
1403
  end
1372
1404
 
1405
+ def update_view_slot(name, value)
1406
+ if value.nil?
1407
+ @view_props.delete(name)
1408
+ else
1409
+ @view_props[name] = value
1410
+ end
1411
+ end
1412
+
1373
1413
  def send_view_patch
1374
1414
  refresh_control_indexes!
1375
1415
  view_patches = build_view_patches
@@ -1379,10 +1419,12 @@ module Ruflet
1379
1419
  "id" => 1,
1380
1420
  "patch" => [
1381
1421
  [0],
1382
- [0, 0, "views", view_patches],
1383
- *page_patch_ops
1422
+ *page_patch_ops,
1423
+ [0, 0, "views", view_patches]
1384
1424
  ]
1385
1425
  })
1426
+ @overlay_container_mounted = true if @overlay_container.wire_id
1427
+ @dialogs_container_mounted = true if @dialogs_container.wire_id
1386
1428
  @services_container_mounted = true if @services_container.wire_id
1387
1429
  end
1388
1430
 
@@ -1534,8 +1576,8 @@ module Ruflet
1534
1576
  end_value = range_value["end_value"] || range_value[:end_value]
1535
1577
  control.props["start_value"] = start_value unless start_value.nil?
1536
1578
  control.props["end_value"] = end_value unless end_value.nil?
1537
- return
1538
1579
  end
1580
+ return
1539
1581
  end
1540
1582
 
1541
1583
  return if value.nil?
@@ -1582,7 +1624,7 @@ module Ruflet
1582
1624
  end
1583
1625
 
1584
1626
  def refresh_dialogs_container!
1585
- dialog_controls = (@dialogs + dialog_slots).uniq
1627
+ dialog_controls = (dialog_slots + @dialogs).uniq
1586
1628
  @dialogs_container.props["controls"] = dialog_controls
1587
1629
  @page_props["_dialogs"] = @dialogs_container
1588
1630
  end
@@ -1611,6 +1653,12 @@ module Ruflet
1611
1653
  def push_dialogs_update!
1612
1654
  refresh_control_indexes!
1613
1655
 
1656
+ # Once the dialogs container is mounted, every change — opening, closing,
1657
+ # even down to no dialogs at all — is an in-place patch of its controls
1658
+ # list. Re-sending the view (or the container as a whole object) would
1659
+ # replace the live container instance on the Flutter side, detaching its
1660
+ # listeners and breaking any other dialog still open. Only the very first
1661
+ # dialog, before the container has a wire id, needs a view patch to mount.
1614
1662
  if @dialogs_container.wire_id
1615
1663
  send_message(Protocol::ACTIONS[:patch_control], {
1616
1664
  "id" => @dialogs_container.wire_id,
@@ -1633,6 +1681,41 @@ module Ruflet
1633
1681
  @dialogs.include?(dialog_control) && dialog_control.props["open"] == true
1634
1682
  end
1635
1683
 
1684
+ def dialog_close_event?(control, name)
1685
+ name = name.to_s
1686
+ name == "dismiss" || (%w[change select select_change].include?(name) && @dialogs.include?(control) && control.props["open"] == false)
1687
+ end
1688
+
1689
+ # Picker dialogs that auto-dismiss on the client after a selection. Their
1690
+ # confirm sends a value event (change/select), not a close, so the server
1691
+ # must flip `open` to false or show_dialog's open-guard blocks reopening.
1692
+ PICKER_DIALOG_TYPES = %w[
1693
+ datepicker daterangepicker timepicker
1694
+ cupertinodatepicker cupertinotimerpicker
1695
+ ].freeze
1696
+
1697
+ def picker_dialog?(control)
1698
+ PICKER_DIALOG_TYPES.include?(control.type.to_s.tr("_", "").downcase)
1699
+ end
1700
+
1701
+ def mark_picker_dialog_closed(control, name)
1702
+ return unless picker_dialog?(control)
1703
+ return unless %w[change select select_change dismiss].include?(name.to_s)
1704
+
1705
+ control.props["open"] = false
1706
+ end
1707
+
1708
+ # Close and untrack every dialog currently shown. Called on navigation so
1709
+ # a dialog opened in one view does not linger as an overlay on the next.
1710
+ def dismiss_tracked_dialogs!
1711
+ return if @dialogs.empty?
1712
+
1713
+ @dialogs.each { |dialog| dialog.props["open"] = false }
1714
+ @dialogs.clear
1715
+ refresh_dialogs_container!
1716
+ push_dialogs_update! if @dialogs_container_mounted
1717
+ end
1718
+
1636
1719
  def remove_dialog_tracking(control)
1637
1720
  return false unless @dialogs.include?(control)
1638
1721
 
@@ -1641,6 +1724,16 @@ module Ruflet
1641
1724
  true
1642
1725
  end
1643
1726
 
1727
+ def remove_existing_singleton_dialogs(control)
1728
+ return unless singleton_dialog_control?(control)
1729
+
1730
+ @dialogs.delete_if { |dialog| dialog != control && singleton_dialog_control?(dialog) }
1731
+ end
1732
+
1733
+ def singleton_dialog_control?(control)
1734
+ control.type.to_s.tr("_", "").downcase == "snackbar"
1735
+ end
1736
+
1644
1737
  def assign_split_prop(key, value)
1645
1738
  if key == "vertical_alignment" || key == "horizontal_alignment"
1646
1739
  @page_props[key] = value
@@ -1668,8 +1761,8 @@ module Ruflet
1668
1761
  # Keep internal containers stable after initial mount.
1669
1762
  # Re-sending them as full objects can replace Control instances with
1670
1763
  # same IDs and detach service invoke listeners on the Flutter side.
1671
- next nil if k == "_overlay" && @overlay_container.wire_id
1672
- next nil if k == "_dialogs" && @dialogs_container.wire_id
1764
+ next nil if k == "_overlay" && @overlay_container_mounted
1765
+ next nil if k == "_dialogs" && @dialogs_container_mounted
1673
1766
  next nil if k == "_services" && @services_container_mounted
1674
1767
 
1675
1768
  [0, 0, k, serialize_patch_value(v)]
@@ -1774,7 +1867,7 @@ module Ruflet
1774
1867
  call_id = "call_#{Ruflet::Control.generate_id}"
1775
1868
  if on_result.respond_to?(:call)
1776
1869
  @invoke_waiters_mutex.synchronize { @invoke_callbacks[call_id] = on_result }
1777
- unless timeout.nil?
1870
+ if embedded_async_timeout_available? && !timeout.nil?
1778
1871
  Thread.new(call_id, timeout.to_f) do |pending_call_id, invoke_timeout|
1779
1872
  sleep([invoke_timeout, 0.0].max + 0.1)
1780
1873
  callback = @invoke_waiters_mutex.synchronize { @invoke_callbacks.delete(pending_call_id) }
@@ -44,10 +44,8 @@ module Ruflet
44
44
  runtime_page&.invoke(self, "pause", timeout: timeout, on_result: on_result)
45
45
  end
46
46
 
47
- def play(position: nil, timeout: 10, on_result: nil)
48
- args = {}
49
- args["position"] = position unless position.nil?
50
- runtime_page&.invoke(self, "play", args: args.empty? ? nil : args, timeout: timeout, on_result: on_result)
47
+ def play(position: 0, timeout: 10, on_result: nil)
48
+ runtime_page&.invoke(self, "play", args: { "position" => position }, timeout: timeout, on_result: on_result)
51
49
  end
52
50
 
53
51
  def release(timeout: 10, on_result: nil)
@@ -4,14 +4,37 @@ module Ruflet
4
4
  module UI
5
5
  module Controls
6
6
  module RufletComponents
7
+ module MapValueNormalizer
8
+ private
9
+
10
+ def normalize_map_coordinate(value)
11
+ case value
12
+ when Array
13
+ if value.length == 2 && value.all? { |item| item.is_a?(Numeric) }
14
+ { "latitude" => value[0], "longitude" => value[1] }
15
+ else
16
+ value.map { |item| normalize_map_coordinate(item) }
17
+ end
18
+ when Hash
19
+ value.transform_keys(&:to_s).each_with_object({}) do |(key, item), result|
20
+ result[key] = normalize_map_coordinate(item)
21
+ end
22
+ else
23
+ value.respond_to?(:to_h) ? normalize_map_coordinate(value.to_h) : value
24
+ end
25
+ end
26
+ end
27
+
7
28
  class MapControl < Ruflet::Control
29
+ include MapValueNormalizer
30
+
8
31
  TYPE = "map".freeze
9
32
  WIRE = "Map".freeze
10
33
 
11
34
  def initialize(id: nil, layers: nil, initial_center: nil, initial_zoom: nil, min_zoom: nil, max_zoom: nil, interaction_configuration: nil, keep_alive: nil, bgcolor: nil, data: nil, expand: nil, height: nil, key: nil, visible: nil, width: nil, on_init: nil, on_long_press: nil, on_position_change: nil, on_secondary_tap: nil, on_tap: nil)
12
35
  props = {}
13
36
  props[:layers] = layers unless layers.nil?
14
- props[:initial_center] = initial_center unless initial_center.nil?
37
+ props[:initial_center] = normalize_map_coordinate(initial_center) unless initial_center.nil?
15
38
  props[:initial_zoom] = initial_zoom unless initial_zoom.nil?
16
39
  props[:min_zoom] = min_zoom unless min_zoom.nil?
17
40
  props[:max_zoom] = max_zoom unless max_zoom.nil?
@@ -32,12 +55,52 @@ module Ruflet
32
55
  super(type: TYPE, id: id, **props)
33
56
  end
34
57
 
35
- def center_on(coordinates, zoom: nil, timeout: 10, on_result: nil)
36
- invoke_map("center_on", args: compact_args("coordinates" => coordinates, "zoom" => zoom), timeout: timeout, on_result: on_result)
58
+ def center_on(point = nil, coordinates: nil, zoom: nil, duration: nil, curve: nil, cancel_ongoing_animations: nil, timeout: 10, on_result: nil)
59
+ point ||= coordinates
60
+ invoke_map(
61
+ "center_on",
62
+ args: compact_args(
63
+ "point" => normalize_map_coordinate(point),
64
+ "zoom" => zoom,
65
+ "duration" => duration,
66
+ "curve" => curve,
67
+ "cancel_ongoing_animations" => cancel_ongoing_animations
68
+ ),
69
+ timeout: timeout,
70
+ on_result: on_result
71
+ )
72
+ end
73
+
74
+ def move_to(destination = nil, coordinates: nil, zoom: nil, rotation: nil, offset: nil, duration: nil, curve: nil, cancel_ongoing_animations: nil, timeout: 10, on_result: nil)
75
+ destination ||= coordinates
76
+ invoke_map(
77
+ "move_to",
78
+ args: compact_args(
79
+ "destination" => normalize_map_coordinate(destination),
80
+ "zoom" => zoom,
81
+ "rotation" => rotation,
82
+ "offset" => offset,
83
+ "duration" => duration,
84
+ "curve" => curve,
85
+ "cancel_ongoing_animations" => cancel_ongoing_animations
86
+ ),
87
+ timeout: timeout,
88
+ on_result: on_result
89
+ )
37
90
  end
38
91
 
39
- def move_to(coordinates, zoom: nil, timeout: 10, on_result: nil)
40
- invoke_map("move_to", args: compact_args("coordinates" => coordinates, "zoom" => zoom), timeout: timeout, on_result: on_result)
92
+ def zoom_to(zoom, duration: nil, curve: nil, cancel_ongoing_animations: nil, timeout: 10, on_result: nil)
93
+ invoke_map(
94
+ "zoom_to",
95
+ args: compact_args(
96
+ "zoom" => zoom,
97
+ "duration" => duration,
98
+ "curve" => curve,
99
+ "cancel_ongoing_animations" => cancel_ongoing_animations
100
+ ),
101
+ timeout: timeout,
102
+ on_result: on_result
103
+ )
41
104
  end
42
105
 
43
106
  def zoom_in(delta: nil, timeout: 10, on_result: nil)
@@ -118,12 +181,14 @@ module Ruflet
118
181
  end
119
182
 
120
183
  class MarkerControl < Ruflet::Control
184
+ include MapValueNormalizer
185
+
121
186
  TYPE = "marker".freeze
122
187
  WIRE = "Marker".freeze
123
188
 
124
189
  def initialize(id: nil, coordinates: nil, content: nil, height: nil, rotate: nil, width: nil, alignment: nil)
125
190
  props = {}
126
- props[:coordinates] = coordinates unless coordinates.nil?
191
+ props[:coordinates] = normalize_map_coordinate(coordinates) unless coordinates.nil?
127
192
  props[:content] = content unless content.nil?
128
193
  props[:height] = height unless height.nil?
129
194
  props[:rotate] = rotate unless rotate.nil?
@@ -146,12 +211,14 @@ module Ruflet
146
211
  end
147
212
 
148
213
  class CircleMarkerControl < Ruflet::Control
214
+ include MapValueNormalizer
215
+
149
216
  TYPE = "circlemarker".freeze
150
217
  WIRE = "CircleMarker".freeze
151
218
 
152
219
  def initialize(id: nil, coordinates: nil, radius: nil, color: nil, border_color: nil, border_stroke_width: nil, use_radius_in_meter: nil)
153
220
  props = {}
154
- props[:coordinates] = coordinates unless coordinates.nil?
221
+ props[:coordinates] = normalize_map_coordinate(coordinates) unless coordinates.nil?
155
222
  props[:radius] = radius unless radius.nil?
156
223
  props[:color] = color unless color.nil?
157
224
  props[:border_color] = border_color unless border_color.nil?
@@ -174,12 +241,14 @@ module Ruflet
174
241
  end
175
242
 
176
243
  class PolylineMarkerControl < Ruflet::Control
244
+ include MapValueNormalizer
245
+
177
246
  TYPE = "polylinemarker".freeze
178
247
  WIRE = "PolylineMarker".freeze
179
248
 
180
249
  def initialize(id: nil, coordinates: nil, color: nil, stroke_width: nil, border_color: nil, border_stroke_width: nil, gradient_colors: nil, stroke_cap: nil, stroke_join: nil)
181
250
  props = {}
182
- props[:coordinates] = coordinates unless coordinates.nil?
251
+ props[:coordinates] = normalize_map_coordinate(coordinates) unless coordinates.nil?
183
252
  props[:color] = color unless color.nil?
184
253
  props[:stroke_width] = stroke_width unless stroke_width.nil?
185
254
  props[:border_color] = border_color unless border_color.nil?
@@ -204,12 +273,14 @@ module Ruflet
204
273
  end
205
274
 
206
275
  class PolygonMarkerControl < Ruflet::Control
276
+ include MapValueNormalizer
277
+
207
278
  TYPE = "polygonmarker".freeze
208
279
  WIRE = "PolygonMarker".freeze
209
280
 
210
281
  def initialize(id: nil, coordinates: nil, color: nil, border_color: nil, border_stroke_width: nil, disable_holes_border: nil, label: nil)
211
282
  props = {}
212
- props[:coordinates] = coordinates unless coordinates.nil?
283
+ props[:coordinates] = normalize_map_coordinate(coordinates) unless coordinates.nil?
213
284
  props[:color] = color unless color.nil?
214
285
  props[:border_color] = border_color unless border_color.nil?
215
286
  props[:border_stroke_width] = border_stroke_width unless border_stroke_width.nil?
@@ -4,11 +4,33 @@ module Ruflet
4
4
  module UI
5
5
  module Controls
6
6
  module RufletComponents
7
+ # WebView control — parity with Flet's WebView
8
+ # (https://flet.dev/docs/controls/webview/).
9
+ #
10
+ # Properties: url, bgcolor, prevent_links, plus the usual layout props.
11
+ # Events: on_page_started, on_page_ended, on_web_resource_error,
12
+ # on_progress, on_url_change, on_scroll, on_console_message,
13
+ # on_javascript_alert_dialog.
14
+ # Methods (invoked over the wire on a mounted control): reload, go_back,
15
+ # go_forward, can_go_back, can_go_forward, run_javascript, load_html,
16
+ # load_request, load_file, scroll_to, scroll_by, clear_cache,
17
+ # clear_local_storage, enable_zoom, disable_zoom, set_javascript_mode,
18
+ # get_current_url, get_title, get_user_agent.
19
+ #
20
+ # Platform note: the native webview (and therefore run_javascript and the
21
+ # events/methods) runs on iOS, Android and macOS. On web it falls back to
22
+ # an <iframe>, which cannot run the methods and which most external sites
23
+ # block via X-Frame-Options/CSP — embed your own same-origin pages there.
7
24
  class WebViewControl < Ruflet::Control
8
25
  TYPE = "WebView".freeze
9
26
  WIRE = "WebView".freeze
10
27
 
11
- def initialize(id: nil, bgcolor: nil, data: nil, enable_javascript: nil, expand: nil, height: nil, key: nil, method: nil, opacity: nil, rtl: nil, tooltip: nil, url: nil, visible: nil, width: nil, on_page_ended: nil, on_page_started: nil, on_web_resource_error: nil)
28
+ def initialize(id: nil, bgcolor: nil, data: nil, enable_javascript: nil, expand: nil,
29
+ height: nil, key: nil, method: nil, opacity: nil, prevent_links: nil,
30
+ rtl: nil, tooltip: nil, url: nil, visible: nil, width: nil,
31
+ on_page_ended: nil, on_page_started: nil, on_web_resource_error: nil,
32
+ on_progress: nil, on_url_change: nil, on_scroll: nil,
33
+ on_console_message: nil, on_javascript_alert_dialog: nil)
12
34
  props = {}
13
35
  props[:bgcolor] = bgcolor unless bgcolor.nil?
14
36
  props[:data] = data unless data.nil?
@@ -18,6 +40,7 @@ module Ruflet
18
40
  props[:key] = key unless key.nil?
19
41
  props[:method] = method unless method.nil?
20
42
  props[:opacity] = opacity unless opacity.nil?
43
+ props[:prevent_links] = prevent_links unless prevent_links.nil?
21
44
  props[:rtl] = rtl unless rtl.nil?
22
45
  props[:tooltip] = tooltip unless tooltip.nil?
23
46
  props[:url] = url unless url.nil?
@@ -26,8 +49,98 @@ module Ruflet
26
49
  props[:on_page_ended] = on_page_ended unless on_page_ended.nil?
27
50
  props[:on_page_started] = on_page_started unless on_page_started.nil?
28
51
  props[:on_web_resource_error] = on_web_resource_error unless on_web_resource_error.nil?
52
+ props[:on_progress] = on_progress unless on_progress.nil?
53
+ props[:on_url_change] = on_url_change unless on_url_change.nil?
54
+ props[:on_scroll] = on_scroll unless on_scroll.nil?
55
+ props[:on_console_message] = on_console_message unless on_console_message.nil?
56
+ props[:on_javascript_alert_dialog] = on_javascript_alert_dialog unless on_javascript_alert_dialog.nil?
29
57
  super(type: TYPE, id: id, **props)
30
58
  end
59
+
60
+ # --- Navigation --------------------------------------------------
61
+
62
+ def reload = invoke_webview_method("reload")
63
+ def go_back = invoke_webview_method("go_back")
64
+ def go_forward = invoke_webview_method("go_forward")
65
+
66
+ def can_go_back(timeout: 10, &on_result)
67
+ invoke_webview_method("can_go_back", timeout: timeout, on_result: on_result)
68
+ end
69
+
70
+ def can_go_forward(timeout: 10, &on_result)
71
+ invoke_webview_method("can_go_forward", timeout: timeout, on_result: on_result)
72
+ end
73
+
74
+ # --- Loading content ---------------------------------------------
75
+
76
+ def load_request(url, method: "get")
77
+ invoke_webview_method("load_request", { "url" => url.to_s, "method" => method.to_s })
78
+ end
79
+
80
+ def load_html(value, base_url: nil)
81
+ args = { "value" => value.to_s }
82
+ args["base_url"] = base_url.to_s unless base_url.nil?
83
+ invoke_webview_method("load_html", args)
84
+ end
85
+
86
+ def load_file(path)
87
+ invoke_webview_method("load_file", { "path" => path.to_s })
88
+ end
89
+
90
+ # --- JavaScript injection ---------------------------------------
91
+
92
+ # Run arbitrary JS inside the page — e.g. hide a node so a native
93
+ # control can take its place:
94
+ # webview.run_javascript("document.getElementById('banner').remove()")
95
+ def run_javascript(value)
96
+ invoke_webview_method("run_javascript", { "value" => value.to_s })
97
+ end
98
+
99
+ def set_javascript_mode(mode)
100
+ invoke_webview_method("set_javascript_mode", { "mode" => mode.to_s })
101
+ end
102
+
103
+ # --- Scrolling ---------------------------------------------------
104
+
105
+ def scroll_to(x, y)
106
+ invoke_webview_method("scroll_to", { "x" => x.to_i, "y" => y.to_i })
107
+ end
108
+
109
+ def scroll_by(x, y)
110
+ invoke_webview_method("scroll_by", { "x" => x.to_i, "y" => y.to_i })
111
+ end
112
+
113
+ # --- Storage / zoom ----------------------------------------------
114
+
115
+ def clear_cache = invoke_webview_method("clear_cache")
116
+ def clear_local_storage = invoke_webview_method("clear_local_storage")
117
+ def enable_zoom = invoke_webview_method("enable_zoom")
118
+ def disable_zoom = invoke_webview_method("disable_zoom")
119
+
120
+ # --- Introspection (result delivered to the block) ---------------
121
+
122
+ def get_current_url(timeout: 10, &on_result)
123
+ invoke_webview_method("get_current_url", timeout: timeout, on_result: on_result)
124
+ end
125
+
126
+ def get_title(timeout: 10, &on_result)
127
+ invoke_webview_method("get_title", timeout: timeout, on_result: on_result)
128
+ end
129
+
130
+ def get_user_agent(timeout: 10, &on_result)
131
+ invoke_webview_method("get_user_agent", timeout: timeout, on_result: on_result)
132
+ end
133
+
134
+ private
135
+
136
+ def invoke_webview_method(name, args = nil, timeout: 10, on_result: nil)
137
+ page = runtime_page
138
+ unless page && wire_id
139
+ raise "WebView ##{id} is not mounted yet — add it to the page before calling #{name}."
140
+ end
141
+
142
+ page.invoke(self, name, args: args, timeout: timeout, on_result: on_result)
143
+ end
31
144
  end
32
145
  end
33
146
  end
@@ -50,10 +50,28 @@ module Ruflet
50
50
  super(type: TYPE, id: id, **props)
51
51
  end
52
52
 
53
- %w[wait_until_ready_to_show to_front center close destroy start_dragging].each do |method_name|
54
- define_method(method_name) do |timeout: 10, on_result: nil|
55
- runtime_page&.invoke(self, method_name, timeout: timeout, on_result: on_result)
56
- end
53
+ def wait_until_ready_to_show(timeout: 10, on_result: nil)
54
+ invoke_window_method("wait_until_ready_to_show", timeout: timeout, on_result: on_result)
55
+ end
56
+
57
+ def to_front(timeout: 10, on_result: nil)
58
+ invoke_window_method("to_front", timeout: timeout, on_result: on_result)
59
+ end
60
+
61
+ def center(timeout: 10, on_result: nil)
62
+ invoke_window_method("center", timeout: timeout, on_result: on_result)
63
+ end
64
+
65
+ def close(timeout: 10, on_result: nil)
66
+ invoke_window_method("close", timeout: timeout, on_result: on_result)
67
+ end
68
+
69
+ def destroy(timeout: 10, on_result: nil)
70
+ invoke_window_method("destroy", timeout: timeout, on_result: on_result)
71
+ end
72
+
73
+ def start_dragging(timeout: 10, on_result: nil)
74
+ invoke_window_method("start_dragging", timeout: timeout, on_result: on_result)
57
75
  end
58
76
 
59
77
  def start_resizing(edge, timeout: 10, on_result: nil)
@@ -65,6 +83,12 @@ module Ruflet
65
83
  on_result: on_result
66
84
  )
67
85
  end
86
+
87
+ private
88
+
89
+ def invoke_window_method(method_name, timeout:, on_result:)
90
+ runtime_page&.invoke(self, method_name, timeout: timeout, on_result: on_result)
91
+ end
68
92
  end
69
93
  end
70
94
  end
@@ -9,6 +9,7 @@ module Ruflet
9
9
  WIRE = "AudioRecorder".freeze
10
10
 
11
11
  def initialize(id: nil, configuration: nil, data: nil, key: nil, on_state_change: nil, on_stream: nil, on_upload: nil)
12
+ configuration ||= {}
12
13
  props = {}
13
14
  props[:configuration] = configuration unless configuration.nil?
14
15
  props[:data] = data unless data.nil?
@@ -44,6 +45,7 @@ module Ruflet
44
45
  end
45
46
 
46
47
  def start_recording(output_path: nil, configuration: nil, upload: nil, timeout: 10, on_result: nil)
48
+ configuration ||= {}
47
49
  invoke_audio_recorder(
48
50
  "start_recording",
49
51
  args: compact_args(
@@ -44,6 +44,8 @@ module Ruflet
44
44
  end
45
45
 
46
46
  def normalize_service_value(value)
47
+ return value.to_s if value.is_a?(Symbol)
48
+
47
49
  value.respond_to?(:to_h) ? value.to_h.transform_keys(&:to_s) : value
48
50
  end
49
51
  end
@@ -23,6 +23,8 @@ module Ruflet
23
23
  end
24
24
 
25
25
  def set(key, value, **options)
26
+ raise ArgumentError, "value must not be nil" if value.nil?
27
+
26
28
  invoke_secure_storage("set", args: option_args(options).merge("key" => key, "value" => value), **invoke_options(options))
27
29
  end
28
30
 
data/lib/ruflet_ui.rb CHANGED
@@ -140,7 +140,7 @@ module Ruflet
140
140
  private
141
141
 
142
142
  def control_delegate
143
- Ruflet::DSL
143
+ Ruflet::WidgetBuilder.new
144
144
  end
145
145
  end
146
146
  end
@@ -154,8 +154,12 @@ module Kernel
154
154
  def app(**opts, &block) = Ruflet::DSL.app(**opts, &block)
155
155
  def page(**props, &block) = Ruflet::DSL.page(**props, &block)
156
156
 
157
+ # Bare widget helpers (view, text, container, …) build real controls anywhere
158
+ # — top-level scripts and Ruflet.run blocks — with no builder to instantiate.
159
+ # The framework owns this plumbing so dev code stays free of it; a fresh
160
+ # builder per call keeps the helpers stateless.
157
161
  def control_delegate
158
- Ruflet::DSL
162
+ Ruflet::WidgetBuilder.new
159
163
  end
160
164
 
161
165
  if Ruflet::UI::SharedControlForwarders.respond_to?(:instance_methods)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruflet_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.13
4
+ version: 0.0.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - AdamMusa