ruflet_core 0.0.18 → 0.0.20

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: c28a07c8c32f3e0eca58c3d1400f06453275690b4e9f75d3a868a4507e78c74a
4
- data.tar.gz: 71f044a3c2f0464571e4d16f779e1f751c42f4e16ea3aeeecb3036b596269022
3
+ metadata.gz: 7b5d3e1bde28f482994a21faa5d1412808941a9a5f6384df22ec4307d7ce4f35
4
+ data.tar.gz: d5d90041346619db3781eb5f67747ccf91d9657f4b62ba1edafd806f959bd5a0
5
5
  SHA512:
6
- metadata.gz: 38de8d3745bbdf5b217e91de9a6dab2a194757964fa39f7a78bb89c83586fb4f0acee955ae7457221b12a15c4ab7fd18465e2a74af58627c8da25d6c75b62463
7
- data.tar.gz: 476528dec0a5b55ca3e2a3622d4cf2691162434794a498d544a9da165efb249bca5e612f6ba8f49da52387223441ec34ebe64769f162a78916db0f27bee3dce9
6
+ metadata.gz: 3b7aa35ac477467d595e0d4e2ce7e246cd5ed309d20bca5a1ef658e4f67735fbc829042750722cf0a513cd300d8621c69977697712e580c5b90d3a482be8377b
7
+ data.tar.gz: 462a296c2022360be5ce1df9ddb97400fb6c6ec87b7a443681f7104f78c4060cb1350c889b41f3b9ad090ba2be27da6e140cfa7c40a5d4093382faf842303109
data/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # ruflet
1
+ # ruflet_core
2
2
 
3
- Part of Ruflet monorepo.
3
+ `ruflet_core` provides Ruflet's Ruby UI API: controls, control builders, page
4
+ operations, events, services, and application lifecycle behavior.
5
+
6
+ Applications normally receive this package through a generated Ruflet project
7
+ or through `ruflet_rails`; it is not a standalone application server.
8
+
9
+ ```ruby
10
+ require "ruflet"
11
+
12
+ Ruflet.run do |page|
13
+ page.title = "Hello"
14
+ page.add(
15
+ container(
16
+ bgcolor: :surface_container_high,
17
+ padding: 24,
18
+ content: text(
19
+ value: "Hello Ruflet",
20
+ style: { color: "DeepOrange500", size: 20, weight: "w600" }
21
+ )
22
+ )
23
+ )
24
+ end
25
+ ```
26
+
27
+ Color properties accept named colors and hex values. Use Ruby-style symbols
28
+ like `:deep_orange_500`, compact strings like `"DeepOrange500"`, spaced names
29
+ like `"Deep Orange 500"`, semantic theme names like `:primary_container`, or
30
+ hex strings like `"#ff6d00"`. Ruflet normalizes named colors before sending
31
+ them to the client.
32
+
33
+ Use `ruflet_server` to run a standalone server-driven application. Use
34
+ `ruflet_rails` when the UI is hosted by Rails.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruflet
4
- VERSION = "0.0.18" unless const_defined?(:VERSION)
4
+ VERSION = "0.0.20" unless const_defined?(:VERSION)
5
5
  end
@@ -78,10 +78,28 @@ module Ruflet
78
78
  }
79
79
  end
80
80
 
81
- def register_response(session_id:)
81
+ # The client's reply to `invoke_control_method`: the pending call id plus its
82
+ # result/error. Ruflet::ConnectionProtocol (ruflet_rails' Rack-hijack adapter
83
+ # and any other host adapter) normalizes this before handing it to
84
+ # Page#handle_invoke_method_result, which matches `call_id` back to the
85
+ # waiting callback. Without this method that path fell through to the DSL's
86
+ # method_missing and every invoke result was silently discarded.
87
+ def normalize_invoke_method_result_payload(payload)
88
+ payload = {} unless payload.is_a?(Hash)
89
+ {
90
+ "call_id" => payload["call_id"] || payload["callId"],
91
+ "result" => payload.key?("result") ? payload["result"] : payload["value"],
92
+ "error" => payload["error"]
93
+ }
94
+ end
95
+
96
+ # page_patch defaults to nil rather than {} because mruby cannot parse a
97
+ # hash literal as a keyword argument default, and this file is compiled
98
+ # into the embedded VM.
99
+ def register_response(session_id:, page_patch: nil)
82
100
  {
83
101
  "session_id" => session_id,
84
- "page_patch" => {},
102
+ "page_patch" => page_patch || {},
85
103
  "error" => ""
86
104
  }
87
105
  end
@@ -321,6 +321,7 @@ module Ruflet
321
321
  def video(**props) = _pending_app.video(**props)
322
322
  def code_editor(value = nil, **props) = _pending_app.code_editor(value, **props)
323
323
  def codeeditor(value = nil, **props) = _pending_app.codeeditor(value, **props)
324
+ def lottie(src = nil, **props) = _pending_app.lottie(src, **props)
324
325
  def rive(src = nil, **props) = _pending_app.rive(src, **props)
325
326
  def fab(content = nil, **props) = _pending_app.fab(content, **props)
326
327
  def cupertino_button(content = nil, **props) = _pending_app.cupertino_button(content, **props)
@@ -17,6 +17,15 @@ module Ruflet
17
17
  @control = control
18
18
  end
19
19
 
20
+ # A stable convenience accessor shared by built-in and extension events.
21
+ # Extension event names are intentionally not part of the core typed-event
22
+ # registry, so fall back to the same value extraction used by GenericEvent.
23
+ def value
24
+ return typed_data.value if typed_data&.respond_to?(:value)
25
+
26
+ Events::GenericEvent.from_data(data).value
27
+ end
28
+
20
29
  private
