rooibos 0.5.0 → 0.6.1

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 (171) 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 +57 -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_rails_developers.md +17 -0
  53. data/doc/getting_started/for_ratatui_ruby_developers.md +17 -0
  54. data/doc/getting_started/for_react_developers.md +17 -0
  55. data/doc/getting_started/index.md +52 -0
  56. data/doc/getting_started/install.md +20 -0
  57. data/doc/getting_started/quickstart.md +9 -45
  58. data/doc/getting_started/ruby_primer.md +19 -0
  59. data/doc/getting_started/why_rooibos.md +20 -0
  60. data/doc/index.md +79 -11
  61. data/doc/scaling_up/async_patterns.md +20 -0
  62. data/doc/scaling_up/command_composition.md +20 -0
  63. data/doc/scaling_up/custom_commands.md +21 -0
  64. data/doc/scaling_up/fractal_architecture.md +20 -0
  65. data/doc/scaling_up/index.md +30 -0
  66. data/doc/scaling_up/message_routing.md +20 -0
  67. data/doc/scaling_up/ractor_safety.md +20 -0
  68. data/doc/scaling_up/testing.md +21 -0
  69. data/doc/troubleshooting/common_errors.md +20 -0
  70. data/doc/troubleshooting/debugging.md +21 -0
  71. data/doc/troubleshooting/index.md +23 -0
  72. data/doc/troubleshooting/performance.md +20 -0
  73. data/doc/tutorial/01_project_setup.md +44 -0
  74. data/doc/tutorial/02_hello_world.md +45 -0
  75. data/doc/tutorial/03_static_file_list.md +44 -0
  76. data/doc/tutorial/04_arrow_navigation.md +47 -0
  77. data/doc/tutorial/05_real_files.md +45 -0
  78. data/doc/tutorial/06_safe_refactoring.md +21 -0
  79. data/doc/tutorial/07_red_first_tdd.md +26 -0
  80. data/doc/tutorial/08_file_metadata.md +42 -0
  81. data/doc/tutorial/09_text_preview.md +44 -0
  82. data/doc/tutorial/10_directory_tree.md +42 -0
  83. data/doc/tutorial/11_pane_focus.md +40 -0
  84. data/doc/tutorial/12_sorting.md +41 -0
  85. data/doc/tutorial/13_filtering.md +43 -0
  86. data/doc/tutorial/14_toggle_hidden.md +41 -0
  87. data/doc/tutorial/15_text_input_widget.md +43 -0
  88. data/doc/tutorial/16_rename_files.md +42 -0
  89. data/doc/tutorial/17_confirmation_dialogs.md +43 -0
  90. data/doc/tutorial/18_progress_indicators.md +43 -0
  91. data/doc/tutorial/19_atomic_operations.md +42 -0
  92. data/doc/tutorial/20_external_editor.md +42 -0
  93. data/doc/tutorial/21_modal_overlays.md +41 -0
  94. data/doc/tutorial/22_error_handling.md +43 -0
  95. data/doc/tutorial/23_terminal_capabilities.md +53 -0
  96. data/doc/tutorial/24_mouse_events.md +43 -0
  97. data/doc/tutorial/25_resize_events.md +43 -0
  98. data/doc/tutorial/26_loading_states.md +42 -0
  99. data/doc/tutorial/27_performance.md +43 -0
  100. data/doc/tutorial/28_color_schemes.md +47 -0
  101. data/doc/tutorial/29_configuration.md +124 -0
  102. data/doc/tutorial/30_going_further.md +17 -0
  103. data/doc/tutorial/index.md +17 -0
  104. data/examples/app_file_browser/app.rb +40 -0
  105. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
  106. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
  107. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
  108. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
  109. data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
  110. data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
  111. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
  112. data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
  113. data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
  114. data/examples/verify_website_first_app/app.rb +85 -0
  115. data/examples/verify_website_hello_mvu/app.rb +31 -0
  116. data/examples/widget_command_system/app.rb +15 -13
  117. data/exe/rooibos +10 -0
  118. data/generate_tutorial_stubs.rb +126 -0
  119. data/lib/rooibos/cli/commands/new.rb +373 -0
  120. data/lib/rooibos/cli/commands/run.rb +98 -0
  121. data/lib/rooibos/cli.rb +78 -0
  122. data/lib/rooibos/command/all.rb +76 -23
  123. data/lib/rooibos/command/batch.rb +61 -34
  124. data/lib/rooibos/command/custom.rb +84 -1
  125. data/lib/rooibos/command/http.rb +121 -55
  126. data/lib/rooibos/command/lifecycle.rb +5 -5
  127. data/lib/rooibos/command/open.rb +93 -0
  128. data/lib/rooibos/command/outlet.rb +105 -3
  129. data/lib/rooibos/command/wait.rb +9 -6
  130. data/lib/rooibos/command.rb +114 -89
  131. data/lib/rooibos/message/batch.rb +39 -0
  132. data/lib/rooibos/message/canceled.rb +51 -0
  133. data/lib/rooibos/message/error.rb +48 -0
  134. data/lib/rooibos/message/open.rb +30 -0
  135. data/lib/rooibos/message.rb +84 -4
  136. data/lib/rooibos/router.rb +11 -14
  137. data/lib/rooibos/runtime.rb +40 -43
  138. data/lib/rooibos/shortcuts.rb +47 -0
  139. data/lib/rooibos/test_helper.rb +71 -6
  140. data/lib/rooibos/version.rb +1 -1
  141. data/lib/rooibos/welcome.rb +237 -0
  142. data/lib/rooibos.rb +4 -3
  143. data/mise.toml +1 -1
  144. data/rbs_collection.lock.yaml +2 -2
  145. data/sig/concurrent.rbs +4 -0
  146. data/sig/gem.rbs +20 -0
  147. data/sig/rooibos/cli.rbs +42 -0
  148. data/sig/rooibos/command.rbs +59 -7
  149. data/sig/rooibos/message.rbs +66 -2
  150. data/sig/rooibos/shortcuts.rbs +14 -0
  151. data/sig/rooibos/test_helper.rbs +6 -2
  152. data/sig/rooibos/welcome.rbs +75 -0
  153. data/tasks/install.rake +29 -0
  154. data/tasks/resources/build.yml.erb +2 -0
  155. metadata +274 -38
  156. data/doc/concepts/application_architecture.md +0 -197
  157. data/doc/concepts/application_testing.md +0 -49
  158. data/doc/concepts/async_work.md +0 -164
  159. data/doc/concepts/commands.md +0 -530
  160. data/doc/concepts/message_processing.md +0 -51
  161. data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
  162. data/doc/contributors/WIP/implementation_plan.md +0 -409
  163. data/doc/contributors/WIP/init_callable_proposal.md +0 -344
  164. data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
  165. data/doc/contributors/WIP/task.md +0 -36
  166. data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
  167. data/doc/contributors/kit-no-outlet.md +0 -238
  168. data/doc/contributors/priorities.md +0 -38
  169. data/doc/images/.gitkeep +0 -0
  170. data/exe/.gitkeep +0 -0
  171. /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
