ratatui_ruby-tea 0.3.0 → 0.4.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +51 -7
  3. data/CHANGELOG.md +109 -0
  4. data/README.md +25 -5
  5. data/Rakefile +1 -1
  6. data/Steepfile +3 -3
  7. data/doc/concepts/async_work.md +164 -0
  8. data/doc/concepts/commands.md +528 -0
  9. data/doc/concepts/message_processing.md +51 -0
  10. data/doc/contributors/WIP/decomposition_strategies_analysis.md +258 -0
  11. data/doc/contributors/WIP/implementation_plan.md +405 -0
  12. data/doc/contributors/WIP/init_callable_proposal.md +341 -0
  13. data/doc/contributors/WIP/mvu_tea_implementations_research.md +372 -0
  14. data/doc/contributors/WIP/runtime_refactoring_status.md +47 -0
  15. data/doc/contributors/WIP/task.md +36 -0
  16. data/doc/contributors/WIP/v0.4.0_todo.md +468 -0
  17. data/doc/contributors/design/commands_and_outlets.md +214 -0
  18. data/doc/contributors/kit-no-outlet.md +237 -0
  19. data/doc/contributors/priorities.md +22 -24
  20. data/examples/app_fractal_dashboard/app.rb +3 -7
  21. data/examples/app_fractal_dashboard/dashboard/base.rb +15 -16
  22. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +8 -8
  23. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +11 -11
  24. data/examples/app_fractal_dashboard/dashboard/update_router.rb +4 -4
  25. data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_input.rb +8 -4
  26. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
  27. data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_output.rb +8 -4
  28. data/examples/app_fractal_dashboard/{bags → fragments}/disk_usage.rb +13 -10
  29. data/examples/app_fractal_dashboard/{bags → fragments}/network_panel.rb +12 -12
  30. data/examples/app_fractal_dashboard/{bags → fragments}/ping.rb +12 -8
  31. data/examples/app_fractal_dashboard/{bags → fragments}/stats_panel.rb +12 -12
  32. data/examples/app_fractal_dashboard/{bags → fragments}/system_info.rb +11 -7
  33. data/examples/app_fractal_dashboard/{bags → fragments}/uptime.rb +11 -7
  34. data/examples/verify_readme_usage/README.md +7 -4
  35. data/examples/verify_readme_usage/app.rb +7 -4
  36. data/lib/ratatui_ruby/tea/command/all.rb +71 -0
  37. data/lib/ratatui_ruby/tea/command/batch.rb +79 -0
  38. data/lib/ratatui_ruby/tea/command/custom.rb +106 -0
  39. data/lib/ratatui_ruby/tea/command/http.rb +194 -0
  40. data/lib/ratatui_ruby/tea/command/lifecycle.rb +136 -0
  41. data/lib/ratatui_ruby/tea/command/outlet.rb +159 -0
  42. data/lib/ratatui_ruby/tea/command/wait.rb +82 -0
  43. data/lib/ratatui_ruby/tea/command.rb +416 -13
  44. data/lib/ratatui_ruby/tea/message/all.rb +47 -0
  45. data/lib/ratatui_ruby/tea/message/http_response.rb +63 -0
  46. data/lib/ratatui_ruby/tea/message/system/batch.rb +63 -0
  47. data/lib/ratatui_ruby/tea/message/system/stream.rb +69 -0
  48. data/lib/ratatui_ruby/tea/message/timer.rb +48 -0
  49. data/lib/ratatui_ruby/tea/message.rb +40 -0
  50. data/lib/ratatui_ruby/tea/router.rb +155 -87
  51. data/lib/ratatui_ruby/tea/runtime.rb +329 -150
  52. data/lib/ratatui_ruby/tea/shortcuts.rb +2 -2
  53. data/lib/ratatui_ruby/tea/test_helper.rb +58 -0
  54. data/lib/ratatui_ruby/tea/version.rb +1 -1
  55. data/lib/ratatui_ruby/tea.rb +44 -10
  56. data/rbs_collection.lock.yaml +108 -0
  57. data/rbs_collection.yaml +15 -0
  58. data/sig/concurrent.rbs +72 -0
  59. data/sig/examples/verify_readme_usage/app.rbs +1 -1
  60. data/sig/examples/widget_command_system/app.rbs +1 -1
  61. data/sig/open3.rbs +17 -0
  62. data/sig/ratatui_ruby/tea/command.rbs +226 -6
  63. data/sig/ratatui_ruby/tea/message.rbs +123 -0
  64. data/sig/ratatui_ruby/tea/router.rbs +110 -54
  65. data/sig/ratatui_ruby/tea/runtime.rbs +63 -12
  66. data/sig/ratatui_ruby/tea/shortcuts.rbs +18 -0
  67. data/sig/ratatui_ruby/tea/test_helper.rbs +12 -0
  68. data/sig/ratatui_ruby/tea/version.rbs +10 -0
  69. data/sig/ratatui_ruby/tea.rbs +39 -7
  70. data/tasks/steep.rake +11 -0
  71. metadata +75 -12
  72. data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +0 -73