21
30
 
22
31
  def parse_data(raw)
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruflet
4
+ module Extensions
5
+ class QrCodeScannerControl < Ruflet::Control
6
+ TYPE = "qrcode_scanner".freeze
7
+ WIRE = "qrcode_scanner".freeze
8
+ KEYWORDS = %i[
9
+ align animate_align animate_margin animate_offset animate_opacity
10
+ animate_position animate_rotation animate_scale animate_size aspect_ratio
11
+ auto_start auto_zoom badge bottom camera_facing col data detection_speed
12
+ detection_timeout disabled expand expand_loose fit formats height
13
+ invert_image key left margin offset opacity return_image right rotate rtl
14
+ scale scan_window size_change_interval tap_to_focus tooltip top
15
+ torch_enabled visible width zoom_scale on_animation_end on_detect on_error
16
+ on_size_change
17
+ ].freeze
18
+
19
+ def initialize(id: nil, **props)
20
+ super(type: TYPE, id: id, **props)
21
+ end
22
+
23
+ def start(timeout: 10, on_result: nil)
24
+ invoke_scanner("start", timeout: timeout, on_result: on_result)
25
+ end
26
+
27
+ def stop(timeout: 10, on_result: nil)
28
+ invoke_scanner("stop", timeout: timeout, on_result: on_result)
29
+ end
30
+
31
+ def switch_camera(timeout: 10, on_result: nil)
32
+ invoke_scanner("switch_camera", timeout: timeout, on_result: on_result)
33
+ end
34
+
35
+ def toggle_torch(timeout: 10, on_result: nil)
36
+ invoke_scanner("toggle_torch", timeout: timeout, on_result: on_result)
37
+ end
38
+
39
+ def set_zoom_scale(value, timeout: 10, on_result: nil)
40
+ invoke_scanner("set_zoom_scale", args: { "value" => value }, timeout: timeout, on_result: on_result)
41
+ end
42
+
43
+ def reset_zoom_scale(timeout: 10, on_result: nil)
44
+ invoke_scanner("reset_zoom_scale", timeout: timeout, on_result: on_result)
45
+ end
46
+
47
+ private
48
+
49
+ def preprocess_props(props)
50
+ mapped = props.dup
51
+ mapped[:formats] = Array(mapped[:formats]).map(&:to_s) if mapped.key?(:formats)
52
+ mapped["formats"] = Array(mapped["formats"]).map(&:to_s) if mapped.key?("formats")
53
+ mapped
54
+ end
55
+
56
+ def invoke_scanner(name, args: nil, timeout: 10, on_result: nil)
57
+ runtime_page&.invoke(self, name, args: args, timeout: timeout, on_result: on_result)
58
+ end
59
+ end
60
+
61
+ register_control(
62
+ :qrcode_scanner,
63
+ control_class: QrCodeScannerControl,
64
+ flutter: {
65
+ package: "ruflet_qrcode_scanner",
66
+ import: "package:ruflet_qrcode_scanner/ruflet_qrcode_scanner.dart",
67
+ alias: "ruflet_qrcode_scanner",
68
+ constructor: "Extension"
69
+ }
70
+ )
71
+ end
72
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruflet
4
+ # Registry used by Ruflet extension gems to add typed Ruby controls and
5
+ # describe the matching Flet Flutter package. Registering a control makes its
6
+ # helpers available in the bare DSL, Ruflet::DSL, Ruflet, Ruflet::UI, and
7
+ # WidgetBuilder without editing Ruflet's generated control method tables.
8
+ module Extensions
9
+ Registration = Struct.new(
10
+ :name,
11
+ :control_class,
12
+ :helpers,
13
+ :flutter,
14
+ keyword_init: true
15
+ )
16
+
17
+ @registrations = {}
18
+
19
+ class << self
20
+ def register_control(name, control_class:, helpers: nil, flutter: nil)
21
+ key = normalize_name(name)
22
+ helper_names = Array(helpers || key).map { |helper| normalize_name(helper) }.uniq.freeze
23
+ registration = Registration.new(
24
+ name: key,
25
+ control_class: control_class,
26
+ helpers: helper_names,
27
+ flutter: normalize_flutter_metadata(flutter)
28
+ ).freeze
29
+
30
+ existing = @registrations[key]
31
+ if existing && existing != registration
32
+ raise ArgumentError, "Ruflet extension `#{key}` is already registered"
33
+ end
34
+
35
+ helper_names.each do |helper|
36
+ UI::ControlFactory.register_extension(helper, control_class)
37
+ define_control_helper(helper)
38
+ end
39
+ @registrations[key] = registration
40
+ end
41
+
42
+ def [](name)
43
+ @registrations[normalize_name(name)]
44
+ end
45
+
46
+ def each(&block)
47
+ return enum_for(:each) unless block
48
+
49
+ @registrations.values.each(&block)
50
+ end
51
+
52
+ def registered?(name)
53
+ @registrations.key?(normalize_name(name))
54
+ end
55
+
56
+ private
57
+
58
+ def normalize_name(value)
59
+ name = value.to_s.strip.downcase.tr("-", "_")
60
+ raise ArgumentError, "Extension name cannot be empty" if name.empty?
61
+ raise ArgumentError, "Invalid extension name `#{value}`" unless name.match?(/\A[a-z][a-z0-9_]*\z/)
62
+
63
+ name
64
+ end
65
+
66
+ def normalize_flutter_metadata(metadata)
67
+ return nil if metadata.nil?
68
+
69
+ values = metadata.transform_keys(&:to_sym)
70
+ package = values.fetch(:package).to_s
71
+ import = values.fetch(:import, "package:#{package}/#{package}.dart").to_s
72
+ alias_name = values.fetch(:alias, package).to_s
73
+ constructor = values.fetch(:constructor, "Extension").to_s
74
+ {
75
+ package: package,
76
+ import: import,
77
+ alias: alias_name,
78
+ constructor: constructor
79
+ }.freeze
80
+ end
81
+
82
+ def define_control_helper(helper)
83
+ type = helper
84
+
85
+ UI::ControlMethods.send(:define_method, helper) do |**props, &block|
86
+ control(type, **props, &block)
87
+ end
88
+
89
+ UI::SharedControlForwarders.send(:define_method, helper) do |**props, &block|
90
+ control_delegate.public_send(type, **props, &block)
91
+ end
92
+
93
+ DSL.define_singleton_method(helper) do |**props, &block|
94
+ _pending_app.public_send(type, **props, &block)
95
+ end
96
+
97
+ Kernel.send(:private, helper) if Kernel.method_defined?(helper)
98
+ end
99
+ end
100
+ end
101
+ end
@@ -300,6 +300,90 @@ module Ruflet
300
300
  self
