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.
Files changed (169) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +9 -5
  3. data/.builds/ruby-3.3.yml +9 -5
  4. data/.builds/ruby-3.4.yml +9 -5
  5. data/.builds/ruby-4.0.0.yml +9 -5
  6. data/AGENTS.md +1 -1
  7. data/CHANGELOG.md +46 -0
  8. data/README.md +2 -2
  9. data/README.rdoc +374 -0
  10. data/REUSE.toml +5 -0
  11. data/Rakefile +1 -1
  12. data/doc/best_practices/forms_and_validation.md +20 -0
  13. data/doc/best_practices/http_workflows.md +20 -0
  14. data/doc/best_practices/index.md +26 -0
  15. data/doc/best_practices/lists_and_tables.md +20 -0
  16. data/doc/best_practices/modal_dialogs.md +20 -0
  17. data/doc/best_practices/no_stateful_widgets.md +184 -0
  18. data/doc/best_practices/orchestration.md +20 -0
  19. data/doc/best_practices/streaming_data.md +20 -0
  20. data/doc/contributors/design/commands_and_outlets.md +1 -1
  21. data/doc/contributors/documentation_plan.md +616 -0
  22. data/doc/contributors/documentation_stub_audit.md +112 -0
  23. data/doc/contributors/documentation_style.md +275 -0
  24. data/doc/contributors/e2e_pty.md +168 -0
  25. data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +70 -0
  26. data/doc/contributors/specs/file_browser.md +789 -0
  27. data/doc/contributors/specs/file_browser_stories.md +774 -0
  28. data/doc/contributors/specs/tutorials_to_stories.rb +167 -0
  29. data/doc/contributors/todo/scrollbar.md +118 -0
  30. data/doc/contributors/tutorial_old/01_project_setup.md +20 -0
  31. data/doc/contributors/tutorial_old/02_hello_world.md +24 -0
  32. data/doc/contributors/tutorial_old/03_adding_state.md +26 -0
  33. data/doc/contributors/tutorial_old/06_organizing_your_code.md +20 -0
  34. data/doc/contributors/tutorial_old/07_your_first_command.md +21 -0
  35. data/doc/contributors/tutorial_old/08_the_preview_pane.md +20 -0
  36. data/doc/contributors/tutorial_old/09_loading_states.md +20 -0
  37. data/doc/contributors/tutorial_old/10_testing_your_app.md +20 -0
  38. data/doc/contributors/tutorial_old/11_polish_and_refine.md +20 -0
  39. data/doc/contributors/tutorial_old/12_going_further.md +20 -0
  40. data/doc/contributors/tutorial_old/index.md +20 -0
  41. data/doc/essentials/commands.md +20 -0
  42. data/doc/essentials/index.md +31 -0
  43. data/doc/essentials/messages.md +21 -0
  44. data/doc/essentials/models.md +21 -0
  45. data/doc/essentials/shortcuts.md +19 -0
  46. data/doc/essentials/the_elm_architecture.md +24 -0
  47. data/doc/essentials/the_runtime.md +21 -0
  48. data/doc/essentials/update_functions.md +20 -0
  49. data/doc/essentials/views.md +22 -0
  50. data/doc/getting_started/for_go_developers.md +16 -0
  51. data/doc/getting_started/for_python_developers.md +16 -0
  52. data/doc/getting_started/for_react_developers.md +17 -0
  53. data/doc/getting_started/index.md +52 -0
  54. data/doc/getting_started/install.md +20 -0
  55. data/doc/getting_started/quickstart.md +9 -45
  56. data/doc/getting_started/ruby_primer.md +19 -0
  57. data/doc/getting_started/why_rooibos.md +20 -0
  58. data/doc/index.md +79 -11
  59. data/doc/scaling_up/async_patterns.md +20 -0
  60. data/doc/scaling_up/command_composition.md +20 -0
  61. data/doc/scaling_up/custom_commands.md +21 -0
  62. data/doc/scaling_up/fractal_architecture.md +20 -0
  63. data/doc/scaling_up/index.md +30 -0
  64. data/doc/scaling_up/message_routing.md +20 -0
  65. data/doc/scaling_up/ractor_safety.md +20 -0
  66. data/doc/scaling_up/testing.md +21 -0
  67. data/doc/troubleshooting/common_errors.md +20 -0
  68. data/doc/troubleshooting/debugging.md +21 -0
  69. data/doc/troubleshooting/index.md +23 -0
  70. data/doc/troubleshooting/performance.md +20 -0
  71. data/doc/tutorial/01_project_setup.md +44 -0
  72. data/doc/tutorial/02_hello_world.md +45 -0
  73. data/doc/tutorial/03_static_file_list.md +44 -0
  74. data/doc/tutorial/04_arrow_navigation.md +47 -0
  75. data/doc/tutorial/05_real_files.md +45 -0
  76. data/doc/tutorial/06_safe_refactoring.md +21 -0
  77. data/doc/tutorial/07_red_first_tdd.md +26 -0
  78. data/doc/tutorial/08_file_metadata.md +42 -0
  79. data/doc/tutorial/09_text_preview.md +44 -0
  80. data/doc/tutorial/10_directory_tree.md +42 -0
  81. data/doc/tutorial/11_pane_focus.md +40 -0
  82. data/doc/tutorial/12_sorting.md +41 -0
  83. data/doc/tutorial/13_filtering.md +43 -0
  84. data/doc/tutorial/14_toggle_hidden.md +41 -0
  85. data/doc/tutorial/15_text_input_widget.md +43 -0
  86. data/doc/tutorial/16_rename_files.md +42 -0
  87. data/doc/tutorial/17_confirmation_dialogs.md +43 -0
  88. data/doc/tutorial/18_progress_indicators.md +43 -0
  89. data/doc/tutorial/19_atomic_operations.md +42 -0
  90. data/doc/tutorial/20_external_editor.md +42 -0
  91. data/doc/tutorial/21_modal_overlays.md +41 -0
  92. data/doc/tutorial/22_error_handling.md +43 -0
  93. data/doc/tutorial/23_terminal_capabilities.md +53 -0
  94. data/doc/tutorial/24_mouse_events.md +43 -0
  95. data/doc/tutorial/25_resize_events.md +43 -0
  96. data/doc/tutorial/26_loading_states.md +42 -0
  97. data/doc/tutorial/27_performance.md +43 -0
  98. data/doc/tutorial/28_color_schemes.md +47 -0
  99. data/doc/tutorial/29_configuration.md +124 -0
  100. data/doc/tutorial/30_going_further.md +17 -0
  101. data/doc/tutorial/index.md +17 -0
  102. data/examples/app_file_browser/app.rb +40 -0
  103. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
  104. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
  105. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
  106. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
  107. data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
  108. data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
  109. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
  110. data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
  111. data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
  112. data/examples/verify_website_first_app/app.rb +85 -0
  113. data/examples/verify_website_hello_mvu/app.rb +31 -0
  114. data/examples/widget_command_system/app.rb +15 -13
  115. data/exe/rooibos +10 -0
  116. data/generate_tutorial_stubs.rb +126 -0
  117. data/lib/rooibos/cli/commands/new.rb +373 -0
  118. data/lib/rooibos/cli/commands/run.rb +98 -0
  119. data/lib/rooibos/cli.rb +78 -0
  120. data/lib/rooibos/command/all.rb +25 -20
  121. data/lib/rooibos/command/batch.rb +26 -25
  122. data/lib/rooibos/command/custom.rb +84 -1
  123. data/lib/rooibos/command/http.rb +59 -55
  124. data/lib/rooibos/command/lifecycle.rb +5 -5
  125. data/lib/rooibos/command/open.rb +86 -0
  126. data/lib/rooibos/command/outlet.rb +105 -3
  127. data/lib/rooibos/command/wait.rb +5 -5
  128. data/lib/rooibos/command.rb +57 -74
  129. data/lib/rooibos/message/batch.rb +39 -0
  130. data/lib/rooibos/message/canceled.rb +51 -0
  131. data/lib/rooibos/message/error.rb +48 -0
  132. data/lib/rooibos/message/open.rb +30 -0
  133. data/lib/rooibos/message.rb +84 -4
  134. data/lib/rooibos/router.rb +11 -14
  135. data/lib/rooibos/runtime.rb +40 -43
  136. data/lib/rooibos/shortcuts.rb +47 -0
  137. data/lib/rooibos/test_helper.rb +71 -6
  138. data/lib/rooibos/version.rb +1 -1
  139. data/lib/rooibos/welcome.rb +237 -0
  140. data/lib/rooibos.rb +4 -3
  141. data/mise.toml +1 -1
  142. data/rbs_collection.lock.yaml +2 -2
  143. data/sig/concurrent.rbs +3 -0
  144. data/sig/gem.rbs +20 -0
  145. data/sig/rooibos/cli.rbs +42 -0
  146. data/sig/rooibos/command.rbs +48 -0
  147. data/sig/rooibos/message.rbs +60 -0
  148. data/sig/rooibos/shortcuts.rbs +14 -0
  149. data/sig/rooibos/test_helper.rbs +6 -2
  150. data/sig/rooibos/welcome.rbs +75 -0
  151. data/tasks/install.rake +29 -0
  152. data/tasks/resources/build.yml.erb +2 -0
  153. metadata +272 -38
  154. data/doc/concepts/application_architecture.md +0 -197
  155. data/doc/concepts/application_testing.md +0 -49
  156. data/doc/concepts/async_work.md +0 -164
  157. data/doc/concepts/commands.md +0 -530
  158. data/doc/concepts/message_processing.md +0 -51
  159. data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
  160. data/doc/contributors/WIP/implementation_plan.md +0 -409
  161. data/doc/contributors/WIP/init_callable_proposal.md +0 -344
  162. data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
  163. data/doc/contributors/WIP/task.md +0 -36
  164. data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
  165. data/doc/contributors/kit-no-outlet.md +0 -238
  166. data/doc/contributors/priorities.md +0 -38
  167. data/doc/images/.gitkeep +0 -0
  168. data/exe/.gitkeep +0 -0
  169. /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