@@ -16,10 +16,10 @@ module DashboardManual
16
16
 
17
17
  # Shared with other UPDATE variants
18
18
  Model = DashboardBase::Model
19
- INITIAL = DashboardBase::INITIAL
20
- VIEW = DashboardBase::VIEW
19
+ Init = DashboardBase::Init
20
+ View = DashboardBase::View
21
21
 
22
- UPDATE = lambda do |message, model|
22
+ Update = -> (message, model) do
23
23
  # Global Force Quit
24
24
  return [model, RatatuiRuby::Tea::Command.exit] if message.respond_to?(:ctrl_c?) && message.ctrl_c?
25
25
 
@@ -29,18 +29,18 @@ module DashboardManual
29
29
  case message
30
30
  # Route command results to panels
31
31
  in [:stats, *rest]
32
- new_panel, command = StatsPanel::UPDATE.call(rest, model.stats)
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
36
  in [:network, *rest]
37
- new_panel, command = NetworkPanel::UPDATE.call(rest, model.network)
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
41
  in [:shell_output, *rest]
42
42
  # Route streaming command output to modal
43
- new_modal, command = CustomShellModal::UPDATE.call(message, model.shell_modal)
43
+ new_modal, command = CustomShellModal::Update.call(message, model.shell_modal)
44
44
  return [model.with(shell_modal: new_modal), command]
45
45
  else
46
46
  nil # Fall through to input handling
@@ -48,7 +48,7 @@ module DashboardManual
48
48
 
49
49
  # Modal intercepts user input (not command results)
50
50
  if CustomShellModal.active?(model.shell_modal)
51
- new_modal, command = CustomShellModal::UPDATE.call(message, model.shell_modal)
51
+ new_modal, command = CustomShellModal::Update.call(message, model.shell_modal)
52
52
  return [model.with(shell_modal: new_modal), command]
53
53
  end
54
54
 
