rooibos 0.5.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 (105) hide show
  1. checksums.yaml +7 -0
  2. data/.builds/ruby-3.2.yml +51 -0
  3. data/.builds/ruby-3.3.yml +51 -0
  4. data/.builds/ruby-3.4.yml +51 -0
  5. data/.builds/ruby-4.0.0.yml +51 -0
  6. data/.pre-commit-config.yaml +16 -0
  7. data/.rubocop.yml +8 -0
  8. data/AGENTS.md +108 -0
  9. data/CHANGELOG.md +214 -0
  10. data/LICENSE +304 -0
  11. data/LICENSES/AGPL-3.0-or-later.txt +235 -0
  12. data/LICENSES/CC-BY-SA-4.0.txt +170 -0
  13. data/LICENSES/CC0-1.0.txt +121 -0
  14. data/LICENSES/LGPL-3.0-or-later.txt +304 -0
  15. data/LICENSES/MIT-0.txt +16 -0
  16. data/LICENSES/MIT.txt +18 -0
  17. data/README.md +183 -0
  18. data/REUSE.toml +24 -0
  19. data/Rakefile +16 -0
  20. data/Steepfile +13 -0
  21. data/doc/concepts/application_architecture.md +197 -0
  22. data/doc/concepts/application_testing.md +49 -0
  23. data/doc/concepts/async_work.md +164 -0
  24. data/doc/concepts/commands.md +530 -0
  25. data/doc/concepts/message_processing.md +51 -0
  26. data/doc/contributors/WIP/decomposition_strategies_analysis.md +258 -0
  27. data/doc/contributors/WIP/implementation_plan.md +409 -0
  28. data/doc/contributors/WIP/init_callable_proposal.md +344 -0
  29. data/doc/contributors/WIP/mvu_tea_implementations_research.md +373 -0
  30. data/doc/contributors/WIP/runtime_refactoring_status.md +47 -0
  31. data/doc/contributors/WIP/task.md +36 -0
  32. data/doc/contributors/WIP/v0.4.0_todo.md +468 -0
  33. data/doc/contributors/design/commands_and_outlets.md +214 -0
  34. data/doc/contributors/kit-no-outlet.md +238 -0
  35. data/doc/contributors/priorities.md +38 -0
  36. data/doc/custom.css +22 -0
  37. data/doc/getting_started/quickstart.md +56 -0
  38. data/doc/images/.gitkeep +0 -0
  39. data/doc/images/verify_readme_usage.png +0 -0
  40. data/doc/images/widget_cmd_exec.png +0 -0
  41. data/doc/index.md +25 -0
  42. data/examples/app_fractal_dashboard/README.md +60 -0
  43. data/examples/app_fractal_dashboard/app.rb +63 -0
  44. data/examples/app_fractal_dashboard/dashboard/base.rb +73 -0
  45. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +86 -0
  46. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +87 -0
  47. data/examples/app_fractal_dashboard/dashboard/update_router.rb +43 -0
  48. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +81 -0
  49. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
  50. data/examples/app_fractal_dashboard/fragments/custom_shell_output.rb +90 -0
  51. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +47 -0
  52. data/examples/app_fractal_dashboard/fragments/network_panel.rb +45 -0
  53. data/examples/app_fractal_dashboard/fragments/ping.rb +47 -0
  54. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +45 -0
  55. data/examples/app_fractal_dashboard/fragments/system_info.rb +47 -0
  56. data/examples/app_fractal_dashboard/fragments/uptime.rb +47 -0
  57. data/examples/verify_readme_usage/README.md +54 -0
  58. data/examples/verify_readme_usage/app.rb +47 -0
  59. data/examples/widget_command_system/README.md +70 -0
  60. data/examples/widget_command_system/app.rb +132 -0
  61. data/exe/.gitkeep +0 -0
  62. data/lib/rooibos/command/all.rb +69 -0
  63. data/lib/rooibos/command/batch.rb +77 -0
  64. data/lib/rooibos/command/custom.rb +104 -0
  65. data/lib/rooibos/command/http.rb +192 -0
  66. data/lib/rooibos/command/lifecycle.rb +134 -0
  67. data/lib/rooibos/command/outlet.rb +157 -0
  68. data/lib/rooibos/command/wait.rb +80 -0
  69. data/lib/rooibos/command.rb +546 -0
  70. data/lib/rooibos/error.rb +55 -0
  71. data/lib/rooibos/message/all.rb +45 -0
  72. data/lib/rooibos/message/http_response.rb +61 -0
  73. data/lib/rooibos/message/system/batch.rb +61 -0
  74. data/lib/rooibos/message/system/stream.rb +67 -0
  75. data/lib/rooibos/message/timer.rb +46 -0
  76. data/lib/rooibos/message.rb +38 -0
  77. data/lib/rooibos/router.rb +403 -0
  78. data/lib/rooibos/runtime.rb +396 -0
  79. data/lib/rooibos/shortcuts.rb +49 -0
  80. data/lib/rooibos/test_helper.rb +56 -0
  81. data/lib/rooibos/version.rb +12 -0
  82. data/lib/rooibos.rb +121 -0
  83. data/mise.toml +8 -0
  84. data/rbs_collection.lock.yaml +108 -0
  85. data/rbs_collection.yaml +15 -0
  86. data/sig/concurrent.rbs +72 -0
  87. data/sig/examples/verify_readme_usage/app.rbs +19 -0
  88. data/sig/examples/widget_command_system/app.rbs +26 -0
  89. data/sig/open3.rbs +17 -0
  90. data/sig/rooibos/command.rbs +265 -0
  91. data/sig/rooibos/error.rbs +13 -0
  92. data/sig/rooibos/message.rbs +121 -0
  93. data/sig/rooibos/router.rbs +153 -0
  94. data/sig/rooibos/runtime.rbs +75 -0
  95. data/sig/rooibos/shortcuts.rbs +16 -0
  96. data/sig/rooibos/test_helper.rbs +10 -0
  97. data/sig/rooibos/version.rbs +8 -0
  98. data/sig/rooibos.rbs +46 -0
  99. data/tasks/example_viewer.html.erb +172 -0
  100. data/tasks/resources/build.yml.erb +53 -0
  101. data/tasks/resources/index.html.erb +44 -0
  102. data/tasks/resources/rubies.yml +7 -0
  103. data/tasks/steep.rake +11 -0
  104. data/vendor/goodcop/base.yml +1047 -0
  105. metadata +241 -0
