rooibos 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.builds/ruby-3.2.yml +9 -5
- data/.builds/ruby-3.3.yml +9 -5
- data/.builds/ruby-3.4.yml +9 -5
- data/.builds/ruby-4.0.0.yml +9 -5
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +46 -0
- data/README.md +2 -2
- data/README.rdoc +374 -0
- data/REUSE.toml +5 -0
- data/Rakefile +1 -1
- data/doc/best_practices/forms_and_validation.md +20 -0
- data/doc/best_practices/http_workflows.md +20 -0
- data/doc/best_practices/index.md +26 -0
- data/doc/best_practices/lists_and_tables.md +20 -0
- data/doc/best_practices/modal_dialogs.md +20 -0
- data/doc/best_practices/no_stateful_widgets.md +184 -0
- data/doc/best_practices/orchestration.md +20 -0
- data/doc/best_practices/streaming_data.md +20 -0
- data/doc/contributors/design/commands_and_outlets.md +1 -1
- data/doc/contributors/documentation_plan.md +616 -0
- data/doc/contributors/documentation_stub_audit.md +112 -0
- data/doc/contributors/documentation_style.md +275 -0
- data/doc/contributors/e2e_pty.md +168 -0
- data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +70 -0
- data/doc/contributors/specs/file_browser.md +789 -0
- data/doc/contributors/specs/file_browser_stories.md +774 -0
- data/doc/contributors/specs/tutorials_to_stories.rb +167 -0
- data/doc/contributors/todo/scrollbar.md +118 -0
- data/doc/contributors/tutorial_old/01_project_setup.md +20 -0
- data/doc/contributors/tutorial_old/02_hello_world.md +24 -0
- data/doc/contributors/tutorial_old/03_adding_state.md +26 -0
- data/doc/contributors/tutorial_old/06_organizing_your_code.md +20 -0
- data/doc/contributors/tutorial_old/07_your_first_command.md +21 -0
- data/doc/contributors/tutorial_old/08_the_preview_pane.md +20 -0
- data/doc/contributors/tutorial_old/09_loading_states.md +20 -0
- data/doc/contributors/tutorial_old/10_testing_your_app.md +20 -0
- data/doc/contributors/tutorial_old/11_polish_and_refine.md +20 -0
- data/doc/contributors/tutorial_old/12_going_further.md +20 -0
- data/doc/contributors/tutorial_old/index.md +20 -0
- data/doc/essentials/commands.md +20 -0
- data/doc/essentials/index.md +31 -0
- data/doc/essentials/messages.md +21 -0
- data/doc/essentials/models.md +21 -0
- data/doc/essentials/shortcuts.md +19 -0
- data/doc/essentials/the_elm_architecture.md +24 -0
- data/doc/essentials/the_runtime.md +21 -0
- data/doc/essentials/update_functions.md +20 -0
- data/doc/essentials/views.md +22 -0
- data/doc/getting_started/for_go_developers.md +16 -0
- data/doc/getting_started/for_python_developers.md +16 -0
- data/doc/getting_started/for_react_developers.md +17 -0
- data/doc/getting_started/index.md +52 -0
- data/doc/getting_started/install.md +20 -0
- data/doc/getting_started/quickstart.md +9 -45
- data/doc/getting_started/ruby_primer.md +19 -0
- data/doc/getting_started/why_rooibos.md +20 -0
- data/doc/index.md +79 -11
- data/doc/scaling_up/async_patterns.md +20 -0
- data/doc/scaling_up/command_composition.md +20 -0
- data/doc/scaling_up/custom_commands.md +21 -0
- data/doc/scaling_up/fractal_architecture.md +20 -0
- data/doc/scaling_up/index.md +30 -0
- data/doc/scaling_up/message_routing.md +20 -0
- data/doc/scaling_up/ractor_safety.md +20 -0
- data/doc/scaling_up/testing.md +21 -0
- data/doc/troubleshooting/common_errors.md +20 -0
- data/doc/troubleshooting/debugging.md +21 -0
- data/doc/troubleshooting/index.md +23 -0
- data/doc/troubleshooting/performance.md +20 -0
- data/doc/tutorial/01_project_setup.md +44 -0
- data/doc/tutorial/02_hello_world.md +45 -0
- data/doc/tutorial/03_static_file_list.md +44 -0
- data/doc/tutorial/04_arrow_navigation.md +47 -0
- data/doc/tutorial/05_real_files.md +45 -0
- data/doc/tutorial/06_safe_refactoring.md +21 -0
- data/doc/tutorial/07_red_first_tdd.md +26 -0
- data/doc/tutorial/08_file_metadata.md +42 -0
- data/doc/tutorial/09_text_preview.md +44 -0
- data/doc/tutorial/10_directory_tree.md +42 -0
- data/doc/tutorial/11_pane_focus.md +40 -0
- data/doc/tutorial/12_sorting.md +41 -0
- data/doc/tutorial/13_filtering.md +43 -0
- data/doc/tutorial/14_toggle_hidden.md +41 -0
- data/doc/tutorial/15_text_input_widget.md +43 -0
- data/doc/tutorial/16_rename_files.md +42 -0
- data/doc/tutorial/17_confirmation_dialogs.md +43 -0
- data/doc/tutorial/18_progress_indicators.md +43 -0
- data/doc/tutorial/19_atomic_operations.md +42 -0
- data/doc/tutorial/20_external_editor.md +42 -0
- data/doc/tutorial/21_modal_overlays.md +41 -0
- data/doc/tutorial/22_error_handling.md +43 -0
- data/doc/tutorial/23_terminal_capabilities.md +53 -0
- data/doc/tutorial/24_mouse_events.md +43 -0
- data/doc/tutorial/25_resize_events.md +43 -0
- data/doc/tutorial/26_loading_states.md +42 -0
- data/doc/tutorial/27_performance.md +43 -0
- data/doc/tutorial/28_color_schemes.md +47 -0
- data/doc/tutorial/29_configuration.md +124 -0
- data/doc/tutorial/30_going_further.md +17 -0
- data/doc/tutorial/index.md +17 -0
- data/examples/app_file_browser/app.rb +40 -0
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
- data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
- data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
- data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
- data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
- data/examples/verify_website_first_app/app.rb +85 -0
- data/examples/verify_website_hello_mvu/app.rb +31 -0
- data/examples/widget_command_system/app.rb +15 -13
- data/exe/rooibos +10 -0
- data/generate_tutorial_stubs.rb +126 -0
- data/lib/rooibos/cli/commands/new.rb +373 -0
- data/lib/rooibos/cli/commands/run.rb +98 -0
- data/lib/rooibos/cli.rb +78 -0
- data/lib/rooibos/command/all.rb +25 -20
- data/lib/rooibos/command/batch.rb +26 -25
- data/lib/rooibos/command/custom.rb +84 -1
- data/lib/rooibos/command/http.rb +59 -55
- data/lib/rooibos/command/lifecycle.rb +5 -5
- data/lib/rooibos/command/open.rb +86 -0
- data/lib/rooibos/command/outlet.rb +105 -3
- data/lib/rooibos/command/wait.rb +5 -5
- data/lib/rooibos/command.rb +57 -74
- data/lib/rooibos/message/batch.rb +39 -0
- data/lib/rooibos/message/canceled.rb +51 -0
- data/lib/rooibos/message/error.rb +48 -0
- data/lib/rooibos/message/open.rb +30 -0
- data/lib/rooibos/message.rb +84 -4
- data/lib/rooibos/router.rb +11 -14
- data/lib/rooibos/runtime.rb +40 -43
- data/lib/rooibos/shortcuts.rb +47 -0
- data/lib/rooibos/test_helper.rb +71 -6
- data/lib/rooibos/version.rb +1 -1
- data/lib/rooibos/welcome.rb +237 -0
- data/lib/rooibos.rb +4 -3
- data/mise.toml +1 -1
- data/rbs_collection.lock.yaml +2 -2
- data/sig/concurrent.rbs +3 -0
- data/sig/gem.rbs +20 -0
- data/sig/rooibos/cli.rbs +42 -0
- data/sig/rooibos/command.rbs +48 -0
- data/sig/rooibos/message.rbs +60 -0
- data/sig/rooibos/shortcuts.rbs +14 -0
- data/sig/rooibos/test_helper.rbs +6 -2
- data/sig/rooibos/welcome.rbs +75 -0
- data/tasks/install.rake +29 -0
- data/tasks/resources/build.yml.erb +2 -0
- metadata +272 -38
- data/doc/concepts/application_architecture.md +0 -197
- data/doc/concepts/application_testing.md +0 -49
- data/doc/concepts/async_work.md +0 -164
- data/doc/concepts/commands.md +0 -530
- data/doc/concepts/message_processing.md +0 -51
- data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
- data/doc/contributors/WIP/implementation_plan.md +0 -409
- data/doc/contributors/WIP/init_callable_proposal.md +0 -344
- data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
- data/doc/contributors/WIP/task.md +0 -36
- data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
- data/doc/contributors/kit-no-outlet.md +0 -238
- data/doc/contributors/priorities.md +0 -38
- data/doc/images/.gitkeep +0 -0
- data/exe/.gitkeep +0 -0
- /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
data/lib/rooibos/runtime.rb
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
require "ratatui_ruby"
|
|
9
9
|
require "concurrent-edge"
|
|
10
10
|
|
|
11
|
+
# Enable inline sync mode for deterministic event ordering in tests.
|
|
12
|
+
# This ensures poll_event returns Event::Sync in sequence with key events.
|
|
13
|
+
RatatuiRuby::SyntheticEvents.inline_sync!
|
|
14
|
+
|
|
11
15
|
module Rooibos
|
|
12
16
|
# Runs the Model-View-Update event loop.
|
|
13
17
|
#
|
|
@@ -95,13 +99,17 @@ module Rooibos
|
|
|
95
99
|
#
|
|
96
100
|
# == Explicit Parameters API
|
|
97
101
|
#
|
|
98
|
-
#
|
|
102
|
+
# Tests need deterministic state. Init reads from the filesystem, network, or
|
|
103
|
+
# environment—sources that change between runs. Injecting a known model makes
|
|
104
|
+
# tests reproducible.
|
|
105
|
+
#
|
|
106
|
+
# Pass <tt>model:</tt>, <tt>view:</tt>, and <tt>update:</tt> directly. The runtime
|
|
107
|
+
# skips Init and uses your model as the starting state.
|
|
99
108
|
#
|
|
100
109
|
# Rooibos.run(
|
|
101
|
-
# model: MyApp::Model.new(count: 0),
|
|
110
|
+
# model: Ractor.make_shareable(MyApp::Model.new(count: 0)),
|
|
102
111
|
# view: MyApp::View,
|
|
103
|
-
# update: MyApp::Update
|
|
104
|
-
# command: Command.http("https://api.example.com/data")
|
|
112
|
+
# update: MyApp::Update
|
|
105
113
|
# )
|
|
106
114
|
#
|
|
107
115
|
# == Parameters
|
|
@@ -120,14 +128,8 @@ module Rooibos
|
|
|
120
128
|
@fragment = fragment_from_kwargs(root_fragment, model:, view:, update:, command:)
|
|
121
129
|
@view = @fragment::View
|
|
122
130
|
@update = @fragment::Update
|
|
123
|
-
@
|
|
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
|
+
@init_callable = init_callable
|
|
132
|
+
@timeout = 1.0 / fps
|
|
131
133
|
|
|
132
134
|
start_runtime
|
|
133
135
|
end
|
|
@@ -176,13 +178,18 @@ module Rooibos
|
|
|
176
178
|
@lifecycle = Command::Lifecycle.new
|
|
177
179
|
|
|
178
180
|
catch(QUIT) do
|
|
179
|
-
dispatch_command
|
|
180
181
|
RatatuiRuby.run do |tui|
|
|
181
182
|
@tui = tui
|
|
183
|
+
|
|
184
|
+
# Init runs after terminal is ready so it can query terminal_size, etc.
|
|
185
|
+
@model, @command = @init_callable.call
|
|
186
|
+
validate_ractor_shareable!(@command, "command")
|
|
187
|
+
validate_ractor_shareable!(@model, "model")
|
|
188
|
+
dispatch_command
|
|
189
|
+
|
|
182
190
|
loop do
|
|
183
191
|
draw_view
|
|
184
192
|
handle_ratatui_event
|
|
185
|
-
handle_sync
|
|
186
193
|
send_pending_messages
|
|
187
194
|
end
|
|
188
195
|
end
|
|
@@ -198,9 +205,12 @@ module Rooibos
|
|
|
198
205
|
end
|
|
199
206
|
|
|
200
207
|
private def draw_view
|
|
208
|
+
# Build widget tree OUTSIDE draw context - queries work here
|
|
209
|
+
widget = @view.call(@model, @tui)
|
|
210
|
+
validate_view_return!(widget)
|
|
211
|
+
|
|
212
|
+
# Render INSIDE draw context - only rendering happens here
|
|
201
213
|
@tui.draw do |frame|
|
|
202
|
-
widget = @view.call(@model, @tui)
|
|
203
|
-
validate_view_return!(widget)
|
|
204
214
|
frame.render_widget(widget, frame.area)
|
|
205
215
|
end
|
|
206
216
|
end
|
|
@@ -322,37 +332,22 @@ module Rooibos
|
|
|
322
332
|
|
|
323
333
|
private def handle_ratatui_event
|
|
324
334
|
message = @tui.poll_event(timeout: @timeout)
|
|
325
|
-
return if message.none?
|
|
335
|
+
return false if message.none?
|
|
336
|
+
|
|
337
|
+
# Handle sync events: wait for pending async work before continuing
|
|
338
|
+
if message.sync?
|
|
339
|
+
@pending_futures.each(&:wait)
|
|
340
|
+
@pending_futures.clear
|
|
341
|
+
Thread.pass
|
|
342
|
+
send_pending_messages
|
|
343
|
+
return true
|
|
344
|
+
end
|
|
326
345
|
|
|
327
346
|
@model, @command = normalize_update_return(@update.call(message, @model), @model)
|
|
328
347
|
validate_ractor_shareable!(@model, "model")
|
|
329
348
|
throw QUIT if Command::Exit === @command
|
|
330
349
|
dispatch_command
|
|
331
|
-
|
|
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
|
|
350
|
+
true # Event was processed
|
|
356
351
|
end
|
|
357
352
|
|
|
358
353
|
QUEUE_EMPTY = Object.new.freeze
|
|
@@ -380,7 +375,9 @@ module Rooibos
|
|
|
380
375
|
future = if @command.nil?
|
|
381
376
|
nil
|
|
382
377
|
elsif Command::Cancel === @command
|
|
383
|
-
@lifecycle.cancel(@command.handle)
|
|
378
|
+
entry = @lifecycle.cancel(@command.handle)
|
|
379
|
+
# Remove cancelled future from pending list so sync doesn't wait for it
|
|
380
|
+
@pending_futures.delete(entry.future) if entry
|
|
384
381
|
nil
|
|
385
382
|
elsif @command.respond_to?(:rooibos_command?) && @command.rooibos_command?
|
|
386
383
|
entry = @lifecycle.run_async(@command, @message_queue)
|
data/lib/rooibos/shortcuts.rb
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
#++
|
|
7
7
|
|
|
8
8
|
require_relative "command"
|
|
9
|
+
require_relative "message"
|
|
9
10
|
|
|
10
11
|
module Rooibos
|
|
11
12
|
# Convenient short aliases for Rooibos APIs.
|
|
@@ -45,5 +46,51 @@ module Rooibos
|
|
|
45
46
|
Command.map(inner_command, &mapper)
|
|
46
47
|
end
|
|
47
48
|
end
|
|
49
|
+
|
|
50
|
+
# Short aliases for +Message+ types.
|
|
51
|
+
#
|
|
52
|
+
# App developers pattern-match against message types frequently.
|
|
53
|
+
# The full names (+Rooibos::Message::HttpResponse+) are verbose.
|
|
54
|
+
# These shortcuts save characters and improve readability.
|
|
55
|
+
#
|
|
56
|
+
# === Example
|
|
57
|
+
#
|
|
58
|
+
# case message
|
|
59
|
+
# in Msg::Timer[envelope: :dismiss]
|
|
60
|
+
# [model.with(notification: nil), nil]
|
|
61
|
+
# in Msg::Http[status: 200, body:]
|
|
62
|
+
# [model.with(data: JSON.parse(body)), nil]
|
|
63
|
+
# in Msg::Sh::Batch[status: 0, stdout:]
|
|
64
|
+
# [model.with(output: stdout), nil]
|
|
65
|
+
# end
|
|
66
|
+
module Msg
|
|
67
|
+
# Timer message type.
|
|
68
|
+
# Alias for +Message::Timer+.
|
|
69
|
+
Timer = Message::Timer
|
|
70
|
+
|
|
71
|
+
# HTTP response message type.
|
|
72
|
+
# Alias for +Message::HttpResponse+.
|
|
73
|
+
Http = Message::HttpResponse
|
|
74
|
+
|
|
75
|
+
# Shell command message types.
|
|
76
|
+
# Mirrors +Cmd.sh+ for symmetry.
|
|
77
|
+
module Sh
|
|
78
|
+
# Batch mode shell output.
|
|
79
|
+
# Alias for +Message::System::Batch+.
|
|
80
|
+
Batch = Message::System::Batch
|
|
81
|
+
|
|
82
|
+
# Streaming mode shell output.
|
|
83
|
+
# Alias for +Message::System::Stream+.
|
|
84
|
+
Stream = Message::System::Stream
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Aggregated parallel results.
|
|
88
|
+
# Alias for +Message::All+.
|
|
89
|
+
All = Message::All
|
|
90
|
+
|
|
91
|
+
# Batch completion signal.
|
|
92
|
+
# Alias for +Message::Batch+.
|
|
93
|
+
Batch = Message::Batch
|
|
94
|
+
end
|
|
48
95
|
end
|
|
49
96
|
end
|
data/lib/rooibos/test_helper.rb
CHANGED
|
@@ -8,11 +8,33 @@
|
|
|
8
8
|
require "ratatui_ruby/test_helper"
|
|
9
9
|
|
|
10
10
|
module Rooibos
|
|
11
|
-
#
|
|
11
|
+
# Assertions and test utilities for Rooibos applications.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
13
|
+
# Custom commands run in background threads. Forgetting to include
|
|
14
|
+
# <tt>Command::Custom</tt> causes cryptic Ractor errors. Validating
|
|
15
|
+
# protocol compliance manually is tedious.
|
|
16
|
+
#
|
|
17
|
+
# This module provides Rooibos-specific assertions. It also includes
|
|
18
|
+
# {RatatuiRuby::TestHelper}[https://www.ratatui-ruby.dev/docs/v1.0/RatatuiRuby/TestHelper.html],
|
|
19
|
+
# giving you access to <tt>with_test_terminal</tt>, <tt>inject_key</tt>, etc.
|
|
20
|
+
#
|
|
21
|
+
# Use it in Minitest classes to validate commands and control test terminals.
|
|
22
|
+
#
|
|
23
|
+
# === Example
|
|
24
|
+
#
|
|
25
|
+
# class TestMyApp < Minitest::Test
|
|
26
|
+
# include Rooibos::TestHelper
|
|
27
|
+
#
|
|
28
|
+
# def test_app_exits_on_ctrl_c
|
|
29
|
+
# with_test_terminal do
|
|
30
|
+
# inject_key(:ctrl_c)
|
|
31
|
+
# Rooibos.run(MyApp)
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
15
35
|
module TestHelper
|
|
36
|
+
include RatatuiRuby::TestHelper
|
|
37
|
+
|
|
16
38
|
# Validates a command implements the Rooibos command protocol.
|
|
17
39
|
#
|
|
18
40
|
# Custom commands run in background threads. They dispatch work and send messages.
|
|
@@ -49,8 +71,51 @@ module Rooibos
|
|
|
49
71
|
"Include Command::Custom or implement this method."
|
|
50
72
|
end
|
|
51
73
|
end
|
|
74
|
+
|
|
75
|
+
# Fails if any Message::Error is present in the messages array.
|
|
76
|
+
#
|
|
77
|
+
# Call after running the runtime and before asserting on expected messages.
|
|
78
|
+
# This ensures tests fail fast with helpful error messages instead of
|
|
79
|
+
# silently passing when errors occur.
|
|
80
|
+
#
|
|
81
|
+
# [messages] Array of messages collected from the update function.
|
|
82
|
+
# [msg] Optional custom failure message prefix.
|
|
83
|
+
#
|
|
84
|
+
# === Example
|
|
85
|
+
#
|
|
86
|
+
# def test_dashboard_loads_data
|
|
87
|
+
# messages = []
|
|
88
|
+
# update = -> (msg, m) do
|
|
89
|
+
# # ... handle keys ...
|
|
90
|
+
# messages << msg
|
|
91
|
+
# [m, nil]
|
|
92
|
+
# end
|
|
93
|
+
#
|
|
94
|
+
# with_test_terminal do
|
|
95
|
+
# inject_key("s")
|
|
96
|
+
# inject_sync
|
|
97
|
+
# inject_key("q")
|
|
98
|
+
# Rooibos::Runtime.run(model:, view:, update:)
|
|
99
|
+
# end
|
|
100
|
+
#
|
|
101
|
+
# assert_no_errors(messages)
|
|
102
|
+
# # ... rest of assertions
|
|
103
|
+
# end
|
|
104
|
+
#
|
|
105
|
+
def assert_no_errors(messages, msg = nil)
|
|
106
|
+
error = messages.find { |m| m.is_a?(Rooibos::Message::Error) }
|
|
107
|
+
return unless error
|
|
108
|
+
|
|
109
|
+
error_detail = "#{error.exception.class}: #{error.exception.message}"
|
|
110
|
+
failure_msg = msg ? "#{msg}\n#{error_detail}" : "Unexpected Message::Error: #{error_detail}"
|
|
111
|
+
|
|
112
|
+
if respond_to?(:flunk)
|
|
113
|
+
# rubocop:disable Style/SendWithLiteralMethodName
|
|
114
|
+
public_send(:flunk, failure_msg)
|
|
115
|
+
# rubocop:enable Style/SendWithLiteralMethodName
|
|
116
|
+
else
|
|
117
|
+
raise failure_msg
|
|
118
|
+
end
|
|
119
|
+
end
|
|
52
120
|
end
|
|
53
121
|
end
|
|
54
|
-
|
|
55
|
-
# Attach Rooibos test helpers to RatatuiRuby::TestHelper
|
|
56
|
-
RatatuiRuby::TestHelper.include(Rooibos::TestHelper)
|
data/lib/rooibos/version.rb
CHANGED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: MIT-0
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
module Rooibos
|
|
9
|
+
# Built-in welcome screen used by scaffolded applications.
|
|
10
|
+
module Welcome
|
|
11
|
+
module UI # :nodoc:
|
|
12
|
+
module Styles # :nodoc:
|
|
13
|
+
TEXT = RatatuiRuby::Style::Style.new
|
|
14
|
+
FILENAME = RatatuiRuby::Style::Style.new(fg: :green)
|
|
15
|
+
COMMAND = RatatuiRuby::Style::Style.new(fg: :red)
|
|
16
|
+
URL = RatatuiRuby::Style::Style.new(fg: :blue)
|
|
17
|
+
|
|
18
|
+
COMMAND_BUTTON = RatatuiRuby::Style::Style.new(fg: :red, modifiers: [:underlined])
|
|
19
|
+
COMMAND_BUTTON_FOCUS = RatatuiRuby::Style::Style.new(fg: :red, modifiers: [:bold, :underlined])
|
|
20
|
+
COMMAND_BUTTON_HOVER = RatatuiRuby::Style::Style.new(fg: :red, modifiers: [:reversed])
|
|
21
|
+
COMMAND_BUTTON_BOTH = RatatuiRuby::Style::Style.new(fg: :red, modifiers: [:bold, :reversed])
|
|
22
|
+
|
|
23
|
+
URL_BUTTON = RatatuiRuby::Style::Style.new(fg: :blue, modifiers: [:underlined])
|
|
24
|
+
URL_BUTTON_FOCUS = RatatuiRuby::Style::Style.new(fg: :blue, modifiers: [:bold, :underlined])
|
|
25
|
+
URL_BUTTON_HOVER = RatatuiRuby::Style::Style.new(fg: :blue, modifiers: [:reversed])
|
|
26
|
+
URL_BUTTON_BOTH = RatatuiRuby::Style::Style.new(fg: :blue, modifiers: [:bold, :reversed])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module Widgets # :nodoc:
|
|
30
|
+
WELCOME_TEXT = {
|
|
31
|
+
"Welcome to Rooibos! You will find the Ruby code " \
|
|
32
|
+
"for this application in " => Styles::TEXT,
|
|
33
|
+
"lib/saturday.rb" => Styles::FILENAME,
|
|
34
|
+
". The tests that verify it are at " => Styles::TEXT,
|
|
35
|
+
"test/test_saturday.rb" => Styles::FILENAME,
|
|
36
|
+
". You can run the tests with " => Styles::TEXT,
|
|
37
|
+
"bundle exec rake test" => Styles::COMMAND,
|
|
38
|
+
". Visit " => Styles::TEXT,
|
|
39
|
+
"www.rooibos.run" => Styles::URL,
|
|
40
|
+
" to learn about Rooibos and to find other " \
|
|
41
|
+
"Rooibos developers. You can press " => Styles::TEXT,
|
|
42
|
+
"Control + C" => Styles::COMMAND,
|
|
43
|
+
" to exit at any time." => Styles::TEXT,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
PARAGRAPH = RatatuiRuby::Widgets::Paragraph.new(
|
|
47
|
+
text: RatatuiRuby::Text::Line.new(
|
|
48
|
+
spans: WELCOME_TEXT.map { |text, style| RatatuiRuby::Text::Span.new(content: text, style:) }
|
|
49
|
+
),
|
|
50
|
+
wrap: true,
|
|
51
|
+
alignment: :left
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def self.website_button(focused: false, hovered: false)
|
|
55
|
+
style = if focused && hovered
|
|
56
|
+
Styles::URL_BUTTON_BOTH
|
|
57
|
+
elsif focused
|
|
58
|
+
Styles::URL_BUTTON_FOCUS
|
|
59
|
+
elsif hovered
|
|
60
|
+
Styles::URL_BUTTON_HOVER
|
|
61
|
+
else
|
|
62
|
+
Styles::URL_BUTTON
|
|
63
|
+
end
|
|
64
|
+
RatatuiRuby::Text::Span.new(content: "[Visit Website]", style:)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.exit_button(focused: false, hovered: false)
|
|
68
|
+
style = if focused && hovered
|
|
69
|
+
Styles::COMMAND_BUTTON_BOTH
|
|
70
|
+
elsif focused
|
|
71
|
+
Styles::COMMAND_BUTTON_FOCUS
|
|
72
|
+
elsif hovered
|
|
73
|
+
Styles::COMMAND_BUTTON_HOVER
|
|
74
|
+
else
|
|
75
|
+
Styles::COMMAND_BUTTON
|
|
76
|
+
end
|
|
77
|
+
RatatuiRuby::Text::Span.new(content: "[Exit App]", style:)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
BUTTON_SLOTS = { website: 1, exit: 3 }.freeze
|
|
82
|
+
FOCUS_ORDER = [:website, :exit].freeze
|
|
83
|
+
|
|
84
|
+
BUTTON_BAR_CONSTRAINTS = [
|
|
85
|
+
RatatuiRuby::Layout::Constraint.fill(1),
|
|
86
|
+
RatatuiRuby::Layout::Constraint.length(18),
|
|
87
|
+
RatatuiRuby::Layout::Constraint.length(2),
|
|
88
|
+
RatatuiRuby::Layout::Constraint.length(14),
|
|
89
|
+
RatatuiRuby::Layout::Constraint.fill(1),
|
|
90
|
+
].freeze
|
|
91
|
+
|
|
92
|
+
CONTENT_CONSTRAINTS = [
|
|
93
|
+
RatatuiRuby::Layout::Constraint.fill(1),
|
|
94
|
+
RatatuiRuby::Layout::Constraint.length(1),
|
|
95
|
+
].freeze
|
|
96
|
+
|
|
97
|
+
def self.button_bar(focused:, hovered:)
|
|
98
|
+
RatatuiRuby::Layout::Layout.new(
|
|
99
|
+
direction: :horizontal,
|
|
100
|
+
constraints: BUTTON_BAR_CONSTRAINTS,
|
|
101
|
+
children: [
|
|
102
|
+
nil,
|
|
103
|
+
Widgets.website_button(focused: focused == :website, hovered: hovered == :website),
|
|
104
|
+
nil,
|
|
105
|
+
Widgets.exit_button(focused: focused == :exit, hovered: hovered == :exit),
|
|
106
|
+
nil,
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.content_layout(focused:, hovered:)
|
|
112
|
+
RatatuiRuby::Layout::Layout.new(
|
|
113
|
+
direction: :vertical,
|
|
114
|
+
constraints: CONTENT_CONSTRAINTS,
|
|
115
|
+
children: [Widgets::PARAGRAPH, button_bar(focused:, hovered:)]
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.frame(focused:, hovered:)
|
|
120
|
+
RatatuiRuby::Widgets::Block.new(
|
|
121
|
+
title: "Hello, Rooibos!",
|
|
122
|
+
borders: [:all],
|
|
123
|
+
border_style: { fg: :cyan },
|
|
124
|
+
padding: [2, 2, 1, 1],
|
|
125
|
+
children: [content_layout(focused:, hovered:)]
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Value object wrapping hit-test areas for clickable buttons.
|
|
130
|
+
ButtonAreas = Data.define(:website, :exit) do
|
|
131
|
+
def contains?(name, x, y) = public_send(name)&.contains?(x, y)
|
|
132
|
+
|
|
133
|
+
def button_at(x, y)
|
|
134
|
+
return :website if website&.contains?(x, y)
|
|
135
|
+
return :exit if exit&.contains?(x, y)
|
|
136
|
+
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def for_viewport(width, height)
|
|
141
|
+
viewport = RatatuiRuby::Layout::Rect.new(x: 0, y: 0, width:, height:)
|
|
142
|
+
frame = UI.frame(focused: nil, hovered: nil)
|
|
143
|
+
inner = frame.inner(viewport)
|
|
144
|
+
|
|
145
|
+
content_rects = RatatuiRuby::Layout::Layout.split(
|
|
146
|
+
inner,
|
|
147
|
+
direction: :vertical,
|
|
148
|
+
constraints: CONTENT_CONSTRAINTS
|
|
149
|
+
)
|
|
150
|
+
button_rects = RatatuiRuby::Layout::Layout.split(
|
|
151
|
+
content_rects[1],
|
|
152
|
+
direction: :horizontal,
|
|
153
|
+
constraints: BUTTON_BAR_CONSTRAINTS
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
ButtonAreas.new(
|
|
157
|
+
website: button_rects[BUTTON_SLOTS[:website]],
|
|
158
|
+
exit: button_rects[BUTTON_SLOTS[:exit]]
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# focused: keyboard focus (persists until Tab/Shift+Tab)
|
|
165
|
+
# hovered: mouse hover (clears on mouse-out)
|
|
166
|
+
Model = Data.define(:button_areas, :focused, :hovered) do
|
|
167
|
+
def tree = UI.frame(focused:, hovered:)
|
|
168
|
+
|
|
169
|
+
# Returns the "active" button for activation (keyboard takes precedence)
|
|
170
|
+
def active_button = focused || hovered
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
View = -> (model, _tui) { model.tree }
|
|
174
|
+
|
|
175
|
+
Update = -> (message, model) {
|
|
176
|
+
case message
|
|
177
|
+
in _ if message.ctrl_c?
|
|
178
|
+
Rooibos::Command.exit
|
|
179
|
+
in _ if message.resize?
|
|
180
|
+
model.with(button_areas: model.button_areas.for_viewport(message.width, message.height))
|
|
181
|
+
in _ if message.tab?
|
|
182
|
+
cycle_focus(model, :forward)
|
|
183
|
+
in _ if message.shift_back_tab?
|
|
184
|
+
cycle_focus(model, :backward)
|
|
185
|
+
in _ if message.enter? && model.active_button
|
|
186
|
+
activate_button(model.active_button, model)
|
|
187
|
+
in _ if message.mouse? && message.down? && message.button == "left"
|
|
188
|
+
handle_click(message, model)
|
|
189
|
+
in { type: :mouse }
|
|
190
|
+
handle_hover(message, model)
|
|
191
|
+
else
|
|
192
|
+
model
|
|
193
|
+
end
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
Init = -> {
|
|
197
|
+
viewport = RatatuiRuby.terminal_size
|
|
198
|
+
areas = UI::ButtonAreas.new(website: nil, exit: nil).for_viewport(viewport.width, viewport.height)
|
|
199
|
+
Ractor.make_shareable Model.new(button_areas: areas, focused: nil, hovered: nil)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
def self.handle_click(message, model)
|
|
203
|
+
button = model.button_areas.button_at(message.x, message.y)
|
|
204
|
+
activate_button(button, model)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.handle_hover(message, model)
|
|
208
|
+
new_hovered = model.button_areas.button_at(message.x, message.y)
|
|
209
|
+
return model if new_hovered == model.hovered
|
|
210
|
+
|
|
211
|
+
model.with(hovered: new_hovered)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def self.cycle_focus(model, direction)
|
|
215
|
+
order = UI::FOCUS_ORDER
|
|
216
|
+
return model.with(focused: order.first) unless model.focused
|
|
217
|
+
|
|
218
|
+
current_index = order.index(model.focused)
|
|
219
|
+
return model.with(focused: order.first) unless current_index
|
|
220
|
+
|
|
221
|
+
next_index = case direction
|
|
222
|
+
when :forward then (current_index + 1) % order.length
|
|
223
|
+
when :backward then (current_index - 1) % order.length
|
|
224
|
+
else 0
|
|
225
|
+
end
|
|
226
|
+
model.with(focused: order[next_index])
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def self.activate_button(button, model)
|
|
230
|
+
case button
|
|
231
|
+
when :website then [model, Rooibos::Command.system("open 'https://www.rooibos.run'", :open_url)]
|
|
232
|
+
when :exit then Rooibos::Command.exit
|
|
233
|
+
else model
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
data/lib/rooibos.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "rooibos/message"
|
|
|
11
11
|
require_relative "rooibos/command"
|
|
12
12
|
require_relative "rooibos/runtime"
|
|
13
13
|
require_relative "rooibos/router"
|
|
14
|
+
require_relative "rooibos/welcome"
|
|
14
15
|
|
|
15
16
|
# The Elm Architecture for Ruby.
|
|
16
17
|
#
|
|
@@ -103,17 +104,17 @@ module Rooibos
|
|
|
103
104
|
# # Verbose:
|
|
104
105
|
# case message
|
|
105
106
|
# in [:stats, *rest]
|
|
106
|
-
# new_child, cmd = StatsPanel::
|
|
107
|
+
# new_child, cmd = StatsPanel::Update.call(rest, model.stats)
|
|
107
108
|
# mapped = cmd ? Command.map(cmd) { |r| [:stats, *r] } : nil
|
|
108
109
|
# [new_child, mapped]
|
|
109
110
|
# end
|
|
110
111
|
#
|
|
111
112
|
# # Concise:
|
|
112
|
-
# Rooibos.delegate(message, :stats, StatsPanel::
|
|
113
|
+
# Rooibos.delegate(message, :stats, StatsPanel::Update, model.stats)
|
|
113
114
|
def self.delegate(message, prefix, child_update, child_model)
|
|
114
115
|
return nil unless message.is_a?(Array) && message.first == prefix
|
|
115
116
|
|
|
116
|
-
rest = message[1
|
|
117
|
+
rest = message[1]
|
|
117
118
|
new_child, command = child_update.call(rest, child_model)
|
|
118
119
|
wrapped = command ? route(command, prefix) : nil
|
|
119
120
|
[new_child, wrapped]
|
data/mise.toml
CHANGED
data/rbs_collection.lock.yaml
CHANGED
data/sig/concurrent.rbs
CHANGED
|
@@ -39,6 +39,9 @@ module Concurrent
|
|
|
39
39
|
|
|
40
40
|
# Races multiple futures/events, returning when any resolves.
|
|
41
41
|
def self.any_event: (*Future[untyped] | ResolvableEvent) -> ResolvableEvent
|
|
42
|
+
|
|
43
|
+
# Joins multiple futures, resolving when all complete.
|
|
44
|
+
def self.zip_futures: (*Future[untyped]) -> Future[Array[untyped]]
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
# Single-element blocking container for synchronization.
|
data/sig/gem.rbs
ADDED
|
@@ -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
|
+
# Local shim for Gem::Version#segments not in stdlib RBS.
|
|
7
|
+
# TODO: Remove when upstream RBS adds this method.
|
|
8
|
+
|
|
9
|
+
module Gem
|
|
10
|
+
class Version
|
|
11
|
+
# Returns version segments as an array of integers and strings.
|
|
12
|
+
def segments: () -> Array[Integer | String]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Local shim for RatatuiRuby.terminal_size alias not in upstream RBS.
|
|
17
|
+
# The Ruby gem has: alias terminal_size get_terminal_size
|
|
18
|
+
module RatatuiRuby
|
|
19
|
+
def self.terminal_size: () -> RatatuiRuby::Layout::Rect
|
|
20
|
+
end
|
data/sig/rooibos/cli.rbs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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 CLI
|
|
8
|
+
COMMANDS: Hash[String, singleton(Commands::New) | singleton(Commands::Run)]
|
|
9
|
+
|
|
10
|
+
def self.call: (Array[String]) -> void
|
|
11
|
+
def self.usage: () -> String
|
|
12
|
+
|
|
13
|
+
module Commands
|
|
14
|
+
module New
|
|
15
|
+
# Options hash from parse_options.
|
|
16
|
+
type options = Hash[Symbol, bool | String]
|
|
17
|
+
|
|
18
|
+
BUNDLE_GEM_DEFAULTS: Array[String]
|
|
19
|
+
|
|
20
|
+
def self.call: (Array[String]) -> void
|
|
21
|
+
def self.usage: () -> String
|
|
22
|
+
def self.parse_options: (Array[String]) -> options
|
|
23
|
+
def self.create_app: (String, Array[String], options) -> void
|
|
24
|
+
def self.build_bundle_gem_args: (Array[String]) -> Array[String]
|
|
25
|
+
def self.git_enabled?: (Array[String]) -> bool
|
|
26
|
+
def self.make_initial_commit: (Pathname) -> void
|
|
27
|
+
def self.to_module_name: (String) -> String
|
|
28
|
+
def self.fix_gemspec_placeholders: (String) -> String
|
|
29
|
+
def self.exe_template: (String, String) -> String
|
|
30
|
+
def self.app_template: (String, String) -> String
|
|
31
|
+
def self.test_template: (String, String) -> String
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
module Run
|
|
35
|
+
def self.call: (Array[String]) -> void
|
|
36
|
+
def self.usage: () -> String
|
|
37
|
+
def self.run_app: () -> void
|
|
38
|
+
def self.find_project_root: () -> Pathname?
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|