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.
- checksums.yaml +7 -0
- data/.builds/ruby-3.2.yml +51 -0
- data/.builds/ruby-3.3.yml +51 -0
- data/.builds/ruby-3.4.yml +51 -0
- data/.builds/ruby-4.0.0.yml +51 -0
- data/.pre-commit-config.yaml +16 -0
- data/.rubocop.yml +8 -0
- data/AGENTS.md +108 -0
- data/CHANGELOG.md +214 -0
- data/LICENSE +304 -0
- data/LICENSES/AGPL-3.0-or-later.txt +235 -0
- data/LICENSES/CC-BY-SA-4.0.txt +170 -0
- data/LICENSES/CC0-1.0.txt +121 -0
- data/LICENSES/LGPL-3.0-or-later.txt +304 -0
- data/LICENSES/MIT-0.txt +16 -0
- data/LICENSES/MIT.txt +18 -0
- data/README.md +183 -0
- data/REUSE.toml +24 -0
- data/Rakefile +16 -0
- data/Steepfile +13 -0
- data/doc/concepts/application_architecture.md +197 -0
- data/doc/concepts/application_testing.md +49 -0
- data/doc/concepts/async_work.md +164 -0
- data/doc/concepts/commands.md +530 -0
- data/doc/concepts/message_processing.md +51 -0
- data/doc/contributors/WIP/decomposition_strategies_analysis.md +258 -0
- data/doc/contributors/WIP/implementation_plan.md +409 -0
- data/doc/contributors/WIP/init_callable_proposal.md +344 -0
- data/doc/contributors/WIP/mvu_tea_implementations_research.md +373 -0
- data/doc/contributors/WIP/runtime_refactoring_status.md +47 -0
- data/doc/contributors/WIP/task.md +36 -0
- data/doc/contributors/WIP/v0.4.0_todo.md +468 -0
- data/doc/contributors/design/commands_and_outlets.md +214 -0
- data/doc/contributors/kit-no-outlet.md +238 -0
- data/doc/contributors/priorities.md +38 -0
- data/doc/custom.css +22 -0
- data/doc/getting_started/quickstart.md +56 -0
- data/doc/images/.gitkeep +0 -0
- data/doc/images/verify_readme_usage.png +0 -0
- data/doc/images/widget_cmd_exec.png +0 -0
- data/doc/index.md +25 -0
- data/examples/app_fractal_dashboard/README.md +60 -0
- data/examples/app_fractal_dashboard/app.rb +63 -0
- data/examples/app_fractal_dashboard/dashboard/base.rb +73 -0
- data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +86 -0
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +87 -0
- data/examples/app_fractal_dashboard/dashboard/update_router.rb +43 -0
- data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +81 -0
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
- data/examples/app_fractal_dashboard/fragments/custom_shell_output.rb +90 -0
- data/examples/app_fractal_dashboard/fragments/disk_usage.rb +47 -0
- data/examples/app_fractal_dashboard/fragments/network_panel.rb +45 -0
- data/examples/app_fractal_dashboard/fragments/ping.rb +47 -0
- data/examples/app_fractal_dashboard/fragments/stats_panel.rb +45 -0
- data/examples/app_fractal_dashboard/fragments/system_info.rb +47 -0
- data/examples/app_fractal_dashboard/fragments/uptime.rb +47 -0
- data/examples/verify_readme_usage/README.md +54 -0
- data/examples/verify_readme_usage/app.rb +47 -0
- data/examples/widget_command_system/README.md +70 -0
- data/examples/widget_command_system/app.rb +132 -0
- data/exe/.gitkeep +0 -0
- data/lib/rooibos/command/all.rb +69 -0
- data/lib/rooibos/command/batch.rb +77 -0
- data/lib/rooibos/command/custom.rb +104 -0
- data/lib/rooibos/command/http.rb +192 -0
- data/lib/rooibos/command/lifecycle.rb +134 -0
- data/lib/rooibos/command/outlet.rb +157 -0
- data/lib/rooibos/command/wait.rb +80 -0
- data/lib/rooibos/command.rb +546 -0
- data/lib/rooibos/error.rb +55 -0
- data/lib/rooibos/message/all.rb +45 -0
- data/lib/rooibos/message/http_response.rb +61 -0
- data/lib/rooibos/message/system/batch.rb +61 -0
- data/lib/rooibos/message/system/stream.rb +67 -0
- data/lib/rooibos/message/timer.rb +46 -0
- data/lib/rooibos/message.rb +38 -0
- data/lib/rooibos/router.rb +403 -0
- data/lib/rooibos/runtime.rb +396 -0
- data/lib/rooibos/shortcuts.rb +49 -0
- data/lib/rooibos/test_helper.rb +56 -0
- data/lib/rooibos/version.rb +12 -0
- data/lib/rooibos.rb +121 -0
- data/mise.toml +8 -0
- data/rbs_collection.lock.yaml +108 -0
- data/rbs_collection.yaml +15 -0
- data/sig/concurrent.rbs +72 -0
- data/sig/examples/verify_readme_usage/app.rbs +19 -0
- data/sig/examples/widget_command_system/app.rbs +26 -0
- data/sig/open3.rbs +17 -0
- data/sig/rooibos/command.rbs +265 -0
- data/sig/rooibos/error.rbs +13 -0
- data/sig/rooibos/message.rbs +121 -0
- data/sig/rooibos/router.rbs +153 -0
- data/sig/rooibos/runtime.rbs +75 -0
- data/sig/rooibos/shortcuts.rbs +16 -0
- data/sig/rooibos/test_helper.rbs +10 -0
- data/sig/rooibos/version.rbs +8 -0
- data/sig/rooibos.rbs +46 -0
- data/tasks/example_viewer.html.erb +172 -0
- data/tasks/resources/build.yml.erb +53 -0
- data/tasks/resources/index.html.erb +44 -0
- data/tasks/resources/rubies.yml +7 -0
- data/tasks/steep.rake +11 -0
- data/vendor/goodcop/base.yml +1047 -0
- 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
|
+
[](../../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
|
+
[](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
|