ruflet_rails 0.0.7 → 0.0.8
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 +152 -5
- data/lib/generators/ruflet/form/form_generator.rb +55 -0
- data/lib/generators/ruflet/install/install_generator.rb +111 -22
- data/lib/generators/ruflet/scaffold/scaffold_generator.rb +60 -0
- data/lib/ruflet/rails/desktop_launcher.rb +116 -0
- data/lib/ruflet/rails/form_helpers.rb +161 -0
- data/lib/ruflet/rails/install_support.rb +911 -133
- data/lib/ruflet/rails/protocol/endpoint.rb +21 -5
- data/lib/ruflet/rails/protocol/local_server.rb +55 -12
- data/lib/ruflet/rails/protocol/runner.rb +22 -9
- data/lib/ruflet/rails/protocol/web_socket_connection.rb +3 -118
- data/lib/ruflet/rails/protocol/wire_codec.rb +3 -243
- data/lib/ruflet/rails/railtie.rb +63 -4
- data/lib/ruflet/rails/resource_component.rb +191 -0
- data/lib/ruflet/rails/resource_view.rb +124 -0
- data/lib/ruflet/rails/session_registry.rb +94 -0
- data/lib/ruflet/rails/view.rb +57 -0
- data/lib/ruflet/rails.rb +167 -2
- data/lib/ruflet/version.rb +1 -1
- data/lib/ruflet_rails.rb +8 -1
- metadata +13 -5
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Ruflet
|
|
7
|
+
module Rails
|
|
8
|
+
class ResourceComponent
|
|
9
|
+
include Ruflet::UI::SharedControlForwarders
|
|
10
|
+
|
|
11
|
+
attr_reader :page, :controller
|
|
12
|
+
|
|
13
|
+
def initialize(page, controller:)
|
|
14
|
+
@page = page
|
|
15
|
+
@controller = controller
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def control_delegate
|
|
21
|
+
Ruflet::DSL
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def open_dialog(dialog)
|
|
25
|
+
prune_closed_dialogs
|
|
26
|
+
return open_primary_dialog(dialog) if primary_dialog?(dialog) && !latest_stack_dialog
|
|
27
|
+
|
|
28
|
+
preserve_rendered_open_dialog_state
|
|
29
|
+
page.show_dialog(dialog)
|
|
30
|
+
dialog
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def close_dialog(dialog)
|
|
34
|
+
close_tracked_dialog(dialog)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def close_dialogs(*dialogs)
|
|
38
|
+
dialogs.flatten.compact.each do |dialog|
|
|
39
|
+
remove_tracked_dialog(dialog)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def date_picker_value(value, fallback: Date.today)
|
|
44
|
+
return value.to_date.iso8601 if value.respond_to?(:to_date)
|
|
45
|
+
return fallback.iso8601 if value.to_s.empty?
|
|
46
|
+
|
|
47
|
+
Date.parse(value.to_s).iso8601
|
|
48
|
+
rescue ArgumentError
|
|
49
|
+
fallback.iso8601
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def datetime_picker_value(value, fallback: Time.now)
|
|
53
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
54
|
+
return fallback.iso8601 if value.to_s.empty?
|
|
55
|
+
|
|
56
|
+
Time.parse(value.to_s).iso8601
|
|
57
|
+
rescue ArgumentError
|
|
58
|
+
fallback.iso8601
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def time_picker_value(value)
|
|
62
|
+
return value.strftime("%H:%M") if value.respond_to?(:strftime)
|
|
63
|
+
|
|
64
|
+
value.to_s
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def date_range_picker_values(value)
|
|
68
|
+
if value.respond_to?(:begin) && value.respond_to?(:end)
|
|
69
|
+
return [date_picker_value(value.begin), date_picker_value(value.end)]
|
|
70
|
+
end
|
|
71
|
+
if value.respond_to?(:to_a) && value.to_a.length >= 2
|
|
72
|
+
first, last = value.to_a.first(2)
|
|
73
|
+
return [date_picker_value(first), date_picker_value(last)]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
raw = value.to_s.strip.gsub(/\A[\[(]|[\])]\z/, "")
|
|
77
|
+
start_value, end_value =
|
|
78
|
+
if raw.include?("...")
|
|
79
|
+
raw.split("...", 2)
|
|
80
|
+
elsif raw.include?("..")
|
|
81
|
+
raw.split("..", 2)
|
|
82
|
+
else
|
|
83
|
+
raw.split(",", 2)
|
|
84
|
+
end
|
|
85
|
+
[date_picker_value(start_value), date_picker_value(end_value || start_value)]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def date_display_value(value)
|
|
89
|
+
visible = value.to_s.split("T", 2).first
|
|
90
|
+
visible.empty? ? "Not selected" : visible
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def date_range_display_value(start_value, end_value)
|
|
94
|
+
"#{date_display_value(start_value)} - #{date_display_value(end_value)}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def time_display_value(value)
|
|
98
|
+
visible = value.to_s
|
|
99
|
+
visible.empty? ? "Not selected" : visible
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def close_tracked_dialog(dialog)
|
|
103
|
+
return unless dialog
|
|
104
|
+
|
|
105
|
+
close_dialog_stack_entry(dialog)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def remove_tracked_dialog(dialog)
|
|
109
|
+
return unless dialog
|
|
110
|
+
|
|
111
|
+
close_dialog_stack_entry(dialog)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def close_dialog_stack_entry(dialog)
|
|
115
|
+
return close_primary_dialog(dialog) if primary_dialog_slot?(dialog)
|
|
116
|
+
|
|
117
|
+
unless page.respond_to?(:remove_dialog_tracking, true)
|
|
118
|
+
page.close_dialog(dialog)
|
|
119
|
+
return
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
page.update(dialog, open: false) if dialog.wire_id
|
|
123
|
+
page.__send__(:remove_dialog_tracking, dialog)
|
|
124
|
+
sync_dialogs_container
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def sync_dialogs_container
|
|
128
|
+
dialogs_container = page.instance_variable_get(:@dialogs_container) if page.instance_variable_defined?(:@dialogs_container)
|
|
129
|
+
return page.update unless dialogs_container&.wire_id
|
|
130
|
+
|
|
131
|
+
page.update(dialogs_container, controls: dialogs_container.props["controls"])
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def open_primary_dialog(dialog)
|
|
135
|
+
dialog.props["open"] = true
|
|
136
|
+
page.dialog = dialog
|
|
137
|
+
dialog
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def close_primary_dialog(dialog)
|
|
141
|
+
page.update(dialog, open: false) if dialog.wire_id
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def primary_dialog_slot?(dialog)
|
|
145
|
+
page.instance_variable_defined?(:@dialog) && page.instance_variable_get(:@dialog).equal?(dialog)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def latest_stack_dialog
|
|
149
|
+
page.__send__(:latest_open_dialog) if page.respond_to?(:latest_open_dialog, true)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def primary_dialog?(dialog)
|
|
153
|
+
dialog.type.to_s.tr("_", "").casecmp("alertdialog").zero?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def preserve_rendered_open_dialog_state
|
|
157
|
+
tracked_dialogs.each do |dialog|
|
|
158
|
+
next if dialog.props["open"] == false
|
|
159
|
+
|
|
160
|
+
dialog.props["_open"] = true
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def tracked_dialogs
|
|
165
|
+
dialogs = []
|
|
166
|
+
dialogs << page.instance_variable_get(:@dialog) if page.instance_variable_defined?(:@dialog)
|
|
167
|
+
dialogs.concat(Array(page.instance_variable_get(:@dialogs))) if page.instance_variable_defined?(:@dialogs)
|
|
168
|
+
dialogs.compact.uniq
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def prune_closed_dialogs
|
|
172
|
+
return unless page.respond_to?(:remove_dialog_tracking, true)
|
|
173
|
+
|
|
174
|
+
dialogs = page.instance_variable_get(:@dialogs) if page.instance_variable_defined?(:@dialogs)
|
|
175
|
+
Array(dialogs).select { |dialog| dialog.props["open"] == false }.each do |dialog|
|
|
176
|
+
page.__send__(:remove_dialog_tracking, dialog)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
181
|
+
return controller.__send__(name, *args, **kwargs, &block) if controller.respond_to?(name, true)
|
|
182
|
+
|
|
183
|
+
super
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def respond_to_missing?(name, include_private = false)
|
|
187
|
+
controller.respond_to?(name, true) || super
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
require_relative "view"
|
|
6
|
+
|
|
7
|
+
module Ruflet
|
|
8
|
+
module Rails
|
|
9
|
+
class ResourceView < ::RufletView
|
|
10
|
+
class << self
|
|
11
|
+
def model(value = nil)
|
|
12
|
+
@model_class = value if value
|
|
13
|
+
@model_class || inferred_model_class
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def title(value = nil)
|
|
17
|
+
@resource_title = value if value
|
|
18
|
+
@resource_title || model_name.plural.humanize.titleize
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def component(value = nil)
|
|
22
|
+
@component_class = value if value
|
|
23
|
+
@component_class || inferred_component_class
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def inferred_model_class
|
|
29
|
+
model_name.name.safe_constantize
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def inferred_component_class
|
|
33
|
+
"#{model_name.name}Component".safe_constantize
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def model_name
|
|
37
|
+
ActiveModel::Name.new(name.to_s.sub(/View\z/, "").singularize.constantize)
|
|
38
|
+
rescue NameError
|
|
39
|
+
ActiveModel::Name.new(Object, nil, name.to_s.sub(/View\z/, "").singularize)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def close_dialog(dialog)
|
|
46
|
+
close_open_dialogs_above(dialog)
|
|
47
|
+
close_tracked_dialog(dialog)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def show_errors(record)
|
|
51
|
+
show_snackbar(error_message(record))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def show_snackbar(message)
|
|
55
|
+
page.snackbar = snackbar(text(message), open: true)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def error_message(record)
|
|
59
|
+
messages = record.errors.full_messages
|
|
60
|
+
messages.respond_to?(:to_sentence) ? messages.to_sentence : messages.join(", ")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def compact?
|
|
64
|
+
width = page.client_details["width"].to_f
|
|
65
|
+
width > 0 && width < 600
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def dialog_width
|
|
69
|
+
width = page.client_details["width"].to_f
|
|
70
|
+
return 520 if width <= 0
|
|
71
|
+
|
|
72
|
+
[[width - 64, 280].max, 520].min
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def record_id(record)
|
|
76
|
+
record.respond_to?(:id) ? record.id : nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def close_open_dialogs_above(dialog)
|
|
80
|
+
return false unless page.respond_to?(:latest_open_dialog, true)
|
|
81
|
+
|
|
82
|
+
while (latest = page.__send__(:latest_open_dialog)) && !latest.equal?(dialog)
|
|
83
|
+
close_tracked_dialog(latest)
|
|
84
|
+
end
|
|
85
|
+
rescue StandardError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def close_tracked_dialog(dialog)
|
|
90
|
+
return unless dialog
|
|
91
|
+
|
|
92
|
+
close_dialog_stack_entry(dialog)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def close_dialog_stack_entry(dialog)
|
|
96
|
+
return close_primary_dialog(dialog) if primary_dialog_slot?(dialog)
|
|
97
|
+
|
|
98
|
+
unless page.respond_to?(:remove_dialog_tracking, true)
|
|
99
|
+
page.close_dialog(dialog)
|
|
100
|
+
return
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
page.update(dialog, open: false) if dialog.wire_id
|
|
104
|
+
page.__send__(:remove_dialog_tracking, dialog)
|
|
105
|
+
sync_dialogs_container
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def sync_dialogs_container
|
|
109
|
+
dialogs_container = page.instance_variable_get(:@dialogs_container) if page.instance_variable_defined?(:@dialogs_container)
|
|
110
|
+
return page.update unless dialogs_container&.wire_id
|
|
111
|
+
|
|
112
|
+
page.update(dialogs_container, controls: dialogs_container.props["controls"])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def close_primary_dialog(dialog)
|
|
116
|
+
page.update(dialog, open: false) if dialog.wire_id
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def primary_dialog_slot?(dialog)
|
|
120
|
+
page.instance_variable_defined?(:@dialog) && page.instance_variable_get(:@dialog).equal?(dialog)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module Ruflet
|
|
6
|
+
module Rails
|
|
7
|
+
class Session
|
|
8
|
+
attr_reader :key, :page, :env, :connected_at, :connection_key
|
|
9
|
+
|
|
10
|
+
def initialize(key:, page:, env: nil, connected_at: Time.now, connection_key: nil)
|
|
11
|
+
@key = key
|
|
12
|
+
@page = page
|
|
13
|
+
@env = env
|
|
14
|
+
@connected_at = connected_at
|
|
15
|
+
@connection_key = connection_key
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def session_id
|
|
19
|
+
page.session_id
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def client_details
|
|
23
|
+
page.client_details
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class SessionRegistry
|
|
28
|
+
include Enumerable
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@sessions = {}
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def add(key:, page:, env: nil, connection_key: nil)
|
|
36
|
+
session = Session.new(key: key, page: page, env: env, connection_key: connection_key)
|
|
37
|
+
@mutex.synchronize { @sessions[key] = session }
|
|
38
|
+
session
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def remove(key, connection_key: nil)
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
session = @sessions[key]
|
|
44
|
+
return nil if connection_key && session&.connection_key && session.connection_key != connection_key
|
|
45
|
+
|
|
46
|
+
@sessions.delete(key)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def [](key)
|
|
51
|
+
@mutex.synchronize { @sessions[key] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def each(&block)
|
|
55
|
+
return enum_for(:each) unless block
|
|
56
|
+
|
|
57
|
+
snapshot.each(&block)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def size
|
|
61
|
+
@mutex.synchronize { @sessions.size }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def empty?
|
|
65
|
+
size.zero?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def clear
|
|
69
|
+
@mutex.synchronize { @sessions.clear }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pages
|
|
73
|
+
map(&:page)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def broadcast(&block)
|
|
77
|
+
raise ArgumentError, "Ruflet::Rails.broadcast requires a block" unless block
|
|
78
|
+
|
|
79
|
+
count = 0
|
|
80
|
+
each do |session|
|
|
81
|
+
block.arity == 1 ? block.call(session.page) : block.call(session.page, session)
|
|
82
|
+
count += 1
|
|
83
|
+
end
|
|
84
|
+
count
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def snapshot
|
|
90
|
+
@mutex.synchronize { @sessions.values.dup }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
|
|
5
|
+
# RufletView is the base class for all server-driven Ruflet views in a Rails app.
|
|
6
|
+
# It includes Ruflet::UI::SharedControlForwarders so that all widget builder
|
|
7
|
+
# methods (text, column, row, container, safe_area, filled_button, …) are
|
|
8
|
+
# available directly on the view instance — the same way showcase uses them.
|
|
9
|
+
class RufletView
|
|
10
|
+
include Ruflet::UI::SharedControlForwarders
|
|
11
|
+
|
|
12
|
+
attr_reader :page
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def route(path = nil)
|
|
16
|
+
@route_path = normalize_route(path) if path
|
|
17
|
+
@route_path || inferred_route
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def inherited(child)
|
|
21
|
+
super
|
|
22
|
+
Ruflet::Rails.register_view(child) if defined?(Ruflet::Rails) && Ruflet::Rails.respond_to?(:register_view)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def inferred_route
|
|
28
|
+
name_part = name.to_s.split("::").last.to_s.sub(/View\z/, "")
|
|
29
|
+
path = name_part.underscore.pluralize
|
|
30
|
+
normalize_route(path.empty? ? "/" : path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def normalize_route(path)
|
|
34
|
+
value = path.to_s.strip
|
|
35
|
+
return "/" if value.empty? || value == "/"
|
|
36
|
+
|
|
37
|
+
"/#{value.gsub(%r{\A/+|/+\z}, "")}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.render(page, *args, **kwargs, &block)
|
|
42
|
+
new(page).render(*args, **kwargs, &block)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(page)
|
|
46
|
+
@page = page
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Delegates all widget builder calls (text, column, row, …) to Ruflet::DSL,
|
|
52
|
+
# matching the same delegate target that Kernel uses. Subclasses can override
|
|
53
|
+
# this to scope widget building to a local WidgetBuilder instance instead.
|
|
54
|
+
def control_delegate
|
|
55
|
+
Ruflet::DSL
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/ruflet/rails.rb
CHANGED
|
@@ -4,14 +4,179 @@ module Ruflet
|
|
|
4
4
|
module Rails
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
|
+
def view_classes
|
|
8
|
+
@view_classes ||= []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register_view(view_class)
|
|
12
|
+
view_classes << view_class unless view_classes.include?(view_class)
|
|
13
|
+
view_class
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render(page, routes: nil, default: nil)
|
|
17
|
+
ViewRouter.new(page, routes: routes, default: default).start
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def load_views(root)
|
|
21
|
+
return [] if root.to_s.empty?
|
|
22
|
+
|
|
23
|
+
files = Dir[File.join(root.to_s, "components", "**", "*.rb")].sort
|
|
24
|
+
files += Dir[File.join(root.to_s, "**", "*_view.rb")].sort
|
|
25
|
+
|
|
26
|
+
files.each do |file|
|
|
27
|
+
Kernel.load(file)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def sessions
|
|
32
|
+
@sessions ||= SessionRegistry.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def broadcast(&block)
|
|
36
|
+
sessions.broadcast(&block)
|
|
37
|
+
end
|
|
38
|
+
|
|
7
39
|
# Mount inside Rails routes; route "at:" controls URL path.
|
|
8
40
|
def endpoint(&block)
|
|
9
41
|
Protocol::Runner.new(&block).build_endpoint
|
|
10
42
|
end
|
|
11
43
|
|
|
12
|
-
# Load app
|
|
44
|
+
# Load a Ruflet app file (MyApp.new.run) and mount it in Rails routes.
|
|
45
|
+
def app(file_path)
|
|
46
|
+
Protocol::Runner.new.build_app_endpoint(file_path: file_path)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Backward-compatible alias for older Rails installs.
|
|
13
50
|
def mobile(file_path)
|
|
14
|
-
|
|
51
|
+
app(file_path)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
module Rails
|
|
56
|
+
# ViewRouter dispatches incoming page connections to the correct RufletView
|
|
57
|
+
# subclass based on the current route. It includes SharedControlForwarders
|
|
58
|
+
# so that widget helpers (text, column, safe_area, …) can be called directly
|
|
59
|
+
# inside its own rendering helpers — the same pattern showcase uses.
|
|
60
|
+
class ViewRouter
|
|
61
|
+
include Ruflet::UI::SharedControlForwarders
|
|
62
|
+
|
|
63
|
+
def initialize(page, routes: nil, default: nil)
|
|
64
|
+
@page = page
|
|
65
|
+
@routes = normalize_routes(routes || self.class.discovered_routes)
|
|
66
|
+
@default = default || @routes["/"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def start
|
|
70
|
+
@page.on_route_change = ->(_event) { render }
|
|
71
|
+
render
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render
|
|
75
|
+
if root_route_without_default?
|
|
76
|
+
render_route_index
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
target = route_target(@page.route)
|
|
81
|
+
|
|
82
|
+
if target.respond_to?(:render)
|
|
83
|
+
target.render(@page)
|
|
84
|
+
elsif target.respond_to?(:call)
|
|
85
|
+
target.call(@page)
|
|
86
|
+
else
|
|
87
|
+
render_empty_state
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.discovered_routes
|
|
92
|
+
Ruflet::Rails.view_classes.each_with_object({}) do |view_class, routes|
|
|
93
|
+
next unless view_class.respond_to?(:route)
|
|
94
|
+
|
|
95
|
+
routes[view_class.route] = view_class
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Widget builder calls delegate to the global DSL, same as Kernel does,
|
|
102
|
+
# keeping the showcase pattern consistent across all contexts.
|
|
103
|
+
def control_delegate
|
|
104
|
+
Ruflet::DSL
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def route_target(route)
|
|
108
|
+
path = route_path(route)
|
|
109
|
+
return @routes[path] if @routes.key?(path)
|
|
110
|
+
return @default if path == "/"
|
|
111
|
+
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def root_route_without_default?
|
|
116
|
+
route_path(@page.route) == "/" && @default.nil? && @routes["/"].nil? && @routes.any?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def normalize_routes(routes)
|
|
120
|
+
routes.to_h.transform_keys { |path| normalize_route(path) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def route_path(route)
|
|
124
|
+
normalize_route(route.to_s.split("?", 2).first)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def normalize_route(path)
|
|
128
|
+
value = path.to_s.strip
|
|
129
|
+
return "/" if value.empty? || value == "/"
|
|
130
|
+
|
|
131
|
+
"/#{value.gsub(%r{\A/+|/+\z}, "")}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def render_empty_state
|
|
135
|
+
@page.title = "Ruflet"
|
|
136
|
+
@page.add(
|
|
137
|
+
container(
|
|
138
|
+
expand: true,
|
|
139
|
+
alignment: Ruflet::MainAxisAlignment::CENTER,
|
|
140
|
+
content: text("No Ruflet views found")
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
@page.update
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def render_route_index
|
|
147
|
+
@page.title = "Ruflet"
|
|
148
|
+
@page.add(
|
|
149
|
+
safe_area(
|
|
150
|
+
container(
|
|
151
|
+
expand: true,
|
|
152
|
+
padding: { left: 24, top: 16, right: 24, bottom: 24 },
|
|
153
|
+
content: column(
|
|
154
|
+
spacing: 12,
|
|
155
|
+
controls: [
|
|
156
|
+
text("Ruflet", size: 24, weight: "bold"),
|
|
157
|
+
*route_index_buttons
|
|
158
|
+
]
|
|
159
|
+
)
|
|
160
|
+
),
|
|
161
|
+
expand: true
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
@page.update
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def route_index_buttons
|
|
168
|
+
@routes.keys.sort.map do |path|
|
|
169
|
+
filled_button(
|
|
170
|
+
content: text(route_label(path)),
|
|
171
|
+
on_click: ->(_event) { @page.go(path) }
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def route_label(path)
|
|
177
|
+
label = path.to_s.delete_prefix("/").tr("_-", " ")
|
|
178
|
+
label.empty? ? "Home" : label.split.map(&:capitalize).join(" ")
|
|
179
|
+
end
|
|
15
180
|
end
|
|
16
181
|
end
|
|
17
182
|
end
|
data/lib/ruflet/version.rb
CHANGED
data/lib/ruflet_rails.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
%w[ruflet_core ruflet].reverse_each do |package|
|
|
3
|
+
%w[ruflet_core ruflet_server ruflet].reverse_each do |package|
|
|
4
4
|
local_package_lib = File.expand_path("../../#{package}/lib", __dir__)
|
|
5
5
|
if File.directory?(local_package_lib) && !$LOAD_PATH.include?(local_package_lib)
|
|
6
6
|
$LOAD_PATH.unshift(local_package_lib)
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
require "ruflet_core"
|
|
11
|
+
require "ruflet_server"
|
|
12
|
+
require_relative "ruflet/rails/session_registry"
|
|
13
|
+
require_relative "ruflet/rails/form_helpers"
|
|
14
|
+
require_relative "ruflet/rails/view"
|
|
15
|
+
require_relative "ruflet/rails/resource_component"
|
|
16
|
+
require_relative "ruflet/rails/resource_view"
|
|
17
|
+
require_relative "ruflet/rails/desktop_launcher"
|
|
11
18
|
require_relative "ruflet/rails/protocol"
|
|
12
19
|
require_relative "ruflet/rails/install_support"
|
|
13
20
|
require_relative "ruflet/rails"
|