@@ -0,0 +1,48 @@
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
+ module Message
10
+ # Error message from a failed command.
11
+ #
12
+ # Commands run in background threads. Exceptions bubble up silently.
13
+ # Your update function never sees them. Backtraces in STDERR corrupt the TUI.
14
+ #
15
+ # The runtime catches exceptions and wraps them in Error messages.
16
+ # Pattern match on Error in your update function. Display the error, log it, or recover.
17
+ #
18
+ # Use it to surface failures from HTTP requests, file I/O, or external processes.
19
+ #
20
+ # === Examples
21
+ #
22
+ # Update = ->(message, model) {
23
+ # case message
24
+ # in { type: :error, command:, exception: }
25
+ # # Show error toast
26
+ # model.with(error: exception.message)
27
+ # in Message::Error
28
+ # # Store for later inspection
29
+ # model.with(last_error: message.exception)
30
+ # end
31
+ # }
32
+ Error = Data.define(:command, :exception) do
33
+ include Predicates
34
+
35
+ # Returns <tt>true</tt> for error messages.
36
+ def error?
37
+ true
38
+ end
39
+
40
+ # Deconstructs for pattern matching.
41
+ #
42
+ # Returns a hash with <tt>type</tt>, <tt>command</tt>, and <tt>exception</tt>.
43
+ def deconstruct_keys(_keys)
44
+ { type: :error, command:, exception: }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,30 @@
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
+ module Message
10
+ # Signals that a file or URL was successfully opened.
11
+ #
12
+ # This message arrives after +Command.open+ completes with exit status 0.
13
+ # Non-zero exits produce +Message::Error+ instead.
14
+ #
15
+ # === Pattern Matching
16
+ #
17
+ # case message
18
+ # in { type: :open, envelope: path }
19
+ # model.with(status: "Opened #{path}")
20
+ # end
21
+ #
22
+ class Open < Data.define(:envelope)
23
+ include Predicates
24
+
25
+ def deconstruct_keys(_keys)
26
+ { type: :open, envelope: }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -8,21 +8,97 @@
8
8
  module Rooibos
