rooibos 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. checksums.yaml +7 -0
  2. data/.builds/ruby-3.2.yml +51 -0
  3. data/.builds/ruby-3.3.yml +51 -0
  4. data/.builds/ruby-3.4.yml +51 -0
  5. data/.builds/ruby-4.0.0.yml +51 -0
  6. data/.pre-commit-config.yaml +16 -0
  7. data/.rubocop.yml +8 -0
  8. data/AGENTS.md +108 -0
  9. data/CHANGELOG.md +214 -0
  10. data/LICENSE +304 -0
  11. data/LICENSES/AGPL-3.0-or-later.txt +235 -0
  12. data/LICENSES/CC-BY-SA-4.0.txt +170 -0
  13. data/LICENSES/CC0-1.0.txt +121 -0
  14. data/LICENSES/LGPL-3.0-or-later.txt +304 -0
  15. data/LICENSES/MIT-0.txt +16 -0
  16. data/LICENSES/MIT.txt +18 -0
  17. data/README.md +183 -0
  18. data/REUSE.toml +24 -0
  19. data/Rakefile +16 -0
  20. data/Steepfile +13 -0
  21. data/doc/concepts/application_architecture.md +197 -0
  22. data/doc/concepts/application_testing.md +49 -0
  23. data/doc/concepts/async_work.md +164 -0
  24. data/doc/concepts/commands.md +530 -0
  25. data/doc/concepts/message_processing.md +51 -0
  26. data/doc/contributors/WIP/decomposition_strategies_analysis.md +258 -0
  27. data/doc/contributors/WIP/implementation_plan.md +409 -0
  28. data/doc/contributors/WIP/init_callable_proposal.md +344 -0
  29. data/doc/contributors/WIP/mvu_tea_implementations_research.md +373 -0
  30. data/doc/contributors/WIP/runtime_refactoring_status.md +47 -0
  31. data/doc/contributors/WIP/task.md +36 -0
  32. data/doc/contributors/WIP/v0.4.0_todo.md +468 -0
  33. data/doc/contributors/design/commands_and_outlets.md +214 -0
  34. data/doc/contributors/kit-no-outlet.md +238 -0
  35. data/doc/contributors/priorities.md +38 -0
  36. data/doc/custom.css +22 -0
  37. data/doc/getting_started/quickstart.md +56 -0
  38. data/doc/images/.gitkeep +0 -0
  39. data/doc/images/verify_readme_usage.png +0 -0
  40. data/doc/images/widget_cmd_exec.png +0 -0
  41. data/doc/index.md +25 -0
  42. data/examples/app_fractal_dashboard/README.md +60 -0
  43. data/examples/app_fractal_dashboard/app.rb +63 -0
  44. data/examples/app_fractal_dashboard/dashboard/base.rb +73 -0
  45. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +86 -0
  46. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +87 -0
  47. data/examples/app_fractal_dashboard/dashboard/update_router.rb +43 -0
  48. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +81 -0
  49. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
  50. data/examples/app_fractal_dashboard/fragments/custom_shell_output.rb +90 -0
  51. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +47 -0
  52. data/examples/app_fractal_dashboard/fragments/network_panel.rb +45 -0
  53. data/examples/app_fractal_dashboard/fragments/ping.rb +47 -0
  54. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +45 -0
  55. data/examples/app_fractal_dashboard/fragments/system_info.rb +47 -0
  56. data/examples/app_fractal_dashboard/fragments/uptime.rb +47 -0
  57. data/examples/verify_readme_usage/README.md +54 -0
  58. data/examples/verify_readme_usage/app.rb +47 -0
  59. data/examples/widget_command_system/README.md +70 -0
  60. data/examples/widget_command_system/app.rb +132 -0
  61. data/exe/.gitkeep +0 -0
  62. data/lib/rooibos/command/all.rb +69 -0
  63. data/lib/rooibos/command/batch.rb +77 -0
  64. data/lib/rooibos/command/custom.rb +104 -0
  65. data/lib/rooibos/command/http.rb +192 -0
  66. data/lib/rooibos/command/lifecycle.rb +134 -0
  67. data/lib/rooibos/command/outlet.rb +157 -0
  68. data/lib/rooibos/command/wait.rb +80 -0
  69. data/lib/rooibos/command.rb +546 -0
  70. data/lib/rooibos/error.rb +55 -0
  71. data/lib/rooibos/message/all.rb +45 -0
  72. data/lib/rooibos/message/http_response.rb +61 -0
  73. data/lib/rooibos/message/system/batch.rb +61 -0
  74. data/lib/rooibos/message/system/stream.rb +67 -0
  75. data/lib/rooibos/message/timer.rb +46 -0
  76. data/lib/rooibos/message.rb +38 -0
  77. data/lib/rooibos/router.rb +403 -0
  78. data/lib/rooibos/runtime.rb +396 -0
  79. data/lib/rooibos/shortcuts.rb +49 -0
  80. data/lib/rooibos/test_helper.rb +56 -0
  81. data/lib/rooibos/version.rb +12 -0
  82. data/lib/rooibos.rb +121 -0
  83. data/mise.toml +8 -0
  84. data/rbs_collection.lock.yaml +108 -0
  85. data/rbs_collection.yaml +15 -0
  86. data/sig/concurrent.rbs +72 -0
  87. data/sig/examples/verify_readme_usage/app.rbs +19 -0
  88. data/sig/examples/widget_command_system/app.rbs +26 -0
  89. data/sig/open3.rbs +17 -0
  90. data/sig/rooibos/command.rbs +265 -0
  91. data/sig/rooibos/error.rbs +13 -0
  92. data/sig/rooibos/message.rbs +121 -0
  93. data/sig/rooibos/router.rbs +153 -0
  94. data/sig/rooibos/runtime.rbs +75 -0
  95. data/sig/rooibos/shortcuts.rbs +16 -0
  96. data/sig/rooibos/test_helper.rbs +10 -0
  97. data/sig/rooibos/version.rbs +8 -0
  98. data/sig/rooibos.rbs +46 -0
  99. data/tasks/example_viewer.html.erb +172 -0
  100. data/tasks/resources/build.yml.erb +53 -0
  101. data/tasks/resources/index.html.erb +44 -0
  102. data/tasks/resources/rubies.yml +7 -0
  103. data/tasks/steep.rake +11 -0
  104. data/vendor/goodcop/base.yml +1047 -0
  105. metadata +241 -0
