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.
- checksums.yaml +7 -0
- data/.builds/ruby-3.2.yml +51 -0
- data/.builds/ruby-3.3.yml +51 -0
- data/.builds/ruby-3.4.yml +51 -0
- data/.builds/ruby-4.0.0.yml +51 -0
- data/.pre-commit-config.yaml +16 -0
- data/.rubocop.yml +8 -0
- data/AGENTS.md +108 -0
- data/CHANGELOG.md +214 -0
- data/LICENSE +304 -0
- data/LICENSES/AGPL-3.0-or-later.txt +235 -0
- data/LICENSES/CC-BY-SA-4.0.txt +170 -0
- data/LICENSES/CC0-1.0.txt +121 -0
- data/LICENSES/LGPL-3.0-or-later.txt +304 -0
- data/LICENSES/MIT-0.txt +16 -0
- data/LICENSES/MIT.txt +18 -0
- data/README.md +183 -0
- data/REUSE.toml +24 -0
- data/Rakefile +16 -0
- data/Steepfile +13 -0
- data/doc/concepts/application_architecture.md +197 -0
- data/doc/concepts/application_testing.md +49 -0
- data/doc/concepts/async_work.md +164 -0
- data/doc/concepts/commands.md +530 -0
- data/doc/concepts/message_processing.md +51 -0
- data/doc/contributors/WIP/decomposition_strategies_analysis.md +258 -0
- data/doc/contributors/WIP/implementation_plan.md +409 -0
- data/doc/contributors/WIP/init_callable_proposal.md +344 -0
- data/doc/contributors/WIP/mvu_tea_implementations_research.md +373 -0
- data/doc/contributors/WIP/runtime_refactoring_status.md +47 -0
- data/doc/contributors/WIP/task.md +36 -0
- data/doc/contributors/WIP/v0.4.0_todo.md +468 -0
- data/doc/contributors/design/commands_and_outlets.md +214 -0
- data/doc/contributors/kit-no-outlet.md +238 -0
- data/doc/contributors/priorities.md +38 -0
- data/doc/custom.css +22 -0
- data/doc/getting_started/quickstart.md +56 -0
- data/doc/images/.gitkeep +0 -0
- data/doc/images/verify_readme_usage.png +0 -0
- data/doc/images/widget_cmd_exec.png +0 -0
- data/doc/index.md +25 -0
- data/examples/app_fractal_dashboard/README.md +60 -0
- data/examples/app_fractal_dashboard/app.rb +63 -0
- data/examples/app_fractal_dashboard/dashboard/base.rb +73 -0
- data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +86 -0
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +87 -0
- data/examples/app_fractal_dashboard/dashboard/update_router.rb +43 -0
- data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +81 -0
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
- data/examples/app_fractal_dashboard/fragments/custom_shell_output.rb +90 -0
- data/examples/app_fractal_dashboard/fragments/disk_usage.rb +47 -0
- data/examples/app_fractal_dashboard/fragments/network_panel.rb +45 -0
- data/examples/app_fractal_dashboard/fragments/ping.rb +47 -0
- data/examples/app_fractal_dashboard/fragments/stats_panel.rb +45 -0
- data/examples/app_fractal_dashboard/fragments/system_info.rb +47 -0
- data/examples/app_fractal_dashboard/fragments/uptime.rb +47 -0
- data/examples/verify_readme_usage/README.md +54 -0
- data/examples/verify_readme_usage/app.rb +47 -0
- data/examples/widget_command_system/README.md +70 -0
- data/examples/widget_command_system/app.rb +132 -0
- data/exe/.gitkeep +0 -0
- data/lib/rooibos/command/all.rb +69 -0
- data/lib/rooibos/command/batch.rb +77 -0
- data/lib/rooibos/command/custom.rb +104 -0
- data/lib/rooibos/command/http.rb +192 -0
- data/lib/rooibos/command/lifecycle.rb +134 -0
- data/lib/rooibos/command/outlet.rb +157 -0
- data/lib/rooibos/command/wait.rb +80 -0
- data/lib/rooibos/command.rb +546 -0
- data/lib/rooibos/error.rb +55 -0
- data/lib/rooibos/message/all.rb +45 -0
- data/lib/rooibos/message/http_response.rb +61 -0
- data/lib/rooibos/message/system/batch.rb +61 -0
- data/lib/rooibos/message/system/stream.rb +67 -0
- data/lib/rooibos/message/timer.rb +46 -0
- data/lib/rooibos/message.rb +38 -0
- data/lib/rooibos/router.rb +403 -0
- data/lib/rooibos/runtime.rb +396 -0
- data/lib/rooibos/shortcuts.rb +49 -0
- data/lib/rooibos/test_helper.rb +56 -0
- data/lib/rooibos/version.rb +12 -0
- data/lib/rooibos.rb +121 -0
- data/mise.toml +8 -0
- data/rbs_collection.lock.yaml +108 -0
- data/rbs_collection.yaml +15 -0
- data/sig/concurrent.rbs +72 -0
- data/sig/examples/verify_readme_usage/app.rbs +19 -0
- data/sig/examples/widget_command_system/app.rbs +26 -0
- data/sig/open3.rbs +17 -0
- data/sig/rooibos/command.rbs +265 -0
- data/sig/rooibos/error.rbs +13 -0
- data/sig/rooibos/message.rbs +121 -0
- data/sig/rooibos/router.rbs +153 -0
- data/sig/rooibos/runtime.rbs +75 -0
- data/sig/rooibos/shortcuts.rbs +16 -0
- data/sig/rooibos/test_helper.rbs +10 -0
- data/sig/rooibos/version.rbs +8 -0
- data/sig/rooibos.rbs +46 -0
- data/tasks/example_viewer.html.erb +172 -0
- data/tasks/resources/build.yml.erb +53 -0
- data/tasks/resources/index.html.erb +44 -0
- data/tasks/resources/rubies.yml +7 -0
- data/tasks/steep.rake +11 -0
- data/vendor/goodcop/base.yml +1047 -0
- 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
|