9
9
  # Messages sent from commands to update functions.
10
10
  #
11
- # All built-in response types live here. Each includes the +Predicates+
11
+ # All built-in response types live here. Each includes the <tt>Predicates</tt>
12
12
  # mixin for safe predicate calls.
13
13
  module Message
14
+ # Matches built-in framework message types for case/when dispatch.
15
+ #
16
+ # Returns <tt>true</tt> only for classes under <tt>Rooibos::Message::</tt>.
17
+ # Rejects key events and user-defined message classes.
18
+ #
19
+ # === Example
20
+ #
21
+ # case message
22
+ # when Rooibos::Message
23
+ # handle_command_response(message)
24
+ # when RatatuiRuby::Event::Key
25
+ # handle_key(message)
26
+ # end
27
+ def self.===(other)
28
+ other.class.name&.start_with?("Rooibos::Message::")
29
+ end
30
+
14
31
  # Fallback predicate mixin.
15
32
  #
16
- # Returns +false+ for any unknown predicate method (ending in +?+).
17
- # Include in custom message types for safe predicate calls.
33
+ # Update functions receive many message types. Checking unknown predicates
34
+ # crashes with NoMethodError. Verifying every predicate clutters the code.
35
+ #
36
+ # This mixin returns <tt>false</tt> for unknown predicates. It also adds
37
+ # symbol comparison via <tt>to_sym</tt> and <tt>==</tt>.
38
+ #
39
+ # Include in custom message types for safe predicate calls and symbol matching.
18
40
  module Predicates
19
- # Returns +false+ for unknown predicate methods.
41
+ # Converts the message to a Symbol.
42
+ #
43
+ # Returns the <tt>:type</tt> value from <tt>deconstruct_keys</tt> prefixed
44
+ # with <tt>message_</tt>. The prefix avoids collision with RatatuiRuby
45
+ # event symbols like <tt>:resize</tt> or <tt>:mouse</tt>.
46
+ #
47
+ # === Example
48
+ #
49
+ # timer = Message::Timer.new(envelope: :tick, elapsed: 0.016)
50
+ # timer.to_sym # => :message_timer
51
+ def to_sym
52
+ :"message_#{deconstruct_keys(nil)[:type]}"
53
+ end
54
+
55
+ # Compares the message with another object.
56
+ #
57
+ # Symbols compare against <tt>to_sym</tt>. Other objects use default equality.
58
+ #
59
+ # === Example
60
+ #
61
+ # if message == :message_timer
62
+ # handle_tick(message)
63
+ # end
64
+ def ==(other)
65
+ case other
66
+ when Symbol then to_sym == other
67
+ else super
68
+ end
69
+ end
70
+
71
+ # Returns <tt>false</tt> for unknown predicate methods.
20
72
  def method_missing(name, *args, **kwargs, &block)
21
73
  return false if name.to_s.end_with?("?") && args.empty? && kwargs.empty?
22
74
 
23
75
  super
24
76
  end
25
77
 
