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
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ #
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ #++
8
+
9
+ require "rooibos"
10
+
11
+ module Tutorial01
12
+ module FileBrowser
13
+ Model = Data.define(:current_directory, :file_names)
14
+
15
+ View = -> (model, tui) {
16
+ tui.layout(children: [
17
+ tui.paragraph(text: model.current_directory),
18
+ tui.list(items: model.file_names),
19
+ ])
20
+ }
21
+
22
+ Update = -> (message, model) {
23
+ if message.ctrl_c? or message.q?
24
+ Rooibos::Command.exit
25
+ else
26
+ model
27
+ end
28
+ }
29
+
30
+ Init = -> {
31
+ current_directory = Dir.pwd
32
+ file_names = Dir.children(current_directory)
33
+ Ractor.make_shareable Model.new(current_directory, file_names)
34
+ }
35
+ end
36
+ end
37
+
38
+ if __FILE__ == $0
39
+ Rooibos.run(Tutorial01::FileBrowser)
40
+ end
@@ -28,17 +28,17 @@ module DashboardManual
28
28
  # modal is active. Only user input (keys/mouse) should be blocked.
29
29
  case message
30
30
  # Route command results to panels
31
- in [:stats, *rest]
31
+ in [:stats, rest]
32
32
  new_panel, command = StatsPanel::Update.call(rest, model.stats)
33
33
  mapped_command = command ? Command.map(command) { |child_result| [:stats, *child_result] } : nil
34
34
  return [model.with(stats: new_panel), mapped_command]
35
35
 
36
- in [:network, *rest]
36
+ in [:network, rest]
37
37
  new_panel, command = NetworkPanel::Update.call(rest, model.network)
38
38
  mapped_command = command ? Command.map(command) { |child_result| [:network, *child_result] } : nil
39
39
  return [model.with(network: new_panel), mapped_command]
40
40
 
41
- in [:shell_output, *rest]
41
+ in [:shell_output, rest]
42
42
  # Route streaming command output to modal
43
43
  new_modal, command = CustomShellModal::Update.call(message, model.shell_modal)
44
44
  return [model.with(shell_modal: new_modal), command]
@@ -61,22 +61,22 @@ module DashboardManual
61
61
  [model.with(shell_modal: CustomShellModal.open), nil]
62
62
 
63
63
  in _ if message.s?
64
- command = Command.map(SystemInfo.fetch_command) { |batch| [:stats, batch.envelope, batch] }
64
+ command = Command.map(SystemInfo.fetch_command) { |batch| [:stats, batch] }
65
65
  new_stats = model.stats.with(system_info: model.stats.system_info.with(loading: true))
66
66
  [model.with(stats: new_stats), command]
67
67
 
68
68
  in _ if message.d?
69
- command = Command.map(DiskUsage.fetch_command) { |batch| [:stats, batch.envelope, batch] }
69
+ command = Command.map(DiskUsage.fetch_command) { |batch| [:stats, batch] }
70
70
  new_stats = model.stats.with(disk_usage: model.stats.disk_usage.with(loading: true))
71
71
  [model.with(stats: new_stats), command]
72
72
 
73
73
  in _ if message.p?
74
- command = Command.map(Ping.fetch_command) { |batch| [:network, batch.envelope, batch] }
74
+ command = Command.map(Ping.fetch_command) { |batch| [:network, batch] }
75
75
  new_network = model.network.with(ping: model.network.ping.with(loading: true))
76
76
  [model.with(network: new_network), command]
77
77
 
78
78
  in _ if message.u?
79
- command = Command.map(Uptime.fetch_command) { |batch| [:network, batch.envelope, batch] }
79
+ command = Command.map(Uptime.fetch_command) { |batch| [:network, batch] }
80
80
  new_network = model.network.with(uptime: model.network.uptime.with(loading: true))
81
81
  [model.with(network: new_network), command]
82
82
 
@@ -8,12 +8,12 @@
8
8
  require "rooibos"
9
9
  # Text input fragment for custom shell command modal.
10
10
  #
11
- # Handles text entry. Sets cancelled: or submitted: in model for parent to detect.
11
+ # Handles text entry. Sets canceled: or submitted: in model for parent to detect.
12
12
  module CustomShellInput
13
- Model = Data.define(:text, :cancelled, :submitted)
13
+ Model = Data.define(:text, :canceled, :submitted)
14
14
 
15
15
  Init = -> do
16
- Ractor.make_shareable(Model.new(text: "", cancelled: false, submitted: false))
16
+ Ractor.make_shareable(Model.new(text: "", canceled: false, submitted: false))
17
17
  end