301
301
  end
302
302
 
303
+ # Complete page state as a property map for the register_client response
304
+ # (Flet's page_patch). The client applies this through Control.update,
305
+ # which merges by control id and keeps existing instances alive — the only
306
+ # patch path that survives a re-register on a client with prior state
307
+ # (reconnect after a backend restart). The op-based view patch replaces
308
+ # control instances and detaches containers on such clients.
309
+ def register_page_patch
310
+ refresh_control_indexes!
311
+ patch = { "views" => build_view_patches }
312
+ @page_props.each { |key, value| patch[key] = serialize_patch_value(value) }
313
+ @overlay_container_mounted = true if @overlay_container.wire_id
314
+ @dialogs_container_mounted = true if @dialogs_container.wire_id
315
+ @services_container_mounted = true if @services_container.wire_id
316
+ patch
317
+ end
318
+
319
+ # Page props that must survive a hot reload: the route (kept on purpose),
320
+ # the client-reported window, and the internal overlay/services/dialogs
321
+ # containers (kept mounted so the client keeps their bindings).
322
+ RELOAD_PRESERVED_PAGE_PROPS = %w[route window _overlay _dialogs _services].freeze
323
+
324
+ # Quietly clears session content and page chrome so a reloaded app block
325
+ # can re-render on this same Page without recreating it. The page object
326
+ # must survive a hot reload: a new Page re-sends the internal
327
+ # overlay/service/dialogs containers, which replaces their client-side
328
+ # instances and detaches them (see build_page_patch_ops) — after that,
329
+ # dialog and service patches are silently ignored by the client.
330
+ #
331
+ # Chrome (appbar, drawer, FAB, bgcolor, title, ...) lives in @view_props
332
+ # and @page_props. If the reloaded block no longer sets a piece of chrome,
333
+ # the previous run's value would linger, so both are cleared here. The
334
+ # view is fully rebuilt on the next patch, which drops any @view_props not
335
+ # re-set; page-level props merge on the client, so finalize_reload! sends
336
+ # explicit nils for the ones that disappeared. Sends nothing itself.
337
+ def reset_for_reload!
338
+ @root_controls = []
339
+ @views = []
340
+ @dialogs = []
341
+ @dialog = nil
342
+ @snack_bar = nil
343
+ @bottom_sheet = nil
344
+ @route_change_seen_since_reset = false
345
+
346
+ @reload_prior_page_prop_keys = @page_props.keys - RELOAD_PRESERVED_PAGE_PROPS
347
+ @view_props = {}
348
+ @page_props = @page_props.select { |key, _| RELOAD_PRESERVED_PAGE_PROPS.include?(key) }
349
+ @page_props["route"] ||= (@client_details["route"] || "/")
350
+ @page_props["window"] ||= @window
351
+ @overlay_container.children.clear
352
+ refresh_overlay_container!
353
+ refresh_services_container!
354
+ refresh_dialogs_container!
355
+ self
356
+ end
357
+
358
+ # Called after the reloaded app block ran, before the reload view patch.
359
+ # Page-level props merge on the client (the Page control is not recreated),
360
+ # so chrome the reloaded block dropped must be sent as an explicit nil or
361
+ # it lingers. View props need no such treatment: the View is rebuilt
362
+ # wholesale from @view_props. Also flushes the overlay container, which
363
+ # stays mounted and is skipped by a plain view patch.
364
+ def finalize_reload!
365
+ keys = @reload_prior_page_prop_keys
366
+ if keys
367
+ (keys - @page_props.keys).each { |key| @page_props[key] = nil }
368
+ @reload_prior_page_prop_keys = nil
369
+ end
370
+ push_overlay_update!
371
+ self
372
+ end
373
+
374
+ # Called after the reloaded app block ran. The route survives a reload
375
+ # (page props are kept), but the client only sends its route_change event
376
+ # at connect time — apps that render routes exclusively inside
377
+ # on_route_change would be stuck on stale content after a reload. Replay
378
+ # the event for them, unless the block already routed itself (page.go).
379
+ def replay_route_after_reload!
380
+ return self if @route_change_seen_since_reset
381
+ return self unless @page_event_handlers["route_change"].respond_to?(:call)
382
+
383
+ dispatch_page_event(name: "route_change", data: @page_props["route"])
384
+ self
385
+ end
386
+
303
387
  def overlay
