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.
@@ -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/mobile/main.rb (MyApp.new.run) and mount it in Rails routes.
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
- Protocol::Runner.new.build_mobile_endpoint(file_path: file_path)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruflet
4
- VERSION = "0.0.7" unless const_defined?(:VERSION)
4
+ VERSION = "0.0.8" unless const_defined?(:VERSION)
5
5
  end
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"