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.
Files changed (217) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSES/BSD-2-Clause.txt +9 -0
  3. data/REUSE.toml +5 -0
  4. data/exe/.gitkeep +0 -0
  5. data/lib/rooibos/cli/commands/new.rb +24 -0
  6. data/lib/rooibos/command/batch.rb +10 -0
  7. data/lib/rooibos/command/bubble.rb +34 -0
  8. data/lib/rooibos/command/custom.rb +3 -2
  9. data/lib/rooibos/command/deliver.rb +50 -0
  10. data/lib/rooibos/command/http.rb +1 -1
  11. data/lib/rooibos/command/lifecycle.rb +3 -1
  12. data/lib/rooibos/command/outlet.rb +19 -9
  13. data/lib/rooibos/command.rb +107 -3
  14. data/lib/rooibos/configuration.rb +29 -0
  15. data/lib/rooibos/message/bubbled.rb +29 -0
  16. data/lib/rooibos/message.rb +24 -6
  17. data/lib/rooibos/router/action.rb +36 -0
  18. data/lib/rooibos/router/flow/dispatch.rb +39 -0
  19. data/lib/rooibos/router/flow/inward.rb +41 -0
  20. data/lib/rooibos/router/flow/outward.rb +44 -0
  21. data/lib/rooibos/router/guard.rb +56 -0
  22. data/lib/rooibos/router/predicate.rb +65 -0
  23. data/lib/rooibos/router/registry/actions.rb +41 -0
  24. data/lib/rooibos/router/registry/forwards.rb +58 -0
  25. data/lib/rooibos/router/registry/observes.rb +57 -0
  26. data/lib/rooibos/router/registry/otherwises.rb +29 -0
  27. data/lib/rooibos/router/registry/receives.rb +57 -0
  28. data/lib/rooibos/router/registry/routes.rb +59 -0
  29. data/lib/rooibos/router/registry.rb +26 -0
  30. data/lib/rooibos/router/route.rb +42 -0
  31. data/lib/rooibos/router/router_update.rb +53 -0
  32. data/lib/rooibos/router/rule/forward.rb +39 -0
  33. data/lib/rooibos/router/rule/observe.rb +22 -0
  34. data/lib/rooibos/router/rule/otherwise.rb +26 -0
  35. data/lib/rooibos/router/rule/receive.rb +22 -0
  36. data/lib/rooibos/router/rule.rb +40 -0
  37. data/lib/rooibos/router.rb +424 -438
  38. data/lib/rooibos/runtime.rb +37 -52
  39. data/lib/rooibos/test_helper.rb +22 -0
  40. data/lib/rooibos/transition.rb +92 -0
  41. data/lib/rooibos/version.rb +1 -1
  42. data/lib/rooibos.rb +2 -57
  43. data/sig/rooibos/cli.rbs +1 -0
  44. data/sig/rooibos/command.rbs +44 -0
  45. data/sig/rooibos/configuration.rbs +20 -0
  46. data/sig/rooibos/message.rbs +12 -0
  47. data/sig/rooibos/router/action.rbs +33 -0
  48. data/sig/rooibos/router/actions.rbs +27 -0
  49. data/sig/rooibos/router/flow/dispatch.rbs +29 -0
  50. data/sig/rooibos/router/flow/inward.rbs +37 -0
  51. data/sig/rooibos/router/flow/outward.rbs +36 -0
  52. data/sig/rooibos/router/forward.rbs +35 -0
  53. data/sig/rooibos/router/forwards.rbs +34 -0
  54. data/sig/rooibos/router/guard.rbs +21 -0
  55. data/sig/rooibos/router/observe.rbs +20 -0
  56. data/sig/rooibos/router/observes.rbs +38 -0
  57. data/sig/rooibos/router/otherwise.rbs +22 -0
  58. data/sig/rooibos/router/otherwises.rbs +20 -0
  59. data/sig/rooibos/router/predicate.rbs +51 -0
  60. data/sig/rooibos/router/receive.rbs +20 -0
  61. data/sig/rooibos/router/receives.rbs +38 -0
  62. data/sig/rooibos/router/registry.rbs +24 -0
  63. data/sig/rooibos/router/route.rbs +46 -0
  64. data/sig/rooibos/router/router_update.rbs +33 -0
  65. data/sig/rooibos/router/routes.rbs +41 -0
  66. data/sig/rooibos/router/rule.rbs +36 -0
  67. data/sig/rooibos/router.rbs +216 -161
  68. data/sig/rooibos/runtime.rbs +0 -1
  69. data/sig/rooibos/test_helper.rbs +6 -0
  70. data/sig/rooibos/transition.rbs +33 -0
  71. data/sig/rooibos.rbs +0 -10
  72. metadata +144 -198
  73. data/.builds/ruby-3.2.yml +0 -55
  74. data/.builds/ruby-3.3.yml +0 -55
  75. data/.builds/ruby-3.4.yml +0 -55
  76. data/.builds/ruby-4.0.0.yml +0 -55
  77. data/.pre-commit-config.yaml +0 -16
  78. data/.rubocop.yml +0 -8
  79. data/AGENTS.md +0 -108
  80. data/CHANGELOG.md +0 -308
  81. data/README.md +0 -183
  82. data/README.rdoc +0 -374
  83. data/Rakefile +0 -16
  84. data/Steepfile +0 -13
  85. data/doc/best_practices/forms_and_validation.md +0 -20
  86. data/doc/best_practices/http_workflows.md +0 -20
  87. data/doc/best_practices/index.md +0 -26
  88. data/doc/best_practices/lists_and_tables.md +0 -20
  89. data/doc/best_practices/modal_dialogs.md +0 -20
  90. data/doc/best_practices/no_stateful_widgets.md +0 -184
  91. data/doc/best_practices/orchestration.md +0 -20
  92. data/doc/best_practices/streaming_data.md +0 -20
  93. data/doc/contributors/design/commands_and_outlets.md +0 -214
  94. data/doc/contributors/design/mvu_tea_implementations_research.md +0 -373
  95. data/doc/contributors/documentation_plan.md +0 -616
  96. data/doc/contributors/documentation_stub_audit.md +0 -112
  97. data/doc/contributors/documentation_style.md +0 -275
  98. data/doc/contributors/e2e_pty.md +0 -168
  99. data/doc/contributors/maybe_stateful_router.md +0 -56
  100. data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +0 -70
  101. data/doc/contributors/specs/file_browser.md +0 -789
  102. data/doc/contributors/specs/file_browser_stories.md +0 -784
  103. data/doc/contributors/specs/tutorials_to_stories.rb +0 -167
  104. data/doc/contributors/todo/scrollbar.md +0 -118
  105. data/doc/contributors/tutorial_old/01_project_setup.md +0 -20
  106. data/doc/contributors/tutorial_old/02_hello_world.md +0 -24
  107. data/doc/contributors/tutorial_old/03_adding_state.md +0 -26
  108. data/doc/contributors/tutorial_old/06_organizing_your_code.md +0 -20
  109. data/doc/contributors/tutorial_old/07_your_first_command.md +0 -21
  110. data/doc/contributors/tutorial_old/08_the_preview_pane.md +0 -20
  111. data/doc/contributors/tutorial_old/09_loading_states.md +0 -20
  112. data/doc/contributors/tutorial_old/10_testing_your_app.md +0 -20
  113. data/doc/contributors/tutorial_old/11_polish_and_refine.md +0 -20
  114. data/doc/contributors/tutorial_old/12_going_further.md +0 -20
  115. data/doc/contributors/tutorial_old/index.md +0 -20
  116. data/doc/custom.css +0 -22
  117. data/doc/essentials/commands.md +0 -20
  118. data/doc/essentials/index.md +0 -31
  119. data/doc/essentials/messages.md +0 -21
  120. data/doc/essentials/models.md +0 -21
  121. data/doc/essentials/shortcuts.md +0 -19
  122. data/doc/essentials/the_elm_architecture.md +0 -24
  123. data/doc/essentials/the_runtime.md +0 -21
  124. data/doc/essentials/update_functions.md +0 -20
  125. data/doc/essentials/views.md +0 -22
  126. data/doc/getting_started/for_go_developers.md +0 -16
  127. data/doc/getting_started/for_python_developers.md +0 -16
  128. data/doc/getting_started/for_rails_developers.md +0 -17
  129. data/doc/getting_started/for_ratatui_ruby_developers.md +0 -17
  130. data/doc/getting_started/for_react_developers.md +0 -17
  131. data/doc/getting_started/index.md +0 -52
  132. data/doc/getting_started/install.md +0 -20
  133. data/doc/getting_started/quickstart.md +0 -20
  134. data/doc/getting_started/ruby_primer.md +0 -19
  135. data/doc/getting_started/why_rooibos.md +0 -20
  136. data/doc/images/verify_readme_usage.png +0 -0
  137. data/doc/images/widget_cmd_exec.png +0 -0
  138. data/doc/index.md +0 -93
  139. data/doc/scaling_up/async_patterns.md +0 -20
  140. data/doc/scaling_up/command_composition.md +0 -20
  141. data/doc/scaling_up/custom_commands.md +0 -21
  142. data/doc/scaling_up/fractal_architecture.md +0 -20
  143. data/doc/scaling_up/index.md +0 -30
  144. data/doc/scaling_up/message_routing.md +0 -20
  145. data/doc/scaling_up/ractor_safety.md +0 -20
  146. data/doc/scaling_up/testing.md +0 -21
  147. data/doc/troubleshooting/common_errors.md +0 -20
  148. data/doc/troubleshooting/debugging.md +0 -21
  149. data/doc/troubleshooting/index.md +0 -23
  150. data/doc/troubleshooting/performance.md +0 -20
  151. data/doc/tutorial/01_project_setup.md +0 -44
  152. data/doc/tutorial/02_hello_world.md +0 -45
  153. data/doc/tutorial/03_static_file_list.md +0 -44
  154. data/doc/tutorial/04_arrow_navigation.md +0 -47
  155. data/doc/tutorial/05_real_files.md +0 -45
  156. data/doc/tutorial/06_safe_refactoring.md +0 -21
  157. data/doc/tutorial/07_red_first_tdd.md +0 -26
  158. data/doc/tutorial/08_file_metadata.md +0 -42
  159. data/doc/tutorial/09_text_preview.md +0 -44
  160. data/doc/tutorial/10_directory_tree.md +0 -42
  161. data/doc/tutorial/11_pane_focus.md +0 -40
  162. data/doc/tutorial/12_sorting.md +0 -41
  163. data/doc/tutorial/13_filtering.md +0 -43
  164. data/doc/tutorial/14_toggle_hidden.md +0 -41
  165. data/doc/tutorial/15_text_input_widget.md +0 -43
  166. data/doc/tutorial/16_rename_files.md +0 -42
  167. data/doc/tutorial/17_confirmation_dialogs.md +0 -43
  168. data/doc/tutorial/18_progress_indicators.md +0 -43
  169. data/doc/tutorial/19_atomic_operations.md +0 -42
  170. data/doc/tutorial/20_external_editor.md +0 -42
  171. data/doc/tutorial/21_modal_overlays.md +0 -41
  172. data/doc/tutorial/22_error_handling.md +0 -43
  173. data/doc/tutorial/23_terminal_capabilities.md +0 -53
  174. data/doc/tutorial/24_mouse_events.md +0 -43
  175. data/doc/tutorial/25_resize_events.md +0 -43
  176. data/doc/tutorial/26_loading_states.md +0 -42
  177. data/doc/tutorial/27_performance.md +0 -43
  178. data/doc/tutorial/28_color_schemes.md +0 -47
  179. data/doc/tutorial/29_configuration.md +0 -124
  180. data/doc/tutorial/30_going_further.md +0 -17
  181. data/doc/tutorial/index.md +0 -17
  182. data/examples/app_fractal_dashboard/README.md +0 -60
  183. data/examples/app_fractal_dashboard/app.rb +0 -63
  184. data/examples/app_fractal_dashboard/dashboard/base.rb +0 -73
  185. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +0 -86
  186. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +0 -87
  187. data/examples/app_fractal_dashboard/dashboard/update_router.rb +0 -43
  188. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +0 -81
  189. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +0 -82
  190. data/examples/app_fractal_dashboard/fragments/custom_shell_output.rb +0 -90
  191. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +0 -47
  192. data/examples/app_fractal_dashboard/fragments/network_panel.rb +0 -45
  193. data/examples/app_fractal_dashboard/fragments/ping.rb +0 -47
  194. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +0 -45
  195. data/examples/app_fractal_dashboard/fragments/system_info.rb +0 -47
  196. data/examples/app_fractal_dashboard/fragments/uptime.rb +0 -47
  197. data/examples/tutorial/01/app.rb +0 -50
  198. data/examples/tutorial/02/app.rb +0 -64
  199. data/examples/tutorial/03/app.rb +0 -91
  200. data/examples/tutorial/06_safe_refactoring/app.rb +0 -124
  201. data/examples/verify_readme_usage/README.md +0 -54
  202. data/examples/verify_readme_usage/app.rb +0 -47
  203. data/examples/verify_website_first_app/app.rb +0 -85
  204. data/examples/verify_website_hello_mvu/app.rb +0 -31
  205. data/examples/widget_command_system/README.md +0 -70
  206. data/examples/widget_command_system/app.rb +0 -134
  207. data/generate_tutorial_stubs.rb +0 -126
  208. data/mise.toml +0 -8
  209. data/rbs_collection.lock.yaml +0 -108
  210. data/rbs_collection.yaml +0 -15
  211. data/tasks/example_viewer.html.erb +0 -172
  212. data/tasks/install.rake +0 -29
  213. data/tasks/resources/build.yml.erb +0 -55
  214. data/tasks/resources/index.html.erb +0 -44
  215. data/tasks/resources/rubies.yml +0 -7
  216. data/tasks/steep.rake +0 -11
  217. /data/{vendor/goodcop/base.yml → lib/rooibos/rubocop.yml} +0 -0
@@ -165,7 +165,7 @@ module Rooibos
165
165
  # SPDX-SnippetEnd
166
166
  #++
167
167
  def self.normalize_init(result)
168
- normalize_update_return(result, nil)
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(:Init, -> { [model, command] })
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
- if Proc === @fragment::Init or Method === @fragment::Init
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
- -> { @fragment::Model.new }
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.capitalize} is not Ractor-shareable. Use Ractor.make_shareable or Object#freeze."
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 = normalize_update_return(@update.call(message, @model), @model)
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 = normalize_update_return(result, @model)
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
@@ -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
@@ -8,5 +8,5 @@
8
8
  module Rooibos
9
9
  # The version of this gem.
10
10
  # See https://semver.org/spec/v2.0.0.html
11
- VERSION = "0.6.2"
11
+ VERSION = "0.7.0"
12
12
  end
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
@@ -29,6 +29,7 @@ module Rooibos
29
29
  def self.exe_template: (String, String) -> String
30
30
  def self.app_template: (String, String) -> String
31
31
  def self.test_template: (String, String) -> String
32
+ def self.rubocop_template: () -> String
32
33
  end
33
34
 
34
35
  module Run
@@ -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
@@ -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