ratatui_ruby-tea 0.2.0 → 0.3.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 +4 -4
- data/AGENTS.md +8 -0
- data/CHANGELOG.md +41 -0
- data/README.md +1 -1
- data/doc/concepts/application_architecture.md +182 -3
- data/examples/app_fractal_dashboard/README.md +60 -0
- data/examples/app_fractal_dashboard/app.rb +67 -0
- data/examples/app_fractal_dashboard/bags/custom_shell_input.rb +77 -0
- data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +73 -0
- data/examples/app_fractal_dashboard/bags/custom_shell_output.rb +86 -0
- data/examples/app_fractal_dashboard/bags/disk_usage.rb +44 -0
- data/examples/app_fractal_dashboard/bags/network_panel.rb +45 -0
- data/examples/app_fractal_dashboard/bags/ping.rb +43 -0
- data/examples/app_fractal_dashboard/bags/stats_panel.rb +45 -0
- data/examples/app_fractal_dashboard/bags/system_info.rb +43 -0
- data/examples/app_fractal_dashboard/bags/uptime.rb +43 -0
- data/examples/app_fractal_dashboard/dashboard/base.rb +74 -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/verify_readme_usage/README.md +1 -1
- data/examples/verify_readme_usage/app.rb +1 -1
- data/examples/{widget_cmd_exec → widget_command_system}/app.rb +18 -18
- data/lib/ratatui_ruby/tea/command.rb +145 -0
- data/lib/ratatui_ruby/tea/router.rb +337 -0
- data/lib/ratatui_ruby/tea/runtime.rb +99 -39
- data/lib/ratatui_ruby/tea/shortcuts.rb +51 -0
- data/lib/ratatui_ruby/tea/version.rb +1 -1
- data/lib/ratatui_ruby/tea.rb +59 -1
- data/sig/ratatui_ruby/tea/command.rbs +47 -0
- data/sig/ratatui_ruby/tea/router.rbs +99 -0
- metadata +26 -8
- data/lib/ratatui_ruby/tea/cmd.rb +0 -88
- data/sig/ratatui_ruby/tea/cmd.rbs +0 -32
- /data/examples/{widget_cmd_exec → widget_command_system}/README.md +0 -0
- /data/sig/examples/{widget_cmd_exec → widget_command_system}/app.rbs +0 -0
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
# Fetches and displays disk usage via +df -h+.
|
|
9
|
+
# A bag for fetching and displaying disk usage.
|
|
10
|
+
module DiskUsage
|
|
11
|
+
Command = RatatuiRuby::Tea::Command
|
|
12
|
+
|
|
13
|
+
Model = Data.define(:output, :loading)
|
|
14
|
+
INITIAL = Model.new(output: "Press 'd' for disk usage", loading: false)
|
|
15
|
+
|
|
16
|
+
VIEW = lambda do |model, tui, disabled: false|
|
|
17
|
+
text_style = if disabled && model.output == INITIAL.output
|
|
18
|
+
tui.style(fg: :dark_gray)
|
|
19
|
+
else
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
tui.paragraph(
|
|
24
|
+
text: tui.text_span(content: model.output, style: text_style),
|
|
25
|
+
block: tui.block(title: "Disk Usage", borders: [:all], border_style: { fg: :cyan })
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
UPDATE = lambda do |message, model|
|
|
30
|
+
case message
|
|
31
|
+
in [:disk_usage, { stdout:, status: 0 }]
|
|
32
|
+
lines = Ractor.make_shareable(stdout.lines.first(4).join.strip)
|
|
33
|
+
[model.with(output: lines, loading: false), nil]
|
|
34
|
+
in [:disk_usage, { stderr:, _status: }]
|
|
35
|
+
[model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
|
|
36
|
+
else
|
|
37
|
+
[model, nil]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.fetch_command
|
|
42
|
+
Command.system("df -h", :disk_usage)
|
|
43
|
+
end
|
|
44
|
+
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_relative "ping"
|
|
9
|
+
require_relative "uptime"
|
|
10
|
+
|
|
11
|
+
# Composes Ping and Uptime in a horizontal layout.
|
|
12
|
+
module NetworkPanel
|
|
13
|
+
Model = Data.define(:ping, :uptime)
|
|
14
|
+
|
|
15
|
+
INITIAL = Model.new(
|
|
16
|
+
ping: Ping::INITIAL,
|
|
17
|
+
uptime: Uptime::INITIAL
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
VIEW = lambda do |model, tui, disabled: false|
|
|
21
|
+
tui.layout(
|
|
22
|
+
direction: :horizontal,
|
|
23
|
+
constraints: [tui.constraint_percentage(50), tui.constraint_percentage(50)],
|
|
24
|
+
children: [
|
|
25
|
+
Ping::VIEW.call(model.ping, tui, disabled:),
|
|
26
|
+
Uptime::VIEW.call(model.uptime, tui, disabled:),
|
|
27
|
+
]
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
UPDATE = lambda do |message, model|
|
|
32
|
+
case message
|
|
33
|
+
in [:ping, *rest]
|
|
34
|
+
child_message = [:ping, *rest]
|
|
35
|
+
new_child, command = Ping::UPDATE.call(child_message, model.ping)
|
|
36
|
+
[model.with(ping: new_child), command]
|
|
37
|
+
in [:uptime, *rest]
|
|
38
|
+
child_message = [:uptime, *rest]
|
|
39
|
+
new_child, command = Uptime::UPDATE.call(child_message, model.uptime)
|
|
40
|
+
[model.with(uptime: new_child), command]
|
|
41
|
+
else
|
|
42
|
+
[model, nil]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
# Pings localhost to check network connectivity.
|
|
9
|
+
# A bag for pinging localhost.
|
|
10
|
+
module Ping
|
|
11
|
+
Command = RatatuiRuby::Tea::Command
|
|
12
|
+
|
|
13
|
+
Model = Data.define(:output, :loading)
|
|
14
|
+
INITIAL = Model.new(output: "Press 'p' for ping", loading: false)
|
|
15
|
+
|
|
16
|
+
VIEW = lambda do |model, tui, disabled: false|
|
|
17
|
+
text_style = if disabled && model.output == INITIAL.output
|
|
18
|
+
tui.style(fg: :dark_gray)
|
|
19
|
+
else
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
tui.paragraph(
|
|
24
|
+
text: tui.text_span(content: model.output, style: text_style),
|
|
25
|
+
block: tui.block(title: "Ping", borders: [:all], border_style: { fg: :magenta })
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
UPDATE = lambda do |message, model|
|
|
30
|
+
case message
|
|
31
|
+
in [:ping, { stdout:, status: 0 }]
|
|
32
|
+
[model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
|
|
33
|
+
in [:ping, { stderr:, _status: }]
|
|
34
|
+
[model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
|
|
35
|
+
else
|
|
36
|
+
[model, nil]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.fetch_command
|
|
41
|
+
Command.system("ping -c 1 localhost", :ping)
|
|
42
|
+
end
|
|
43
|
+
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_relative "system_info"
|
|
9
|
+
require_relative "disk_usage"
|
|
10
|
+
|
|
11
|
+
# Composes SystemInfo and DiskUsage in a horizontal layout.
|
|
12
|
+
module StatsPanel
|
|
13
|
+
Model = Data.define(:system_info, :disk_usage)
|
|
14
|
+
|
|
15
|
+
INITIAL = Model.new(
|
|
16
|
+
system_info: SystemInfo::INITIAL,
|
|
17
|
+
disk_usage: DiskUsage::INITIAL
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
VIEW = lambda do |model, tui, disabled: false|
|
|
21
|
+
tui.layout(
|
|
22
|
+
direction: :horizontal,
|
|
23
|
+
constraints: [tui.constraint_percentage(50), tui.constraint_percentage(50)],
|
|
24
|
+
children: [
|
|
25
|
+
SystemInfo::VIEW.call(model.system_info, tui, disabled:),
|
|
26
|
+
DiskUsage::VIEW.call(model.disk_usage, tui, disabled:),
|
|
27
|
+
]
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
UPDATE = lambda do |message, model|
|
|
32
|
+
case message
|
|
33
|
+
in [:system_info, *rest]
|
|
34
|
+
child_message = [:system_info, *rest]
|
|
35
|
+
new_child, command = SystemInfo::UPDATE.call(child_message, model.system_info)
|
|
36
|
+
[model.with(system_info: new_child), command]
|
|
37
|
+
in [:disk_usage, *rest]
|
|
38
|
+
child_message = [:disk_usage, *rest]
|
|
39
|
+
new_child, command = DiskUsage::UPDATE.call(child_message, 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,43 @@
|
|
|
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
|
+
# Fetches and displays system information via +uname -a+.
|
|
9
|
+
# A bag for fetching and displaying system information.
|
|
10
|
+
module SystemInfo
|
|
11
|
+
Command = RatatuiRuby::Tea::Command
|
|
12
|
+
|
|
13
|
+
Model = Data.define(:output, :loading)
|
|
14
|
+
INITIAL = Model.new(output: "Press 's' for system info", loading: false)
|
|
15
|
+
|
|
16
|
+
VIEW = lambda do |model, tui, disabled: false|
|
|
17
|
+
text_style = if disabled && model.output == INITIAL.output
|
|
18
|
+
tui.style(fg: :dark_gray)
|
|
19
|
+
else
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
tui.paragraph(
|
|
24
|
+
text: tui.text_span(content: model.output, style: text_style),
|
|
25
|
+
block: tui.block(title: "System Info", borders: [:all], border_style: { fg: :cyan })
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
UPDATE = lambda do |message, model|
|
|
30
|
+
case message
|
|
31
|
+
in [:system_info, { stdout:, status: 0 }]
|
|
32
|
+
[model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
|
|
33
|
+
in [:system_info, { stderr:, _status: }]
|
|
34
|
+
[model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
|
|
35
|
+
else
|
|
36
|
+
[model, nil]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.fetch_command
|
|
41
|
+
Command.system("uname -a", :system_info)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
# Displays system uptime.
|
|
9
|
+
# A bag for displaying system uptime.
|
|
10
|
+
module Uptime
|
|
11
|
+
Command = RatatuiRuby::Tea::Command
|
|
12
|
+
|
|
13
|
+
Model = Data.define(:output, :loading)
|
|
14
|
+
INITIAL = Model.new(output: "Press 'u' for uptime", loading: false)
|
|
15
|
+
|
|
16
|
+
VIEW = lambda do |model, tui, disabled: false|
|
|
17
|
+
text_style = if disabled && model.output == INITIAL.output
|
|
18
|
+
tui.style(fg: :dark_gray)
|
|
19
|
+
else
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
tui.paragraph(
|
|
24
|
+
text: tui.text_span(content: model.output, style: text_style),
|
|
25
|
+
block: tui.block(title: "Uptime", borders: [:all], border_style: { fg: :magenta })
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
UPDATE = lambda do |message, model|
|
|
30
|
+
case message
|
|
31
|
+
in [:uptime, { stdout:, status: 0 }]
|
|
32
|
+
[model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
|
|
33
|
+
in [:uptime, { stderr:, _status: }]
|
|
34
|
+
[model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
|
|
35
|
+
else
|
|
36
|
+
[model, nil]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.fetch_command
|
|
41
|
+
Command.system("uptime", :uptime)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
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_relative "../bags/stats_panel"
|
|
9
|
+
require_relative "../bags/network_panel"
|
|
10
|
+
require_relative "../bags/custom_shell_modal"
|
|
11
|
+
|
|
12
|
+
# Shared Model, INITIAL, and VIEW for the Dashboard.
|
|
13
|
+
#
|
|
14
|
+
# This module is extended by the three UPDATE variants to demonstrate
|
|
15
|
+
# the progression from verbose manual routing to declarative DSL.
|
|
16
|
+
module DashboardBase
|
|
17
|
+
Command = RatatuiRuby::Tea::Command
|
|
18
|
+
|
|
19
|
+
Model = Data.define(:stats, :network, :shell_modal)
|
|
20
|
+
|
|
21
|
+
INITIAL = Model.new(
|
|
22
|
+
stats: StatsPanel::INITIAL,
|
|
23
|
+
network: NetworkPanel::INITIAL,
|
|
24
|
+
shell_modal: CustomShellModal::INITIAL
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
VIEW = lambda do |model, tui|
|
|
28
|
+
modal_active = CustomShellModal.active?(model.shell_modal)
|
|
29
|
+
hotkey, label_style = if modal_active
|
|
30
|
+
[tui.style(fg: :dark_gray), tui.style(fg: :dark_gray)]
|
|
31
|
+
else
|
|
32
|
+
[tui.style(modifiers: [:bold, :underlined]), nil]
|
|
33
|
+
end
|
|
34
|
+
dim = tui.style(fg: :dark_gray)
|
|
35
|
+
|
|
36
|
+
controls = tui.paragraph(
|
|
37
|
+
text: [
|
|
38
|
+
tui.text_line(spans: [
|
|
39
|
+
tui.text_span(content: "s", style: hotkey),
|
|
40
|
+
tui.text_span(content: ": System ", style: label_style),
|
|
41
|
+
tui.text_span(content: "d", style: hotkey),
|
|
42
|
+
tui.text_span(content: ": Disk ", style: label_style),
|
|
43
|
+
tui.text_span(content: "p", style: hotkey),
|
|
44
|
+
tui.text_span(content: ": Ping ", style: label_style),
|
|
45
|
+
tui.text_span(content: "u", style: hotkey),
|
|
46
|
+
tui.text_span(content: ": Uptime ", style: label_style),
|
|
47
|
+
tui.text_span(content: "c", style: hotkey),
|
|
48
|
+
tui.text_span(content: ": Custom ", style: label_style),
|
|
49
|
+
tui.text_span(content: "q", style: hotkey),
|
|
50
|
+
tui.text_span(content: ": Quit", style: label_style),
|
|
51
|
+
]),
|
|
52
|
+
],
|
|
53
|
+
block: tui.block(title: "Fractal Dashboard", borders: [:all], border_style: dim)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
dashboard = tui.layout(
|
|
57
|
+
direction: :vertical,
|
|
58
|
+
constraints: [tui.constraint_fill(1), tui.constraint_fill(1), tui.constraint_length(3)],
|
|
59
|
+
children: [
|
|
60
|
+
StatsPanel::VIEW.call(model.stats, tui, disabled: modal_active),
|
|
61
|
+
NetworkPanel::VIEW.call(model.network, tui, disabled: modal_active),
|
|
62
|
+
controls,
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Compose modal overlay if active
|
|
67
|
+
modal_widget = CustomShellModal::VIEW.call(model.shell_modal, tui)
|
|
68
|
+
if modal_widget
|
|
69
|
+
tui.overlay(layers: [dashboard, modal_widget])
|
|
70
|
+
else
|
|
71
|
+
dashboard
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
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_relative "base"
|
|
9
|
+
|
|
10
|
+
# UPDATE using Tea.route and Tea.delegate helpers.
|
|
11
|
+
#
|
|
12
|
+
# This is the medium-verbosity approach: routing helpers reduce boilerplate
|
|
13
|
+
# while keeping the case statement visible. A good middle ground.
|
|
14
|
+
module DashboardHelpers
|
|
15
|
+
Command = RatatuiRuby::Tea::Command
|
|
16
|
+
Tea = RatatuiRuby::Tea
|
|
17
|
+
|
|
18
|
+
# Shared with other UPDATE variants
|
|
19
|
+
Model = DashboardBase::Model
|
|
20
|
+
INITIAL = DashboardBase::INITIAL
|
|
21
|
+
VIEW = DashboardBase::VIEW
|
|
22
|
+
|
|
23
|
+
UPDATE = lambda do |message, model|
|
|
24
|
+
# Global Force Quit
|
|
25
|
+
return [model, RatatuiRuby::Tea::Command.exit] if message.respond_to?(:ctrl_c?) && message.ctrl_c?
|
|
26
|
+
|
|
27
|
+
# IMPORTANT: Route command results BEFORE modal intercept.
|
|
28
|
+
# Async command results must always reach their destination, even when a
|
|
29
|
+
# modal is active. Only user input (keys/mouse) should be blocked.
|
|
30
|
+
|
|
31
|
+
# Route streaming command output to modal
|
|
32
|
+
if (result = Tea.delegate(message, :shell_output, CustomShellModal::UPDATE, model.shell_modal))
|
|
33
|
+
new_modal, command = result
|
|
34
|
+
return [model.with(shell_modal: new_modal), command]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Route to child bags
|
|
38
|
+
if (result = Tea.delegate(message, :stats, StatsPanel::UPDATE, model.stats))
|
|
39
|
+
new_child, command = result
|
|
40
|
+
return [model.with(stats: new_child), command && Tea.route(command, :stats)]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if (result = Tea.delegate(message, :network, NetworkPanel::UPDATE, model.network))
|
|
44
|
+
new_child, command = result
|
|
45
|
+
return [model.with(network: new_child), command && Tea.route(command, :network)]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Modal intercepts user input (not command results)
|
|
49
|
+
if CustomShellModal.active?(model.shell_modal)
|
|
50
|
+
new_modal, command = CustomShellModal::UPDATE.call(message, model.shell_modal)
|
|
51
|
+
return [model.with(shell_modal: new_modal), command]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Handle user input
|
|
55
|
+
case message
|
|
56
|
+
in _ if message.q? || message.ctrl_c?
|
|
57
|
+
Command.exit
|
|
58
|
+
|
|
59
|
+
in _ if message.c?
|
|
60
|
+
[model.with(shell_modal: CustomShellModal.open), nil]
|
|
61
|
+
|
|
62
|
+
in _ if message.s?
|
|
63
|
+
command = Tea.route(SystemInfo.fetch_command, :stats)
|
|
64
|
+
new_stats = model.stats.with(system_info: model.stats.system_info.with(loading: true))
|
|
65
|
+
[model.with(stats: new_stats), command]
|
|
66
|
+
|
|
67
|
+
in _ if message.d?
|
|
68
|
+
command = Tea.route(DiskUsage.fetch_command, :stats)
|
|
69
|
+
new_stats = model.stats.with(disk_usage: model.stats.disk_usage.with(loading: true))
|
|
70
|
+
[model.with(stats: new_stats), command]
|
|
71
|
+
|
|
72
|
+
in _ if message.p?
|
|
73
|
+
command = Tea.route(Ping.fetch_command, :network)
|
|
74
|
+
new_network = model.network.with(ping: model.network.ping.with(loading: true))
|
|
75
|
+
[model.with(network: new_network), command]
|
|
76
|
+
|
|
77
|
+
in _ if message.u?
|
|
78
|
+
command = Tea.route(Uptime.fetch_command, :network)
|
|
79
|
+
new_network = model.network.with(uptime: model.network.uptime.with(loading: true))
|
|
80
|
+
[model.with(network: new_network), command]
|
|
81
|
+
|
|
82
|
+
else
|
|
83
|
+
model
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
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_relative "base"
|
|
9
|
+
|
|
10
|
+
# UPDATE using verbose manual routing.
|
|
11
|
+
#
|
|
12
|
+
# This is the most explicit approach: full pattern matching, explicit
|
|
13
|
+
# Command.map calls, manual model updates. Maximum control, maximum boilerplate.
|
|
14
|
+
module DashboardManual
|
|
15
|
+
Command = RatatuiRuby::Tea::Command
|
|
16
|
+
|
|
17
|
+
# Shared with other UPDATE variants
|
|
18
|
+
Model = DashboardBase::Model
|
|
19
|
+
INITIAL = DashboardBase::INITIAL
|
|
20
|
+
VIEW = DashboardBase::VIEW
|
|
21
|
+
|
|
22
|
+
UPDATE = lambda do |message, model|
|
|
23
|
+
# Global Force Quit
|
|
24
|
+
return [model, RatatuiRuby::Tea::Command.exit] if message.respond_to?(:ctrl_c?) && message.ctrl_c?
|
|
25
|
+
|
|
26
|
+
# IMPORTANT: Route command results BEFORE modal intercept.
|
|
27
|
+
# Async command results must always reach their destination, even when a
|
|
28
|
+
# modal is active. Only user input (keys/mouse) should be blocked.
|
|
29
|
+
case message
|
|
30
|
+
# Route command results to panels
|
|
31
|
+
in [:stats, *rest]
|
|
32
|
+
new_panel, command = StatsPanel::UPDATE.call(rest, model.stats)
|
|
33
|
+
mapped_command = command ? Command.map(command) { |child_result| [:stats, *child_result] } : nil
|
|
34
|
+
return [model.with(stats: new_panel), mapped_command]
|
|
35
|
+
|
|
36
|
+
in [:network, *rest]
|
|
37
|
+
new_panel, command = NetworkPanel::UPDATE.call(rest, model.network)
|
|
38
|
+
mapped_command = command ? Command.map(command) { |child_result| [:network, *child_result] } : nil
|
|
39
|
+
return [model.with(network: new_panel), mapped_command]
|
|
40
|
+
|
|
41
|
+
in [:shell_output, *rest]
|
|
42
|
+
# Route streaming command output to modal
|
|
43
|
+
new_modal, command = CustomShellModal::UPDATE.call(message, model.shell_modal)
|
|
44
|
+
return [model.with(shell_modal: new_modal), command]
|
|
45
|
+
else
|
|
46
|
+
nil # Fall through to input handling
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Modal intercepts user input (not command results)
|
|
50
|
+
if CustomShellModal.active?(model.shell_modal)
|
|
51
|
+
new_modal, command = CustomShellModal::UPDATE.call(message, model.shell_modal)
|
|
52
|
+
return [model.with(shell_modal: new_modal), command]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
case message
|
|
56
|
+
# Handle user input
|
|
57
|
+
in _ if message.q? || message.ctrl_c?
|
|
58
|
+
Command.exit
|
|
59
|
+
|
|
60
|
+
in _ if message.c?
|
|
61
|
+
[model.with(shell_modal: CustomShellModal.open), nil]
|
|
62
|
+
|
|
63
|
+
in _ if message.s?
|
|
64
|
+
command = Command.map(SystemInfo.fetch_command) { |r| [:stats, *r] }
|
|
65
|
+
new_stats = model.stats.with(system_info: model.stats.system_info.with(loading: true))
|
|
66
|
+
[model.with(stats: new_stats), command]
|
|
67
|
+
|
|
68
|
+
in _ if message.d?
|
|
69
|
+
command = Command.map(DiskUsage.fetch_command) { |r| [:stats, *r] }
|
|
70
|
+
new_stats = model.stats.with(disk_usage: model.stats.disk_usage.with(loading: true))
|
|
71
|
+
[model.with(stats: new_stats), command]
|
|
72
|
+
|
|
73
|
+
in _ if message.p?
|
|
74
|
+
command = Command.map(Ping.fetch_command) { |r| [:network, *r] }
|
|
75
|
+
new_network = model.network.with(ping: model.network.ping.with(loading: true))
|
|
76
|
+
[model.with(network: new_network), command]
|
|
77
|
+
|
|
78
|
+
in _ if message.u?
|
|
79
|
+
command = Command.map(Uptime.fetch_command) { |r| [:network, *r] }
|
|
80
|
+
new_network = model.network.with(uptime: model.network.uptime.with(loading: true))
|
|
81
|
+
[model.with(network: new_network), command]
|
|
82
|
+
|
|
83
|
+
else
|
|
84
|
+
model
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
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_relative "base"
|
|
9
|
+
|
|
10
|
+
# UPDATE using the declarative Tea::Router DSL.
|
|
11
|
+
#
|
|
12
|
+
# This is the minimal-boilerplate approach: declare routes and keymaps,
|
|
13
|
+
# let from_router generate the UPDATE lambda. Maximum DX, least control.
|
|
14
|
+
module DashboardRouter
|
|
15
|
+
include RatatuiRuby::Tea::Router
|
|
16
|
+
|
|
17
|
+
Command = RatatuiRuby::Tea::Command
|
|
18
|
+
|
|
19
|
+
# Shared with other UPDATE variants
|
|
20
|
+
Model = DashboardBase::Model
|
|
21
|
+
INITIAL = DashboardBase::INITIAL
|
|
22
|
+
VIEW = DashboardBase::VIEW
|
|
23
|
+
|
|
24
|
+
route :stats, to: StatsPanel
|
|
25
|
+
route :network, to: NetworkPanel
|
|
26
|
+
|
|
27
|
+
# Guard: only handle keys when modal is not active
|
|
28
|
+
MODAL_INACTIVE = -> (model) { !CustomShellModal.active?(model.shell_modal) }
|
|
29
|
+
|
|
30
|
+
keymap do
|
|
31
|
+
key :ctrl_c, -> { Command.exit }
|
|
32
|
+
only when: MODAL_INACTIVE do
|
|
33
|
+
key :q, -> { Command.exit }
|
|
34
|
+
key :s, -> { SystemInfo.fetch_command }
|
|
35
|
+
key :d, -> { DiskUsage.fetch_command }
|
|
36
|
+
key :p, -> { Ping.fetch_command }
|
|
37
|
+
key :u, -> { Uptime.fetch_command }
|
|
38
|
+
key :c, -> { CustomShellModal.open }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
UPDATE = from_router
|
|
43
|
+
end
|
|
@@ -10,7 +10,7 @@ $LOAD_PATH.unshift File.expand_path("../../lib", __dir__)
|
|
|
10
10
|
require "ratatui_ruby"
|
|
11
11
|
require "ratatui_ruby/tea"
|
|
12
12
|
|
|
13
|
-
# Demonstrates the
|
|
13
|
+
# Demonstrates the Command.execute command for running shell commands.
|
|
14
14
|
#
|
|
15
15
|
# This example shows how to execute shell commands and handle both success
|
|
16
16
|
# and failure cases using pattern matching in update. The split layout shows
|
|
@@ -23,7 +23,7 @@ require "ratatui_ruby/tea"
|
|
|
23
23
|
# ruby examples/widget_cmd_exec/app.rb
|
|
24
24
|
#
|
|
25
25
|
# rdoc-image:/doc/images/widget_cmd_exec.png
|
|
26
|
-
class
|
|
26
|
+
class WidgetCommandSystem
|
|
27
27
|
Model = Data.define(:result, :loading, :last_command)
|
|
28
28
|
INITIAL = Model.new(
|
|
29
29
|
result: "Press a key to run a command...",
|
|
@@ -44,7 +44,7 @@ class WidgetCmdExec
|
|
|
44
44
|
"cyan"
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
title = model.last_command ? "Output: #{model.last_command}" : "
|
|
47
|
+
title = model.last_command ? "Output: #{model.last_command}" : "Command.execute Demo"
|
|
48
48
|
content_text = model.loading ? "Running command..." : model.result
|
|
49
49
|
|
|
50
50
|
# 1. Main Output Widget
|
|
@@ -97,8 +97,8 @@ class WidgetCmdExec
|
|
|
97
97
|
)
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
UPDATE = -> (
|
|
101
|
-
case
|
|
100
|
+
UPDATE = -> (message, model) do
|
|
101
|
+
case message
|
|
102
102
|
# Handle command results
|
|
103
103
|
in [:got_output, { stdout:, status: 0 }]
|
|
104
104
|
[model.with(result: stdout.strip.freeze, loading: false), nil]
|
|
@@ -106,19 +106,19 @@ class WidgetCmdExec
|
|
|
106
106
|
[model.with(result: "Error (exit #{status}): #{stderr.strip}".freeze, loading: false), nil]
|
|
107
107
|
|
|
108
108
|
# Handle key presses
|
|
109
|
-
in _ if
|
|
110
|
-
RatatuiRuby::Tea::
|
|
111
|
-
in _ if
|
|
112
|
-
[model.with(loading: true, last_command: "ls -la"), RatatuiRuby::Tea::
|
|
113
|
-
in _ if
|
|
114
|
-
[model.with(loading: true, last_command: "uname -a"), RatatuiRuby::Tea::
|
|
115
|
-
in _ if
|
|
116
|
-
|
|
117
|
-
[model.with(loading: true, last_command: cmd.freeze), RatatuiRuby::Tea::
|
|
118
|
-
in _ if
|
|
109
|
+
in _ if message.q? || message.ctrl_c?
|
|
110
|
+
RatatuiRuby::Tea::Command.exit
|
|
111
|
+
in _ if message.d?
|
|
112
|
+
[model.with(loading: true, last_command: "ls -la"), RatatuiRuby::Tea::Command.system("ls -la", :got_output)]
|
|
113
|
+
in _ if message.u?
|
|
114
|
+
[model.with(loading: true, last_command: "uname -a"), RatatuiRuby::Tea::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), RatatuiRuby::Tea::Command.system(cmd, :got_output)]
|
|
118
|
+
in _ if message.f?
|
|
119
119
|
# Intentional failure to demonstrate error handling
|
|
120
|
-
|
|
121
|
-
[model.with(loading: true, last_command: cmd.freeze), RatatuiRuby::Tea::
|
|
120
|
+
command = "ls /nonexistent_path_12345"
|
|
121
|
+
[model.with(loading: true, last_command: cmd.freeze), RatatuiRuby::Tea::Command.system(cmd, :got_output)]
|
|
122
122
|
else
|
|
123
123
|
model
|
|
124
124
|
end
|
|
@@ -129,4 +129,4 @@ class WidgetCmdExec
|
|
|
129
129
|
end
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
WidgetCommandSystem.new.run if __FILE__ == $PROGRAM_NAME
|