@@ -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
- # A root fragment is not required. You can pass individual parameters:
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
- @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
+ @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
- 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
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)
@@ -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
@@ -8,11 +8,33 @@
8
8
  require "ratatui_ruby/test_helper"
9
9
 
10
10
  module Rooibos
11
- # Test helpers for Rooibos command validation.
11
+ # Assertions and test utilities for Rooibos applications.
12
12
  #
13
- # This module extends RatatuiRuby::TestHelper with Rooibos-specific assertions
14
- # for verifying custom commands implement the proper protocol.
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)
@@ -8,5 +8,5 @@
8
8
  module Rooibos
9
9
  # The version of this gem.
10
10
  # See https://semver.org/spec/v2.0.0.html
11
- VERSION = "0.5.0"
11
+ VERSION = "0.6.0"
12
12
  end
@@ -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::UPDATE.call(rest, model.stats)
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::UPDATE, model.stats)
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
@@ -2,7 +2,7 @@
2
2
  # SPDX-License-Identifier: LGPL-3.0-or-later
3
3
 
4
4
  [tools]
5
- ruby = "4.0.0"
5
+ ruby = "4.0.1"
6
6
  rust = "1.91.1"
7
7
  python = "3.12"
8
8
  pre-commit = "latest"
@@ -66,11 +66,11 @@ gems:
66
66
  source:
67
67
  type: stdlib
68
68
  - name: prism
69
- version: 1.7.0
69
+ version: 1.8.0
70
70
  source:
71
71
  type: rubygems
72
72
  - name: ratatui_ruby
73
- version: 0.10.2
73
+ version: 1.1.0
74
74
  source:
75
75
  type: rubygems
76
76
  - name: ratatui_ruby-devtools
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
@@ -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