rooibos 0.6.2 → 0.7.0
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/LICENSES/BSD-2-Clause.txt +9 -0
- data/REUSE.toml +5 -0
- data/exe/.gitkeep +0 -0
- data/lib/rooibos/cli/commands/new.rb +24 -0
- data/lib/rooibos/command/batch.rb +10 -0
- data/lib/rooibos/command/bubble.rb +34 -0
- data/lib/rooibos/command/custom.rb +3 -2
- data/lib/rooibos/command/deliver.rb +50 -0
- data/lib/rooibos/command/http.rb +1 -1
- data/lib/rooibos/command/lifecycle.rb +3 -1
- data/lib/rooibos/command/outlet.rb +19 -9
- data/lib/rooibos/command.rb +107 -3
- data/lib/rooibos/configuration.rb +29 -0
- data/lib/rooibos/message/bubbled.rb +29 -0
- data/lib/rooibos/message.rb +24 -6
- data/lib/rooibos/router/action.rb +36 -0
- data/lib/rooibos/router/flow/dispatch.rb +39 -0
- data/lib/rooibos/router/flow/inward.rb +41 -0
- data/lib/rooibos/router/flow/outward.rb +44 -0
- data/lib/rooibos/router/guard.rb +56 -0
- data/lib/rooibos/router/predicate.rb +65 -0
- data/lib/rooibos/router/registry/actions.rb +41 -0
- data/lib/rooibos/router/registry/forwards.rb +58 -0
- data/lib/rooibos/router/registry/observes.rb +57 -0
- data/lib/rooibos/router/registry/otherwises.rb +29 -0
- data/lib/rooibos/router/registry/receives.rb +57 -0
- data/lib/rooibos/router/registry/routes.rb +59 -0
- data/lib/rooibos/router/registry.rb +26 -0
- data/lib/rooibos/router/route.rb +42 -0
- data/lib/rooibos/router/router_update.rb +53 -0
- data/lib/rooibos/router/rule/forward.rb +39 -0
- data/lib/rooibos/router/rule/observe.rb +22 -0
- data/lib/rooibos/router/rule/otherwise.rb +26 -0
- data/lib/rooibos/router/rule/receive.rb +22 -0
- data/lib/rooibos/router/rule.rb +40 -0
- data/lib/rooibos/router.rb +424 -438
- data/lib/rooibos/runtime.rb +37 -52
- data/lib/rooibos/test_helper.rb +22 -0
- data/lib/rooibos/transition.rb +92 -0
- data/lib/rooibos/version.rb +1 -1
- data/lib/rooibos.rb +2 -57
- data/sig/rooibos/cli.rbs +1 -0
- data/sig/rooibos/command.rbs +44 -0
- data/sig/rooibos/configuration.rbs +20 -0
- data/sig/rooibos/message.rbs +12 -0
- data/sig/rooibos/router/action.rbs +33 -0
- data/sig/rooibos/router/actions.rbs +27 -0
- data/sig/rooibos/router/flow/dispatch.rbs +29 -0
- data/sig/rooibos/router/flow/inward.rbs +37 -0
- data/sig/rooibos/router/flow/outward.rbs +36 -0
- data/sig/rooibos/router/forward.rbs +35 -0
- data/sig/rooibos/router/forwards.rbs +34 -0
- data/sig/rooibos/router/guard.rbs +21 -0
- data/sig/rooibos/router/observe.rbs +20 -0
- data/sig/rooibos/router/observes.rbs +38 -0
- data/sig/rooibos/router/otherwise.rbs +22 -0
- data/sig/rooibos/router/otherwises.rbs +20 -0
- data/sig/rooibos/router/predicate.rbs +51 -0
- data/sig/rooibos/router/receive.rbs +20 -0
- data/sig/rooibos/router/receives.rbs +38 -0
- data/sig/rooibos/router/registry.rbs +24 -0
- data/sig/rooibos/router/route.rbs +46 -0
- data/sig/rooibos/router/router_update.rbs +33 -0
- data/sig/rooibos/router/routes.rbs +41 -0
- data/sig/rooibos/router/rule.rbs +36 -0
- data/sig/rooibos/router.rbs +216 -161
- data/sig/rooibos/runtime.rbs +0 -1
- data/sig/rooibos/test_helper.rbs +6 -0
- data/sig/rooibos/transition.rbs +33 -0
- data/sig/rooibos.rbs +0 -10
- metadata +144 -198
- data/.builds/ruby-3.2.yml +0 -55
- data/.builds/ruby-3.3.yml +0 -55
- data/.builds/ruby-3.4.yml +0 -55
- data/.builds/ruby-4.0.0.yml +0 -55
- data/.pre-commit-config.yaml +0 -16
- data/.rubocop.yml +0 -8
- data/AGENTS.md +0 -108
- data/CHANGELOG.md +0 -308
- data/README.md +0 -183
- data/README.rdoc +0 -374
- data/Rakefile +0 -16
- data/Steepfile +0 -13
- data/doc/best_practices/forms_and_validation.md +0 -20
- data/doc/best_practices/http_workflows.md +0 -20
- data/doc/best_practices/index.md +0 -26
- data/doc/best_practices/lists_and_tables.md +0 -20
- data/doc/best_practices/modal_dialogs.md +0 -20
- data/doc/best_practices/no_stateful_widgets.md +0 -184
- data/doc/best_practices/orchestration.md +0 -20
- data/doc/best_practices/streaming_data.md +0 -20
- data/doc/contributors/design/commands_and_outlets.md +0 -214
- data/doc/contributors/design/mvu_tea_implementations_research.md +0 -373
- data/doc/contributors/documentation_plan.md +0 -616
- data/doc/contributors/documentation_stub_audit.md +0 -112
- data/doc/contributors/documentation_style.md +0 -275
- data/doc/contributors/e2e_pty.md +0 -168
- data/doc/contributors/maybe_stateful_router.md +0 -56
- data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +0 -70
- data/doc/contributors/specs/file_browser.md +0 -789
- data/doc/contributors/specs/file_browser_stories.md +0 -784
- data/doc/contributors/specs/tutorials_to_stories.rb +0 -167
- data/doc/contributors/todo/scrollbar.md +0 -118
- data/doc/contributors/tutorial_old/01_project_setup.md +0 -20
- data/doc/contributors/tutorial_old/02_hello_world.md +0 -24
- data/doc/contributors/tutorial_old/03_adding_state.md +0 -26
- data/doc/contributors/tutorial_old/06_organizing_your_code.md +0 -20
- data/doc/contributors/tutorial_old/07_your_first_command.md +0 -21
- data/doc/contributors/tutorial_old/08_the_preview_pane.md +0 -20
- data/doc/contributors/tutorial_old/09_loading_states.md +0 -20
- data/doc/contributors/tutorial_old/10_testing_your_app.md +0 -20
- data/doc/contributors/tutorial_old/11_polish_and_refine.md +0 -20
- data/doc/contributors/tutorial_old/12_going_further.md +0 -20
- data/doc/contributors/tutorial_old/index.md +0 -20
- data/doc/custom.css +0 -22
- data/doc/essentials/commands.md +0 -20
- data/doc/essentials/index.md +0 -31
- data/doc/essentials/messages.md +0 -21
- data/doc/essentials/models.md +0 -21
- data/doc/essentials/shortcuts.md +0 -19
- data/doc/essentials/the_elm_architecture.md +0 -24
- data/doc/essentials/the_runtime.md +0 -21
- data/doc/essentials/update_functions.md +0 -20
- data/doc/essentials/views.md +0 -22
- data/doc/getting_started/for_go_developers.md +0 -16
- data/doc/getting_started/for_python_developers.md +0 -16
- data/doc/getting_started/for_rails_developers.md +0 -17
- data/doc/getting_started/for_ratatui_ruby_developers.md +0 -17
- data/doc/getting_started/for_react_developers.md +0 -17
- data/doc/getting_started/index.md +0 -52
- data/doc/getting_started/install.md +0 -20
- data/doc/getting_started/quickstart.md +0 -20
- data/doc/getting_started/ruby_primer.md +0 -19
- data/doc/getting_started/why_rooibos.md +0 -20
- data/doc/images/verify_readme_usage.png +0 -0
- data/doc/images/widget_cmd_exec.png +0 -0
- data/doc/index.md +0 -93
- data/doc/scaling_up/async_patterns.md +0 -20
- data/doc/scaling_up/command_composition.md +0 -20
- data/doc/scaling_up/custom_commands.md +0 -21
- data/doc/scaling_up/fractal_architecture.md +0 -20
- data/doc/scaling_up/index.md +0 -30
- data/doc/scaling_up/message_routing.md +0 -20
- data/doc/scaling_up/ractor_safety.md +0 -20
- data/doc/scaling_up/testing.md +0 -21
- data/doc/troubleshooting/common_errors.md +0 -20
- data/doc/troubleshooting/debugging.md +0 -21
- data/doc/troubleshooting/index.md +0 -23
- data/doc/troubleshooting/performance.md +0 -20
- data/doc/tutorial/01_project_setup.md +0 -44
- data/doc/tutorial/02_hello_world.md +0 -45
- data/doc/tutorial/03_static_file_list.md +0 -44
- data/doc/tutorial/04_arrow_navigation.md +0 -47
- data/doc/tutorial/05_real_files.md +0 -45
- data/doc/tutorial/06_safe_refactoring.md +0 -21
- data/doc/tutorial/07_red_first_tdd.md +0 -26
- data/doc/tutorial/08_file_metadata.md +0 -42
- data/doc/tutorial/09_text_preview.md +0 -44
- data/doc/tutorial/10_directory_tree.md +0 -42
- data/doc/tutorial/11_pane_focus.md +0 -40
- data/doc/tutorial/12_sorting.md +0 -41
- data/doc/tutorial/13_filtering.md +0 -43
- data/doc/tutorial/14_toggle_hidden.md +0 -41
- data/doc/tutorial/15_text_input_widget.md +0 -43
- data/doc/tutorial/16_rename_files.md +0 -42
- data/doc/tutorial/17_confirmation_dialogs.md +0 -43
- data/doc/tutorial/18_progress_indicators.md +0 -43
- data/doc/tutorial/19_atomic_operations.md +0 -42
- data/doc/tutorial/20_external_editor.md +0 -42
- data/doc/tutorial/21_modal_overlays.md +0 -41
- data/doc/tutorial/22_error_handling.md +0 -43
- data/doc/tutorial/23_terminal_capabilities.md +0 -53
- data/doc/tutorial/24_mouse_events.md +0 -43
- data/doc/tutorial/25_resize_events.md +0 -43
- data/doc/tutorial/26_loading_states.md +0 -42
- data/doc/tutorial/27_performance.md +0 -43
- data/doc/tutorial/28_color_schemes.md +0 -47
- data/doc/tutorial/29_configuration.md +0 -124
- data/doc/tutorial/30_going_further.md +0 -17
- data/doc/tutorial/index.md +0 -17
- data/examples/app_fractal_dashboard/README.md +0 -60
- data/examples/app_fractal_dashboard/app.rb +0 -63
- data/examples/app_fractal_dashboard/dashboard/base.rb +0 -73
- data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +0 -86
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +0 -87
- data/examples/app_fractal_dashboard/dashboard/update_router.rb +0 -43
- data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +0 -81
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +0 -82
- data/examples/app_fractal_dashboard/fragments/custom_shell_output.rb +0 -90
- data/examples/app_fractal_dashboard/fragments/disk_usage.rb +0 -47
- data/examples/app_fractal_dashboard/fragments/network_panel.rb +0 -45
- data/examples/app_fractal_dashboard/fragments/ping.rb +0 -47
- data/examples/app_fractal_dashboard/fragments/stats_panel.rb +0 -45
- data/examples/app_fractal_dashboard/fragments/system_info.rb +0 -47
- data/examples/app_fractal_dashboard/fragments/uptime.rb +0 -47
- data/examples/tutorial/01/app.rb +0 -50
- data/examples/tutorial/02/app.rb +0 -64
- data/examples/tutorial/03/app.rb +0 -91
- data/examples/tutorial/06_safe_refactoring/app.rb +0 -124
- data/examples/verify_readme_usage/README.md +0 -54
- data/examples/verify_readme_usage/app.rb +0 -47
- data/examples/verify_website_first_app/app.rb +0 -85
- data/examples/verify_website_hello_mvu/app.rb +0 -31
- data/examples/widget_command_system/README.md +0 -70
- data/examples/widget_command_system/app.rb +0 -134
- data/generate_tutorial_stubs.rb +0 -126
- data/mise.toml +0 -8
- data/rbs_collection.lock.yaml +0 -108
- data/rbs_collection.yaml +0 -15
- data/tasks/example_viewer.html.erb +0 -172
- data/tasks/install.rake +0 -29
- data/tasks/resources/build.yml.erb +0 -55
- data/tasks/resources/index.html.erb +0 -44
- data/tasks/resources/rubies.yml +0 -7
- data/tasks/steep.rake +0 -11
- /data/{vendor/goodcop/base.yml → lib/rooibos/rubocop.yml} +0 -0
data/lib/rooibos/runtime.rb
CHANGED
|
@@ -165,7 +165,7 @@ module Rooibos
|
|
|
165
165
|
# SPDX-SnippetEnd
|
|
166
166
|
#++
|
|
167
167
|
def self.normalize_init(result)
|
|
168
|
-
|
|
168
|
+
Transition.from(result, nil).to_a
|
|
169
169
|
end
|
|
170
170
|
|
|
171
171
|
# Sentinel value avoids accidentally quitting from application exceptions.
|
|
@@ -185,6 +185,9 @@ module Rooibos
|
|
|
185
185
|
@model, @command = @init_callable.call
|
|
186
186
|
validate_ractor_shareable!(@command, "command")
|
|
187
187
|
validate_ractor_shareable!(@model, "model")
|
|
188
|
+
validate_ractor_shareable!(@update, "update")
|
|
189
|
+
validate_ractor_shareable!(@view, "view")
|
|
190
|
+
validate_ractor_shareable!(@init_callable, "init")
|
|
188
191
|
dispatch_command
|
|
189
192
|
|
|
190
193
|
loop do
|
|
@@ -214,7 +217,6 @@ module Rooibos
|
|
|
214
217
|
frame.render_widget(widget, frame.area)
|
|
215
218
|
end
|
|
216
219
|
end
|
|
217
|
-
|
|
218
220
|
# Enforces invariants
|
|
219
221
|
private def fragment_from_kwargs(root_fragment, model: nil, view: nil, update: nil, command: nil)
|
|
220
222
|
if root_fragment
|
|
@@ -228,7 +230,13 @@ module Rooibos
|
|
|
228
230
|
fragment.const_set(:Model, model)
|
|
229
231
|
fragment.const_set(:View, view)
|
|
230
232
|
fragment.const_set(:Update, update)
|
|
231
|
-
fragment.const_set(:
|
|
233
|
+
fragment.const_set(:InitCommand, command)
|
|
234
|
+
# Init uses a module singleton method accessing constants via self.
|
|
235
|
+
# Module objects are always shareable, so this makes init shareable.
|
|
236
|
+
fragment.define_singleton_method(:call) do
|
|
237
|
+
[self::Model, self::InitCommand] # steep:ignore UnknownConstant
|
|
238
|
+
end
|
|
239
|
+
fragment.const_set(:Init, fragment)
|
|
232
240
|
fragment
|
|
233
241
|
end
|
|
234
242
|
end
|
|
@@ -241,18 +249,20 @@ module Rooibos
|
|
|
241
249
|
private def init_callable
|
|
242
250
|
if @fragment.const_defined?(:Init)
|
|
243
251
|
if @fragment::Init.respond_to?(:call)
|
|
244
|
-
|
|
245
|
-
@fragment::Init
|
|
246
|
-
else
|
|
247
|
-
@fragment::Init.method(:call)
|
|
248
|
-
end
|
|
252
|
+
@fragment::Init
|
|
249
253
|
else
|
|
250
254
|
raise Rooibos::Error::Invariant, "Fragment::Init must respond to :call"
|
|
251
255
|
end
|
|
252
256
|
else
|
|
253
257
|
if @fragment.const_defined?(:Model)
|
|
254
258
|
if @fragment::Model.respond_to?(:new)
|
|
255
|
-
|
|
259
|
+
# Synthesize an Init using module singleton method.
|
|
260
|
+
# Module objects are always shareable, so accessing
|
|
261
|
+
# constants via self makes this Ractor-shareable.
|
|
262
|
+
unless @fragment.respond_to?(:call)
|
|
263
|
+
@fragment.define_singleton_method(:call) { self::Model.new } # steep:ignore UnknownConstant
|
|
264
|
+
end
|
|
265
|
+
@fragment
|
|
256
266
|
else
|
|
257
267
|
raise Rooibos::Error::Invariant, "Fragment::Model must respond to :new; or pass Fragment::Init instead"
|
|
258
268
|
end
|
|
@@ -275,46 +285,6 @@ module Rooibos
|
|
|
275
285
|
"View returned nil. Return a widget, or use TUI#clear for an empty screen."
|
|
276
286
|
end
|
|
277
287
|
|
|
278
|
-
# Extracts [model, command] from Update return value.
|
|
279
|
-
private def normalize_update_return(result, previous_model)
|
|
280
|
-
# Case 0: Nil result - preserve previous model
|
|
281
|
-
return [previous_model, nil] if result.nil?
|
|
282
|
-
|
|
283
|
-
# Case 1: Already a [model, command] tuple
|
|
284
|
-
if result.is_a?(Array) && (result.size == 2)
|
|
285
|
-
model, command = result
|
|
286
|
-
# Verify the second element is a valid command
|
|
287
|
-
if command.nil? ||
|
|
288
|
-
(command.respond_to?(:rooibos_command?) && command.rooibos_command?)
|
|
289
|
-
|
|
290
|
-
return [model, command]
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
# Debug-mode heuristic: warn about suspicious command-like objects
|
|
294
|
-
if RatatuiRuby::Debug.enabled? &&
|
|
295
|
-
command.respond_to?(:call) &&
|
|
296
|
-
!command.respond_to?(:rooibos_command?) &&
|
|
297
|
-
!Ractor.shareable?(result)
|
|
298
|
-
|
|
299
|
-
warn "WARNING: Update returned [model, #{command.class}] but #{command.class} " \
|
|
300
|
-
"responds to #call without #rooibos_command?. Did you forget to include Command::Custom? " \
|
|
301
|
-
"The tuple will be treated as the model, not as [model, command]. " \
|
|
302
|
-
"To suppress this warning if the array is your model, use Ractor.make_shareable on it. " \
|
|
303
|
-
"(#{caller.first})"
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
# Case 2: Result is a Command - use previous model
|
|
309
|
-
if result.respond_to?(:rooibos_command?) && result.rooibos_command?
|
|
310
|
-
command = result #: Rooibos::Command::execution
|
|
311
|
-
return [previous_model, command]
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
# Case 3: Result is the new model
|
|
315
|
-
[result, nil]
|
|
316
|
-
end
|
|
317
|
-
|
|
318
288
|
# Validates an object is Ractor-shareable (deeply frozen).
|
|
319
289
|
#
|
|
320
290
|
# Models and messages must be shareable for future Ractor support.
|
|
@@ -322,12 +292,20 @@ module Rooibos
|
|
|
322
292
|
#
|
|
323
293
|
# Only enforced in debug mode (and tests). Production skips this check
|
|
324
294
|
# for performance; mutable objects will still cause bugs, but silently.
|
|
295
|
+
#
|
|
296
|
+
# This method TRIES to make the object shareable (which auto-freezes).
|
|
297
|
+
# It only fails if the object captures non-shareable state (e.g., a
|
|
298
|
+
# lambda defined inside a method that captures self).
|
|
325
299
|
private def validate_ractor_shareable!(object, name)
|
|
326
300
|
return unless RatatuiRuby::Debug.enabled?
|
|
327
301
|
return if Ractor.shareable?(object)
|
|
328
302
|
|
|
303
|
+
# Try to make it shareable - this will freeze it and succeed for
|
|
304
|
+
# most objects. It only fails for objects that truly can't be shared.
|
|
305
|
+
Ractor.make_shareable(object)
|
|
306
|
+
rescue Ractor::IsolationError => e
|
|
329
307
|
raise Rooibos::Error::Invariant,
|
|
330
|
-
"#{name
|
|
308
|
+
"#{name} cannot be made Ractor-shareable: #{e.message}"
|
|
331
309
|
end
|
|
332
310
|
|
|
333
311
|
private def handle_ratatui_event
|
|
@@ -343,7 +321,7 @@ module Rooibos
|
|
|
343
321
|
return true
|
|
344
322
|
end
|
|
345
323
|
|
|
346
|
-
@model, @command =
|
|
324
|
+
@model, @command = Transition.from(@update.call(message, @model), @model).to_a
|
|
347
325
|
validate_ractor_shareable!(@model, "model")
|
|
348
326
|
throw QUIT if Command::Exit === @command
|
|
349
327
|
dispatch_command
|
|
@@ -359,7 +337,7 @@ module Rooibos
|
|
|
359
337
|
break if background_message == QUEUE_EMPTY
|
|
360
338
|
|
|
361
339
|
result = @update.call(background_message, @model)
|
|
362
|
-
@model, @command =
|
|
340
|
+
@model, @command = Transition.from(result, @model).to_a
|
|
363
341
|
return unless dispatch
|
|
364
342
|
|
|
365
343
|
validate_ractor_shareable!(@model, "model")
|
|
@@ -379,6 +357,13 @@ module Rooibos
|
|
|
379
357
|
# Remove cancelled future from pending list so sync doesn't wait for it
|
|
380
358
|
@pending_futures.delete(entry.future) if entry
|
|
381
359
|
nil
|
|
360
|
+
elsif Command.const_get(:Separate) === @command
|
|
361
|
+
# Internal: dispatch each command independently (no Message::Batch)
|
|
362
|
+
@command.commands.each do |cmd|
|
|
363
|
+
entry = @lifecycle.run_async(cmd, @message_queue)
|
|
364
|
+
@pending_futures << entry.future if entry.future
|
|
365
|
+
end
|
|
366
|
+
nil # No single future to return
|
|
382
367
|
elsif @command.respond_to?(:rooibos_command?) && @command.rooibos_command?
|
|
383
368
|
entry = @lifecycle.run_async(@command, @message_queue)
|
|
384
369
|
entry.future
|
data/lib/rooibos/test_helper.rb
CHANGED
|
@@ -117,5 +117,27 @@ module Rooibos
|
|
|
117
117
|
raise failure_msg
|
|
118
118
|
end
|
|
119
119
|
end
|
|
120
|
+
|
|
121
|
+
# ===========================================================================
|
|
122
|
+
# Shared Fixtures
|
|
123
|
+
#
|
|
124
|
+
# Ractor-shareable callables for common test patterns
|
|
125
|
+
# ===========================================================================
|
|
126
|
+
|
|
127
|
+
# Simple view that just clears the terminal
|
|
128
|
+
ClearView = -> (_model, tui) { tui.clear }
|
|
129
|
+
|
|
130
|
+
# Update that exits on 'q' key, passes through all other messages
|
|
131
|
+
ExitOnQUpdate = -> (msg, model) do
|
|
132
|
+
case msg
|
|
133
|
+
when RatatuiRuby::Event::Key
|
|
134
|
+
(msg.code == "q") ? [model, Rooibos::Command.exit] : [model, nil]
|
|
135
|
+
else
|
|
136
|
+
[model, nil]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Update that exits immediately on any key
|
|
141
|
+
ExitOnAnyKeyUpdate = -> (_msg, model) { [model, Rooibos::Command.exit] }
|
|
120
142
|
end
|
|
121
143
|
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
module Rooibos
|
|
9
|
+
# Transition represents a normalized [model, command] tuple from Update.
|
|
10
|
+
# Use Transition.from to convert DWIM return values.
|
|
11
|
+
class Transition < Data.define(:model, :command)
|
|
12
|
+
# Creates a Transition from an Update return value.
|
|
13
|
+
#
|
|
14
|
+
# Handles:
|
|
15
|
+
# <tt>nil</tt>:: preserve previous model, no command
|
|
16
|
+
# <tt>model</tt>:: new model, no command
|
|
17
|
+
# <tt>command</tt>:: previous model, command
|
|
18
|
+
# <tt>[model, command]</tt>:: as-is
|
|
19
|
+
def self.from(update_return, previous_model)
|
|
20
|
+
case update_return
|
|
21
|
+
when nil
|
|
22
|
+
new(model: previous_model, command: nil)
|
|
23
|
+
when -> (r) { r.respond_to?(:rooibos_command?) && r.rooibos_command? }
|
|
24
|
+
new(model: previous_model, command: update_return)
|
|
25
|
+
when Array
|
|
26
|
+
case update_return
|
|
27
|
+
in [model, command] if command.nil? || (command.respond_to?(:rooibos_command?) && command.rooibos_command?)
|
|
28
|
+
new(model:, command:)
|
|
29
|
+
else
|
|
30
|
+
warn_suspicious_callable(update_return)
|
|
31
|
+
new(model: update_return, command: nil)
|
|
32
|
+
end
|
|
33
|
+
else
|
|
34
|
+
new(model: update_return, command: nil)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Creates an initial Transition with no command.
|
|
39
|
+
def self.initial(model)
|
|
40
|
+
new(model:, command: nil)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns new Transition with an updated model, or self if nil.
|
|
44
|
+
def with_model(new_model)
|
|
45
|
+
return self unless new_model
|
|
46
|
+
|
|
47
|
+
Transition.new(model: new_model, command:)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns new Transition with an updated command, or self if nil.
|
|
51
|
+
def with_command(new_command)
|
|
52
|
+
return self unless new_command
|
|
53
|
+
|
|
54
|
+
Transition.new(model:, command: new_command)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns new Transition with command added (batched if existing).
|
|
58
|
+
def with_added_command(new_command)
|
|
59
|
+
return self unless new_command
|
|
60
|
+
return with_command(new_command) unless command
|
|
61
|
+
|
|
62
|
+
with_command(Command.batch(command, new_command))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns new Transition with command added via Separate (for observe).
|
|
66
|
+
def with_separate_command(new_command)
|
|
67
|
+
return self unless new_command
|
|
68
|
+
return with_command(new_command) unless command
|
|
69
|
+
|
|
70
|
+
with_command(Command.const_get(:Separate).new(commands: [command, new_command]))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Converts to [model, command] tuple.
|
|
74
|
+
def to_a = [model, command]
|
|
75
|
+
alias to_ary to_a
|
|
76
|
+
alias deconstruct to_a
|
|
77
|
+
|
|
78
|
+
# Warns if tuple looks like [model, command] but command isn't marked as such.
|
|
79
|
+
private_class_method def self.warn_suspicious_callable(update_return) # :nodoc:
|
|
80
|
+
return unless RatatuiRuby::Debug.enabled?
|
|
81
|
+
|
|
82
|
+
_, suspect = update_return
|
|
83
|
+
return unless suspect.respond_to?(:call)
|
|
84
|
+
return if Ractor.shareable?(update_return)
|
|
85
|
+
|
|
86
|
+
warn "WARNING: Update returned [model, #{suspect.class}] but #{suspect.class} " \
|
|
87
|
+
"responds to #call without #rooibos_command?. Did you forget to include Command::Custom? " \
|
|
88
|
+
"The tuple will be treated as the model, not as [model, command]. " \
|
|
89
|
+
"To suppress this warning if the array is your model, use Ractor.make_shareable on it."
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/rooibos/version.rb
CHANGED
data/lib/rooibos.rb
CHANGED
|
@@ -9,6 +9,8 @@ require_relative "rooibos/version"
|
|
|
9
9
|
require_relative "rooibos/error"
|
|
10
10
|
require_relative "rooibos/message"
|
|
11
11
|
require_relative "rooibos/command"
|
|
12
|
+
require_relative "rooibos/transition"
|
|
13
|
+
require_relative "rooibos/configuration"
|
|
12
14
|
require_relative "rooibos/runtime"
|
|
13
15
|
require_relative "rooibos/router"
|
|
14
16
|
require_relative "rooibos/welcome"
|
|
@@ -62,61 +64,4 @@ module Rooibos
|
|
|
62
64
|
def self.normalize_init(result)
|
|
63
65
|
Runtime.normalize_init(result)
|
|
64
66
|
end
|
|
65
|
-
|
|
66
|
-
# Wraps a command with a routing prefix.
|
|
67
|
-
#
|
|
68
|
-
# Parent fragments trigger child fragment commands. The results need routing back
|
|
69
|
-
# to the correct child fragment. Manually wrapping every command is tedious.
|
|
70
|
-
#
|
|
71
|
-
# This method prefixes command results automatically. Use it to route
|
|
72
|
-
# child fragment command results in Fractal Architecture.
|
|
73
|
-
#
|
|
74
|
-
# [command] The child fragment command to wrap.
|
|
75
|
-
# [prefix] Symbol prepended to results (e.g., <tt>:stats</tt>).
|
|
76
|
-
#
|
|
77
|
-
# === Example
|
|
78
|
-
#
|
|
79
|
-
# # Verbose:
|
|
80
|
-
# Command.map(child_fragment.fetch_command) { |r| [:stats, *r] }
|
|
81
|
-
#
|
|
82
|
-
# # Concise:
|
|
83
|
-
# Rooibos.route(child_fragment.fetch_command, :stats)
|
|
84
|
-
def self.route(command, prefix)
|
|
85
|
-
Command.map(command) { |result| [prefix, *result] }
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Delegates a prefixed message to a child fragment's UPDATE.
|
|
89
|
-
#
|
|
90
|
-
# Parent fragment UPDATE functions route messages to child fragments. Each route
|
|
91
|
-
# requires pattern matching, calling the child, and rewrapping any returned
|
|
92
|
-
# command. The boilerplate adds up fast.
|
|
93
|
-
#
|
|
94
|
-
# This method handles the dispatch. It checks the prefix, calls the child,
|
|
95
|
-
# and wraps any command. Returns <tt>nil</tt> if the prefix does not match.
|
|
96
|
-
#
|
|
97
|
-
# [message] Incoming message (e.g., <tt>[:stats, :system_info, {...}]</tt>).
|
|
98
|
-
# [prefix] Expected prefix symbol (e.g., <tt>:stats</tt>).
|
|
99
|
-
# [child_update] The child's UPDATE callable.
|
|
100
|
-
# [child_model] The child's current model.
|
|
101
|
-
#
|
|
102
|
-
# === Example
|
|
103
|
-
#
|
|
104
|
-
# # Verbose:
|
|
105
|
-
# case message
|
|
106
|
-
# in [:stats, *rest]
|
|
107
|
-
# new_child, cmd = StatsPanel::Update.call(rest, model.stats)
|
|
108
|
-
# mapped = cmd ? Command.map(cmd) { |r| [:stats, *r] } : nil
|
|
109
|
-
# [new_child, mapped]
|
|
110
|
-
# end
|
|
111
|
-
#
|
|
112
|
-
# # Concise:
|
|
113
|
-
# Rooibos.delegate(message, :stats, StatsPanel::Update, model.stats)
|
|
114
|
-
def self.delegate(message, prefix, child_update, child_model)
|
|
115
|
-
return nil unless message.is_a?(Array) && message.first == prefix
|
|
116
|
-
|
|
117
|
-
rest = message[1]
|
|
118
|
-
new_child, command = child_update.call(rest, child_model)
|
|
119
|
-
wrapped = command ? route(command, prefix) : nil
|
|
120
|
-
[new_child, wrapped]
|
|
121
|
-
end
|
|
122
67
|
end
|
data/sig/rooibos/cli.rbs
CHANGED
data/sig/rooibos/command.rbs
CHANGED
|
@@ -29,6 +29,21 @@ module Rooibos
|
|
|
29
29
|
def self.new: () -> instance
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Internal wrapper for multiple commands to be dispatched separately.
|
|
33
|
+
# Router DSL uses this to return multiple commands from observe + keymap
|
|
34
|
+
# without triggering Message::Batch. The runtime unwraps this and dispatches
|
|
35
|
+
# each command independently.
|
|
36
|
+
class Separate < Data
|
|
37
|
+
include Custom
|
|
38
|
+
|
|
39
|
+
attr_reader commands: Array[execution?]
|
|
40
|
+
|
|
41
|
+
def self.new: (commands: Array[execution?]) -> instance
|
|
42
|
+
|
|
43
|
+
# Stub - Separate is a sentinel unwrapped by runtime before dispatch.
|
|
44
|
+
def call: (Outlet out, Concurrent::Cancellation token) -> void
|
|
45
|
+
end
|
|
46
|
+
|
|
32
47
|
# Runs a shell command and routes its output back as messages.
|
|
33
48
|
class System < Data
|
|
34
49
|
attr_reader command: String
|
|
@@ -107,6 +122,12 @@ module Rooibos
|
|
|
107
122
|
def self.custom: (^(Outlet, Concurrent::Cancellation) -> void callable, ?grace_period: Float?) -> _Command
|
|
108
123
|
| (?grace_period: Float?) { (Outlet, Concurrent::Cancellation) -> void } -> _Command
|
|
109
124
|
|
|
125
|
+
# Delivers a message to Update.
|
|
126
|
+
def self.deliver: (Object message) -> Deliver
|
|
127
|
+
|
|
128
|
+
# Bubbles a message outward through the fragment hierarchy.
|
|
129
|
+
def self.bubble: (Object message) -> Bubble
|
|
130
|
+
|
|
110
131
|
# Creates a one-shot timer command.
|
|
111
132
|
def self.wait: (Float seconds, Symbol tag) -> Wait
|
|
112
133
|
|
|
@@ -160,6 +181,29 @@ module Rooibos
|
|
|
160
181
|
def deconstruct_keys: (Array[Symbol]? keys) -> Hash[Symbol, untyped]
|
|
161
182
|
end
|
|
162
183
|
|
|
184
|
+
# Command that delivers a message to Update.
|
|
185
|
+
class Deliver < Data
|
|
186
|
+
include Custom
|
|
187
|
+
|
|
188
|
+
attr_reader message: Object
|
|
189
|
+
|
|
190
|
+
def self.new: (message: Object) -> instance
|
|
191
|
+
|
|
192
|
+
def call: (Outlet out, Concurrent::Cancellation token) -> void
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Command that bubbles a message outward through the fragment hierarchy.
|
|
196
|
+
class Bubble < Data
|
|
197
|
+
include Custom
|
|
198
|
+
|
|
199
|
+
attr_reader message: Object
|
|
200
|
+
|
|
201
|
+
def self.new: (message: Object) -> instance
|
|
202
|
+
|
|
203
|
+
# Raises an error if called — outer fragments should handle bubbles.
|
|
204
|
+
def call: (Outlet out, Concurrent::Cancellation token) -> void
|
|
205
|
+
end
|
|
206
|
+
|
|
163
207
|
# Minimal interface for callables accepted by Lifecycle.run_sync.
|
|
164
208
|
interface _Callable
|
|
165
209
|
def call: (Outlet out, Concurrent::Cancellation token) -> void
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#--
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
4
|
+
#++
|
|
5
|
+
|
|
6
|
+
module Rooibos
|
|
7
|
+
# Immutable Mealy machine (state, input) pair.
|
|
8
|
+
class Configuration < Data
|
|
9
|
+
attr_reader message: message
|
|
10
|
+
attr_reader model: _DataModel
|
|
11
|
+
|
|
12
|
+
# Derives a new Configuration with an updated model.
|
|
13
|
+
def with_model: (_DataModel? new_model) -> Configuration
|
|
14
|
+
|
|
15
|
+
# Destructures into [message, model] array.
|
|
16
|
+
def to_a: () -> [message, _DataModel]
|
|
17
|
+
alias to_ary to_a
|
|
18
|
+
alias deconstruct to_a
|
|
19
|
+
end
|
|
20
|
+
end
|
data/sig/rooibos/message.rbs
CHANGED
|
@@ -198,5 +198,17 @@ module Rooibos
|
|
|
198
198
|
def respond_to_missing?: (Symbol name, ?bool include_private) -> bool
|
|
199
199
|
def method_missing: (Symbol name, *untyped args, **untyped kwargs) ?{ () -> untyped } -> bool
|
|
200
200
|
end
|
|
201
|
+
|
|
202
|
+
# Message wrapper for bubbled messages from child fragments.
|
|
203
|
+
class Bubbled < Data
|
|
204
|
+
include Predicates
|
|
205
|
+
|
|
206
|
+
attr_reader message: message
|
|
207
|
+
|
|
208
|
+
def self.new: (message: message) -> instance
|
|
209
|
+
|
|
210
|
+
def bubbled?: () -> bool
|
|
211
|
+
def deconstruct_keys: (Array[Symbol]? keys) -> { type: Symbol, message: message }
|
|
212
|
+
end
|
|
201
213
|
end
|
|
202
214
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#--
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
4
|
+
#++
|
|
5
|
+
|
|
6
|
+
module Rooibos
|
|
7
|
+
module Router
|
|
8
|
+
# Action type: either a LambdaAction or RoutedAction.
|
|
9
|
+
type action = LambdaAction | RoutedAction
|
|
10
|
+
|
|
11
|
+
# Lambda action - calls handler directly.
|
|
12
|
+
class LambdaAction < Data
|
|
13
|
+
attr_reader name: Symbol?
|
|
14
|
+
attr_reader handler: handler
|
|
15
|
+
|
|
16
|
+
def routed?: () -> false
|
|
17
|
+
|
|
18
|
+
# Applies the handler and returns a Transition.
|
|
19
|
+
def apply: (message, _DataModel, Routes) -> Transition
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Routed action - wraps message and delegates through route to fragment.
|
|
23
|
+
class RoutedAction < Data
|
|
24
|
+
attr_reader name: Symbol
|
|
25
|
+
attr_reader fragment: Module
|
|
26
|
+
|
|
27
|
+
def routed?: () -> true
|
|
28
|
+
|
|
29
|
+
# Applies via route delegation and returns a Transition.
|
|
30
|
+
def apply: (message, _DataModel, Routes) -> Transition
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#--
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
4
|
+
#++
|
|
5
|
+
|
|
6
|
+
module Rooibos
|
|
7
|
+
module Router
|
|
8
|
+
# Collection of named actions.
|
|
9
|
+
class Actions < Data
|
|
10
|
+
include Enumerable[action]
|
|
11
|
+
|
|
12
|
+
attr_reader rules: Array[action]
|
|
13
|
+
|
|
14
|
+
def initialize: (?rules: Array[action]) -> void
|
|
15
|
+
|
|
16
|
+
# Registers an action by name.
|
|
17
|
+
def add: (Symbol name, handler | Module handler_arg) -> void
|
|
18
|
+
|
|
19
|
+
# Looks up an action by name.
|
|
20
|
+
def []: (Symbol name) -> action
|
|
21
|
+
|
|
22
|
+
# Iterates over all actions.
|
|
23
|
+
def each: () { (action) -> void } -> Array[action]
|
|
24
|
+
| () -> Enumerator[action, Array[action]]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#--
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
4
|
+
#++
|
|
5
|
+
|
|
6
|
+
module Rooibos
|
|
7
|
+
module Router
|
|
8
|
+
module Flow
|
|
9
|
+
# Shared dispatch logic mixed into Inward and Outward flows.
|
|
10
|
+
module Dispatch : _HasRoutes
|
|
11
|
+
# Interface for classes that include Dispatch.
|
|
12
|
+
interface _HasRoutes
|
|
13
|
+
def routes: () -> Routes
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# Runs all rules, accumulating model changes and separate commands.
|
|
19
|
+
def run_all: (Observes rules, message, _DataModel) -> Transition
|
|
20
|
+
|
|
21
|
+
# Applies the first matching rule. Returns Transition or nil.
|
|
22
|
+
def apply_first_matching: (Enumerable[Rule] rules, Configuration config, Transition transition) -> Transition?
|
|
23
|
+
|
|
24
|
+
# Merges model from new_transition, keeps commands separate.
|
|
25
|
+
def separate_commands: (Transition transition, Transition new_transition) -> Transition
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#--
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
4
|
+
#++
|
|
5
|
+
|
|
6
|
+
module Rooibos
|
|
7
|
+
module Router
|
|
8
|
+
module Flow
|
|
9
|
+
# Inward flow: messages traveling toward the leaves.
|
|
10
|
+
class Inward < Data
|
|
11
|
+
include Dispatch
|
|
12
|
+
|
|
13
|
+
attr_reader observes: Observes
|
|
14
|
+
attr_reader receives: Receives
|
|
15
|
+
attr_reader forwards: Forwards
|
|
16
|
+
attr_reader otherwises: Otherwises
|
|
17
|
+
attr_reader routes: Routes
|
|
18
|
+
|
|
19
|
+
def initialize: (
|
|
20
|
+
observes: Observes,
|
|
21
|
+
receives: Receives,
|
|
22
|
+
forwards: Forwards,
|
|
23
|
+
otherwises: Otherwises,
|
|
24
|
+
routes: Routes
|
|
25
|
+
) -> void
|
|
26
|
+
|
|
27
|
+
# Dispatches a message through observe -> receive -> forward -> otherwise.
|
|
28
|
+
def call: (message, _DataModel) -> Transition
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Validates all forward and otherwise targets resolve.
|
|
33
|
+
def validate_routes!: () -> void
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#--
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
4
|
+
#++
|
|
5
|
+
|
|
6
|
+
module Rooibos
|
|
7
|
+
module Router
|
|
8
|
+
module Flow
|
|
9
|
+
# Outward flow: bubbled messages traveling toward the root.
|
|
10
|
+
class Outward < Data
|
|
11
|
+
include Dispatch
|
|
12
|
+
|
|
13
|
+
attr_reader observes: Observes
|
|
14
|
+
attr_reader receives: Receives
|
|
15
|
+
attr_reader routes: Routes
|
|
16
|
+
|
|
17
|
+
def initialize: (
|
|
18
|
+
observes: Observes,
|
|
19
|
+
receives: Receives,
|
|
20
|
+
routes: Routes
|
|
21
|
+
) -> void
|
|
22
|
+
|
|
23
|
+
# Sentinel: intercept consumed the bubble.
|
|
24
|
+
INTERCEPTED: Object
|
|
25
|
+
|
|
26
|
+
# Dispatches a bubbled message through observe -> intercept.
|
|
27
|
+
def call: (message, _DataModel) -> Transition
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Applies the first matching intercept rule.
|
|
32
|
+
def intercept_first_matching: (Receives rules, Configuration config, Transition transition) -> Transition?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#--
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
4
|
+
#++
|
|
5
|
+
|
|
6
|
+
module Rooibos
|
|
7
|
+
module Router
|
|
8
|
+
# Forward rule - matches messages and delegates to route(s).
|
|
9
|
+
class Forward < Data
|
|
10
|
+
include Rule
|
|
11
|
+
|
|
12
|
+
# Targets: a single target, array of targets, or ALL_ROUTES sentinel.
|
|
13
|
+
type targets = route_target | Array[route_target] | all_routes_sentinel
|
|
14
|
+
|
|
15
|
+
attr_reader predicate: predicate
|
|
16
|
+
attr_reader targets: targets
|
|
17
|
+
attr_reader envelope: Symbol?
|
|
18
|
+
attr_reader guard: Guard
|
|
19
|
+
|
|
20
|
+
def initialize: (
|
|
21
|
+
predicate: predicate,
|
|
22
|
+
?targets: targets,
|
|
23
|
+
?envelope: Symbol?,
|
|
24
|
+
?guard: Guard?
|
|
25
|
+
) -> void
|
|
26
|
+
|
|
27
|
+
# Routes the message to target route(s) and returns a Transition.
|
|
28
|
+
def apply: (message, _DataModel, Routes) -> Transition
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def envelop: (message msg) -> (message | Message::Routed)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|