304
388
  @overlay_container.children
305
389
  end
@@ -590,11 +674,13 @@ module Ruflet
590
674
  def dialog=(value)
591
675
  @dialog = value
592
676
  refresh_dialogs_container!
677
+ push_dialogs_update! if @dialogs_container_mounted
593
678
  end
594
679
 
595
680
  def snack_bar=(value)
596
681
  @snack_bar = value
597
682
  refresh_dialogs_container!
683
+ push_dialogs_update! if @dialogs_container_mounted
598
684
  end
599
685
 
600
686
  def snack_bar
@@ -608,6 +694,7 @@ module Ruflet
608
694
  def bottom_sheet=(value)
609
695
  @bottom_sheet = value
610
696
  refresh_dialogs_container!
697
+ push_dialogs_update! if @dialogs_container_mounted
611
698
  end
612
699
 
613
700
  def bottom_sheet
@@ -640,9 +727,22 @@ module Ruflet
640
727
  end
641
728
 
642
729
  def show_bottom_sheet(bottom_sheet_control)
730
+ @bottom_sheet = bottom_sheet_control
643
731
  show_dialog(bottom_sheet_control)
644
732
  end
645
733
 
734
+ def show_bottomsheet(bottom_sheet_control)
735
+ show_bottom_sheet(bottom_sheet_control)
736
+ end
737
+
738
+ def close_bottom_sheet(bottom_sheet_control = nil)
739
+ close_dialog(bottom_sheet_control || @bottom_sheet)
740
+ end
741
+
742
+ def close_bottomsheet(bottom_sheet_control = nil)
743
+ close_bottom_sheet(bottom_sheet_control)
744
+ end
745
+
646
746
  def show_banner(banner_control)
647
747
  show_dialog(banner_control)
648
748
  end
@@ -651,13 +751,27 @@ module Ruflet
651
751
  close_dialog(banner_control)
652
752
  end
653
753
 
754
+ # Flet-compatible generic overlay API. Prefer close_bottom_sheet for sheets
755
+ # so the page's current bottom-sheet slot is handled explicitly.
756
+ def open(dialog_control)
757
+ show_dialog(dialog_control)
758
+ end
759
+
760
+ def close(dialog_control = nil)
761
+ close_dialog(dialog_control)
762
+ end
763
+
654
764
  def close_dialog(dialog_control = nil)
655
765
  target = dialog_control || latest_open_dialog
656
766
  return nil unless target
657
767
 
768
+ clear_dialog_slots(target)
769
+ # The client pops a dialog's route only when the mounted control sees
770
+ # its `open` prop flip to false (alert_dialog.dart tracks open/_open).
771
+ # Replacing the dialogs container recreates the control client-side and
772
+ # the transition is lost, so patch the open prop on the control itself.
658
773
  target.props["open"] = false
659
- refresh_dialogs_container!
660
- push_dialogs_update!
774
+ update(target, open: false)
661
775
  target
662
776
  end
663
777
 
@@ -1192,6 +1306,8 @@ module Ruflet
1192
1306
  patch["content"] = patch.delete("text")
1193
1307
  end
1194
1308
 
1309
+ patch.each { |key, value| control.props[key] = value }
1310
+
1195
1311
  # Keep runtime control tree aligned with incremental patches.
1196
1312
  if patch.key?("controls")
1197
1313
  control.children.clear
@@ -1577,6 +1693,7 @@ module Ruflet
1577
1693
  def dispatch_page_event(name:, data:)
1578
1694
  event_name = name.to_s
1579
1695
  event_name = event_name[3..-1] if event_name.start_with?("on_")
1696
+ @route_change_seen_since_reset = true if event_name == "route_change"
1580
1697
  handler = @page_event_handlers[event_name]
1581
1698
  return unless handler.respond_to?(:call)
1582
1699
 
@@ -1712,10 +1829,17 @@ module Ruflet
1712
1829
  return false unless @dialogs.include?(control)
1713
1830
 
1714
1831
  @dialogs.delete(control)
1832
+ clear_dialog_slots(control)
1715
1833
  refresh_dialogs_container!
1716
1834
  true
1717
1835
  end
1718
1836
 
1837
+ def clear_dialog_slots(control)
1838
+ @dialog = nil if @dialog.equal?(control)
1839
+ @snack_bar = nil if @snack_bar.equal?(control)
1840
+ @bottom_sheet = nil if @bottom_sheet.equal?(control)
1841
+ end
1842
+
1719
1843
  def assign_split_prop(key, value)
1720
1844
  if key == "vertical_alignment" || key == "horizontal_alignment"
1721
1845
  @page_props[key] = value
@@ -13,12 +13,14 @@ module Ruflet
13
13
  Services::RufletServices::CLASS_MAP
14
14
  .merge(Controls::RufletControls::CLASS_MAP)
15
15
  .freeze
16
+ EXTENSION_CLASS_MAP = {}
16
17
  CONSTRUCTOR_KEYWORDS_CACHE = {}
17
18
 
