ruflet_core 0.0.14 → 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: d10c0b00cedaedd9a12709dd513e56aef545f9ba61b5756481e50b270ca647ce
4
- data.tar.gz: 9a247d9aefdee2f5c667c3c5ae55235d17eb1a709c4dc402b0c794ecd5f0a752
3
+ metadata.gz: c1823b100c3e97ee941849763509b25179554464310eb640d7954bb48a30d821
4
+ data.tar.gz: 80d3f6891f3418bf0b2b3aa2007e08e6578ca336b72bb48b9d84c54cac2397b5
5
5
  SHA512:
6
- metadata.gz: 18548e0fe324df386b50cb0d5320e70283d64385244f8cf2101c0add56dd62e9a2ae5a8a29fccb86bc058eef18ccd511efc8218305121d58dba7ba40de73639d
7
- data.tar.gz: da7f855ee5f0721dbfb9ac249b6f34a7857e4a1e5e9ad2846fbb5954ae92b7c528aea6551796e6f1bcc2cec5ef7ad47aa75a7bdda48d18da3d82f6c94f9ceb56
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.14" 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?
@@ -533,7 +533,7 @@ module Ruflet
533
533
  call_id = "call_#{Ruflet::Control.generate_id}"
534
534
  if on_result.respond_to?(:call)
535
535
  @invoke_waiters_mutex.synchronize { @invoke_callbacks[call_id] = on_result }
536
- unless timeout.nil?
536
+ if embedded_async_timeout_available? && !timeout.nil?
537
537
  Thread.new(call_id, timeout.to_f) do |pending_call_id, invoke_timeout|
538
538
  sleep([invoke_timeout, 0.0].max + 0.1)
539
539
  callback = @invoke_waiters_mutex.synchronize { @invoke_callbacks.delete(pending_call_id) }
@@ -1157,12 +1157,15 @@ module Ruflet
1157
1157
  def close_dialog(dialog_control)
1158
1158
  return self unless dialog_control
1159
1159
 
1160
- before_dialog_count = @dialogs_container.props["controls"].length
1161
1160
  dialog_control.props["open"] = false
1162
1161
  @dialog = nil if @dialog.equal?(dialog_control)
1163
1162
  remove_dialog_tracking(dialog_control)
1164
1163
  refresh_dialogs_container!
1165
- push_dialogs_update!(force_view: @dialogs_container.props["controls"].length < before_dialog_count)
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!.
1168
+ push_dialogs_update!
1166
1169
  self
1167
1170
  end
1168
1171
 
@@ -1231,6 +1234,10 @@ module Ruflet
1231
1234
  if page_control_target?(target)
1232
1235
  if name.to_s == "route_change"
1233
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"]
1234
1241
  @page_props["route"] = route_from_event if route_from_event
1235
1242
  end
1236
1243
  dispatch_page_event(name: name, data: data)
@@ -1240,11 +1247,16 @@ module Ruflet
1240
1247
  control = @wire_index[target.to_i] || @control_index[target.to_s]
1241
1248
  return unless control
1242
1249
 
1243
- 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)
1244
1251
  apply_event_value_to_control(control, event) if %w[change select select_change].include?(name.to_s)
1245
- before_dialog_count = @dialogs_container.props["controls"].length
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)
1246
1256
  if dialog_close_event?(control, name) && remove_dialog_tracking(control)
1247
- push_dialogs_update!(force_view: @dialogs_container.props["controls"].length < before_dialog_count)
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).
1259
+ push_dialogs_update!
1248
1260
  end
1249
1261
 
1250
1262
  control.emit(name, event)
@@ -1289,6 +1301,10 @@ module Ruflet
1289
1301
 
1290
1302
  private
1291
1303
 
1304
+ def embedded_async_timeout_available?
1305
+ !Object.const_defined?(:RUFLET_EMBEDDED_FAKE_THREAD)
1306
+ end
1307
+
1292
1308
  def invoke_and_wait(control_or_id, method_name, args: nil, timeout: 10)
1293
1309
  control_id =
1294
1310
  if page_control_target?(control_or_id)
@@ -1634,15 +1650,15 @@ module Ruflet
1634
1650
  end
1635
1651
  end
1636
1652
 
1637
- def push_dialogs_update!(force_view: false)
1653
+ def push_dialogs_update!
1638
1654
  refresh_control_indexes!
1639
1655
 
1640
- if force_view || @dialogs_container.props["controls"].empty?
1641
- @dialogs_container_mounted = false
1642
- send_view_patch
1643
- return
1644
- end
1645
-
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.
1646
1662
  if @dialogs_container.wire_id
1647
1663
  send_message(Protocol::ACTIONS[:patch_control], {
1648
1664
  "id" => @dialogs_container.wire_id,
@@ -1670,6 +1686,36 @@ module Ruflet
1670
1686
  name == "dismiss" || (%w[change select select_change].include?(name) && @dialogs.include?(control) && control.props["open"] == false)
1671
1687
  end
1672
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
+
1673
1719
  def remove_dialog_tracking(control)
1674
1720
  return false unless @dialogs.include?(control)
1675
1721
 
@@ -1821,7 +1867,7 @@ module Ruflet
1821
1867
  call_id = "call_#{Ruflet::Control.generate_id}"
1822
1868
  if on_result.respond_to?(:call)
1823
1869
  @invoke_waiters_mutex.synchronize { @invoke_callbacks[call_id] = on_result }
1824
- unless timeout.nil?
1870
+ if embedded_async_timeout_available? && !timeout.nil?
1825
1871
  Thread.new(call_id, timeout.to_f) do |pending_call_id, invoke_timeout|
1826
1872
  sleep([invoke_timeout, 0.0].max + 0.1)
1827
1873
  callback = @invoke_waiters_mutex.synchronize { @invoke_callbacks.delete(pending_call_id) }
@@ -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
@@ -8,7 +8,8 @@ module Ruflet
8
8
  TYPE = "audiorecorder".freeze
9
9
  WIRE = "AudioRecorder".freeze
10
10
 
11
- def initialize(id: nil, configuration: {}, data: nil, key: nil, on_state_change: nil, on_stream: nil, on_upload: nil)
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?
@@ -43,7 +44,8 @@ module Ruflet
43
44
  )
44
45
  end
45
46
 
46
- def start_recording(output_path: nil, configuration: {}, upload: nil, timeout: 10, on_result: nil)
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(
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.14
4
+ version: 0.0.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - AdamMusa