78
+ # Fallback pattern matching for classes without explicit deconstruct_keys.
79
+ #
80
+ # Derives <tt>:type</tt> from the class name in snake_case. Anonymous
81
+ # classes default to <tt>:custom</tt>.
82
+ #
83
+ # === Example
84
+ #
85
+ # class MyCustomMessage
86
+ # include Rooibos::Message::Predicates
87
+ # end
88
+ #
89
+ # msg = MyCustomMessage.new
90
+ # msg.deconstruct_keys(nil) # => { type: :my_custom_message }
91
+ # msg.to_sym # => :message_my_custom_message
92
+ def deconstruct_keys(_keys)
93
+ class_name = self.class.name&.split("::")&.last
94
+ type_name = if class_name
95
+ class_name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
96
+ else
97
+ :custom
98
+ end
99
+ { type: type_name }
100
+ end
101
+
26
102
  # Responds to all predicate methods.
27
103
  def respond_to_missing?(name, *)
28
104
  name.to_s.end_with?("?")
@@ -32,7 +108,11 @@ module Rooibos
32
108
  end
33
109
 
34
110
  require_relative "message/timer"
111
+ require_relative "message/open"
35
112
  require_relative "message/http_response"
36
113
  require_relative "message/system/batch"
37
114
  require_relative "message/system/stream"
38
115
  require_relative "message/all"
116
+ require_relative "message/batch"
117
+ require_relative "message/error"
118
+ require_relative "message/canceled"
@@ -1,7 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
1
  #--
4
2
  # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
+ # frozen_string_literal: true
4
+
5
5
  # SPDX-License-Identifier: LGPL-3.0-or-later
6
6
  #++
7
7
 
@@ -13,10 +13,10 @@ module Rooibos
13
13
  # Writing this routing logic by hand is tedious and error-prone.
14
14
  #
15
15
  # Include this module to declare routes and keymaps. Call +from_router+ to
16
- # generate an UPDATE lambda that handles routing automatically.
16
+ # generate an Update lambda that handles routing automatically.
17
17
  #
18
- # A *fragment* is a module containing <tt>Model</tt>, <tt>INITIAL</tt>,
19
- # <tt>UPDATE</tt>, and <tt>VIEW</tt> constants. Fragments compose: parent fragments
18
+ # A *fragment* is a module containing <tt>Model</tt>, <tt>Init</tt>,
19
+ # <tt>Update</tt>, and <tt>View</tt> constants. Fragments compose: parent fragments
20
20
  # delegate to child fragments.
21
21
  #
22
22
  # === Example
@@ -33,9 +33,9 @@ module Rooibos
33
33
  # end
34
34
  #
35
35
  # Model = Data.define(:stats, :network)
36
- # INITIAL = Model.new(stats: StatsPanel::INITIAL, network: NetworkPanel::INITIAL)
37
- # VIEW = ->(model, tui) { ... }
38
- # UPDATE = from_router
36
+ # Init = -> { Model.new(stats: StatsPanel::Init.(), network: NetworkPanel::Init.()) }
37
+ # View = ->(model, tui) { ... }
38
+ # Update = from_router
39
39
  # end
40
40
  module Router
41
41
  # Configuration for key handlers.
@@ -59,8 +59,7 @@ module Rooibos
59
59
  end
60
60
  end
61
61
 
62
- # :nodoc:
63
- def self.included(base)
62
+ def self.included(base) # :nodoc:
64
63
  base.extend(ClassMethods)
65
64
  end
66
65
 
@@ -254,8 +253,7 @@ module Rooibos
254
253
  # Returns the registered handlers hash.
255
254
  attr_reader :handlers
256
255
 
257
- # :nodoc:
258
- def initialize
256
+ def initialize # :nodoc:
259
257
  @handlers = {}
260
258
  @guard_stack = []
261
259
  end
@@ -369,8 +367,7 @@ module Rooibos
369
367
  # Returns the registered click handler.
370
368
  attr_reader :click_handler
371
369
 
372
- # :nodoc:
373
- def initialize
370
+ def initialize # :nodoc:
374
371
  @scroll_handlers = {}
375
372
  @click_handler = nil
376
373
  end
@@ -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.1"
12
12
  end