18
- PYTHON_COMMON_ATTRIBUTES = %i[flip ref transform].freeze
19
- PYTHON_ATTRIBUTE_OVERRIDES = {
19
+ COMMON_ATTRIBUTES = %i[flip ref transform].freeze
20
+ ATTRIBUTE_OVERRIDES = {
20
21
  "expansionpanellist" => %i[auto_scroll on_scroll scroll scroll_interval],
21
- "fletapp" => %i[assets_dir on_python_output],
22
+ "ruflet_app" => %i[assets_dir on_python_output],
23
+ "rufletapp" => %i[assets_dir on_python_output],
22
24
  "image" => %i[
23
25
  align animate_align animate_margin animate_offset animate_opacity animate_position
24
26
  animate_rotation animate_scale animate_size aspect_ratio badge bottom col disabled
@@ -40,7 +42,7 @@ module Ruflet
40
42
  if ENV["RUFLET_DEBUG"] == "1" && normalized_type == "floatingactionbutton"
41
43
  Kernel.warn("[factory] type=#{normalized_type} id=#{id.inspect} props=#{props.inspect}")
42
44
  end
43
- klass = CLASS_MAP[normalized_type]
45
+ klass = EXTENSION_CLASS_MAP[normalized_type] || CLASS_MAP[normalized_type]
44
46
  if klass
45
47
  normalized_props, supplemental_props = normalize_constructor_props(klass, normalized_type, props)
46
48
  if ENV["RUFLET_DEBUG"] == "1" && normalized_type == "floatingactionbutton"
@@ -64,7 +66,7 @@ module Ruflet
64
66
 
65
67
  def normalize_constructor_props(klass, type, props)
66
68
  keywords = constructor_keywords(klass)
67
- return [props, {}] if keywords.empty? && PYTHON_ATTRIBUTE_OVERRIDES[type].nil?
69
+ return [props, {}] if keywords.empty? && ATTRIBUTE_OVERRIDES[type].nil?
68
70
 
69
71
  allowed = keywords.map(&:to_s)
70
72
  mapped = props.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
@@ -74,7 +76,7 @@ module Ruflet
74
76
  if mapped.key?(:value) && !allowed.include?("value") && allowed.include?("text") && !mapped.key?(:text)
75
77
  mapped[:text] = mapped.delete(:value)
76
78
  end
77
- supplemental_names = PYTHON_COMMON_ATTRIBUTES + PYTHON_ATTRIBUTE_OVERRIDES.fetch(type, [])
79
+ supplemental_names = COMMON_ATTRIBUTES + ATTRIBUTE_OVERRIDES.fetch(type, [])
78
80
  supplemental = mapped.slice(*supplemental_names)
79
81
  [mapped.reject { |key, _| supplemental_names.include?(key) }, supplemental]
80
82
  end
@@ -96,6 +98,21 @@ module Ruflet
96
98
  []
97
99
  end
98
100
 
101
+ def register_extension(type, klass)
102
+ key = type.to_s.downcase
103
+ raise ArgumentError, "Extension control type cannot be empty" if key.empty?
104
+ raise ArgumentError, "Extension control must inherit Ruflet::Control" unless klass <= Ruflet::Control
105
+
106
+ existing = EXTENSION_CLASS_MAP[key]
107
+ if existing && existing != klass
108
+ raise ArgumentError, "Extension control `#{key}` is already registered by #{existing}"
109
+ end
110
+
111
+ EXTENSION_CLASS_MAP[key] = klass
112
+ CONSTRUCTOR_KEYWORDS_CACHE.delete(klass)
113
+ klass
114
+ end
115
+
99
116
  def normalize_common_value(value)
100
117
  case value
101
118
  when Hash
@@ -4,11 +4,32 @@ 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 runs on iOS, Android, macOS, Windows,
21
+ # and Linux. Linux distributions must provide WebKitGTK 4.1. On web it
22
+ # uses an iframe, so browser cross-origin restrictions still apply.
7
23
  class WebViewControl < Ruflet::Control
8
24
  TYPE = "WebView".freeze
9
25
  WIRE = "WebView".freeze
10
26
 
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)
27
+ def initialize(id: nil, bgcolor: nil, data: nil, enable_javascript: nil, expand: nil,
28
+ height: nil, key: nil, method: nil, opacity: nil, prevent_links: nil,
29
+ rtl: nil, tooltip: nil, url: nil, visible: nil, width: nil,
30
+ on_page_ended: nil, on_page_started: nil, on_web_resource_error: nil,
31
+ on_progress: nil, on_url_change: nil, on_scroll: nil,
32
+ on_console_message: nil, on_javascript_alert_dialog: nil)
12
33
  props = {}
13
34
  props[:bgcolor] = bgcolor unless bgcolor.nil?
14
35
  props[:data] = data unless data.nil?
@@ -18,6 +39,7 @@ module Ruflet
18
39
  props[:key] = key unless key.nil?
19
40
  props[:method] = method unless method.nil?
20
41
  props[:opacity] = opacity unless opacity.nil?
42
+ props[:prevent_links] = prevent_links unless prevent_links.nil?
21
43
  props[:rtl] = rtl unless rtl.nil?
22
44
  props[:tooltip] = tooltip unless tooltip.nil?
23
45
  props[:url] = url unless url.nil?
@@ -26,8 +48,98 @@ module Ruflet
26
48
  props[:on_page_ended] = on_page_ended unless on_page_ended.nil?
27
49
  props[:on_page_started] = on_page_started unless on_page_started.nil?
28
50
  props[:on_web_resource_error] = on_web_resource_error unless on_web_resource_error.nil?
51
+ props[:on_progress] = on_progress unless on_progress.nil?
52
+ props[:on_url_change] = on_url_change unless on_url_change.nil?
53
+ props[:on_scroll] = on_scroll unless on_scroll.nil?
54
+ props[:on_console_message] = on_console_message unless on_console_message.nil?
55
+ props[:on_javascript_alert_dialog] = on_javascript_alert_dialog unless on_javascript_alert_dialog.nil?
29
56
  super(type: TYPE, id: id, **props)
30
57
  end
58
+
59
+ # --- Navigation --------------------------------------------------
60
+
61
+ def reload = invoke_webview_method("reload")
62
+ def go_back = invoke_webview_method("go_back")
63
+ def go_forward = invoke_webview_method("go_forward")
64
+
65
+ def can_go_back(timeout: 10, &on_result)
66
+ invoke_webview_method("can_go_back", timeout: timeout, on_result: on_result)
67
+ end
68
+
69
+ def can_go_forward(timeout: 10, &on_result)
70
+ invoke_webview_method("can_go_forward", timeout: timeout, on_result: on_result)
71
+ end
72
+
73
+ # --- Loading content ---------------------------------------------
74
+
75
+ def load_request(url, method: "get")
76
+ invoke_webview_method("load_request", { "url" => url.to_s, "method" => method.to_s })
77
+ end
78
+
79
+ def load_html(value, base_url: nil)
80
+ args = { "value" => value.to_s }
81
+ args["base_url"] = base_url.to_s unless base_url.nil?
82
+ invoke_webview_method("load_html", args)
83
+ end
84
+
85
+ def load_file(path)
86
+ invoke_webview_method("load_file", { "path" => path.to_s })
87
+ end
88
+
89
+ # --- JavaScript injection ---------------------------------------
90
+
91
+ # Run arbitrary JS inside the page — e.g. hide a node so a native
92
+ # control can take its place:
93
+ # webview.run_javascript("document.getElementById('banner').remove()")
94
+ def run_javascript(value)
95
+ invoke_webview_method("run_javascript", { "value" => value.to_s })
96
+ end
97
+
98
+ def set_javascript_mode(mode)
99
+ invoke_webview_method("set_javascript_mode", { "mode" => mode.to_s })
100
+ end
101
+
102
+ # --- Scrolling ---------------------------------------------------
103
+
104
+ def scroll_to(x, y)
105
+ invoke_webview_method("scroll_to", { "x" => x.to_i, "y" => y.to_i })
106
+ end
107
+
108
+ def scroll_by(x, y)
109
+ invoke_webview_method("scroll_by", { "x" => x.to_i, "y" => y.to_i })
110
+ end
111
+
112
+ # --- Storage / zoom ----------------------------------------------
113
+
114
+ def clear_cache = invoke_webview_method("clear_cache")
115
+ def clear_local_storage = invoke_webview_method("clear_local_storage")
116
+ def enable_zoom = invoke_webview_method("enable_zoom")
117
+ def disable_zoom = invoke_webview_method("disable_zoom")
118
+
119
+ # --- Introspection (result delivered to the block) ---------------
120
+
121
+ def get_current_url(timeout: 10, &on_result)
122
+ invoke_webview_method("get_current_url", timeout: timeout, on_result: on_result)
123
+ end
124
+
125
+ def get_title(timeout: 10, &on_result)
126
+ invoke_webview_method("get_title", timeout: timeout, on_result: on_result)
127
+ end
128
+
129
+ def get_user_agent(timeout: 10, &on_result)
130
+ invoke_webview_method("get_user_agent", timeout: timeout, on_result: on_result)
131
+ end
132
+
133
+ private
134
+
135
+ def invoke_webview_method(name, args = nil, timeout: 10, on_result: nil)
136
+ page = runtime_page
137
+ unless page && wire_id
138
+ raise "WebView ##{id} is not mounted yet — add it to the page before calling #{name}."
139
+ end
140
+
141
+ page.invoke(self, name, args: args, timeout: timeout, on_result: on_result)
142
+ end
31
143
  end
32
144
  end
33
145
  end
@@ -115,7 +115,6 @@ require_relative "shared/dismissible_control"
115
115
  require_relative "shared/draggable_control"
116
116
  require_relative "shared/dragtarget_control"
117
117
  require_relative "shared/fill_control"
118
- require_relative "shared/fletapp_control"
119
118
  require_relative "shared/gesturedetector_control"
120
119
  require_relative "shared/gridview_control"
121
120
  require_relative "shared/hero_control"
@@ -140,6 +139,7 @@ require_relative "shared/reorderabledraghandle_control"
140
139
  require_relative "shared/responsiverow_control"
141
140
  require_relative "shared/row_control"
142
141
  require_relative "shared/rotatedbox_control"
142
+ require_relative "shared/ruflet_app_control"
143
143
  require_relative "shared/safearea_control"
144
144
  require_relative "shared/screenshot_control"
145
145
  require_relative "shared/semantics_control"
@@ -301,8 +301,6 @@ module Ruflet
301
301
  "fillediconbutton" => RufletComponents::FilledIconButtonControl,
302
302
  "filledtonalbutton" => RufletComponents::FilledTonalButtonControl,
303
303
  "filledtonaliconbutton" => RufletComponents::FilledTonalIconButtonControl,
304
- "flet_app" => RufletComponents::FletAppControl,
305
- "fletapp" => RufletComponents::FletAppControl,
306
304
  "floating_action_button" => RufletComponents::FloatingActionButtonControl,
307
305
  "floatingactionbutton" => RufletComponents::FloatingActionButtonControl,
308
306
  "gesture_detector" => RufletComponents::GestureDetectorControl,
@@ -399,6 +397,8 @@ module Ruflet
399
397
  "row" => RufletComponents::RowControl,
400
398
  "rotated_box" => RufletComponents::RotatedBoxControl,
401
399
  "rotatedbox" => RufletComponents::RotatedBoxControl,
400
+ "ruflet_app" => RufletComponents::RufletAppControl,
401
+ "rufletapp" => RufletComponents::RufletAppControl,
402
402
  "safe_area" => RufletComponents::SafeAreaControl,
403
403
  "safearea" => RufletComponents::SafeAreaControl,
404
404
  "screenshot" => RufletComponents::ScreenshotControl,
@@ -4,8 +4,10 @@ module Ruflet
4
4
  module UI
5
5
  module Controls
6
6
  module RufletComponents
7
- class FletAppControl < Ruflet::Control
8
- TYPE = "fletapp".freeze
7
+ class RufletAppControl < Ruflet::Control
8
+ TYPE = "ruflet_app".freeze
9
+ # The client widget is still registered under its upstream name, so the
10
+ # wire type stays FletApp while the Ruby API is ruflet_app.
9
11
  WIRE = "FletApp".freeze
10
12
 
11
13
  KEYWORDS = [:align, :animate_align, :animate_margin, :animate_offset, :animate_opacity, :animate_position, :animate_rotation, :animate_scale, :animate_size, :app_error_message, :app_startup_screen_message, :args, :aspect_ratio, :badge, :bottom, :col, :data, :disabled, :expand, :expand_loose, :force_pyodide, :height, :key, :left, :margin, :offset, :opacity, :reconnect_interval_ms, :reconnect_timeout_ms, :right, :rotate, :rtl, :scale, :show_app_startup_screen, :size_change_interval, :tooltip, :top, :url, :visible, :width, :on_animation_end, :on_error, :on_size_change].freeze
@@ -14,7 +14,6 @@ require_relative "dismissible_control"
14
14
  require_relative "draggable_control"
15
15
  require_relative "dragtarget_control"
16
16
  require_relative "fill_control"
17
- require_relative "fletapp_control"
18
17
  require_relative "gesturedetector_control"
19
18
  require_relative "gridview_control"
20
19
  require_relative "hero_control"
@@ -39,6 +38,7 @@ require_relative "reorderabledraghandle_control"
39
38
  require_relative "responsiverow_control"
40
39
  require_relative "row_control"
41
40
  require_relative "rotatedbox_control"
41
+ require_relative "ruflet_app_control"
42
42
  require_relative "safearea_control"
43
43
  require_relative "screenshot_control"
44
44
  require_relative "semantics_control"
@@ -81,8 +81,6 @@ module Ruflet
81
81
  "draggable" => RufletComponents::DraggableControl,
82
82
  "dragtarget" => RufletComponents::DragTargetControl,
83
83
  "fill" => RufletComponents::FillControl,
84
- "flet_app" => RufletComponents::FletAppControl,
85
- "fletapp" => RufletComponents::FletAppControl,
86
84
  "gesture_detector" => RufletComponents::GestureDetectorControl,
87
85
  "gesturedetector" => RufletComponents::GestureDetectorControl,
88
86
  "grid_view" => RufletComponents::GridViewControl,
@@ -117,6 +115,8 @@ module Ruflet
117
115
  "row" => RufletComponents::RowControl,
118
116
  "rotated_box" => RufletComponents::RotatedBoxControl,
119
117
  "rotatedbox" => RufletComponents::RotatedBoxControl,
118
+ "ruflet_app" => RufletComponents::RufletAppControl,
119
+ "rufletapp" => RufletComponents::RufletAppControl,
120
120
  "safe_area" => RufletComponents::SafeAreaControl,
121
121
  "safearea" => RufletComponents::SafeAreaControl,
122
122
  "screenshot" => RufletComponents::ScreenshotControl,
@@ -21,7 +21,7 @@ module Ruflet
21
21
 
22
22
  def view(children = nil, **props, &block)
23
23
  mapped = props.dup
24
- mapped[:children] = children unless children.nil?
24
+ mapped[:controls] = children unless children.nil?
25
25
  build_widget(:view, **mapped, &block)
26
26
  end
27
27
  def column(children = nil, **props, &block)
@@ -702,6 +702,11 @@ module Ruflet
702
702
  build_widget(:codeeditor, **mapped)
703
703
  end
704
704
  def codeeditor(value = nil, **props) = code_editor(value, **props)
705
+ def lottie(src = nil, **props)
706
+ mapped = props.dup
707
+ mapped[:src] = src unless src.nil?
708
+ build_widget(:lottie, **mapped)
709
+ end
705
710
  def rive(src = nil, **props)
706
711
  mapped = props.dup
707
712
  mapped[:src] = src unless src.nil?
@@ -116,6 +116,7 @@ module Ruflet
116
116
  "simple_attribution" => "SimpleAttribution",
117
117
  "codeeditor" => "CodeEditor",
118
118
  "code_editor" => "CodeEditor",
119
+ "lottie" => "Lottie",
119
120
  "flashlight" => "Flashlight",
120
121
  "barchart" => "BarChart",
121
122
  "barchartgroup" => "group",
@@ -6,7 +6,11 @@ module Ruflet
6
6
  def control(type, **props, &block) = control_delegate.control(type, **props, &block)
7
7
  def widget(type, **props, &block) = control_delegate.widget(type, **props, &block)
8
8
  def service(type, **props, &block) = control_delegate.service(type, **props, &block)
9
- def view(children = nil, **props, &block) = control_delegate.view(children, **props, &block)
9
+ def view(children = nil, **props, &block)
10
+ mapped = props.dup
11
+ mapped[:controls] = children unless children.nil?
12
+ control_delegate.control(:view, **mapped, &block)
13
+ end
10
14
  def column(children = nil, **props, &block) = control_delegate.column(children, **props, &block)
11
15
  def center(**props, &block) = control_delegate.center(**props, &block)
12
16
  def row(children = nil, **props, &block) = control_delegate.row(children, **props, &block)
@@ -278,6 +282,7 @@ module Ruflet
278
282
  def video(**props) = control_delegate.video(**props)
279
283
  def code_editor(value = nil, **props) = control_delegate.code_editor(value, **props)
280
284
  def codeeditor(value = nil, **props) = control_delegate.codeeditor(value, **props)
285
+ def lottie(src = nil, **props) = control_delegate.lottie(src, **props)
281
286
  def rive(src = nil, **props) = control_delegate.rive(src, **props)
282
287
  def cupertino_button(content = nil, **props) = control_delegate.cupertino_button(content, **props)
283
288
  def cupertinobutton(content = nil, **props) = control_delegate.cupertinobutton(content, **props)
data/lib/ruflet_ui.rb CHANGED
@@ -3,8 +3,6 @@
3
3
  require "ruflet_protocol"
4
4
  require_relative "ruflet_ui/ruflet/colors"
5
5
  require_relative "ruflet_ui/ruflet/icon_data"
6
- require_relative "ruflet_ui/ruflet/icons/material/material_icons"
7
- require_relative "ruflet_ui/ruflet/icons/cupertino/cupertino_icons"
8
6
  require_relative "ruflet_ui/ruflet/types/text_style"
9
7
  require_relative "ruflet_ui/ruflet/types/geometry"
10
8
  require_relative "ruflet_ui/ruflet/control"
@@ -15,8 +13,25 @@ require_relative "ruflet_ui/ruflet/event"
15
13
  require_relative "ruflet_ui/ruflet/page"
16
14
  require_relative "ruflet_ui/ruflet/app"
17
15
  require_relative "ruflet_ui/ruflet/dsl"
16
+ require_relative "ruflet_ui/ruflet/extensions"
17
+ require_relative "ruflet_ui/ruflet/extensions/qrcode_scanner"
18
18
 
19
19
  module Ruflet
20
+ # Icon tables are large (the material set alone parses a ~234KB map and
21
+ # defines thousands of constants) but the common `icon("home")` path passes
22
+ # the name straight through — nothing in the runtime touches these modules
23
+ # unless an app references a constant like `Ruflet::Icons::HOME`. Autoload
24
+ # them so boot (and every full restart) skips that cost until first use.
25
+ #
26
+ # CRuby only: under the embedded mruby VM the framework is concatenated into
27
+ # one blob with no filesystem require, so these modules are already defined
28
+ # there and the guard keeps the autoload calls out of that path entirely.
29
+ if RUBY_ENGINE != "mruby"
30
+ icons_root = File.expand_path("ruflet_ui/ruflet/icons", __dir__)
31
+ autoload :MaterialIcons, File.join(icons_root, "material/material_icons")
32
+ autoload :CupertinoIcons, File.join(icons_root, "cupertino/cupertino_icons")
33
+ end
34
+
20
35
  TextStyle = UI::Types::TextStyle
21
36
  StrutStyle = UI::Types::StrutStyle
22
37
  TextOverflow = UI::Types::TextOverflow
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.18
4
+ version: 0.0.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - AdamMusa
@@ -29,6 +29,8 @@ files:
29
29
  - lib/ruflet_ui/ruflet/dsl.rb
30
30
  - lib/ruflet_ui/ruflet/event.rb
31
31
  - lib/ruflet_ui/ruflet/events/gesture_events.rb
32
+ - lib/ruflet_ui/ruflet/extensions.rb
33
+ - lib/ruflet_ui/ruflet/extensions/qrcode_scanner.rb
32
34
  - lib/ruflet_ui/ruflet/icon_data.rb
33
35
  - lib/ruflet_ui/ruflet/icons/cupertino/cupertino_icons.json
34
36
  - lib/ruflet_ui/ruflet/icons/cupertino/cupertino_icons.rb
@@ -161,7 +163,6 @@ files:
161
163
  - lib/ruflet_ui/ruflet/ui/controls/shared/draggable_control.rb
162
164
  - lib/ruflet_ui/ruflet/ui/controls/shared/dragtarget_control.rb
163
165
  - lib/ruflet_ui/ruflet/ui/controls/shared/fill_control.rb
164
- - lib/ruflet_ui/ruflet/ui/controls/shared/fletapp_control.rb
165
166
  - lib/ruflet_ui/ruflet/ui/controls/shared/gesturedetector_control.rb
166
167
  - lib/ruflet_ui/ruflet/ui/controls/shared/gridview_control.rb
167
168
  - lib/ruflet_ui/ruflet/ui/controls/shared/hero_control.rb
@@ -186,6 +187,7 @@ files:
186
187
  - lib/ruflet_ui/ruflet/ui/controls/shared/responsiverow_control.rb
187
188
  - lib/ruflet_ui/ruflet/ui/controls/shared/rotatedbox_control.rb
188
189
  - lib/ruflet_ui/ruflet/ui/controls/shared/row_control.rb
190
+ - lib/ruflet_ui/ruflet/ui/controls/shared/ruflet_app_control.rb
189
191
  - lib/ruflet_ui/ruflet/ui/controls/shared/ruflet_controls.rb
190
192
  - lib/ruflet_ui/ruflet/ui/controls/shared/safearea_control.rb
191
193
  - lib/ruflet_ui/ruflet/ui/controls/shared/screenshot_control.rb