18
18
 
19
19
  View = -> (model, tui) do
@@ -57,10 +57,10 @@ module CustomShellInput
57
57
  Update = -> (message, model) do
58
58
  case message
59
59
  in _ if message.respond_to?(:esc?) && message.esc?
60
- [model.with(cancelled: true), nil]
60
+ [model.with(canceled: true), nil]
61
61
 
62
62
  in _ if message.respond_to?(:enter?) && message.enter?
63
- return [model.with(cancelled: true), nil] if model.text.strip.empty?
63
+ return [model.with(canceled: true), nil] if model.text.strip.empty?
64
64
  [model.with(submitted: true), nil]
65
65
 
66
66
  in _ if message.respond_to?(:backspace?) && message.backspace?
@@ -37,7 +37,7 @@ module CustomShellModal
37
37
  # Delegate first, then check if user wants to close
38
38
  new_input, _cmd = CustomShellInput::Update.call(message, model.input)
39
39
 
40
- if new_input.cancelled
40
+ if new_input.canceled
41
41
  [Init.(), nil]
42
42
  elsif new_input.submitted
43
43
  shell_cmd = new_input.text
@@ -32,9 +32,9 @@ module DiskUsage
32
32
 
33
33
  Update = -> (message, model) do
34
34
  case message
35
- in [{ type: :system, envelope: :disk_usage, status: 0, stdout: }]
35
+ in { type: :system, envelope: :disk_usage, status: 0, stdout: }
36
36
  [model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
37
- in [{ type: :system, envelope: :disk_usage, stderr: }]
37
+ in { type: :system, envelope: :disk_usage, stderr: }
38
38
  [model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
39
39
  else
40
40
  [model, nil]
@@ -32,11 +32,11 @@ module NetworkPanel
32
32
 
33
33
  Update = -> (message, model) do
34
34
  case message
35
- in [:ping, *rest]
36
- new_child, command = Ping::Update.call(rest, model.ping)
35
+ in { envelope: :ping, ** }
36
+ new_child, command = Ping::Update.call(message, model.ping)
37
37
  [model.with(ping: new_child), command]
38
- in [:uptime, *rest]
39
- new_child, command = Uptime::Update.call(rest, model.uptime)
38
+ in { envelope: :uptime, ** }
39
+ new_child, command = Uptime::Update.call(message, model.uptime)
40
40
  [model.with(uptime: new_child), command]
41
41
  else
42
42
  [model, nil]
@@ -32,9 +32,9 @@ module Ping
32
32
 
33
33
  Update = -> (message, model) do
34
34
  case message
35
- in [{ type: :system, envelope: :ping, status: 0, stdout: }]
35
+ in { type: :system, envelope: :ping, status: 0, stdout: }
36
36
  [model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
37
- in [{ type: :system, envelope: :ping, stderr: }]
37
+ in { type: :system, envelope: :ping, stderr: }
38
38
  [model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
39
39
  else
40
40
  [model, nil]
@@ -32,11 +32,11 @@ module StatsPanel
32
32
 
33
33
  Update = -> (message, model) do
34
34
  case message
35
- in [:system_info, *rest]
36
- new_child, command = SystemInfo::Update.call(rest, model.system_info)
35
+ in { envelope: :system_info, ** }
36
+ new_child, command = SystemInfo::Update.call(message, model.system_info)
37
37
  [model.with(system_info: new_child), command]
38
- in [:disk_usage, *rest]
39
- new_child, command = DiskUsage::Update.call(rest, model.disk_usage)
38
+ in { envelope: :disk_usage, ** }
39
+ new_child, command = DiskUsage::Update.call(message, model.disk_usage)
40
40
  [model.with(disk_usage: new_child), command]
41
41
  else
42
42
  [model, nil]
@@ -32,9 +32,9 @@ module SystemInfo
32
32
 
33
33
  Update = -> (message, model) do
34
34
  case message
35
- in [{ type: :system, envelope: :system_info, status: 0, stdout: }]
35
+ in { type: :system, envelope: :system_info, status: 0, stdout: }
36
36
  [model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
37
- in [{ type: :system, envelope: :system_info, stderr: }]
37
+ in { type: :system, envelope: :system_info, stderr: }
38
38
  [model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
39
39
  else
40
40
  [model, nil]
@@ -32,9 +32,9 @@ module Uptime
32
32
 
33
33
  Update = -> (message, model) do
34
34
  case message
35
- in [{ type: :system, envelope: :uptime, status: 0, stdout: }]
35
+ in { type: :system, envelope: :uptime, status: 0, stdout: }
36
36
  [model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
37
- in [{ type: :system, envelope: :uptime, stderr: }]
37
+ in { type: :system, envelope: :uptime, stderr: }
38
38
  [model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
39
39
  else
40
40
  [model, nil]
@@ -0,0 +1,85 @@
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
+ require "rooibos"
9
+
10
+ module FileBrowser
11
+ # Model: What state does your app need?
12
+ Model = Data.define(:path, :entries, :selected, :error)
13
+
14
+ Init = -> {
15
+ path = Dir.pwd
16
+ entries = Entries[path]
17
+ Ractor.make_shareable( # Ensures thread safety
18
+ Model.new(path:, entries:, selected: entries.first, error: nil))
19
+ }
20
+
21
+ View = -> (model, tui) {
22
+ tui.block(
23
+ titles: [
24
+ model.error || model.path,
25
+ { content: KEYS, position: :bottom, alignment: :right },
26
+ ],
27
+ borders: [:all],
28
+ border_style: if model.error then tui.style(fg: :red) else nil end,
29
+ children: [
30
+ tui.list(items: model.entries.map(&ListItem[model, tui]),
31
+ selected_index: model.entries.index(model.selected),
32
+ highlight_symbol: "",
33
+ highlight_style: tui.style(modifiers: [:reversed])),
34
+ ]
35
+ )
36
+ }
37
+
38
+ Update = -> (message, model) {
39
+ return model.with(error: ERROR) if message.error?
40
+ model = model.with(error: nil) if model.error && message.key?
41
+
42
+ if message.ctrl_c? || message.q? then Rooibos::Command.exit
43
+ elsif message.home? || message.g? then model.with(selected: model.entries.first)
44
+ elsif message.end? || message.G? then model.with(selected: model.entries.last)
45
+ elsif message.up_arrow? || message.k? then Select[:-, model]
46
+ elsif message.down_arrow? || message.j? then Select[:+, model]
47
+ elsif message.enter? then Open[model]
48
+ elsif message.escape? then Navigate[File.dirname(model.path), model]
49
+ end
50
+ }
51
+
52
+ KEYS = "↑/↓/Home/End: Select | Enter: Open | Esc: Navigate Up | q: Quit"
53
+ ERROR = "Sorry, opening the selected file failed."
54
+
55
+ ListItem = -> (model, tui) {
56
+ -> (name) {
57
+ modifiers = name.start_with?(".") ? [:dim] : []
58
+ fg = :blue if name.end_with?("/")
59
+ tui.list_item(content: name, style: tui.style(fg:, modifiers:))
60
+ }
61
+ }
62
+
63
+ Select = -> (operator, model) {
64
+ new_index = model.entries.index(model.selected).public_send(operator, 1)
65
+ model.with(selected: model.entries[new_index.clamp(0, model.entries.length - 1)])
66
+ }
67
+
68
+ Open = -> (model) {
69
+ full = File.join(model.path, model.selected.delete_suffix("/"))
70
+ model.selected.end_with?("/") ? Navigate[full, model] : Rooibos::Command.open(full)
71
+ }
72
+
73
+ Navigate = -> (path, model) {
74
+ entries = Entries[path]
75
+ model.with(path:, entries:, selected: entries.first, error: nil)
76
+ }
77
+
78
+ Entries = -> (path) {
79
+ Dir.children(path).map { |name|
80
+ File.directory?(File.join(path, name)) ? "#{name}/" : name
81
+ }.sort_by { |name| [name.end_with?("/") ? 0 : 1, name.downcase] }
82
+ }
83
+ end
84
+
85
+ Rooibos.run(FileBrowser) if __FILE__ == $0
@@ -0,0 +1,31 @@
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
+ require "rooibos"
9
+
10
+ module Counter
11
+ # Init: How do you create the initial model?
12
+ Init = -> { 0 }
13
+
14
+ # View: What does the user see?
15
+ View = -> (model, tui) { tui.paragraph(text: <<~END) }
16
+ Current count: #{model}.
17
+ Press any key to increment.
18
+ Press Ctrl+C to quit.
19
+ END
20
+
21
+ # Update: What happens when things change?
22
+ Update = -> (message, model) {
23
+ if message.ctrl_c?
24
+ Rooibos::Command.exit
25
+ elsif message.key?
26
+ model + 1
27
+ end
28
+ }
29
+ end
30
+
31
+ Rooibos.run(Counter) if __FILE__ == $0
@@ -25,13 +25,15 @@ require "rooibos"
25
25
  # rdoc-image:/doc/images/widget_cmd_exec.png
26
26
  class WidgetCommandSystem
27
27
  Model = Data.define(:result, :loading, :last_command)
28
- INITIAL = Model.new(
29
- result: "Press a key to run a command...",
30
- loading: false,
31
- last_command: nil
32
- )
28
+ Init = -> {
29
+ Model.new(
30
+ result: "Press a key to run a command...",
31
+ loading: false,
32
+ last_command: nil
33
+ )
34
+ }
33
35
 
34
- VIEW = -> (model, tui) do
36
+ View = -> (model, tui) do
35
37
  hotkey_style = tui.style(modifiers: [:bold, :underlined])
36
38
  dim_style = tui.style(fg: :dark_gray)
37
39
 
@@ -97,12 +99,12 @@ class WidgetCommandSystem
97
99
  )
98
100
  end
99
101
 
100
- UPDATE = -> (message, model) do
102
+ Update = -> (message, model) do
101
103
  case message
102
- # Handle command results
103
- in [:got_output, { stdout:, status: 0 }]
104
+ # Handle command results (hash-based pattern matching)
105
+ in { type: :system, envelope: :got_output, stdout:, status: 0 }
104
106
  [model.with(result: stdout.strip.freeze, loading: false), nil]
105
- in [:got_output, { stderr:, status: }]
107
+ in { type: :system, envelope: :got_output, stderr:, status: }
106
108
  [model.with(result: "Error (exit #{status}): #{stderr.strip}".freeze, loading: false), nil]
107
109
 
108
110
  # Handle key presses
@@ -113,11 +115,11 @@ class WidgetCommandSystem
113
115
  in _ if message.u?
114
116
  [model.with(loading: true, last_command: "uname -a"), Rooibos::Command.system("uname -a", :got_output)]
115
117
  in _ if message.s?
116
- command = "sleep 3 && echo 'Slept for 3s'"
118
+ cmd = "sleep 3 && echo 'Slept for 3s'"
117
119
  [model.with(loading: true, last_command: cmd.freeze), Rooibos::Command.system(cmd, :got_output)]
118
120
  in _ if message.f?
119
121
  # Intentional failure to demonstrate error handling
120
- command = "ls /nonexistent_path_12345"
122
+ cmd = "ls /nonexistent_path_12345"
121
123
  [model.with(loading: true, last_command: cmd.freeze), Rooibos::Command.system(cmd, :got_output)]
122
124
  else
123
125
  model
@@ -125,7 +127,7 @@ class WidgetCommandSystem
125
127
  end
126
128
 
127
129
  def run
128
- Rooibos.run(model: INITIAL, view: VIEW, update: UPDATE)
130
+ Rooibos.run(WidgetCommandSystem)
129
131
  end
130
132
  end
131
133
 
data/exe/rooibos ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #--
5
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
6
+ # SPDX-License-Identifier: LGPL-3.0-or-later
7
+ #++
8
+
9
+ require "rooibos/cli"
10
+ Rooibos::CLI.call(ARGV)
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+
6
+ # Parse tutorial structure from documentation_plan.md
7
+ TUTORIAL_FILES = {
8
+ "index.md" => { title: "Tutorial: Build a File Browser", story: nil },
9
+ "01_project_setup.md" => { title: "Project Setup", story: "-4" },
10
+ "02_hello_world.md" => { title: "Hello World", story: "-3" },
11
+ "03_static_file_list.md" => { title: "Static File List", story: "-2" },
12
+ "04_arrow_navigation.md" => { title: "Arrow Navigation", story: "-1" },
13
+ "05_real_files.md" => { title: "Real Files", story: "0" },
14
+ "06_safe_refactoring.md" => { title: "Safe Refactoring", story: "4a" },
15
+ "07_red_first_tdd.md" => { title: "Red-First TDD", story: "4b" },
16
+ "08_file_metadata.md" => { title: "File Metadata", story: "5" },
17
+ "09_text_preview.md" => { title: "Text Preview", story: "6" },
18
+ "10_directory_tree.md" => { title: "Directory Tree", story: "7" },
19
+ "11_pane_focus.md" => { title: "Pane Focus", story: "8" },
20
+ "12_sorting.md" => { title: "Sorting", story: "9" },
21
+ "13_filtering.md" => { title: "Filtering", story: "10" },
22
+ "14_toggle_hidden.md" => { title: "Toggle Hidden Files", story: "11" },
23
+ "15_text_input_widget.md" => { title: "Text Input Widget", story: "12" },
24
+ "16_rename_files.md" => { title: "Rename Files", story: "13" },
25
+ "17_confirmation_dialogs.md" => { title: "Confirmation Dialogs", story: "14" },
26
+ "18_progress_indicators.md" => { title: "Progress Indicators", story: "15" },
27
+ "19_atomic_operations.md" => { title: "Atomic Operations", story: "16" },
28
+ "20_external_editor.md" => { title: "External Editor", story: "17" },
29
+ "21_modal_overlays.md" => { title: "Modal Overlays", story: "18" },
30
+ "22_error_handling.md" => { title: "Error Handling", story: "19" },
31
+ "23_terminal_capabilities.md" => { title: "Terminal Capabilities", story: "23" },
32
+ "24_mouse_events.md" => { title: "Mouse Events", story: "20" },
33
+ "25_resize_events.md" => { title: "Resize Events", story: "21" },
34
+ "26_loading_states.md" => { title: "Loading States", story: "22" },
35
+ "27_performance.md" => { title: "Performance", story: "24" },
36
+ "28_color_schemes.md" => { title: "Color Schemes", story: "26" },
37
+ "29_configuration.md" => { title: "Configuration", story: "27" },
38
+ "30_going_further.md" => { title: "Going Further", story: nil },
39
+ }
40
+
41
+ # Parse stories from file_browser_stories.md
42
+ stories_content = File.read("doc/contributors/specs/file_browser_stories.md")
43
+
44
+ # Extract each story
45
+ stories = {}
46
+ current_story = nil
47
+ current_content = []
48
+
49
+ stories_content.each_line do |line|
50
+ if line =~ /^## Story (-?\d+[ab]?): (.+)$/
51
+ # Save previous story
52
+ if current_story
53
+ stories[current_story] = current_content.join
54
+ end
55
+
56
+ current_story = $1
57
+ current_content = [line]
58
+ elsif current_story
59
+ current_content << line
60
+
61
+ # Stop at the next story or end of stories section
62
+ if line =~ /^---$/ && current_content.size > 5
63
+ # Check if next section is another story or implementation notes
64
+ peek_ahead = stories_content.lines[stories_content.lines.index(line) + 1]
65
+ if peek_ahead && !peek_ahead.start_with?("## Story")
66
+ stories[current_story] = current_content.join
67
+ break if peek_ahead.start_with?("## Implementation Notes")
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ # Save last story
74
+ stories[current_story] = current_content.join if current_story
75
+
76
+ # Create tutorial directory
77
+ FileUtils.mkdir_p("doc/tutorial")
78
+
79
+ # Generate stub files
80
+ TUTORIAL_FILES.each do |filename, meta|
81
+ filepath = "doc/tutorial/#{filename}"
82
+
83
+ # Determine previous/next files
84
+ files_list = TUTORIAL_FILES.keys
85
+ current_index = files_list.index(filename)
86
+ prev_file = (current_index > 0) ? files_list[current_index - 1] : nil
87
+ next_file = (current_index < files_list.size - 1) ? files_list[current_index + 1] : nil
88
+
89
+ prev_title = prev_file ? TUTORIAL_FILES[prev_file][:title] : nil
90
+ next_title = next_file ? TUTORIAL_FILES[next_file][:title] : nil
91
+
92
+ # Get story content if applicable
93
+ story_section = ""
94
+ if meta[:story]
95
+ story_key = meta[:story]
96
+ if stories[story_key]
97
+ story_section = "\n## User Stories\n\n#{stories[story_key]}\n"
98
+ end
99
+ end
100
+
101
+ # Generate stub content
102
+ content = <<~MARKDOWN
103
+ <!--
104
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
105
+ SPDX-License-Identifier: CC-BY-SA-4.0
106
+ -->
107
+
108
+ # #{meta[:title]}
109
+
110
+
111
+ By the end of this guide, you will:
112
+
113
+ - TODO: Write learning objectives
114
+
115
+ > ⚠️ **This page is a stub.** Help us write it! See the [Documentation Plan](../contributors/documentation_plan.md) and [Style Guide](../contributors/documentation_style.md).
116
+ #{story_section}
117
+ ---
118
+
119
+ #{prev_file ? "[**Previous:** #{prev_title}](./#{prev_file})" : ''} | #{next_file ? "[**Next:** #{next_title}](./#{next_file})" : ''}
120
+ MARKDOWN
121
+
122
+ File.write(filepath, content)
123
+ puts "Created #{filepath}"
124
+ end
125
+
126
+ puts "\nDone! Created #{TUTORIAL_FILES.size} tutorial stub files."