@@ -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) { |r| [:stats, *r] }
64
+ command = Command.map(SystemInfo.fetch_command) { |batch| [:stats, batch.envelope, 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) { |r| [:stats, *r] }
69
+ command = Command.map(DiskUsage.fetch_command) { |batch| [:stats, batch.envelope, 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) { |r| [:network, *r] }
74
+ command = Command.map(Ping.fetch_command) { |batch| [:network, batch.envelope, 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) { |r| [:network, *r] }
79
+ command = Command.map(Uptime.fetch_command) { |batch| [:network, batch.envelope, 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
 
@@ -16,10 +16,10 @@ module DashboardRouter
16
16
 
17
17
  Command = RatatuiRuby::Tea::Command
18
18
 
19
- # Shared with other UPDATE variants
19
+ # Shared with other Update variants
20
20
  Model = DashboardBase::Model
21
- INITIAL = DashboardBase::INITIAL
22
- VIEW = DashboardBase::VIEW
21
+ Init = DashboardBase::Init
22
+ View = DashboardBase::View
23
23
 
24
24
  route :stats, to: StatsPanel
25
25
  route :network, to: NetworkPanel
@@ -39,5 +39,5 @@ module DashboardRouter
39
39
  end
40
40
  end
41
41
 
42
- UPDATE = from_router
42
+ Update = from_router
43
43
  end
@@ -5,14 +5,18 @@
5
5
  # SPDX-License-Identifier: LGPL-3.0-or-later
6
6
  #++
7
7
 
8
- # Text input bag for custom shell command modal.
8
+ require "ratatui_ruby/tea"
9
+ # Text input fragment for custom shell command modal.
9
10
  #
10
11
  # Handles text entry. Sets cancelled: or submitted: in model for parent to detect.
11
12
  module CustomShellInput
12
13
  Model = Data.define(:text, :cancelled, :submitted)
13
- INITIAL = Ractor.make_shareable(Model.new(text: "", cancelled: false, submitted: false))
14
14
 
15
- VIEW = lambda do |model, tui|
15
+ Init = -> do
16
+ Ractor.make_shareable(Model.new(text: "", cancelled: false, submitted: false))
17
+ end
18
+
19
+ View = -> (model, tui) do
16
20
  content = if model.text.empty?
17
21
  tui.paragraph(text: tui.text_span(content: "Type a command...", style: { fg: :dark_gray }))
18
22
  else
@@ -50,7 +54,7 @@ module CustomShellInput
50
54
  )
51
55
  end
52
56
 
53
- UPDATE = lambda do |message, model|
57
+ Update = -> (message, model) do
54
58
  case message
55
59
  in _ if message.respond_to?(:esc?) && message.esc?
56
60
  [model.with(cancelled: true), nil]
@@ -0,0 +1,82 @@
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
+ require_relative "custom_shell_input"
9
+ require_relative "custom_shell_output"
10
+
11
+ # Modal overlay for custom shell command execution.
12
+ module CustomShellModal
13
+ Command = RatatuiRuby::Tea::Command
14
+
15
+ Model = Data.define(:mode, :input, :output)
16
+
17
+ Init = -> do
18
+ input, = RatatuiRuby::Tea.normalize_init(CustomShellInput::Init.())
19
+ output, = RatatuiRuby::Tea.normalize_init(CustomShellOutput::Init.())
20
+ Ractor.make_shareable(Model.new(mode: :none, input:, output:))
21
+ end
22
+
23
+ View = -> (model, tui) do
24
+ case model.mode
25
+ when :input
26
+ CustomShellInput::View.call(model.input, tui)
27
+ when :output
28
+ CustomShellOutput::View.call(model.output, tui)
29
+ else
30
+ nil
31
+ end
32
+ end
33
+
34
+ Update = -> (message, model) do
35
+ case [model.mode, message]
36
+ in [:input, _]
37
+ # Delegate first, then check if user wants to close
38
+ new_input, _cmd = CustomShellInput::Update.call(message, model.input)
39
+
40
+ if new_input.cancelled
41
+ [Init.(), nil]
42
+ elsif new_input.submitted
43
+ shell_cmd = new_input.text
44
+ new_output = CustomShellOutput::Init.().with(command: shell_cmd, running: true)
45
+ reset_input, = RatatuiRuby::Tea.normalize_init(CustomShellInput::Init.())
46
+ [
47
+ model.with(mode: :output, input: reset_input, output: new_output),
48
+ Command.system(shell_cmd, :shell_output, stream: true),
49
+ ]
50
+ else
51
+ [model.with(input: new_input), nil]
52
+ end
53
+
54
+ in [:output, _]
55
+ # Delegate first, then check if user wants to close
56
+ case message
57
+ in RatatuiRuby::Event::Key if message.ctrl_c?
58
+ [Init.(), nil]
59
+ else
60
+ new_output, _cmd = CustomShellOutput::Update.call(message, model.output)
61
+
62
+ if new_output.dismissed
63
+ [Init.(), nil]
64
+ else
65
+ [model.with(output: new_output), nil]
66
+ end
67
+ end
68
+
69
+ else
70
+ [model, nil]
71
+ end
72
+ end
73
+
74
+ def self.open
75
+ input, = RatatuiRuby::Tea.normalize_init(CustomShellInput::Init.())
76
+ Init.().with(mode: :input, input:)
77
+ end
78
+
79
+ def self.active?(model)
80
+ model.mode != :none
81
+ end
82
+ end
@@ -5,16 +5,20 @@
5
5
  # SPDX-License-Identifier: LGPL-3.0-or-later
6
6
  #++
7
7
 
8
- # Streaming output bag for custom shell command modal.
8
+ require "ratatui_ruby/tea"
9
+ # Streaming output fragment for custom shell command modal.
9
10
  #
10
11
  # Displays interleaved stdout/stderr. Border color reflects exit status.
11
12
  # Sets dismissed: in model for parent to detect.
12
13
  module CustomShellOutput
13
14
  Chunk = Data.define(:stream, :text)
14
15
  Model = Data.define(:command, :chunks, :running, :exit_status, :dismissed)
15
- INITIAL = Ractor.make_shareable(Model.new(command: "", chunks: [].freeze, running: false, exit_status: nil, dismissed: false))
16
16
 
17
- VIEW = lambda do |model, tui|
17
+ Init = -> do
18
+ Ractor.make_shareable(Model.new(command: "", chunks: [].freeze, running: false, exit_status: nil, dismissed: false))
19
+ end
20
+
21
+ View = -> (model, tui) do
18
22
  # Build styled spans from chunks
19
23
  spans = if model.chunks.empty? && model.running
20
24
  [tui.text_span(content: "Running...", style: tui.style(fg: :dark_gray))]
@@ -56,7 +60,7 @@ module CustomShellOutput
56
60
  )
57
61
  end
58
62
 
59
- UPDATE = lambda do |message, model|
63
+ Update = -> (message, model) do
60
64
  case message
61
65
  in [:stdout, chunk]
62
66
  new_chunks = Ractor.make_shareable([*model.chunks, Chunk.new(stream: :stdout, text: chunk)].freeze)
@@ -5,16 +5,20 @@
5
5
  # SPDX-License-Identifier: MIT-0
6
6
  #++
7
7
 
8
+ require "ratatui_ruby/tea"
8
9
  # Fetches and displays disk usage via +df -h+.
9
- # A bag for fetching and displaying disk usage.
10
+ # A fragment for fetching and displaying disk usage.
10
11
  module DiskUsage
11
12
  Command = RatatuiRuby::Tea::Command
12
13
 
13
14
  Model = Data.define(:output, :loading)
14
- INITIAL = Model.new(output: "Press 'd' for disk usage", loading: false)
15
15
 
16
- VIEW = lambda do |model, tui, disabled: false|
17
- text_style = if disabled && model.output == INITIAL.output
16
+ Init = -> do
17
+ Model.new(output: "Press 'd' for disk usage", loading: false)
18
+ end
19
+
20
+ View = -> (model, tui, disabled: false) do
21
+ text_style = if disabled && model.output == Init.().output
18
22
  tui.style(fg: :dark_gray)
19
23
  else
20
24
  nil
@@ -26,12 +30,11 @@ module DiskUsage
26
30
  )
27
31
  end
28
32
 
29
- UPDATE = lambda do |message, model|
33
+ Update = -> (message, model) do
30
34
  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
+ in [{ type: :system, envelope: :disk_usage, status: 0, stdout: }]
36
+ [model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
37
+ in [{ type: :system, envelope: :disk_usage, stderr: }]
35
38
  [model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
36
39
  else
37
40
  [model, nil]
@@ -39,6 +42,6 @@ module DiskUsage
39
42
  end
40
43
 
41
44
  def self.fetch_command
42
- Command.system("df -h", :disk_usage)
45
+ Command.system("df -h /", :disk_usage)
43
46
  end
44
47
  end
@@ -5,6 +5,7 @@
5
5
  # SPDX-License-Identifier: MIT-0
6
6
  #++
7
7
 
8
+ require "ratatui_ruby/tea"
8
9
  require_relative "ping"
9
10
  require_relative "uptime"
10
11
 
@@ -12,31 +13,30 @@ require_relative "uptime"
12
13
  module NetworkPanel
13
14
  Model = Data.define(:ping, :uptime)
14
15
 
15
- INITIAL = Model.new(
16
- ping: Ping::INITIAL,
17
- uptime: Uptime::INITIAL
18
- )
16
+ Init = -> do
17
+ ping, = RatatuiRuby::Tea.normalize_init(Ping::Init.())
18
+ uptime, = RatatuiRuby::Tea.normalize_init(Uptime::Init.())
19
+ Model.new(ping:, uptime:)
20
+ end
19
21
 
20
- VIEW = lambda do |model, tui, disabled: false|
22
+ View = -> (model, tui, disabled: false) do
21
23
  tui.layout(
22
24
  direction: :horizontal,
23
25
  constraints: [tui.constraint_percentage(50), tui.constraint_percentage(50)],
24
26
  children: [
25
- Ping::VIEW.call(model.ping, tui, disabled:),
26
- Uptime::VIEW.call(model.uptime, tui, disabled:),
27
+ Ping::View.call(model.ping, tui, disabled:),
28
+ Uptime::View.call(model.uptime, tui, disabled:),
27
29
  ]
28
30
  )
29
31
  end
30
32
 
31
- UPDATE = lambda do |message, model|
33
+ Update = -> (message, model) do
32
34
  case message
33
35
  in [:ping, *rest]
34
- child_message = [:ping, *rest]
35
- new_child, command = Ping::UPDATE.call(child_message, model.ping)
36
+ new_child, command = Ping::Update.call(rest, model.ping)
36
37
  [model.with(ping: new_child), command]
37
38
  in [:uptime, *rest]
38
- child_message = [:uptime, *rest]
39
- new_child, command = Uptime::UPDATE.call(child_message, model.uptime)
39
+ new_child, command = Uptime::Update.call(rest, model.uptime)
40
40
  [model.with(uptime: new_child), command]
41
41
  else
42
42
  [model, nil]
@@ -5,16 +5,20 @@
5
5
  # SPDX-License-Identifier: MIT-0
6
6
  #++
7
7
 
8
+ require "ratatui_ruby/tea"
8
9
  # Pings localhost to check network connectivity.
9
- # A bag for pinging localhost.
10
+ # A fragment for pinging localhost.
10
11
  module Ping
11
12
  Command = RatatuiRuby::Tea::Command
12
13
 
13
14
  Model = Data.define(:output, :loading)
14
- INITIAL = Model.new(output: "Press 'p' for ping", loading: false)
15
15
 
16
- VIEW = lambda do |model, tui, disabled: false|
17
- text_style = if disabled && model.output == INITIAL.output
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
18
22
  tui.style(fg: :dark_gray)
19
23
  else
20
24
  nil
@@ -26,11 +30,11 @@ module Ping
26
30
  )
27
31
  end
28
32
 
29
- UPDATE = lambda do |message, model|
33
+ Update = -> (message, model) do
30
34
  case message
31
- in [:ping, { stdout:, status: 0 }]
35
+ in [{ type: :system, envelope: :ping, status: 0, stdout: }]
32
36
  [model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
33
- in [:ping, { stderr:, _status: }]
37
+ in [{ type: :system, envelope: :ping, stderr: }]
34
38
  [model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
35
39
  else
36
40
  [model, nil]
@@ -38,6 +42,6 @@ module Ping
38
42
  end
39
43
 
40
44
  def self.fetch_command
41
- Command.system("ping -c 1 localhost", :ping)
45
+ Command.system("ping -c 3 8.8.8.8", :ping)
42
46
  end
43
47
  end
@@ -5,6 +5,7 @@
5
5
  # SPDX-License-Identifier: MIT-0
6
6
  #++
7
7
 
8
+ require "ratatui_ruby/tea"
8
9
  require_relative "system_info"
9
10
  require_relative "disk_usage"
10
11
 
@@ -12,31 +13,30 @@ require_relative "disk_usage"
12
13
  module StatsPanel
13
14
  Model = Data.define(:system_info, :disk_usage)
14
15
 
15
- INITIAL = Model.new(
16
- system_info: SystemInfo::INITIAL,
17
- disk_usage: DiskUsage::INITIAL
18
- )
16
+ Init = -> do
17
+ system_info, = RatatuiRuby::Tea.normalize_init(SystemInfo::Init.())
18
+ disk_usage, = RatatuiRuby::Tea.normalize_init(DiskUsage::Init.())
19
+ Model.new(system_info:, disk_usage:)
20
+ end
19
21
 
20
- VIEW = lambda do |model, tui, disabled: false|
22
+ View = -> (model, tui, disabled: false) do
21
23
  tui.layout(
22
24
  direction: :horizontal,
23
25
  constraints: [tui.constraint_percentage(50), tui.constraint_percentage(50)],
24
26
  children: [
25
- SystemInfo::VIEW.call(model.system_info, tui, disabled:),
26
- DiskUsage::VIEW.call(model.disk_usage, tui, disabled:),
27
+ SystemInfo::View.call(model.system_info, tui, disabled:),
28
+ DiskUsage::View.call(model.disk_usage, tui, disabled:),
27
29
  ]
28
30
  )
29
31
  end
30
32
 
31
- UPDATE = lambda do |message, model|
33
+ Update = -> (message, model) do
32
34
  case message
33
35
  in [:system_info, *rest]
34
- child_message = [:system_info, *rest]
35
- new_child, command = SystemInfo::UPDATE.call(child_message, model.system_info)
36
+ new_child, command = SystemInfo::Update.call(rest, model.system_info)
36
37
  [model.with(system_info: new_child), command]
37
38
  in [:disk_usage, *rest]
38
- child_message = [:disk_usage, *rest]
39
- new_child, command = DiskUsage::UPDATE.call(child_message, model.disk_usage)
39
+ new_child, command = DiskUsage::Update.call(rest, model.disk_usage)
40
40
  [model.with(disk_usage: new_child), command]
41
41
  else
42
42
  [model, nil]
@@ -5,16 +5,20 @@
5
5
  # SPDX-License-Identifier: MIT-0
6
6
  #++
7
7
 
8
+ require "ratatui_ruby/tea"
8
9
  # Fetches and displays system information via +uname -a+.
9
- # A bag for fetching and displaying system information.
10
+ # A fragment for fetching and displaying system information.
10
11
  module SystemInfo
11
12
  Command = RatatuiRuby::Tea::Command
12
13
 
13
14
  Model = Data.define(:output, :loading)
14
- INITIAL = Model.new(output: "Press 's' for system info", loading: false)
15
15
 
16
- VIEW = lambda do |model, tui, disabled: false|
17
- text_style = if disabled && model.output == INITIAL.output
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
18
22
  tui.style(fg: :dark_gray)
19
23
  else
20
24
  nil
@@ -26,11 +30,11 @@ module SystemInfo
26
30
  )
27
31
  end
28
32
 
29
- UPDATE = lambda do |message, model|
33
+ Update = -> (message, model) do
30
34
  case message
31
- in [:system_info, { stdout:, status: 0 }]
35
+ in [{ type: :system, envelope: :system_info, status: 0, stdout: }]
32
36
  [model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
33
- in [:system_info, { stderr:, _status: }]
37
+ in [{ type: :system, envelope: :system_info, stderr: }]
34
38
  [model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
35
39
  else
36
40
  [model, nil]
@@ -5,16 +5,20 @@
5
5
  # SPDX-License-Identifier: MIT-0
6
6
  #++
7
7
 
8
+ require "ratatui_ruby/tea"
8
9
  # Displays system uptime.
9
- # A bag for displaying system uptime.
10
+ # A fragment for displaying system uptime.
10
11
  module Uptime
11
12
  Command = RatatuiRuby::Tea::Command
12
13
 
13
14
  Model = Data.define(:output, :loading)
14
- INITIAL = Model.new(output: "Press 'u' for uptime", loading: false)
15
15
 
16
- VIEW = lambda do |model, tui, disabled: false|
17
- text_style = if disabled && model.output == INITIAL.output
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
18
22
  tui.style(fg: :dark_gray)
19
23
  else
20
24
  nil
@@ -26,11 +30,11 @@ module Uptime
26
30
  )
27
31
  end
28
32
 
29
- UPDATE = lambda do |message, model|
33
+ Update = -> (message, model) do
30
34
  case message
31
- in [:uptime, { stdout:, status: 0 }]
35
+ in [{ type: :system, envelope: :uptime, status: 0, stdout: }]
32
36
  [model.with(output: Ractor.make_shareable(stdout.strip), loading: false), nil]
33
- in [:uptime, { stderr:, _status: }]
37
+ in [{ type: :system, envelope: :uptime, stderr: }]
34
38
  [model.with(output: Ractor.make_shareable("Error: #{stderr.strip}"), loading: false), nil]
35
39
  else
36
40
  [model, nil]
@@ -19,9 +19,12 @@ This example exists as a documentation regression test. It ensures that the very
19
19
  <!-- SYNC:START:./app.rb:mvu -->
20
20
  ```ruby
21
21
  Model = Data.define(:text)
22
- MODEL = Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
23
22
 
24
- VIEW = -> (model, tui) do
23
+ Init = -> do
24
+ Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
25
+ end
26
+
27
+ View = -> (model, tui) do
25
28
  tui.paragraph(
26
29
  text: model.text,
27
30
  alignment: :center,
@@ -33,7 +36,7 @@ VIEW = -> (model, tui) do
33
36
  )
34
37
  end
35
38
 
36
- UPDATE = -> (msg, model) do
39
+ Update = -> (msg, model) do
37
40
  if msg.q? || msg.ctrl_c?
38
41
  RatatuiRuby::Tea::Command.exit
39
42
  else
@@ -42,7 +45,7 @@ UPDATE = -> (msg, model) do
42
45
  end
43
46
 
44
47
  def run
45
- RatatuiRuby::Tea.run(model: MODEL, view: VIEW, update: UPDATE)
48
+ RatatuiRuby::Tea.run(VerifyReadmeUsage)
46
49
  end
47
50
  ```
48
51
  <!-- SYNC:END -->
@@ -13,9 +13,12 @@ require "ratatui_ruby/tea"
13
13
  class VerifyReadmeUsage
14
14
  # [SYNC:START:mvu]
15
15
  Model = Data.define(:text)
16
- MODEL = Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
17
16
 
18
- VIEW = -> (model, tui) do
17
+ Init = -> do
18
+ Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
19
+ end
20
+
21
+ View = -> (model, tui) do
19
22
  tui.paragraph(
20
23
  text: model.text,
21
24
  alignment: :center,
@@ -27,7 +30,7 @@ class VerifyReadmeUsage
27
30
  )
28
31
  end
29
32
 
30
- UPDATE = -> (msg, model) do
33
+ Update = -> (msg, model) do
31
34
  if msg.q? || msg.ctrl_c?
32
35
  RatatuiRuby::Tea::Command.exit
33
36
  else
@@ -36,7 +39,7 @@ class VerifyReadmeUsage
36
39
  end
37
40
 
38
41
  def run
39
- RatatuiRuby::Tea.run(model: MODEL, view: VIEW, update: UPDATE)
42
+ RatatuiRuby::Tea.run(VerifyReadmeUsage)
40
43
  end
41
44
  # [SYNC:END:mvu]
42
45
  end
@@ -0,0 +1,71 @@
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 RatatuiRuby
9
+ module Tea
10
+ module Command
11
+ # An aggregating parallel command.
12
+ All = Data.define(:envelope, :commands, :nested) do
13
+ include Custom
14
+
15
+ def self.new(tag, *args)
16
+ # DWIM: detect nested vs splatted based on call-site arity
17
+ if args.size == 1 && args.first.is_a?(Array)
18
+ commands = args.first
19
+ nested = true
20
+ else
21
+ commands = args
22
+ nested = false
23
+ end
24
+
25
+ if RatatuiRuby::Debug.enabled?
26
+ commands.each do |cmd|
27
+ unless Ractor.shareable?(cmd)
28
+ raise RatatuiRuby::Error::Invariant,
29
+ "Command is not Ractor-shareable: #{cmd.inspect}\n" \
30
+ "Use Ractor.make_shareable or a Data.define command."
31
+ end
32
+ end
33
+ end
34
+
35
+ instance = allocate
36
+ instance.__send__(:initialize, envelope: tag, commands: commands.freeze, nested:)
37
+ instance
38
+ end
39
+
40
+ def call(out, token)
41
+ # Early return for empty commands - prevents hang from zip_futures([])
42
+ if commands.empty?
43
+ response = Message::All.new(envelope:, results: [].freeze, nested:)
44
+ out.put(Ractor.make_shareable(response))
45
+ return
46
+ end
47
+
48
+ child_lifecycle = Lifecycle.new
49
+
50
+ futures = commands.map do |command|
51
+ Concurrent::Promises.future do
52
+ child_channel = Concurrent::Promises::Channel.new
53
+ child_outlet = Outlet.new(child_channel, lifecycle: child_lifecycle)
54
+ command.call(child_outlet, token)
55
+ child_channel.pop
56
+ end
57
+ end
58
+
59
+ all_done = Concurrent::Promises.zip_futures(*futures)
60
+ Concurrent::Promises.any_event(all_done, token.origin).wait
61
+
62
+ return out.put(Command.cancel(self)) if token.canceled?
63
+
64
+ shareable_results = Ractor.make_shareable(all_done.value!)
65
+ response = Message::All.new(envelope:, results: shareable_results, nested:)
66
+ out.put(Ractor.make_shareable(response))
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end