@@ -0,0 +1,47 @@
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
+ # Pings localhost to check network connectivity.
10
+ # A fragment for pinging localhost.
11
+ module Ping
12
+ Command = Rooibos::Command
13
+
14
+ Model = Data.define(:output, :loading)
15
+
16
+ Init = -> do
17
+ Model.new(output: "Press 'p' for ping", loading: false)
18
+ end
19
+
20
+ View = -> (model, tui, disabled: false) do
21
+ text_style = if disabled && model.output == Init.().output
22
+ tui.style(fg: :dark_gray)
23
+ else
24
+ nil
25
+ end
26
+
27
+ tui.paragraph(
28
+ text: tui.text_span(content: model.output, style: text_style),
29
+ block: tui.block(title: "Ping", borders: [:all], border_style: { fg: :magenta })
30
+ )
31
+ end
32
+
33
+ Update = -> (message, model) do
34
+ case message
35
+ in [{ type: :system, envelope: :ping, status: 0, stdout: }]
36
+ [model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
37
+ in [{ type: :system, envelope: :ping, stderr: }]
38
+ [model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
39
+ else
40
+ [model, nil]
41
+ end
42
+ end
43
+
44
+ def self.fetch_command
45
+ Command.system("ping -c 3 8.8.8.8", :ping)
46
+ end
47
+ end
@@ -0,0 +1,45 @@
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
+ require_relative "system_info"
10
+ require_relative "disk_usage"
11
+
12
+ # Composes SystemInfo and DiskUsage in a horizontal layout.
13
+ module StatsPanel
14
+ Model = Data.define(:system_info, :disk_usage)
15
+
16
+ Init = -> do
17
+ system_info, = Rooibos.normalize_init(SystemInfo::Init.())
18
+ disk_usage, = Rooibos.normalize_init(DiskUsage::Init.())
19
+ Model.new(system_info:, disk_usage:)
20
+ end
21
+
22
+ View = -> (model, tui, disabled: false) do
23
+ tui.layout(
24
+ direction: :horizontal,
25
+ constraints: [tui.constraint_percentage(50), tui.constraint_percentage(50)],
26
+ children: [
27
+ SystemInfo::View.call(model.system_info, tui, disabled:),
28
+ DiskUsage::View.call(model.disk_usage, tui, disabled:),
29
+ ]
30
+ )
31
+ end
32
+
33
+ Update = -> (message, model) do
34
+ case message
35
+ in [:system_info, *rest]
36
+ new_child, command = SystemInfo::Update.call(rest, model.system_info)
37
+ [model.with(system_info: new_child), command]
38
+ in [:disk_usage, *rest]
39
+ new_child, command = DiskUsage::Update.call(rest, model.disk_usage)
40
+ [model.with(disk_usage: new_child), command]
41
+ else
42
+ [model, nil]
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,47 @@
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
+ # Fetches and displays system information via +uname -a+.
10
+ # A fragment for fetching and displaying system information.
11
+ module SystemInfo
12
+ Command = Rooibos::Command
13
+
14
+ Model = Data.define(:output, :loading)
15
+
16
+ Init = -> do
17
+ Model.new(output: "Press 's' for system info", loading: false)
18
+ end
19
+
20
+ View = -> (model, tui, disabled: false) do
21
+ text_style = if disabled && model.output == Init.().output
22
+ tui.style(fg: :dark_gray)
23
+ else
24
+ nil
25
+ end
26
+
27
+ tui.paragraph(
28
+ text: tui.text_span(content: model.output, style: text_style),
29
+ block: tui.block(title: "System Info", borders: [:all], border_style: { fg: :cyan })
30
+ )
31
+ end
32
+
33
+ Update = -> (message, model) do
34
+ case message
35
+ in [{ type: :system, envelope: :system_info, status: 0, stdout: }]
36
+ [model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
37
+ in [{ type: :system, envelope: :system_info, stderr: }]
38
+ [model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
39
+ else
40
+ [model, nil]
41
+ end
42
+ end
43
+
44
+ def self.fetch_command
45
+ Command.system("uname -a", :system_info)
46
+ end
47
+ end
@@ -0,0 +1,47 @@
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
+ # Displays system uptime.
10
+ # A fragment for displaying system uptime.
11
+ module Uptime
12
+ Command = Rooibos::Command
13
+
14
+ Model = Data.define(:output, :loading)
15
+
16
+ Init = -> do
17
+ Model.new(output: "Press 'u' for uptime", loading: false)
18
+ end
19
+
20
+ View = -> (model, tui, disabled: false) do
21
+ text_style = if disabled && model.output == Init.().output
22
+ tui.style(fg: :dark_gray)
23
+ else
24
+ nil
25
+ end
26
+
27
+ tui.paragraph(
28
+ text: tui.text_span(content: model.output, style: text_style),
29
+ block: tui.block(title: "Uptime", borders: [:all], border_style: { fg: :magenta })
30
+ )
31
+ end
32
+
33
+ Update = -> (message, model) do
34
+ case message
35
+ in [{ type: :system, envelope: :uptime, status: 0, stdout: }]
36
+ [model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
37
+ in [{ type: :system, envelope: :uptime, stderr: }]
38
+ [model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
39
+ else
40
+ [model, nil]
41
+ end
42
+ end
43
+
44
+ def self.fetch_command
45
+ Command.system("uptime", :uptime)
46
+ end
47
+ end
@@ -0,0 +1,54 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
+ SPDX-License-Identifier: CC-BY-SA-4.0
4
+ -->
5
+
6
+ # README Usage Verification
7
+
8
+ Verifies the primary usage example for the Rooibos gem.
9
+
10
+ This example exists as a documentation regression test. It ensures that the very first MVU pattern a user sees actually works.
11
+
12
+ ## Usage
13
+
14
+ <!-- SPDX-SnippetBegin -->
15
+ <!--
16
+ SPDX-FileCopyrightText: 2026 Kerrick Long
17
+ SPDX-License-Identifier: MIT-0
18
+ -->
19
+ <!-- SYNC:START:./app.rb:mvu -->
20
+ ```ruby
21
+ Model = Data.define(:text)
22
+
23
+ Init = -> do
24
+ Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
25
+ end
26
+
27
+ View = -> (model, tui) do
28
+ tui.paragraph(
29
+ text: model.text,
30
+ alignment: :center,
31
+ block: tui.block(
32
+ title: "My Ruby TUI App",
33
+ borders: [:all],
34
+ border_style: { fg: "cyan" }
35
+ )
36
+ )
37
+ end
38
+
39
+ Update = -> (msg, model) do
40
+ if msg.q? || msg.ctrl_c?
41
+ Rooibos::Command.exit
42
+ else
43
+ model
44
+ end
45
+ end
46
+
47
+ def run
48
+ Rooibos.run(VerifyReadmeUsage)
49
+ end
50
+ ```
51
+ <!-- SYNC:END -->
52
+ <!-- SPDX-SnippetEnd -->
53
+
54
+ [![verify_readme_usage](../../doc/images/verify_readme_usage.png)](../../README.md#usage)
@@ -0,0 +1,47 @@
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
+ $LOAD_PATH.unshift File.expand_path("../../lib", __dir__)
9
+
10
+ require "ratatui_ruby"
11
+ require "rooibos"
12
+
13
+ class VerifyReadmeUsage
14
+ # [SYNC:START:mvu]
15
+ Model = Data.define(:text)
16
+
17
+ Init = -> do
18
+ Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
19
+ end
20
+
21
+ View = -> (model, tui) do
22
+ tui.paragraph(
23
+ text: model.text,
24
+ alignment: :center,
25
+ block: tui.block(
26
+ title: "My Ruby TUI App",
27
+ borders: [:all],
28
+ border_style: { fg: "cyan" }
29
+ )
30
+ )
31
+ end
32
+
33
+ Update = -> (msg, model) do
34
+ if msg.q? || msg.ctrl_c?
35
+ Rooibos::Command.exit
36
+ else
37
+ model
38
+ end
39
+ end
40
+
41
+ def run
42
+ Rooibos.run(VerifyReadmeUsage)
43
+ end
44
+ # [SYNC:END:mvu]
45
+ end
46
+
47
+ VerifyReadmeUsage.new.run if __FILE__ == $PROGRAM_NAME
@@ -0,0 +1,70 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
+ SPDX-License-Identifier: CC-BY-SA-4.0
4
+ -->
5
+ # Cmd.exec Example
6
+
7
+ Demonstrates running shell commands using `Cmd.exec`.
8
+
9
+ Commands in TEA produce **messages**, not callbacks. When a command completes, the runtime sends a tagged tuple to your `update` function. Pattern match on the tag to handle success and failure.
10
+
11
+ ## Key Concepts
12
+
13
+ - **Message Tags:** `Cmd.exec(command, tag)` produces `[tag, {stdout:, stderr:, status:}]`.
14
+ - **Non-Blocking:** Commands run in background threads. The UI remains responsive.
15
+ - **Success Handling:** Match on `status: 0` to handle successful execution.
16
+ - **Error Handling:** Match on non-zero status to handle failures.
17
+ - **Ractor-Safe:** No callbacks means no Proc captures. Messages are shareable.
18
+
19
+ ## Hotkeys
20
+
21
+ - `d`: Run `ls -la` (directory listing)
22
+ - `u`: Run `uname -a` (system info)
23
+ - `f`: Run a command that fails (demonstrates error handling)
24
+ - `q`: **Quit**
25
+
26
+ ## Usage
27
+
28
+ <!-- SPDX-SnippetBegin -->
29
+ <!--
30
+ SPDX-FileCopyrightText: 2026 Kerrick Long
31
+ SPDX-License-Identifier: MIT-0
32
+ -->
33
+ ```bash
34
+ ruby examples/widget_cmd_exec/app.rb
35
+ ```
36
+ <!-- SPDX-SnippetEnd -->
37
+
38
+ ## How It Works
39
+
40
+ The update function handles both key presses and command results:
41
+
42
+ <!-- SPDX-SnippetBegin -->
43
+ <!--
44
+ SPDX-FileCopyrightText: 2026 Kerrick Long
45
+ SPDX-License-Identifier: MIT-0
46
+ -->
47
+ ```ruby
48
+ UPDATE = -> (msg, model) do
49
+ case msg
50
+ # Handle command results
51
+ in [:got_output, {stdout:, status: 0}]
52
+ [model.with(result: stdout.strip, loading: false), nil]
53
+ in [:got_output, {stderr:, status:}]
54
+ [model.with(result: "Error (exit #{status}): #{stderr.strip}", loading: false), nil]
55
+
56
+ # Handle key presses
57
+ in _ if msg.d?
58
+ [model.with(loading: true), Cmd.exec("ls -la", :got_output)]
59
+ else
60
+ model
61
+ end
62
+ end
63
+ ```
64
+ <!-- SPDX-SnippetEnd -->
65
+
66
+ All logic stays in `update`. The command just runs and produces a message.
67
+
68
+ [Read the source code →](app.rb)
69
+
70
+ [![widget_cmd_exec](../../doc/images/widget_cmd_exec.png)](app.rb)
@@ -0,0 +1,132 @@
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
+ $LOAD_PATH.unshift File.expand_path("../../lib", __dir__)
9
+
10
+ require "ratatui_ruby"
11
+ require "rooibos"
12
+
13
+ # Demonstrates the Command.execute command for running shell commands.
14
+ #
15
+ # This example shows how to execute shell commands and handle both success
16
+ # and failure cases using pattern matching in update. The split layout shows
17
+ # command output in the main area with controls at the bottom.
18
+ #
19
+ # === Examples
20
+ #
21
+ # Run the demo from the terminal:
22
+ #
23
+ # ruby examples/widget_cmd_exec/app.rb
24
+ #
25
+ # rdoc-image:/doc/images/widget_cmd_exec.png
26
+ class WidgetCommandSystem
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
+ )
33
+
34
+ VIEW = -> (model, tui) do
35
+ hotkey_style = tui.style(modifiers: [:bold, :underlined])
36
+ dim_style = tui.style(fg: :dark_gray)
37
+
38
+ # Styles
39
+ border_color = if model.loading
40
+ "yellow"
41
+ elsif model.result.start_with?("Error")
42
+ "red"
43
+ else
44
+ "cyan"
45
+ end
46
+
47
+ title = model.last_command ? "Output: #{model.last_command}" : "Command.execute Demo"
48
+ content_text = model.loading ? "Running command..." : model.result
49
+
50
+ # 1. Main Output Widget
51
+ output_widget = tui.paragraph(
52
+ text: content_text,
53
+ block: tui.block(
54
+ title:,
55
+ borders: [:all],
56
+ border_style: { fg: border_color },
57
+ padding: 1
58
+ )
59
+ )
60
+
61
+ # 2. Control Panel Widget
62
+ control_widget = tui.paragraph(
63
+ text: [
64
+ tui.text_line(spans: [
65
+ tui.text_span(content: "d", style: hotkey_style),
66
+ tui.text_span(content: ": Directory listing (ls -la) "),
67
+ tui.text_span(content: "u", style: hotkey_style),
68
+ tui.text_span(content: ": System info (uname -a)"),
69
+ ]),
70
+ tui.text_line(spans: [
71
+ tui.text_span(content: "f", style: hotkey_style),
72
+ tui.text_span(content: ": Force failure "),
73
+ tui.text_span(content: "s", style: hotkey_style),
74
+ tui.text_span(content: ": Sleep (3s) "),
75
+ tui.text_span(content: "q", style: hotkey_style),
76
+ tui.text_span(content: ": Quit"),
77
+ ]),
78
+ ],
79
+ block: tui.block(
80
+ title: "Controls",
81
+ borders: [:all],
82
+ border_style: dim_style
83
+ )
84
+ )
85
+
86
+ # Return the Root Layout Widget (Blueprint)
87
+ tui.layout(
88
+ direction: :vertical,
89
+ constraints: [
90
+ tui.constraint_fill(1),
91
+ tui.constraint_length(6),
92
+ ],
93
+ children: [
94
+ output_widget,
95
+ control_widget,
96
+ ]
97
+ )
98
+ end
99
+
100
+ UPDATE = -> (message, model) do
101
+ case message
102
+ # Handle command results
103
+ in [:got_output, { stdout:, status: 0 }]
104
+ [model.with(result: stdout.strip.freeze, loading: false), nil]
105
+ in [:got_output, { stderr:, status: }]
106
+ [model.with(result: "Error (exit #{status}): #{stderr.strip}".freeze, loading: false), nil]
107
+
108
+ # Handle key presses
109
+ in _ if message.q? || message.ctrl_c?
110
+ Rooibos::Command.exit
111
+ in _ if message.d?
112
+ [model.with(loading: true, last_command: "ls -la"), Rooibos::Command.system("ls -la", :got_output)]
113
+ in _ if message.u?
114
+ [model.with(loading: true, last_command: "uname -a"), Rooibos::Command.system("uname -a", :got_output)]
115
+ in _ if message.s?
116
+ command = "sleep 3 && echo 'Slept for 3s'"
117
+ [model.with(loading: true, last_command: cmd.freeze), Rooibos::Command.system(cmd, :got_output)]
118
+ in _ if message.f?
119
+ # Intentional failure to demonstrate error handling
120
+ command = "ls /nonexistent_path_12345"
121
+ [model.with(loading: true, last_command: cmd.freeze), Rooibos::Command.system(cmd, :got_output)]
122
+ else
123
+ model
124
+ end
125
+ end
126
+
127
+ def run
128
+ Rooibos.run(model: INITIAL, view: VIEW, update: UPDATE)
129
+ end
130
+ end
131
+
132
+ WidgetCommandSystem.new.run if __FILE__ == $PROGRAM_NAME
data/exe/.gitkeep ADDED
File without changes
@@ -0,0 +1,69 @@
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 Command
10
+ # An aggregating parallel command.
11
+ All = Data.define(:envelope, :commands, :nested) do
12
+ include Custom
13
+
14
+ def self.new(tag, *args)
15
+ # DWIM: detect nested vs splatted based on call-site arity
16
+ if args.size == 1 && args.first.is_a?(Array)
17
+ commands = args.first
18
+ nested = true
19
+ else
20
+ commands = args
21
+ nested = false
22
+ end
23
+
24
+ if RatatuiRuby::Debug.enabled?
25
+ commands.each do |cmd|
26
+ unless Ractor.shareable?(cmd)
27
+ raise Rooibos::Error::Invariant,
28
+ "Command is not Ractor-shareable: #{cmd.inspect}\n" \
29
+ "Use Ractor.make_shareable or a Data.define command."
30
+ end
31
+ end
32
+ end
33
+
34
+ instance = allocate
35
+ instance.__send__(:initialize, envelope: tag, commands: commands.freeze, nested:)
36
+ instance
37
+ end
38
+
39
+ def call(out, token)
40
+ # Early return for empty commands - prevents hang from zip_futures([])
41
+ if commands.empty?
42
+ response = Message::All.new(envelope:, results: [].freeze, nested:)
43
+ out.put(Ractor.make_shareable(response))
44
+ return
45
+ end
46
+
47
+ child_lifecycle = Lifecycle.new
48
+
49
+ futures = commands.map do |command|
50
+ Concurrent::Promises.future do
51
+ child_channel = Concurrent::Promises::Channel.new
52
+ child_outlet = Outlet.new(child_channel, lifecycle: child_lifecycle)
53
+ command.call(child_outlet, token)
54
+ child_channel.pop
55
+ end
56
+ end
57
+
58
+ all_done = Concurrent::Promises.zip_futures(*futures)
59
+ Concurrent::Promises.any_event(all_done, token.origin).wait
60
+
61
+ return out.put(Command.cancel(self)) if token.canceled?
62
+
63
+ shareable_results = Ractor.make_shareable(all_done.value!)
64
+ response = Message::All.new(envelope:, results: shareable_results, nested:)
65
+ out.put(Ractor.make_shareable(response))
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,77 @@
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 Command
10
+ # A fire-and-forget parallel command.
11
+ #
12
+ # Applications fetch data from multiple sources. Dashboard panels load
13
+ # users, stats, and notifications. Waiting sequentially is slow.
14
+ # Managing threads and error handling manually is error-prone.
15
+ #
16
+ # This command runs children in parallel. Each child sends its own messages
17
+ # independently. The batch completes when all children finish or when
18
+ # cancellation fires. On cancellation, emits <tt>Command.cancel(self)</tt>.
19
+ #
20
+ # Use it for parallel fetches, concurrent refreshes, or any work that
21
+ # does not need coordinated results.
22
+ #
23
+ # === Example
24
+ #
25
+ # def update(msg, model)
26
+ # case msg
27
+ # in :refresh_all
28
+ # batch = Command.batch(
29
+ # Command.http(:get, "/users", :users),
30
+ # Command.http(:get, "/stats", :stats),
31
+ # )
32
+ # [model.with(loading: true), batch]
33
+ # in :users | :stats
34
+ # [model.with(msg => data), nil]
35
+ # end
36
+ # end
37
+ class Batch < Data.define(:commands) do
38
+ include Custom
39
+
40
+ # Initialize
41
+ def self.new(*args)
42
+ # DWIM: accept (cmd1, cmd2) or ([cmd1, cmd2])
43
+ commands = (args.size == 1 && args.first.is_a?(Array)) ? args.first : args
44
+
45
+ if RatatuiRuby::Debug.enabled?
46
+ commands.each do |cmd|
47
+ unless Ractor.shareable?(cmd)
48
+ raise Rooibos::Error::Invariant,
49
+ "Command is not Ractor-shareable: #{cmd.inspect}\n" \
50
+ "Use Ractor.make_shareable or a Data.define command."
51
+ end
52
+ end
53
+ end
54
+
55
+ instance = allocate
56
+ instance.__send__(:initialize, commands: commands.freeze)
57
+ instance
58
+ end
59
+
60
+ # Call it
61
+ def call(out, token)
62
+ futures = commands.map do |command|
63
+ Concurrent::Promises.future { command.call(out, token) }
64
+ end
65
+
66
+ all_done = Concurrent::Promises.zip_futures(*futures)
67
+ Concurrent::Promises.any_event(all_done, token.origin).wait
68
+
69
+ # Re-raise any child exception for runtime to wrap in Command::Error
70
+ futures.each { |f| raise f.reason if f.rejected? }
71
+
72
+ out.put(Command.cancel(self)) if token.canceled?
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end