ruflet_rails 0.0.13 → 0.0.14
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 +4 -4
- data/README.md +165 -62
- data/lib/generators/ruflet/install/install_generator.rb +3 -3
- data/lib/ruflet/rails/configuration.rb +13 -7
- data/lib/ruflet/rails/install_support.rb +19 -637
- data/lib/ruflet/rails/native_app/actions.rb +167 -0
- data/lib/ruflet/rails/native_app/customization.rb +118 -0
- data/lib/ruflet/rails/native_app/html_adapter.rb +248 -0
- data/lib/ruflet/rails/native_app/navigation.rb +645 -0
- data/lib/ruflet/rails/native_app/overlays.rb +222 -0
- data/lib/ruflet/rails/native_app/shell.rb +390 -0
- data/lib/ruflet/rails/native_app.rb +25 -222
- data/lib/ruflet/rails/protocol/web_app.rb +1 -1
- data/lib/ruflet/rails/railtie.rb +3 -20
- data/lib/ruflet/rails/view_helpers.rb +205 -0
- data/lib/ruflet/rails.rb +22 -55
- data/lib/ruflet/version.rb +1 -1
- data/lib/ruflet_rails.rb +0 -4
- metadata +14 -15
- data/lib/generators/ruflet/form/form_generator.rb +0 -55
- data/lib/generators/ruflet/scaffold/scaffold_generator.rb +0 -54
- data/lib/ruflet/rails/form_helpers.rb +0 -161
- data/lib/ruflet/rails/generator_hooks.rb +0 -25
- data/lib/ruflet/rails/resource_component.rb +0 -370
- data/lib/ruflet/rails/route_stack.rb +0 -90
- data/lib/ruflet/rails/webview_app.rb +0 -54
|
@@ -1,249 +1,52 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "uri"
|
|
4
5
|
|
|
5
6
|
module Ruflet
|
|
6
7
|
module Rails
|
|
7
|
-
# Managed
|
|
8
|
-
#
|
|
9
|
-
# Your existing web app is the body, rendered in a WebView. Navigation works
|
|
10
|
-
# out of the box: a tiny JS bridge injected into each page intercepts link
|
|
11
|
-
# clicks and proposes them to native, so every visit becomes a NATIVE screen
|
|
12
|
-
# pushed onto the stack (with an automatic back button) — no per-link code.
|
|
13
|
-
# The native AppBar title tracks each page's <title>, and you declare special
|
|
14
|
-
# paths as data.
|
|
15
|
-
#
|
|
16
|
-
# Ruflet.run do |page|
|
|
17
|
-
# Ruflet::Rails.native_app(
|
|
18
|
-
# page,
|
|
19
|
-
# start_url: "https://myapp.com",
|
|
20
|
-
# title: "My App", # auto-updates from <title>
|
|
21
|
-
# actions: -> { [icon_button("search", on_click: ->(_e) { ... })] },
|
|
22
|
-
# navigation_bar: navigation_bar(destinations: [...]),
|
|
23
|
-
#
|
|
24
|
-
# # web content shown in a bottom sheet (auth, quick forms):
|
|
25
|
-
# modal: ["/sign_in", "/sign_up", %r{/new\z}],
|
|
26
|
-
#
|
|
27
|
-
# # optional: override a path with a fully native screen:
|
|
28
|
-
# native: { %r{\A/products/(\d+)\z} => ->(ctx) { product_screen(ctx.match[1]) } }
|
|
29
|
-
# )
|
|
30
|
-
# end
|
|
31
|
-
#
|
|
32
|
-
# Normal links just push a native webview screen (back returns). Paths listed
|
|
33
|
-
# in `modal:` open as a bottom sheet. Paths in `native:` render your own UI.
|
|
34
|
-
#
|
|
35
|
-
# The bridge talks to native over the webview's console channel
|
|
36
|
-
# (on_console_message), which works on iOS/Android/macOS. On platforms
|
|
37
|
-
# without a native webview the body degrades to a plain frame.
|
|
8
|
+
# Managed native shell for a Rails WebView body.
|
|
38
9
|
class NativeApp
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
BRIDGE_JS = <<~JS
|
|
42
|
-
(function () {
|
|
43
|
-
function report(kind, value) { console.log("ruflet:" + kind + ":" + value); }
|
|
44
|
-
report("title", document.title || "");
|
|
45
|
-
if (window.__rufletBridgeBound) return;
|
|
46
|
-
window.__rufletBridgeBound = true;
|
|
47
|
-
document.addEventListener("click", function (e) {
|
|
48
|
-
var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
|
|
49
|
-
if (!a || a.target === "_blank") return;
|
|
50
|
-
if (a.origin && a.origin !== location.origin) return; // external: leave it
|
|
51
|
-
e.preventDefault();
|
|
52
|
-
report("visit", a.href);
|
|
53
|
-
}, true);
|
|
54
|
-
})();
|
|
55
|
-
JS
|
|
10
|
+
Screen = Struct.new(:url, :view, :webview, :loading, :body, :title_text, :appbar_spec, :appbar_signature,
|
|
11
|
+
:drawer, :end_drawer, :drawer_signature, :rail, :rail_signature, keyword_init: true)
|
|
56
12
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
Screen = Struct.new(:kind, :url, :view, :webview, :title_text, keyword_init: true)
|
|
61
|
-
Context = Struct.new(:url, :path, :match, keyword_init: true)
|
|
62
|
-
|
|
63
|
-
def initialize(page, start_url:, title: nil, actions: nil, navigation_bar: nil,
|
|
64
|
-
bottom_appbar: nil, modal: [], native: {})
|
|
13
|
+
def initialize(page, start_url:, title: nil, actions: nil, navigation_bar: nil, bottom_appbar: nil,
|
|
14
|
+
loading: :shimmer)
|
|
65
15
|
@page = page
|
|
66
16
|
@start_url = start_url.to_s
|
|
67
17
|
@title = title.to_s
|
|
68
18
|
@actions = actions
|
|
69
19
|
@navigation_bar = navigation_bar
|
|
70
20
|
@bottom_appbar = bottom_appbar
|
|
71
|
-
@
|
|
72
|
-
@native_rules = native.map { |pattern, builder| [compile_pattern(pattern), builder] }
|
|
21
|
+
@loading = loading
|
|
73
22
|
@screens = []
|
|
74
|
-
@
|
|
23
|
+
@sheet = nil
|
|
24
|
+
@dialog = nil
|
|
25
|
+
@bottomnav_signature = nil
|
|
26
|
+
@bottomnav_spec = nil
|
|
27
|
+
@appbar_specs_by_url = {}
|
|
28
|
+
@drawer_spec = nil
|
|
29
|
+
@drawer_open_requested = false
|
|
75
30
|
end
|
|
76
31
|
|
|
77
|
-
def self.bridge_js = BRIDGE_JS
|
|
78
|
-
|
|
79
32
|
def start
|
|
80
33
|
@page.on_view_pop = ->(_event) { pop }
|
|
81
34
|
push_webview(@start_url, root: true)
|
|
82
35
|
self
|
|
83
36
|
end
|
|
84
|
-
|
|
85
|
-
private
|
|
86
|
-
|
|
87
|
-
# --- Stack operations ---------------------------------------------------
|
|
88
|
-
|
|
89
|
-
def push_webview(url, root: false)
|
|
90
|
-
screen = Screen.new(kind: :webview, url: url.to_s)
|
|
91
|
-
screen.title_text = Ruflet::UI::ControlFactory.build(:text, value: @title)
|
|
92
|
-
screen.webview = build_webview(screen)
|
|
93
|
-
screen.view = build_view(screen, root: root)
|
|
94
|
-
@screens.push(screen)
|
|
95
|
-
flush
|
|
96
|
-
screen
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def push_native(builder, ctx)
|
|
100
|
-
view = builder.call(ctx)
|
|
101
|
-
return unless view.is_a?(Ruflet::Control)
|
|
102
|
-
|
|
103
|
-
@screens.push(Screen.new(kind: :native, url: ctx.url, view: view))
|
|
104
|
-
flush
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def pop
|
|
108
|
-
return if @screens.size <= 1
|
|
109
|
-
|
|
110
|
-
@screens.pop
|
|
111
|
-
flush
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def flush
|
|
115
|
-
@page.views = @screens.map(&:view)
|
|
116
|
-
@page.update
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# --- Webview screen + native chrome ------------------------------------
|
|
120
|
-
|
|
121
|
-
def build_webview(screen)
|
|
122
|
-
Ruflet::UI::ControlFactory.build(
|
|
123
|
-
:webview,
|
|
124
|
-
url: screen.url,
|
|
125
|
-
method: "get",
|
|
126
|
-
expand: true,
|
|
127
|
-
on_page_ended: ->(_event) { inject_bridge(screen) },
|
|
128
|
-
on_console_message: ->(event) { handle_message(screen, message_of(event)) }
|
|
129
|
-
)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def build_view(screen, root:)
|
|
133
|
-
args = { route: root ? "/" : "/screen", controls: [screen.webview], appbar: build_appbar(screen) }
|
|
134
|
-
if root
|
|
135
|
-
args[:navigation_bar] = @navigation_bar unless @navigation_bar.nil?
|
|
136
|
-
args[:bottom_appbar] = @bottom_appbar unless @bottom_appbar.nil?
|
|
137
|
-
end
|
|
138
|
-
Ruflet::UI::ControlFactory.build(:view, **args)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def build_appbar(screen)
|
|
142
|
-
args = { title: screen.title_text }
|
|
143
|
-
actions = resolve_actions
|
|
144
|
-
args[:actions] = actions if actions
|
|
145
|
-
Ruflet::UI::ControlFactory.build(:appbar, **args)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def resolve_actions
|
|
149
|
-
@actions.respond_to?(:call) ? @actions.call : @actions
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# --- Bridge ------------------------------------------------------------
|
|
153
|
-
|
|
154
|
-
def inject_bridge(screen)
|
|
155
|
-
screen.webview.run_javascript(BRIDGE_JS) if screen.webview.respond_to?(:run_javascript)
|
|
156
|
-
rescue StandardError
|
|
157
|
-
nil
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def handle_message(screen, message)
|
|
161
|
-
return if message.nil?
|
|
162
|
-
|
|
163
|
-
if message.start_with?(TITLE_PREFIX)
|
|
164
|
-
update_title(screen, message[TITLE_PREFIX.length..])
|
|
165
|
-
elsif message.start_with?(VISIT_PREFIX)
|
|
166
|
-
visit(message[VISIT_PREFIX.length..])
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def update_title(screen, value)
|
|
171
|
-
return unless screen.title_text&.wire_id
|
|
172
|
-
|
|
173
|
-
@page.update(screen.title_text, value: value.to_s)
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
# A proposed visit: native screen > modal sheet > push a webview screen.
|
|
177
|
-
def visit(url)
|
|
178
|
-
path = path_of(url)
|
|
179
|
-
return if path.nil?
|
|
180
|
-
|
|
181
|
-
builder, match = match_native(path)
|
|
182
|
-
if builder
|
|
183
|
-
push_native(builder, Context.new(url: url, path: path, match: match))
|
|
184
|
-
elsif modal?(path)
|
|
185
|
-
present_modal(url)
|
|
186
|
-
else
|
|
187
|
-
push_webview(url)
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# --- Modal (bottom sheet of web content) -------------------------------
|
|
192
|
-
|
|
193
|
-
def present_modal(url)
|
|
194
|
-
sheet_webview = Ruflet::UI::ControlFactory.build(:webview, url: url.to_s, method: "get", expand: true)
|
|
195
|
-
@modal_sheet = Ruflet::UI::ControlFactory.build(
|
|
196
|
-
:bottomsheet,
|
|
197
|
-
open: true,
|
|
198
|
-
dismissible: true,
|
|
199
|
-
content: Ruflet::UI::ControlFactory.build(:container, height: 520, content: sheet_webview),
|
|
200
|
-
on_dismiss: ->(_event) { @modal_sheet = nil }
|
|
201
|
-
)
|
|
202
|
-
@page.bottom_sheet = @modal_sheet
|
|
203
|
-
@page.update
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
# --- Matching ----------------------------------------------------------
|
|
207
|
-
|
|
208
|
-
def match_native(path)
|
|
209
|
-
@native_rules.each do |pattern, builder|
|
|
210
|
-
if pattern.is_a?(Regexp)
|
|
211
|
-
m = pattern.match(path)
|
|
212
|
-
return [builder, m] if m
|
|
213
|
-
elsif pattern == path
|
|
214
|
-
return [builder, nil]
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
nil
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def modal?(path)
|
|
221
|
-
@modal_patterns.any? do |pattern|
|
|
222
|
-
pattern.is_a?(Regexp) ? pattern.match?(path) : pattern == path
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def compile_pattern(pattern)
|
|
227
|
-
return pattern if pattern.is_a?(Regexp)
|
|
228
|
-
|
|
229
|
-
value = pattern.to_s
|
|
230
|
-
value.start_with?("/") ? value : "/#{value}"
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def path_of(url)
|
|
234
|
-
URI.parse(url.to_s).path
|
|
235
|
-
rescue URI::InvalidURIError
|
|
236
|
-
nil
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
def message_of(event)
|
|
240
|
-
data = event.respond_to?(:data) ? event.data : event
|
|
241
|
-
return data["message"] || data[:message] if data.is_a?(Hash)
|
|
242
|
-
|
|
243
|
-
data
|
|
244
|
-
end
|
|
245
37
|
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
246
40
|
|
|
41
|
+
require_relative "native_app/html_adapter"
|
|
42
|
+
require_relative "native_app/customization"
|
|
43
|
+
require_relative "native_app/shell"
|
|
44
|
+
require_relative "native_app/navigation"
|
|
45
|
+
require_relative "native_app/actions"
|
|
46
|
+
require_relative "native_app/overlays"
|
|
47
|
+
|
|
48
|
+
module Ruflet
|
|
49
|
+
module Rails
|
|
247
50
|
module_function
|
|
248
51
|
|
|
249
52
|
# Start a managed webview app. See NativeApp.
|
|
@@ -74,7 +74,7 @@ module Ruflet
|
|
|
74
74
|
|
|
75
75
|
def entrypoint
|
|
76
76
|
@entrypoint_option || @app_block ||
|
|
77
|
-
raise(ArgumentError, "web_app requires one of
|
|
77
|
+
raise(ArgumentError, "web_app requires one of app_file: or a block")
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
# The web build must NOT live under public/, or Rails' static
|
data/lib/ruflet/rails/railtie.rb
CHANGED
|
@@ -3,11 +3,6 @@
|
|
|
3
3
|
module Ruflet
|
|
4
4
|
module Rails
|
|
5
5
|
class Railtie < ::Rails::Railtie
|
|
6
|
-
generators do
|
|
7
|
-
require "ruflet/rails/generator_hooks"
|
|
8
|
-
Ruflet::Rails::GeneratorHooks.install!
|
|
9
|
-
end
|
|
10
|
-
|
|
11
6
|
# Make ruflet_frame and friends available in every .erb template.
|
|
12
7
|
initializer "ruflet_rails.view_helpers" do
|
|
13
8
|
ActiveSupport.on_load(:action_view) do
|
|
@@ -15,18 +10,6 @@ module Ruflet
|
|
|
15
10
|
end
|
|
16
11
|
end
|
|
17
12
|
|
|
18
|
-
# Ruflet components live under app/views so Rails will not discover them
|
|
19
|
-
# by default. Add that directory as a Zeitwerk root and collapse its
|
|
20
|
-
# organizational subdirectories, matching generated top-level constants
|
|
21
|
-
# such as components/products/product_component.rb -> ProductComponent.
|
|
22
|
-
initializer "ruflet_rails.components", before: :bootstrap_hook do |app|
|
|
23
|
-
components = app.root.join("app/views/ruflet/components")
|
|
24
|
-
next unless components.directory?
|
|
25
|
-
|
|
26
|
-
app.autoloaders.main.push_dir(components)
|
|
27
|
-
app.autoloaders.main.collapse(components.join("**"))
|
|
28
|
-
end
|
|
29
|
-
|
|
30
13
|
initializer "ruflet_rails.desktop_launcher", after: :load_config_initializers do |_app|
|
|
31
14
|
next unless defined?(::Rails.root)
|
|
32
15
|
|
|
@@ -61,10 +44,10 @@ module Ruflet
|
|
|
61
44
|
require "tempfile"
|
|
62
45
|
|
|
63
46
|
exit_code = Dir.chdir(::Rails.root) do
|
|
64
|
-
#
|
|
65
|
-
#
|
|
47
|
+
# Rails build metadata lives in config/initializers/ruflet.rb.
|
|
48
|
+
# Serialize it to the CLI's yaml shape for this build invocation.
|
|
66
49
|
yaml_hash = cfg.to_ruflet_yaml_hash
|
|
67
|
-
use_temp = yaml_hash.any?
|
|
50
|
+
use_temp = yaml_hash.any?
|
|
68
51
|
|
|
69
52
|
if use_temp
|
|
70
53
|
Tempfile.create(["ruflet", ".yaml"], ::Rails.root) do |f|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "erb"
|
|
4
|
+
require "json"
|
|
4
5
|
|
|
5
6
|
module Ruflet
|
|
6
7
|
module Rails
|
|
@@ -44,8 +45,212 @@ module Ruflet
|
|
|
44
45
|
ruflet_html_safe(markup)
|
|
45
46
|
end
|
|
46
47
|
|
|
48
|
+
# --- Native annotations ------------------------------------------------
|
|
49
|
+
#
|
|
50
|
+
# These emit plain HTML decorated with `ruflet-*` attributes. In a normal
|
|
51
|
+
# browser they render and behave as ordinary HTML; inside the Ruflet
|
|
52
|
+
# native shell (Ruflet::Rails.native_app) the HTML adapter reads the
|
|
53
|
+
# attributes and drives native navigation/chrome instead. They degrade
|
|
54
|
+
# gracefully — no JavaScript required for the web rendering.
|
|
55
|
+
|
|
56
|
+
# A header the native shell hides and re-renders as a native AppBar. Place
|
|
57
|
+
# action items inside with `ruflet-icon` (use ruflet_appbar_action).
|
|
58
|
+
#
|
|
59
|
+
# <%= ruflet_appbar "Inbox" do %>
|
|
60
|
+
# <%= ruflet_appbar_action "search", search_path %>
|
|
61
|
+
# <% end %>
|
|
62
|
+
def ruflet_appbar(title = nil, payload: {}, leading: nil, actions: nil, **attrs, &block)
|
|
63
|
+
appbar_payload = ruflet_stringify_keys(payload)
|
|
64
|
+
appbar_payload["leading"] = ruflet_stringify_keys(leading) unless leading.nil?
|
|
65
|
+
appbar_payload["actions"] = ruflet_stringify_keys(actions) unless actions.nil?
|
|
66
|
+
data = { "data-ruflet-appbar" => appbar_payload.empty? ? "" : ruflet_json(appbar_payload),
|
|
67
|
+
"data-ruflet-title" => title,
|
|
68
|
+
"hidden" => true }
|
|
69
|
+
ruflet_build_tag("header", ruflet_capture(&block), data.merge(ruflet_dash_attrs(attrs)))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# An AppBar action button (icon taps navigate).
|
|
73
|
+
def ruflet_appbar_action(icon, href = nil, nav: :push, payload: {}, **attrs)
|
|
74
|
+
payload = ruflet_compact_hash({ "icon" => icon, "action" => nav.to_s }.merge(ruflet_stringify_keys(payload)))
|
|
75
|
+
data = { "data-ruflet-icon" => ruflet_json(payload), "href" => href }
|
|
76
|
+
ruflet_build_tag("a", "", data.merge(ruflet_dash_attrs(attrs)))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A nav the native shell hides and re-renders as a native bottom
|
|
80
|
+
# NavigationBar. Items are `ruflet_nav_item` links.
|
|
81
|
+
#
|
|
82
|
+
# <%= ruflet_bottom_nav do %>
|
|
83
|
+
# <%= ruflet_nav_item "Home", root_path, icon: "house", selected: true %>
|
|
84
|
+
# <%= ruflet_nav_item "Profile", profile_path, icon: "person" %>
|
|
85
|
+
# <% end %>
|
|
86
|
+
def ruflet_bottom_nav(name: "bottom-navigation", payload: {}, **attrs, &block)
|
|
87
|
+
nav_payload = ruflet_stringify_keys(payload)
|
|
88
|
+
ruflet_build_tag("nav", ruflet_capture(&block), {
|
|
89
|
+
"data-ruflet-tabs" => nav_payload.empty? ? name : ruflet_json(nav_payload),
|
|
90
|
+
"hidden" => true
|
|
91
|
+
}.merge(ruflet_dash_attrs(attrs)))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A destination inside ruflet_bottom_nav.
|
|
95
|
+
def ruflet_nav_item(label, href, icon:, selected: false, color: nil, size: 24, payload: {}, **attrs)
|
|
96
|
+
item_payload = ruflet_stringify_keys(payload)
|
|
97
|
+
data = {
|
|
98
|
+
"href" => href,
|
|
99
|
+
"data-ruflet-icon" => ruflet_json(ruflet_compact_hash(item_payload.merge(icon: icon, label: label,
|
|
100
|
+
color: color, size: size)))
|
|
101
|
+
}
|
|
102
|
+
data["data-ruflet-selected"] = "true" if selected
|
|
103
|
+
ruflet_build_tag("a", label, data.merge(ruflet_dash_attrs(attrs)))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A drawer the native shell hides and re-renders as a native
|
|
107
|
+
# NavigationDrawer. Items are `ruflet_drawer_item` links.
|
|
108
|
+
#
|
|
109
|
+
# <%= ruflet_drawer do %>
|
|
110
|
+
# <%= ruflet_drawer_item "Home", root_path, icon: "home" %>
|
|
111
|
+
# <%= ruflet_drawer_item "Settings", settings_path, icon: "settings", nav: :push %>
|
|
112
|
+
# <% end %>
|
|
113
|
+
def ruflet_drawer(action: :root, payload: {}, **attrs, &block)
|
|
114
|
+
ruflet_build_tag("nav", ruflet_capture(&block), {
|
|
115
|
+
"data-ruflet-drawer" => ruflet_json(ruflet_compact_hash(ruflet_stringify_keys(payload).merge(action: action.to_s))),
|
|
116
|
+
"hidden" => true
|
|
117
|
+
}.merge(ruflet_dash_attrs(attrs)))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A destination inside ruflet_drawer.
|
|
121
|
+
def ruflet_drawer_item(label, href, icon:, selected: false, nav: nil, payload: {}, **attrs)
|
|
122
|
+
item_payload = ruflet_stringify_keys(payload)
|
|
123
|
+
data = {
|
|
124
|
+
"href" => href,
|
|
125
|
+
"data-ruflet-icon" => ruflet_json(ruflet_compact_hash(item_payload.merge(icon: icon, label: label,
|
|
126
|
+
action: nav&.to_s)))
|
|
127
|
+
}
|
|
128
|
+
data["data-ruflet-selected"] = "true" if selected
|
|
129
|
+
ruflet_build_tag("a", label, data.merge(ruflet_dash_attrs(attrs)))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Desktop/tablet navigation the native shell hides and re-renders as a
|
|
133
|
+
# native NavigationRail beside the WebView body.
|
|
134
|
+
#
|
|
135
|
+
# <%= ruflet_navigation_rail extended: true do %>
|
|
136
|
+
# <%= ruflet_rail_item "Home", root_path, icon: "home" %>
|
|
137
|
+
# <%= ruflet_rail_item "Inbox", inbox_path, icon: "mail" %>
|
|
138
|
+
# <% end %>
|
|
139
|
+
def ruflet_navigation_rail(action: :root, extended: nil, label_type: nil, breakpoint: nil, payload: {}, **attrs, &block)
|
|
140
|
+
payload = ruflet_compact_hash(ruflet_stringify_keys(payload).merge(action: action.to_s, extended: extended,
|
|
141
|
+
label_type: label_type, breakpoint: breakpoint))
|
|
142
|
+
ruflet_build_tag("nav", ruflet_capture(&block), {
|
|
143
|
+
"data-ruflet-rail" => ruflet_json(payload),
|
|
144
|
+
"hidden" => true
|
|
145
|
+
}.merge(ruflet_dash_attrs(attrs)))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
alias ruflet_rail ruflet_navigation_rail
|
|
149
|
+
|
|
150
|
+
# A destination inside ruflet_navigation_rail.
|
|
151
|
+
def ruflet_rail_item(label, href, icon:, selected: false, nav: nil, payload: {}, **attrs)
|
|
152
|
+
item_payload = ruflet_stringify_keys(payload)
|
|
153
|
+
data = {
|
|
154
|
+
"href" => href,
|
|
155
|
+
"data-ruflet-icon" => ruflet_json(ruflet_compact_hash(item_payload.merge(icon: icon, label: label,
|
|
156
|
+
action: nav&.to_s)))
|
|
157
|
+
}
|
|
158
|
+
data["data-ruflet-selected"] = "true" if selected
|
|
159
|
+
ruflet_build_tag("a", label, data.merge(ruflet_dash_attrs(attrs)))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Native platform services from ERB. These are normal HTML elements in a
|
|
163
|
+
# browser and native service calls inside Ruflet::Rails.native_app.
|
|
164
|
+
def ruflet_share_link(label, href = "#", text: nil, title: nil, subject: nil, url: nil, files: nil, **attrs)
|
|
165
|
+
payload = ruflet_compact_hash(component: "share", text: text, title: title, subject: subject,
|
|
166
|
+
url: url || href, files: files)
|
|
167
|
+
ruflet_action_tag("a", label, payload, { "href" => href }.merge(ruflet_dash_attrs(attrs)))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def ruflet_copy_button(label, text:, toast: "Copied", haptic: true, **attrs)
|
|
171
|
+
payload = ruflet_compact_hash(component: "clipboard", text: text, toast: toast, haptic: haptic)
|
|
172
|
+
ruflet_action_tag("button", label, payload, { "type" => "button" }.merge(ruflet_dash_attrs(attrs)))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def ruflet_launch_link(label, href, mode: nil, **attrs)
|
|
176
|
+
payload = ruflet_compact_hash(component: "url_launcher", url: href, mode: mode)
|
|
177
|
+
ruflet_action_tag("a", label, payload, { "href" => href }.merge(ruflet_dash_attrs(attrs)))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def ruflet_haptic_button(label, style: "selection", **attrs)
|
|
181
|
+
payload = ruflet_compact_hash(component: "haptic", style: style)
|
|
182
|
+
ruflet_action_tag("button", label, payload, { "type" => "button" }.merge(ruflet_dash_attrs(attrs)))
|
|
183
|
+
end
|
|
184
|
+
|
|
47
185
|
private
|
|
48
186
|
|
|
187
|
+
def ruflet_action_tag(name, content, payload, attrs)
|
|
188
|
+
attrs["data-ruflet-action"] = ruflet_json(ruflet_stringify_keys(payload))
|
|
189
|
+
ruflet_build_tag(name, content, attrs)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Capture block content via ActionView when available (real ERB), else
|
|
193
|
+
# fall back to the block's return value (standalone use / tests).
|
|
194
|
+
def ruflet_capture(&block)
|
|
195
|
+
return nil unless block
|
|
196
|
+
|
|
197
|
+
respond_to?(:capture) ? capture(&block) : block.call
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Build a tag from a name, inner content, and an attribute hash. nil/false
|
|
201
|
+
# attribute values are dropped; "" is kept (a bare boolean attribute).
|
|
202
|
+
def ruflet_build_tag(name, content, attributes)
|
|
203
|
+
markup = +"<#{name}"
|
|
204
|
+
attributes.each do |key, value|
|
|
205
|
+
next if value.nil? || value == false
|
|
206
|
+
|
|
207
|
+
markup << %( #{key}="#{ERB::Util.html_escape(value)}")
|
|
208
|
+
end
|
|
209
|
+
markup << ">"
|
|
210
|
+
markup << content.to_s if content
|
|
211
|
+
markup << "</#{name}>"
|
|
212
|
+
ruflet_html_safe(markup)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Turn extra helper kwargs (data_role:) into dashed attribute keys
|
|
216
|
+
# (data-role), mirroring ruflet_frame.
|
|
217
|
+
def ruflet_dash_attrs(attrs)
|
|
218
|
+
attrs.each_with_object({}) do |(key, value), out|
|
|
219
|
+
if value.is_a?(Hash)
|
|
220
|
+
value.each do |nested_key, nested_value|
|
|
221
|
+
out["#{key}-#{nested_key}".to_s.tr("_", "-")] = nested_value.nil? ? nil : nested_value.to_s
|
|
222
|
+
end
|
|
223
|
+
else
|
|
224
|
+
out[key.to_s.tr("_", "-")] = value
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def ruflet_compact_hash(hash)
|
|
230
|
+
hash.each_with_object({}) do |(key, value), out|
|
|
231
|
+
next if value.nil?
|
|
232
|
+
|
|
233
|
+
out[key] = value
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def ruflet_stringify_keys(value)
|
|
238
|
+
case value
|
|
239
|
+
when Hash
|
|
240
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
241
|
+
out[key.to_s] = ruflet_stringify_keys(nested)
|
|
242
|
+
end
|
|
243
|
+
when Array
|
|
244
|
+
value.map { |nested| ruflet_stringify_keys(nested) }
|
|
245
|
+
else
|
|
246
|
+
value
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def ruflet_json(value)
|
|
251
|
+
JSON.generate(value)
|
|
252
|
+
end
|
|
253
|
+
|
|
49
254
|
def ruflet_css_dimension(value)
|
|
50
255
|
value.is_a?(Numeric) ? "#{value}px" : value.to_s
|
|
51
256
|
end
|
data/lib/ruflet/rails.rb
CHANGED
|
@@ -20,18 +20,6 @@ module Ruflet
|
|
|
20
20
|
yield config
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
# Flet-style routed navigation stack for complex multi-screen apps. Wires
|
|
24
|
-
# up on_route_change / on_view_pop and starts at the current route. See
|
|
25
|
-
# Ruflet::Rails::RouteStack.
|
|
26
|
-
#
|
|
27
|
-
# Ruflet::Rails.routed(page) do |route, nav|
|
|
28
|
-
# nav.push(home_view)
|
|
29
|
-
# nav.push(store_view) if route == "/store"
|
|
30
|
-
# end
|
|
31
|
-
def routed(page, &builder)
|
|
32
|
-
RouteStack.new(page, &builder).start
|
|
33
|
-
end
|
|
34
|
-
|
|
35
23
|
def sessions
|
|
36
24
|
@sessions ||= SessionRegistry.new
|
|
37
25
|
end
|
|
@@ -40,30 +28,24 @@ module Ruflet
|
|
|
40
28
|
sessions.broadcast(&block)
|
|
41
29
|
end
|
|
42
30
|
|
|
43
|
-
# WebSocket endpoint for native mobile/desktop clients. The developer
|
|
44
|
-
#
|
|
45
|
-
# the app shows live in dev code, never in framework auto-discovery:
|
|
31
|
+
# WebSocket endpoint for native mobile/desktop clients. The developer owns
|
|
32
|
+
# the Ruflet entrypoint explicitly; there is no Rails component discovery:
|
|
46
33
|
#
|
|
47
34
|
# # a standalone Ruflet app file (Ruflet.run/MyApp.new.run), per session:
|
|
48
35
|
# match "/ws", to: Ruflet::Rails.endpoint(app_file: Rails.root.join("app/ruflet/main.rb")), via: :all
|
|
49
36
|
#
|
|
50
|
-
# # a single component/view class (resolved lazily, so reloading works):
|
|
51
|
-
# match "/ws", to: Ruflet::Rails.endpoint(view: "ProductComponent"), via: :all
|
|
52
|
-
#
|
|
53
37
|
# # a custom block:
|
|
54
38
|
# match "/ws", to: Ruflet::Rails.endpoint { |page| MyHome.render(page) }, via: :all
|
|
55
39
|
#
|
|
56
|
-
# One of
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
raise ArgumentError, "endpoint
|
|
61
|
-
raise ArgumentError, "endpoint requires one of view:, app_file:, or a block" if sources.empty?
|
|
40
|
+
# One of app_file: or a block is required.
|
|
41
|
+
def endpoint(app_file: nil, &block)
|
|
42
|
+
sources = [app_file, block].compact
|
|
43
|
+
raise ArgumentError, "endpoint accepts only one of app_file: or a block" if sources.length > 1
|
|
44
|
+
raise ArgumentError, "endpoint requires one of app_file: or a block" if sources.empty?
|
|
62
45
|
|
|
63
46
|
return Protocol::Runner.new.build_app_endpoint(file_path: app_file) if app_file
|
|
64
47
|
|
|
65
|
-
|
|
66
|
-
Protocol::Runner.new(&entry).build_endpoint
|
|
48
|
+
Protocol::Runner.new(&block).build_endpoint
|
|
67
49
|
end
|
|
68
50
|
|
|
69
51
|
# Shorthand for a standalone app-file endpoint.
|
|
@@ -78,49 +60,34 @@ module Ruflet
|
|
|
78
60
|
# answers the Ruflet WebSocket on the same path. Routes stay routing-only;
|
|
79
61
|
# UI code lives in dev files:
|
|
80
62
|
#
|
|
81
|
-
# # a single view class (resolved lazily, so reloading works):
|
|
82
|
-
# mount Ruflet::Rails.web_app(view: "CounterView"), at: "/myfrontend"
|
|
83
|
-
#
|
|
84
63
|
# # a standalone Ruflet app file (MyApp.new.run), loaded per session:
|
|
85
64
|
# mount Ruflet::Rails.web_app(app_file: "app/ruflet/showcase/main.rb"), at: "/showcase"
|
|
86
65
|
#
|
|
87
66
|
# # a custom block:
|
|
88
67
|
# mount Ruflet::Rails.web_app { |page| MyHome.render(page) }, at: "/app"
|
|
89
68
|
#
|
|
90
|
-
# One of
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
raise ArgumentError, "web_app
|
|
95
|
-
raise ArgumentError, "web_app requires one of view:, app_file:, or a block" if sources.empty?
|
|
69
|
+
# One of app_file: or a block is required.
|
|
70
|
+
def web_app(app_file: nil, build_dir: nil, &app_block)
|
|
71
|
+
sources = [app_file, app_block].compact
|
|
72
|
+
raise ArgumentError, "web_app accepts only one of app_file: or a block" if sources.length > 1
|
|
73
|
+
raise ArgumentError, "web_app requires one of app_file: or a block" if sources.empty?
|
|
96
74
|
|
|
97
75
|
Protocol::WebApp.new(
|
|
98
76
|
build_dir: build_dir,
|
|
99
|
-
entrypoint: web_app_entrypoint(
|
|
77
|
+
entrypoint: web_app_entrypoint(app_file: app_file),
|
|
100
78
|
&app_block
|
|
101
79
|
)
|
|
102
80
|
end
|
|
103
81
|
|
|
104
|
-
def web_app_entrypoint(
|
|
105
|
-
|
|
106
|
-
lambda do |page|
|
|
107
|
-
view_class_for(view).render(page)
|
|
108
|
-
end
|
|
109
|
-
elsif app_file
|
|
110
|
-
absolute = app_file.to_s
|
|
111
|
-
lambda do |page, env|
|
|
112
|
-
loaded = Protocol::MobileLoader.new(File.expand_path(absolute)).load!
|
|
113
|
-
entry = loaded[:entrypoint]
|
|
114
|
-
entry.arity == 1 ? entry.call(page) : entry.call(page, env)
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
end
|
|
82
|
+
def web_app_entrypoint(app_file: nil)
|
|
83
|
+
return unless app_file
|
|
118
84
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
85
|
+
absolute = app_file.to_s
|
|
86
|
+
lambda do |page, env|
|
|
87
|
+
loaded = Protocol::MobileLoader.new(File.expand_path(absolute)).load!
|
|
88
|
+
entry = loaded[:entrypoint]
|
|
89
|
+
entry.arity == 1 ? entry.call(page) : entry.call(page, env)
|
|
90
|
+
end
|
|
124
91
|
end
|
|
125
92
|
|
|
126
93
|
end
|
data/lib/ruflet/version.rb
CHANGED