@@ -0,0 +1,396 @@
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
+ require "ratatui_ruby"
9
+ require "concurrent-edge"
10
+
11
+ module Rooibos
12
+ # Runs the Model-View-Update event loop.
13
+ #
14
+ # Applications need a render loop. You poll events, update state, redraw. Every frame.
15
+ # The boilerplate is tedious and error-prone.
16
+ #
17
+ # This class handles the loop. You provide the model, view, and update. It handles the rest.
18
+ #
19
+ # Use it to build applications with predictable state.
20
+ #
21
+ # === Example
22
+ #
23
+ #--
24
+ # SPDX-SnippetBegin
25
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
26
+ # SPDX-License-Identifier: MIT-0
27
+ #++
28
+ # Rooibos.run(
29
+ # model: { count: 0 }.freeze,
30
+ # view: ->(model, tui) { tui.paragraph(text: model[:count].to_s) },
31
+ # update: ->(message, model) { message.q? ? [model, Command.exit] : [model, nil] }
32
+ # )
33
+ #--
34
+ # SPDX-SnippetEnd
35
+ #++
36
+ class Runtime
37
+ # Starts the MVU event loop.
38
+ #
39
+ # Runs until the update function returns a <tt>Command.exit</tt> command.
40
+ #
41
+ # == Root Fragment with Init
42
+ #
43
+ # Pass a fragment module with <tt>Init</tt>, <tt>Update</tt>, and <tt>View</tt> constants.
44
+ # This allows your application to do work at startup, and your Update will be called with the result.
45
+ # It also allows you to create a more complex model with access to <tt>ARGV</tt> and <tt>ENV</tt>.
46
+ #
47
+ # module MyApp
48
+ # # Init is any callable, and returns an immutable Model and/or Command, according to your application's needs.
49
+ # # The Model is your application's initial state, and the Command is any command to run at startup.
50
+ # Init = -> () {
51
+ # # To do work at startup:
52
+ # return [Data.define(:count).new(count: 0), Command.http("https://api.example.com/data")]
53
+ # # To start idle:
54
+ # return Data.define(:count).new(count: 0)
55
+ # }
56
+ #
57
+ # # Update has access to a single Message, and your fragment's latest immutable Model.
58
+ # # It returns a new Model and/or a Command, according to your application's needs.
59
+ # Update = ->(message, model) {
60
+ # [model, Command.exit]
61
+ # }
62
+ #
63
+ # # View has access to your fragment's latest immutable Model, and a RatatuiRuby::TUI.
64
+ # # It returns a tree of RatatuiRuby::Widget and/or Custom Widgets.
65
+ # View = ->(model, tui) {
66
+ # tui.paragraph(text: model.count.to_s)
67
+ # }
68
+ # end
69
+ #
70
+ # Rooibos.run(MyApp)
71
+ #
72
+ # == Root Fragment with auto-Init
73
+ #
74
+ # Pass a fragment module with a <tt>Model</tt> class and <tt>Update</tt> and <tt>View</tt> constants.
75
+ # Your application will be idle until a RatatuiRuby::Event message is sent to your Update.
76
+ #
77
+ # module MyApp
78
+ # # Model is anything that responds to <tt>new</tt>.
79
+ # Model = Data.define(:count).new(count: 0)
80
+ #
81
+ # # Update has access to a single Message, and your fragment's latest immutable Model.
82
+ # # It returns a new Model and/or a Command, according to your application's needs.
83
+ # Update = ->(message, model) {
84
+ # [model, Command.exit]
85
+ # }
86
+ #
87
+ # # View has access to your fragment's latest immutable Model, and a RatatuiRuby::TUI.
88
+ # # It returns a tree of RatatuiRuby::Widget and/or Custom Widgets.
89
+ # View = ->(model, tui) {
90
+ # tui.paragraph(text: model.count.to_s)
91
+ # }
92
+ # end
93
+ #
94
+ # Rooibos.run(MyApp)
95
+ #
96
+ # == Explicit Parameters API
97
+ #
98
+ # A root fragment is not required. You can pass individual parameters:
99
+ #
100
+ # Rooibos.run(
101
+ # model: MyApp::Model.new(count: 0),
102
+ # view: MyApp::View,
103
+ # update: MyApp::Update,
104
+ # command: Command.http("https://api.example.com/data")
105
+ # )
106
+ #
107
+ # == Parameters
108
+ #
109
+ # [root_fragment] Module with Model, Init, Update, View constants. *Mutually exclusive with model/view/update.*
110
+ # [fps] Target frames per second for the application. Higher values feel more responsive, but may spike CPU usage.
111
+ # [model] Initial application state (immutable). *Required if fragment not provided.*
112
+ # [view] Callable receiving <tt>(model, tui)</tt>, returns a widget. *Required if fragment not provided.*
113
+ # [update] Callable receiving <tt>(message, model)</tt>, returns <tt>[new_model, command]</tt> or just <tt>new_model</tt>. *Required if fragment not provided.*
114
+ # [command] Optional callable to run at startup. Returns a message for update.
115
+ #
116
+ # == Raises
117
+ #
118
+ # [Rooibos::Error::Invariant] If both fragment and any of (model, view, update, command) are provided.
119
+ def self.run(root_fragment = nil, fps: 60, model: nil, view: nil, update: nil, command: nil)
120
+ @fragment = fragment_from_kwargs(root_fragment, model:, view:, update:, command:)
121
+ @view = @fragment::View
122
+ @update = @fragment::Update
123
+ @model, @command = init_callable.call
124
+ @timeout = 1 / fps
125
+
126
+ # commands do significant work, so they run off the main thread
127
+ validate_ractor_shareable!(@command, "command")
128
+ # models get passed to and from commands on other threads
129
+ validate_ractor_shareable!(@model, "model")
130
+ # views and updates run on the main thread, so they don't need to be shareable
131
+
132
+ start_runtime
133
+ end
134
+
135
+ # Normalizes Init callable return value to <tt>[model, command]</tt> tuple.
136
+ #
137
+ # Init callables return initial state and optional startup command. They can use
138
+ # DWIM (Do What I Mean) syntax: return just a model, just a command, or a full tuple.
139
+ #
140
+ # This method handles all formats. Use it when composing child fragment Inits.
141
+ #
142
+ # [result] The Init return value (model, command, or <tt>[model, command]</tt> tuple).
143
+ #
144
+ # === Examples
145
+ #
146
+ #--
147
+ # SPDX-SnippetBegin
148
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
149
+ # SPDX-License-Identifier: MIT-0
150
+ #++
151
+ # # Just model
152
+ # model, cmd = Rooibos.normalize_init(Model.new(...))
153
+ # # => [Model.new(...), nil]
154
+ #
155
+ # # Just command
156
+ # model, cmd = Rooibos.normalize_init(Command.http(...))
157
+ # # => [nil, Command.http(...)]
158
+ #
159
+ # # Tuple (already normalized)
160
+ # model, cmd = Rooibos.normalize_init([Model.new(...), Command.http(...)])
161
+ # # => [Model.new(...), Command.http(...)]
162
+ #--
163
+ # SPDX-SnippetEnd
164
+ #++
165
+ def self.normalize_init(result)
166
+ normalize_update_return(result, nil)
167
+ end
168
+
169
+ # Sentinel value avoids accidentally quitting from application exceptions.
170
+ QUIT = Object.new.freeze
171
+
172
+ class << self
173
+ private def start_runtime
174
+ @message_queue = Concurrent::Promises::Channel.new
175
+ @pending_futures = [] #: Array[Concurrent::Promises::Future[void]]
176
+ @lifecycle = Command::Lifecycle.new
177
+
178
+ catch(QUIT) do
179
+ dispatch_command
180
+ RatatuiRuby.run do |tui|
181
+ @tui = tui
182
+ loop do
183
+ draw_view
184
+ handle_ratatui_event
185
+ handle_sync
186
+ send_pending_messages
187
+ end
188
+ end
189
+ end
190
+
191
+ # Shutdown: signal all, wait grace periods (cooperative cancellation)
192
+ @lifecycle.shutdown
193
+
194
+ # Process any final messages from completed commands
195
+ send_pending_messages(dispatch: false)
196
+
197
+ @model
198
+ end
199
+
200
+ private def draw_view
201
+ @tui.draw do |frame|
202
+ widget = @view.call(@model, @tui)
203
+ validate_view_return!(widget)
204
+ frame.render_widget(widget, frame.area)
205
+ end
206
+ end
207
+
208
+ # Enforces invariants
209
+ private def fragment_from_kwargs(root_fragment, model: nil, view: nil, update: nil, command: nil)
210
+ if root_fragment
211
+ fragment_invariant!("model") if model
212
+ fragment_invariant!("view") if view
213
+ fragment_invariant!("update") if update
214
+ fragment_invariant!("command") if command
215
+ root_fragment
216
+ else
217
+ fragment = Module.new
218
+ fragment.const_set(:Model, model)
219
+ fragment.const_set(:View, view)
220
+ fragment.const_set(:Update, update)
221
+ fragment.const_set(:Init, -> { [model, command] })
222
+ fragment
223
+ end
224
+ end
225
+
226
+ # Helps app developers understand invariants
227
+ private def fragment_invariant!(param)
228
+ raise Rooibos::Error::Invariant, "Cannot provide both fragment: and #{param}: parameters. Use fragment-first API (fragment:) OR explicit parameters (model:, view:, update:, command:), not both."
229
+ end
230
+
231
+ private def init_callable
232
+ if @fragment.const_defined?(:Init)
233
+ if @fragment::Init.respond_to?(:call)
234
+ if Proc === @fragment::Init or Method === @fragment::Init
235
+ @fragment::Init
236
+ else
237
+ @fragment::Init.method(:call)
238
+ end
239
+ else
240
+ raise Rooibos::Error::Invariant, "Fragment::Init must respond to :call"
241
+ end
242
+ else
243
+ if @fragment.const_defined?(:Model)
244
+ if @fragment::Model.respond_to?(:new)
245
+ -> { @fragment::Model.new }
246
+ else
247
+ raise Rooibos::Error::Invariant, "Fragment::Model must respond to :new; or pass Fragment::Init instead"
248
+ end
249
+ else
250
+ raise Rooibos::Error::Invariant, "Fragment must define a Model class or an Init callable"
251
+ end
252
+ end
253
+ end
254
+
255
+ private
256
+
257
+ # Validates the view returned a widget.
258
+ #
259
+ # Views return widget trees. Returning +nil+ is a bug—you forgot to
260
+ # return something. For an intentionally empty screen, use TUI#clear.
261
+ private def validate_view_return!(widget)
262
+ return unless widget.nil?
263
+
264
+ raise Rooibos::Error::Invariant,
265
+ "View returned nil. Return a widget, or use TUI#clear for an empty screen."
266
+ end
267
+
268
+ # Extracts [model, command] from Update return value.
269
+ private def normalize_update_return(result, previous_model)
270
+ # Case 0: Nil result - preserve previous model
271
+ return [previous_model, nil] if result.nil?
272
+
273
+ # Case 1: Already a [model, command] tuple
274
+ if result.is_a?(Array) && (result.size == 2)
275
+ model, command = result
276
+ # Verify the second element is a valid command
277
+ if command.nil? ||
278
+ (command.respond_to?(:rooibos_command?) && command.rooibos_command?)
279
+
280
+ return [model, command]
281
+ end
282
+
283
+ # Debug-mode heuristic: warn about suspicious command-like objects
284
+ if RatatuiRuby::Debug.enabled? &&
285
+ command.respond_to?(:call) &&
286
+ !command.respond_to?(:rooibos_command?) &&
287
+ !Ractor.shareable?(result)
288
+
289
+ warn "WARNING: Update returned [model, #{command.class}] but #{command.class} " \
290
+ "responds to #call without #rooibos_command?. Did you forget to include Command::Custom? " \
291
+ "The tuple will be treated as the model, not as [model, command]. " \
292
+ "To suppress this warning if the array is your model, use Ractor.make_shareable on it. " \
293
+ "(#{caller.first})"
294
+ end
295
+
296
+ end
297
+
298
+ # Case 2: Result is a Command - use previous model
299
+ if result.respond_to?(:rooibos_command?) && result.rooibos_command?
300
+ command = result #: Rooibos::Command::execution
301
+ return [previous_model, command]
302
+ end
303
+
304
+ # Case 3: Result is the new model
305
+ [result, nil]
306
+ end
307
+
308
+ # Validates an object is Ractor-shareable (deeply frozen).
309
+ #
310
+ # Models and messages must be shareable for future Ractor support.
311
+ # Mutable objects cause race conditions. Freeze your data.
312
+ #
313
+ # Only enforced in debug mode (and tests). Production skips this check
314
+ # for performance; mutable objects will still cause bugs, but silently.
315
+ private def validate_ractor_shareable!(object, name)
316
+ return unless RatatuiRuby::Debug.enabled?
317
+ return if Ractor.shareable?(object)
318
+
319
+ raise Rooibos::Error::Invariant,
320
+ "#{name.capitalize} is not Ractor-shareable. Use Ractor.make_shareable or Object#freeze."
321
+ end
322
+
323
+ private def handle_ratatui_event
324
+ message = @tui.poll_event(timeout: @timeout)
325
+ return if message.none?
326
+
327
+ @model, @command = normalize_update_return(@update.call(message, @model), @model)
328
+ validate_ractor_shareable!(@model, "model")
329
+ throw QUIT if Command::Exit === @command
330
+ dispatch_command
331
+ end
332
+
333
+ # This must come *after* handle_ratatui_event so Sync waits for commands
334
+ # dispatched by the preceding event. For example, in a test:
335
+ #
336
+ # inject_key("a")
337
+ # inject_sync
338
+ #
339
+ # We need <kbd>a</kbd> to call the Update and queue its message before
340
+ # processing the Sync.
341
+ private def handle_sync
342
+ if RatatuiRuby::SyntheticEvents.pending?
343
+ synthetic = RatatuiRuby::SyntheticEvents.pop
344
+ if synthetic&.sync?
345
+ # Wait for all pending futures to complete
346
+ @pending_futures.each(&:wait)
347
+ @pending_futures.clear
348
+
349
+ # Yield to ensure any final queue writes are visible
350
+ Thread.pass
351
+
352
+ # Process all pending message queue items
353
+ send_pending_messages
354
+ end
355
+ end
356
+ end
357
+
358
+ QUEUE_EMPTY = Object.new.freeze
359
+ private_constant :QUEUE_EMPTY
360
+
361
+ private def send_pending_messages(dispatch: true)
362
+ loop do
363
+ background_message = @message_queue.try_pop(QUEUE_EMPTY)
364
+ break if background_message == QUEUE_EMPTY
365
+
366
+ result = @update.call(background_message, @model)
367
+ @model, @command = normalize_update_return(result, @model)
368
+ return unless dispatch
369
+
370
+ validate_ractor_shareable!(@model, "model")
371
+ throw QUIT if Command::Exit === @command
372
+
373
+ dispatch_command
374
+ end
375
+ end
376
+
377
+ # Spawns a future and pushes results to the message queue.
378
+ # See Command.system for message formats.
379
+ private def dispatch_command
380
+ future = if @command.nil?
381
+ nil
382
+ elsif Command::Cancel === @command
383
+ @lifecycle.cancel(@command.handle)
384
+ nil
385
+ elsif @command.respond_to?(:rooibos_command?) && @command.rooibos_command?
386
+ entry = @lifecycle.run_async(@command, @message_queue)
387
+ entry.future
388
+ else
389
+ raise Rooibos::Error::Invariant,
390
+ "#{@command.inspect} is not a valid Rooibos command."
391
+ end
392
+ @pending_futures << future if future
393
+ end
394
+ end
395
+ end
396
+ end
@@ -0,0 +1,49 @@
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
+ require_relative "command"
9
+
10
+ module Rooibos
11
+ # Convenient short aliases for Rooibos APIs.
12
+ #
13
+ # The library uses intention-revealing names that match Ruby built-ins:
14
+ # +Command+, +System+, +Exit+. These are great for readability.
15
+ #
16
+ # This module provides the short aliases common in TEA-style code:
17
+ #
18
+ # === Example
19
+ #
20
+ # require "rooibos/shortcuts"
21
+ # include Rooibos::Shortcuts
22
+ #
23
+ # # Now use short names freely:
24
+ # Cmd.exit # → Command.exit
25
+ # Cmd.sh("ls", :files) # → Command.system("ls", :files)
26
+ # Cmd.map(child) { ... } # → Command.map(child) { ... }
27
+ module Shortcuts
28
+ # Short alias for +Command+.
29
+ module Cmd
30
+ # Creates an exit command.
31
+ # Alias for +Command.exit+.
32
+ def self.exit
33
+ Command.exit
34
+ end
35
+
36
+ # Creates a shell execution command.
37
+ # Short alias for +Command.system+.
38
+ def self.sh(command, envelope)
39
+ Command.system(command, envelope)
40
+ end
41
+
42
+ # Creates a mapped command.
43
+ # Short alias for +Command.map+.
44
+ def self.map(inner_command, &mapper)
45
+ Command.map(inner_command, &mapper)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,56 @@
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
+ require "ratatui_ruby/test_helper"
9
+
10
+ module Rooibos
11
+ # Test helpers for Rooibos command validation.
12
+ #
13
+ # This module extends RatatuiRuby::TestHelper with Rooibos-specific assertions
14
+ # for verifying custom commands implement the proper protocol.
15
+ module TestHelper
16
+ # Validates a command implements the Rooibos command protocol.
17
+ #
18
+ # Custom commands run in background threads. They dispatch work and send messages.
19
+ # Forgetting to include \<tt>Command::Custom\</tt> breaks dispatch. The runtime
20
+ # treats \<tt>[model, bad_command]\</tt> as a model, not a tuple. Tests fail with
21
+ # confusing Ractor shareability errors.
22
+ #
23
+ # This method checks the protocol. Call it in tests to catch mistakes early.
24
+ #
25
+ # [command] The command object to validate.
26
+ #
27
+ # === Example
28
+ #
29
+ # def test_websocket_command_protocol
30
+ # cmd = WebSocketCommand.new("wss://example.com")
31
+ # validate_rooibos_command!(cmd)
32
+ # end
33
+ def validate_rooibos_command!(command)
34
+ unless command.respond_to?(:rooibos_command?)
35
+ raise Rooibos::Error::Invariant,
36
+ "#{command.class} does not respond to #rooibos_command?. " \
37
+ "Include Command::Custom or implement the rooibos_command? predicate."
38
+ end
39
+
40
+ unless command.respond_to?(:call)
41
+ raise Rooibos::Error::Invariant,
42
+ "#{command.class} does not respond to #call. " \
43
+ "Implement call(out, token) to execute the command."
44
+ end
45
+
46
+ unless command.respond_to?(:rooibos_cancellation_grace_period)
47
+ raise Rooibos::Error::Invariant,
48
+ "#{command.class} does not respond to #rooibos_cancellation_grace_period. " \
49
+ "Include Command::Custom or implement this method."
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ # Attach Rooibos test helpers to RatatuiRuby::TestHelper
56
+ RatatuiRuby::TestHelper.include(Rooibos::TestHelper)
@@ -0,0 +1,12 @@
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
+ # The version of this gem.
10
+ # See https://semver.org/spec/v2.0.0.html
11
+ VERSION = "0.5.0"
12
+ end
data/lib/rooibos.rb ADDED
@@ -0,0 +1,121 @@
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
+ require_relative "rooibos/version"
9
+ require_relative "rooibos/error"
10
+ require_relative "rooibos/message"
11
+ require_relative "rooibos/command"
12
+ require_relative "rooibos/runtime"
13
+ require_relative "rooibos/router"
14
+
15
+ # The Elm Architecture for Ruby.
16
+ #
17
+ # Building TUI applications means managing state, events, and rendering. Mixing them leads to
18
+ # spaghetti code. Bugs hide in the tangles.
19
+ #
20
+ # This module implements The Elm Architecture (TEA). It separates your application into three
21
+ # pure functions: model, view, and update. The runtime handles the rest.
22
+ #
23
+ # Use it to build applications with predictable, testable state management.
24
+ module Rooibos
25
+ # Starts the MVU event loop.
26
+ #
27
+ # Convenience delegator to Runtime.run. See Runtime for full documentation.
28
+ def self.run(root_fragment = nil, **)
29
+ Runtime.run(root_fragment, **)
30
+ end
31
+
32
+ # Normalizes Init callable return value to <tt>[model, command]</tt> tuple.
33
+ #
34
+ # Init callables use DWIM syntax. They can return just a model, just a command,
35
+ # or a full <tt>[model, command]</tt> tuple.
36
+ #
37
+ # This method handles all formats. Use it when composing child fragment Inits
38
+ # in fractal architecture.
39
+ #
40
+ # [result] The Init return value.
41
+ #
42
+ # === Examples
43
+ #
44
+ #--
45
+ # SPDX-SnippetBegin
46
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
47
+ # SPDX-License-Identifier: MIT-0
48
+ #++
49
+ # # Parent fragment composes children
50
+ # Init = ->(theme:) do
51
+ # stats_model, stats_cmd = Rooibos.normalize_init(StatsPanel::Init.(theme: theme))
52
+ # network_model, network_cmd = Rooibos.normalize_init(NetworkPanel::Init.(theme: theme))
53
+ #
54
+ # model = Model.new(stats: stats_model, network: network_model)
55
+ # command = Command.batch(stats_cmd, network_cmd)
56
+ # [model, command]
57
+ # end
58
+ #--
59
+ # SPDX-SnippetEnd
60
+ #++
61
+ def self.normalize_init(result)
62
+ Runtime.normalize_init(result)
63
+ end
64
+
65
+ # Wraps a command with a routing prefix.
66
+ #
67
+ # Parent fragments trigger child fragment commands. The results need routing back
68
+ # to the correct child fragment. Manually wrapping every command is tedious.
69
+ #
70
+ # This method prefixes command results automatically. Use it to route
71
+ # child fragment command results in Fractal Architecture.
72
+ #
73
+ # [command] The child fragment command to wrap.
74
+ # [prefix] Symbol prepended to results (e.g., <tt>:stats</tt>).
75
+ #
76
+ # === Example
77
+ #
78
+ # # Verbose:
79
+ # Command.map(child_fragment.fetch_command) { |r| [:stats, *r] }
80
+ #
81
+ # # Concise:
82
+ # Rooibos.route(child_fragment.fetch_command, :stats)
83
+ def self.route(command, prefix)
84
+ Command.map(command) { |result| [prefix, *result] }
85
+ end
86
+
87
+ # Delegates a prefixed message to a child fragment's UPDATE.
88
+ #
89
+ # Parent fragment UPDATE functions route messages to child fragments. Each route
90
+ # requires pattern matching, calling the child, and rewrapping any returned
91
+ # command. The boilerplate adds up fast.
92
+ #
93
+ # This method handles the dispatch. It checks the prefix, calls the child,
94
+ # and wraps any command. Returns <tt>nil</tt> if the prefix does not match.
95
+ #
96
+ # [message] Incoming message (e.g., <tt>[:stats, :system_info, {...}]</tt>).
97
+ # [prefix] Expected prefix symbol (e.g., <tt>:stats</tt>).
98
+ # [child_update] The child's UPDATE callable.
99
+ # [child_model] The child's current model.
100
+ #
101
+ # === Example
102
+ #
103
+ # # Verbose:
104
+ # case message
105
+ # in [:stats, *rest]
106
+ # new_child, cmd = StatsPanel::UPDATE.call(rest, model.stats)
107
+ # mapped = cmd ? Command.map(cmd) { |r| [:stats, *r] } : nil
108
+ # [new_child, mapped]
109
+ # end
110
+ #
111
+ # # Concise:
112
+ # Rooibos.delegate(message, :stats, StatsPanel::UPDATE, model.stats)
113
+ def self.delegate(message, prefix, child_update, child_model)
114
+ return nil unless message.is_a?(Array) && message.first == prefix
115
+
116
+ rest = message[1..]
117
+ new_child, command = child_update.call(rest, child_model)
118
+ wrapped = command ? route(command, prefix) : nil
119
+ [new_child, wrapped]
120
+ end
121
+ end
data/mise.toml ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
2
+ # SPDX-License-Identifier: LGPL-3.0-or-later
3
+
4
+ [tools]
5
+ ruby = "4.0.0"
6
+ rust = "1.91.1"
7
+ python = "3.12"
8
+ pre-commit = "latest"