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.
- checksums.yaml +4 -4
- data/AGENTS.md +51 -7
- data/CHANGELOG.md +109 -0
- data/README.md +25 -5
- data/Rakefile +1 -1
- data/Steepfile +3 -3
- data/doc/concepts/async_work.md +164 -0
- data/doc/concepts/commands.md +528 -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 +405 -0
- data/doc/contributors/WIP/init_callable_proposal.md +341 -0
- data/doc/contributors/WIP/mvu_tea_implementations_research.md +372 -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 +237 -0
- data/doc/contributors/priorities.md +22 -24
- data/examples/app_fractal_dashboard/app.rb +3 -7
- data/examples/app_fractal_dashboard/dashboard/base.rb +15 -16
- data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +8 -8
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +11 -11
- data/examples/app_fractal_dashboard/dashboard/update_router.rb +4 -4
- data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_input.rb +8 -4
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
- data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_output.rb +8 -4
- data/examples/app_fractal_dashboard/{bags → fragments}/disk_usage.rb +13 -10
- data/examples/app_fractal_dashboard/{bags → fragments}/network_panel.rb +12 -12
- data/examples/app_fractal_dashboard/{bags → fragments}/ping.rb +12 -8
- data/examples/app_fractal_dashboard/{bags → fragments}/stats_panel.rb +12 -12
- data/examples/app_fractal_dashboard/{bags → fragments}/system_info.rb +11 -7
- data/examples/app_fractal_dashboard/{bags → fragments}/uptime.rb +11 -7
- data/examples/verify_readme_usage/README.md +7 -4
- data/examples/verify_readme_usage/app.rb +7 -4
- data/lib/ratatui_ruby/tea/command/all.rb +71 -0
- data/lib/ratatui_ruby/tea/command/batch.rb +79 -0
- data/lib/ratatui_ruby/tea/command/custom.rb +106 -0
- data/lib/ratatui_ruby/tea/command/http.rb +194 -0
- data/lib/ratatui_ruby/tea/command/lifecycle.rb +136 -0
- data/lib/ratatui_ruby/tea/command/outlet.rb +159 -0
- data/lib/ratatui_ruby/tea/command/wait.rb +82 -0
- data/lib/ratatui_ruby/tea/command.rb +416 -13
- data/lib/ratatui_ruby/tea/message/all.rb +47 -0
- data/lib/ratatui_ruby/tea/message/http_response.rb +63 -0
- data/lib/ratatui_ruby/tea/message/system/batch.rb +63 -0
- data/lib/ratatui_ruby/tea/message/system/stream.rb +69 -0
- data/lib/ratatui_ruby/tea/message/timer.rb +48 -0
- data/lib/ratatui_ruby/tea/message.rb +40 -0
- data/lib/ratatui_ruby/tea/router.rb +155 -87
- data/lib/ratatui_ruby/tea/runtime.rb +329 -150
- data/lib/ratatui_ruby/tea/shortcuts.rb +2 -2
- data/lib/ratatui_ruby/tea/test_helper.rb +58 -0
- data/lib/ratatui_ruby/tea/version.rb +1 -1
- data/lib/ratatui_ruby/tea.rb +44 -10
- 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 +1 -1
- data/sig/examples/widget_command_system/app.rbs +1 -1
- data/sig/open3.rbs +17 -0
- data/sig/ratatui_ruby/tea/command.rbs +226 -6
- data/sig/ratatui_ruby/tea/message.rbs +123 -0
- data/sig/ratatui_ruby/tea/router.rbs +110 -54
- data/sig/ratatui_ruby/tea/runtime.rbs +63 -12
- data/sig/ratatui_ruby/tea/shortcuts.rbs +18 -0
- data/sig/ratatui_ruby/tea/test_helper.rbs +12 -0
- data/sig/ratatui_ruby/tea/version.rbs +10 -0
- data/sig/ratatui_ruby/tea.rbs +39 -7
- data/tasks/steep.rake +11 -0
- metadata +75 -12
- data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +0 -73
|
@@ -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
|
+
module RatatuiRuby
|
|
9
|
+
module Tea
|
|
10
|
+
module Command
|
|
11
|
+
# A one-shot timer command.
|
|
12
|
+
#
|
|
13
|
+
# Applications need timed events. Notification auto-dismissal,
|
|
14
|
+
# debounced search, and animation frames all depend on delays.
|
|
15
|
+
# Building timers from scratch with threads is error-prone.
|
|
16
|
+
# Cancellation is tricky.
|
|
17
|
+
#
|
|
18
|
+
# This command waits, then sends a message. It responds to
|
|
19
|
+
# cancellation cooperatively. When cancelled, it sends
|
|
20
|
+
# <tt>Command.cancel(self)</tt> so you know the timer stopped.
|
|
21
|
+
#
|
|
22
|
+
# Use it for delayed actions, debounced inputs, or animation loops.
|
|
23
|
+
#
|
|
24
|
+
# === Example: Notification dismissal
|
|
25
|
+
#
|
|
26
|
+
# def update(msg, model)
|
|
27
|
+
# case msg
|
|
28
|
+
# in :save_clicked
|
|
29
|
+
# [model.with(notification: "Saved!"), Command.wait(3.0, :dismiss)]
|
|
30
|
+
# in :dismiss
|
|
31
|
+
# [model.with(notification: nil), nil]
|
|
32
|
+
# in Command::Cancel
|
|
33
|
+
# [model.with(notification: nil), nil] # User navigated away
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# === Example: Animation loop
|
|
38
|
+
#
|
|
39
|
+
# def update(msg, model)
|
|
40
|
+
# case msg
|
|
41
|
+
# in :start_animation
|
|
42
|
+
# [model.with(frame: 0), Command.tick(0.1, :animate)]
|
|
43
|
+
# in :animate
|
|
44
|
+
# frame = (model.frame + 1) % 10
|
|
45
|
+
# [model.with(frame:), Command.tick(0.1, :animate)]
|
|
46
|
+
# end
|
|
47
|
+
# end
|
|
48
|
+
Wait = Data.define(:seconds, :envelope) do
|
|
49
|
+
include Custom
|
|
50
|
+
|
|
51
|
+
# Cooperative cancellation needs no grace period.
|
|
52
|
+
# The command responds instantly to cancellation via
|
|
53
|
+
# <tt>Concurrent::Cancellation.timeout</tt>.
|
|
54
|
+
def tea_cancellation_grace_period
|
|
55
|
+
0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Executes the timer.
|
|
59
|
+
#
|
|
60
|
+
# Waits for <tt>seconds</tt>, then sends <tt>TimerResponse</tt>.
|
|
61
|
+
# If cancelled, sends <tt>Command.cancel(self)</tt> instead.
|
|
62
|
+
#
|
|
63
|
+
# [out] Outlet for sending messages.
|
|
64
|
+
# [token] Cancellation token from the runtime.
|
|
65
|
+
def call(out, token)
|
|
66
|
+
start_time = Time.now
|
|
67
|
+
timer_cancellation, _origin = Concurrent::Cancellation.timeout(seconds)
|
|
68
|
+
combined = token.join(timer_cancellation)
|
|
69
|
+
combined.origin.wait
|
|
70
|
+
|
|
71
|
+
if token.canceled?
|
|
72
|
+
out.put(Command.cancel(self))
|
|
73
|
+
else
|
|
74
|
+
elapsed = Time.now - start_time
|
|
75
|
+
response = Message::Timer.new(envelope:, elapsed:)
|
|
76
|
+
out.put(Ractor.make_shareable(response))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
6
|
#++
|
|
7
7
|
|
|
8
|
+
require "concurrent-edge"
|
|
9
|
+
require_relative "command/custom"
|
|
10
|
+
require_relative "command/outlet"
|
|
11
|
+
require_relative "command/lifecycle"
|
|
12
|
+
require_relative "command/wait"
|
|
13
|
+
require_relative "command/batch"
|
|
14
|
+
require_relative "command/all"
|
|
15
|
+
require_relative "command/http"
|
|
16
|
+
|
|
8
17
|
module RatatuiRuby
|
|
9
18
|
module Tea
|
|
10
19
|
# Commands represent side effects.
|
|
@@ -30,7 +39,14 @@ module RatatuiRuby
|
|
|
30
39
|
# Sentinel value for application termination.
|
|
31
40
|
#
|
|
32
41
|
# The runtime detects this before dispatching. It breaks the loop immediately.
|
|
33
|
-
Exit
|
|
42
|
+
class Exit < Data.define
|
|
43
|
+
include Custom
|
|
44
|
+
|
|
45
|
+
# Stub - Exit is a sentinel handled by runtime before dispatch.
|
|
46
|
+
def call(_out, _token)
|
|
47
|
+
raise "Exit command should never be dispatched"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
34
50
|
|
|
35
51
|
# Creates a quit command.
|
|
36
52
|
#
|
|
@@ -50,23 +66,243 @@ module RatatuiRuby
|
|
|
50
66
|
Exit.new
|
|
51
67
|
end
|
|
52
68
|
|
|
53
|
-
#
|
|
69
|
+
# Creates a fresh cancellation that never fires.
|
|
70
|
+
#
|
|
71
|
+
# Some I/O operations cannot be cancelled mid-execution. Ruby's <tt>Net::HTTP</tt>
|
|
72
|
+
# blocks until completion or timeout — there is no way to interrupt it.
|
|
73
|
+
#
|
|
74
|
+
# A shared singleton would be unsafe. If any code path accidentally resolves
|
|
75
|
+
# the origin, all commands using it become cancelled.
|
|
76
|
+
#
|
|
77
|
+
# Use it for commands that wrap non-cancellable blocking I/O.
|
|
78
|
+
#
|
|
79
|
+
# === Example
|
|
80
|
+
#
|
|
81
|
+
# token = Command.uncancellable
|
|
82
|
+
# HttpCommand.new(url).call(outlet, token)
|
|
83
|
+
def self.uncancellable
|
|
84
|
+
cancellation, _origin = Concurrent::Cancellation.new(Concurrent::Promises.resolvable_event)
|
|
85
|
+
cancellation
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Sentinel value for command cancellation.
|
|
89
|
+
#
|
|
90
|
+
# Long-running commands (WebSocket listeners, database pollers) run until stopped.
|
|
91
|
+
# Stopping them requires signaling from outside the command. The runtime tracks
|
|
92
|
+
# active commands by their object identity and routes cancel requests.
|
|
93
|
+
#
|
|
94
|
+
# This type carries the handle (command object) to cancel. The runtime pattern-matches
|
|
95
|
+
# on <tt>Command::Cancel</tt> and signals the token.
|
|
96
|
+
class Cancel < Data.define(:handle)
|
|
97
|
+
include Custom
|
|
98
|
+
|
|
99
|
+
# Stub - Cancel is a sentinel handled by runtime before dispatch.
|
|
100
|
+
def call(_out, _token)
|
|
101
|
+
raise "Cancel command should never be dispatched"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Request cancellation of a running command.
|
|
106
|
+
#
|
|
107
|
+
# The model stores the command handle (the command object itself). Returning
|
|
108
|
+
# <tt>Command.cancel(handle)</tt> signals the runtime to stop it.
|
|
54
109
|
#
|
|
55
|
-
# The
|
|
56
|
-
# (default), a single message arrives: <tt>[tag, {stdout:, stderr:, status:}]</tt>.
|
|
110
|
+
# [handle] The command object to cancel.
|
|
57
111
|
#
|
|
58
|
-
#
|
|
112
|
+
# === Example
|
|
113
|
+
#
|
|
114
|
+
# # Dispatch and store handle
|
|
115
|
+
# cmd = FetchData.new(url)
|
|
116
|
+
# [model.with(active_fetch: cmd), cmd]
|
|
117
|
+
#
|
|
118
|
+
# # User clicks cancel
|
|
119
|
+
# when :cancel_clicked
|
|
120
|
+
# [model.with(active_fetch: nil), Command.cancel(model.active_fetch)]
|
|
121
|
+
def self.cancel(handle)
|
|
122
|
+
Cancel.new(handle:)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Error message from a failed command.
|
|
126
|
+
#
|
|
127
|
+
# Commands run in background threads. Exceptions bubble up silently.
|
|
128
|
+
# Your update function never sees them. Backtraces in STDERR corrupt the TUI.
|
|
129
|
+
#
|
|
130
|
+
# The runtime catches exceptions and wraps them in Error messages.
|
|
131
|
+
# Pattern match on Error in your update function. Display the error, log it, or recover.
|
|
132
|
+
#
|
|
133
|
+
# Use it to surface failures from HTTP requests, file I/O, or external processes.
|
|
134
|
+
#
|
|
135
|
+
# === Examples
|
|
136
|
+
#
|
|
137
|
+
# Update = ->(message, model) {
|
|
138
|
+
# case message
|
|
139
|
+
# in Command::Error[command:, exception:]
|
|
140
|
+
# # Show error toast
|
|
141
|
+
# [model.with(error: exception.message), nil]
|
|
142
|
+
# in Command::Error[command: Command::Http, exception:]
|
|
143
|
+
# # Retry HTTP request
|
|
144
|
+
# [model, command]
|
|
145
|
+
# in Command::Error
|
|
146
|
+
# # Log and continue
|
|
147
|
+
# warn "Command failed: #{message.exception}"
|
|
148
|
+
# [model, nil]
|
|
149
|
+
# end
|
|
150
|
+
# }
|
|
151
|
+
class Error < Data.define(:command, :exception); end
|
|
152
|
+
|
|
153
|
+
# Creates an error sentinel.
|
|
154
|
+
#
|
|
155
|
+
# The runtime produces this automatically when a command raises.
|
|
156
|
+
# Use this factory for testing or for commands that want to signal
|
|
157
|
+
# error completion without raising.
|
|
158
|
+
#
|
|
159
|
+
# [command] The command that failed.
|
|
160
|
+
# [exception] The exception that was raised.
|
|
161
|
+
#
|
|
162
|
+
# === Example
|
|
163
|
+
#
|
|
164
|
+
# def update(message, model)
|
|
165
|
+
# case message
|
|
166
|
+
# in Command::Error(command:, exception:)
|
|
167
|
+
# model.with(error: "#{command.class} failed: #{exception.message}")
|
|
168
|
+
# end
|
|
169
|
+
# end
|
|
170
|
+
def self.error(command, exception)
|
|
171
|
+
Error.new(command:, exception:)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Runs a shell command and routes its output back as messages.
|
|
175
|
+
#
|
|
176
|
+
# Apps run external tools: linters, compilers, scripts, system utilities.
|
|
177
|
+
# The runtime dispatches the command in a thread, so the UI stays responsive.
|
|
178
|
+
# Batch mode (default) waits for completion; streaming mode shows output live.
|
|
179
|
+
# Orphaned child processes linger and waste resources, so cancellation sends
|
|
180
|
+
# <tt>SIGTERM</tt> for graceful shutdown, then <tt>SIGKILL</tt> to prevent orphans.
|
|
181
|
+
#
|
|
182
|
+
# Use it to run builds, lint files, execute scripts, or invoke any CLI tool.
|
|
183
|
+
#
|
|
184
|
+
# === Batch Mode (default)
|
|
185
|
+
#
|
|
186
|
+
# A single message arrives when the command finishes:
|
|
187
|
+
# <tt>[tag, {stdout:, stderr:, status:}]</tt>
|
|
188
|
+
#
|
|
189
|
+
# === Streaming Mode
|
|
190
|
+
#
|
|
191
|
+
# Messages arrive incrementally:
|
|
59
192
|
# - <tt>[tag, :stdout, line]</tt> for each stdout line
|
|
60
193
|
# - <tt>[tag, :stderr, line]</tt> for each stderr line
|
|
61
194
|
# - <tt>[tag, :complete, {status:}]</tt> when the command finishes
|
|
62
195
|
# - <tt>[tag, :error, {message:}]</tt> if the command cannot start
|
|
63
196
|
#
|
|
64
197
|
# The <tt>status</tt> is the integer exit code (0 = success).
|
|
65
|
-
System
|
|
198
|
+
class System < Data.define(:command, :envelope, :stream)
|
|
199
|
+
include Custom
|
|
200
|
+
|
|
66
201
|
# Returns true if streaming mode is enabled.
|
|
67
202
|
def stream?
|
|
68
203
|
stream
|
|
69
204
|
end
|
|
205
|
+
|
|
206
|
+
# Executes the shell command and sends results via outlet.
|
|
207
|
+
#
|
|
208
|
+
# In batch mode, sends a single message with all output.
|
|
209
|
+
# In streaming mode, sends incremental messages as output arrives.
|
|
210
|
+
# Respects cancellation token by sending SIGTERM (then SIGKILL) to child.
|
|
211
|
+
def call(out, token)
|
|
212
|
+
require "open3"
|
|
213
|
+
|
|
214
|
+
if stream?
|
|
215
|
+
stream_execution(out, token)
|
|
216
|
+
else
|
|
217
|
+
batch_execution(out)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private def batch_execution(out)
|
|
222
|
+
stdout, stderr, status = Open3.capture3(command)
|
|
223
|
+
message = Message::System::Batch.new(
|
|
224
|
+
envelope:,
|
|
225
|
+
stdout:,
|
|
226
|
+
stderr:,
|
|
227
|
+
status: status.exitstatus
|
|
228
|
+
)
|
|
229
|
+
out.put(Ractor.make_shareable(message))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private def stream_execution(out, token)
|
|
233
|
+
Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
|
|
234
|
+
stdin.close
|
|
235
|
+
pid = wait_thr.pid
|
|
236
|
+
|
|
237
|
+
# Track which streams are still open
|
|
238
|
+
streams = { stdout => :stdout, stderr => :stderr }
|
|
239
|
+
|
|
240
|
+
until streams.empty?
|
|
241
|
+
# Check cancellation before blocking on IO.select
|
|
242
|
+
if token.canceled? && wait_thr.alive?
|
|
243
|
+
begin
|
|
244
|
+
Process.kill("TERM", pid)
|
|
245
|
+
rescue Errno::ESRCH
|
|
246
|
+
# Already dead
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Wait up to 0.05s for any stream to have data
|
|
251
|
+
ready = IO.select(streams.keys, nil, nil, 0.05)
|
|
252
|
+
next unless ready
|
|
253
|
+
|
|
254
|
+
ready[0].each do |io|
|
|
255
|
+
stream_type = streams[io]
|
|
256
|
+
begin
|
|
257
|
+
line = io.read_nonblock(8192, exception: false)
|
|
258
|
+
case line
|
|
259
|
+
when :wait_readable
|
|
260
|
+
next
|
|
261
|
+
when nil, ""
|
|
262
|
+
# EOF - stream closed
|
|
263
|
+
streams.delete(io)
|
|
264
|
+
else
|
|
265
|
+
# Split into lines and send each
|
|
266
|
+
line.each_line do |l|
|
|
267
|
+
msg = Message::System::Stream.new(
|
|
268
|
+
envelope:,
|
|
269
|
+
stream: stream_type,
|
|
270
|
+
content: l.freeze,
|
|
271
|
+
status: nil
|
|
272
|
+
)
|
|
273
|
+
out.put(Ractor.make_shareable(msg))
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
rescue EOFError
|
|
277
|
+
streams.delete(io)
|
|
278
|
+
rescue IOError
|
|
279
|
+
# Stream forcibly closed
|
|
280
|
+
streams.delete(io)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Wait for process to finish
|
|
286
|
+
wait_thr.join
|
|
287
|
+
|
|
288
|
+
status = wait_thr.value.exitstatus
|
|
289
|
+
msg = Message::System::Stream.new(
|
|
290
|
+
envelope:,
|
|
291
|
+
stream: :complete,
|
|
292
|
+
content: nil,
|
|
293
|
+
status:
|
|
294
|
+
)
|
|
295
|
+
out.put(Ractor.make_shareable(msg))
|
|
296
|
+
end
|
|
297
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
298
|
+
msg = Message::System::Stream.new(
|
|
299
|
+
envelope:,
|
|
300
|
+
stream: :error,
|
|
301
|
+
content: e.message,
|
|
302
|
+
status: nil
|
|
303
|
+
)
|
|
304
|
+
out.put(Ractor.make_shareable(msg))
|
|
305
|
+
end
|
|
70
306
|
end
|
|
71
307
|
|
|
72
308
|
# Creates a shell execution command.
|
|
@@ -110,16 +346,49 @@ module RatatuiRuby
|
|
|
110
346
|
# [model.with(loading: false, error: message), nil]
|
|
111
347
|
# end
|
|
112
348
|
# end
|
|
113
|
-
def self.system(command,
|
|
114
|
-
System.new(command:,
|
|
349
|
+
def self.system(command, envelope, stream: false)
|
|
350
|
+
System.new(command:, envelope:, stream:)
|
|
115
351
|
end
|
|
116
352
|
|
|
117
|
-
#
|
|
353
|
+
# Wraps another command's result with a transformation.
|
|
354
|
+
#
|
|
355
|
+
# Fractal Architecture requires composition. Child fragments produce commands
|
|
356
|
+
# with their own tags. Parent fragments need those results routed back with
|
|
357
|
+
# a parent prefix. Without transformation, update functions become
|
|
358
|
+
# monolithic "God Reducers" that know about every child's internals.
|
|
118
359
|
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
|
|
360
|
+
# This command wraps an inner command and transforms its result message.
|
|
361
|
+
# The parent fragment delegates to the child, then intercepts the result and
|
|
362
|
+
# adds its routing prefix. Clean separation. No coupling.
|
|
363
|
+
#
|
|
364
|
+
# Use it to compose child fragments that return their own commands.
|
|
365
|
+
class Mapped < Data.define(:inner_command, :mapper)
|
|
366
|
+
include Custom
|
|
367
|
+
|
|
368
|
+
# Grace period delegates to inner command.
|
|
369
|
+
def tea_cancellation_grace_period
|
|
370
|
+
inner_command.respond_to?(:tea_cancellation_grace_period) ?
|
|
371
|
+
inner_command.tea_cancellation_grace_period : 0.1
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Executes the inner command, waits for result, and transforms it.
|
|
375
|
+
def call(out, token)
|
|
376
|
+
inner_channel = Concurrent::Promises::Channel.new
|
|
377
|
+
inner_outlet = Outlet.new(inner_channel, lifecycle: out.live)
|
|
378
|
+
|
|
379
|
+
# Dispatch inner command
|
|
380
|
+
if inner_command.respond_to?(:call)
|
|
381
|
+
inner_command.call(inner_outlet, token)
|
|
382
|
+
else
|
|
383
|
+
raise ArgumentError, "Inner command must respond to #call"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Transform result and send
|
|
387
|
+
inner_message = inner_channel.pop
|
|
388
|
+
transformed = mapper.call(inner_message)
|
|
389
|
+
out.put(*transformed)
|
|
390
|
+
end
|
|
391
|
+
end
|
|
123
392
|
|
|
124
393
|
# Creates a mapped command for Fractal Architecture composition.
|
|
125
394
|
#
|
|
@@ -140,6 +409,140 @@ module RatatuiRuby
|
|
|
140
409
|
def self.map(inner_command, &mapper)
|
|
141
410
|
Mapped.new(inner_command:, mapper:)
|
|
142
411
|
end
|
|
412
|
+
|
|
413
|
+
# Gives a callable unique identity for cancellation.
|
|
414
|
+
#
|
|
415
|
+
# Reusable procs and lambdas share identity. Dispatch them twice, and
|
|
416
|
+
# +Command.cancel+ would cancel both. Wrap them to get distinct handles.
|
|
417
|
+
#
|
|
418
|
+
# The callable must be Ractor-shareable (cannot capture mutable state).
|
|
419
|
+
# Create resources like database connections inside the callable, not in
|
|
420
|
+
# the closure. See the Custom Commands guide for details.
|
|
421
|
+
#
|
|
422
|
+
# [callable] Proc, lambda, or any object responding to +call(out, token)+.
|
|
423
|
+
# If omitted, the block is used.
|
|
424
|
+
# [grace_period] Cleanup time override. Default: 2.0 seconds.
|
|
425
|
+
#
|
|
426
|
+
# === Example
|
|
427
|
+
#
|
|
428
|
+
# # With callable
|
|
429
|
+
# cmd = Command.custom(->(out, token) { out.put(:fetched, data) })
|
|
430
|
+
#
|
|
431
|
+
# # With block
|
|
432
|
+
# cmd = Command.custom(grace_period: 5.0) do |out, token|
|
|
433
|
+
# until token.canceled?
|
|
434
|
+
# out.put(:tick, Time.now)
|
|
435
|
+
# sleep 1
|
|
436
|
+
# end
|
|
437
|
+
# end
|
|
438
|
+
def self.custom(callable = nil, grace_period: nil, &block)
|
|
439
|
+
c = callable || block
|
|
440
|
+
|
|
441
|
+
# Debug mode: validate that callable can be made shareable (fail fast)
|
|
442
|
+
if RatatuiRuby::Debug.enabled?
|
|
443
|
+
begin
|
|
444
|
+
c = Ractor.make_shareable(c)
|
|
445
|
+
rescue Ractor::IsolationError
|
|
446
|
+
raise RatatuiRuby::Error::Invariant,
|
|
447
|
+
"Command.custom requires a Ractor-shareable callable. " \
|
|
448
|
+
"#{c.class} is not shareable. Use Ractor.make_shareable or define at top-level."
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
# Production mode: skip validation (Ractors not yet used, avoid overhead)
|
|
452
|
+
|
|
453
|
+
Wrapped.new(callable: c, grace_period:)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Creates a one-shot timer command.
|
|
457
|
+
#
|
|
458
|
+
# Waits for +seconds+ then sends +TimerResponse+ to the update function.
|
|
459
|
+
# Use for delayed actions like notification dismissal or debounced search.
|
|
460
|
+
#
|
|
461
|
+
# [seconds] Duration to wait (Float or Integer).
|
|
462
|
+
# [envelope] Symbol to tag the result message.
|
|
463
|
+
def self.wait(seconds, envelope)
|
|
464
|
+
Wait.new(seconds:, envelope:)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Creates a recurring timer command.
|
|
468
|
+
#
|
|
469
|
+
# Identical to +wait+, but semantically used for animation frames where
|
|
470
|
+
# the update function re-dispatches to continue the animation loop.
|
|
471
|
+
#
|
|
472
|
+
# [interval] Duration between ticks (Float or Integer).
|
|
473
|
+
# [tag] Symbol to tag the result message.
|
|
474
|
+
singleton_class.alias_method :tick, :wait
|
|
475
|
+
|
|
476
|
+
# Creates a parallel batch command.
|
|
477
|
+
#
|
|
478
|
+
# Applications fetch data from multiple sources. Dashboard panels load
|
|
479
|
+
# users, stats, and notifications. Waiting sequentially is slow.
|
|
480
|
+
# Managing threads and error handling manually is error-prone.
|
|
481
|
+
#
|
|
482
|
+
# This command runs children in parallel. Each child sends its own messages
|
|
483
|
+
# independently. The batch completes when all children finish or when
|
|
484
|
+
# cancellation fires.
|
|
485
|
+
#
|
|
486
|
+
# Use it for parallel fetches, concurrent refreshes, or any work that
|
|
487
|
+
# does not need coordinated results.
|
|
488
|
+
#
|
|
489
|
+
# [commands] One or more commands to run in parallel. Pass multiple
|
|
490
|
+
# arguments or a single array.
|
|
491
|
+
#
|
|
492
|
+
# === Example
|
|
493
|
+
#
|
|
494
|
+
# # Variadic syntax
|
|
495
|
+
# Command.batch(
|
|
496
|
+
# Command.http(:get, "/users", :users),
|
|
497
|
+
# Command.http(:get, "/stats", :stats),
|
|
498
|
+
# )
|
|
499
|
+
#
|
|
500
|
+
# # Array syntax
|
|
501
|
+
# Command.batch([cmd1, cmd2, cmd3])
|
|
502
|
+
def self.batch(*)
|
|
503
|
+
Batch.new(*)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Creates an aggregating parallel command.
|
|
507
|
+
#
|
|
508
|
+
# Applications load dashboards that combine user, settings, and stats.
|
|
509
|
+
# Fire-and-forget loses correlation. This command waits for all children
|
|
510
|
+
# and returns their results together in a single message.
|
|
511
|
+
#
|
|
512
|
+
# [commands] One or more commands to run in parallel. Pass multiple
|
|
513
|
+
# arguments or a single array.
|
|
514
|
+
#
|
|
515
|
+
# === Example
|
|
516
|
+
#
|
|
517
|
+
# # Variadic syntax
|
|
518
|
+
# Command.all(
|
|
519
|
+
# Command.http(:get, "/users", :_),
|
|
520
|
+
# Command.http(:get, "/stats", :_),
|
|
521
|
+
# )
|
|
522
|
+
# # Produces: [:all, [user_result, stats_result]]
|
|
523
|
+
def self.all(envelope, *)
|
|
524
|
+
All.new(envelope, *)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# Creates an HTTP request command.
|
|
528
|
+
# Supports DWIM arity - see Http.new for patterns.
|
|
529
|
+
def self.http(*, **)
|
|
530
|
+
Http.new(*, **)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# :nodoc:
|
|
534
|
+
class Wrapped < Data.define(:callable, :grace_period)
|
|
535
|
+
include Custom
|
|
536
|
+
def tea_cancellation_grace_period
|
|
537
|
+
grace_period || super
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# :nodoc:
|
|
541
|
+
def call(out, token)
|
|
542
|
+
callable.call(out, token)
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
private_constant :Wrapped
|
|
143
546
|
end
|
|
144
547
|
end
|
|
145
548
|
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: LGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
module RatatuiRuby
|
|
9
|
+
module Tea
|
|
10
|
+
module Message
|
|
11
|
+
# Response from Command.all aggregating parallel execution.
|
|
12
|
+
#
|
|
13
|
+
# Running multiple commands in parallel needs aggregated results. Without
|
|
14
|
+
# structured responses, handling mixed success/failure requires manual
|
|
15
|
+
# array parsing.
|
|
16
|
+
#
|
|
17
|
+
# This response collects all child results and provides predicates for
|
|
18
|
+
# success checking. Include Predicates for safe predicate calls.
|
|
19
|
+
#
|
|
20
|
+
# Use it to handle <tt>Command.all</tt> completions.
|
|
21
|
+
#
|
|
22
|
+
# === Example
|
|
23
|
+
#
|
|
24
|
+
# case msg
|
|
25
|
+
# in { type: :all, envelope: :parallel, results: }
|
|
26
|
+
# model.with(outputs: results)
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
All = Data.define(:envelope, :results, :nested) do
|
|
30
|
+
include Predicates
|
|
31
|
+
|
|
32
|
+
# Returns <tt>true</tt> for all responses.
|
|
33
|
+
def all?
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Deconstructs for pattern matching.
|
|
38
|
+
#
|
|
39
|
+
# Returns a hash with <tt>:type</tt>, <tt>:envelope</tt>, <tt>:results</tt>,
|
|
40
|
+
# and <tt>:nested</tt>.
|
|
41
|
+
def deconstruct_keys(_keys)
|
|
42
|
+
{ type: :all, envelope:, results:, nested: }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
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 Message
|
|
11
|
+
# Response from an HTTP command.
|
|
12
|
+
#
|
|
13
|
+
# HTTP requests return status codes, bodies, and headers. Errors may occur.
|
|
14
|
+
# Without structured responses, handling success vs. error requires manual
|
|
15
|
+
# checks on raw data.
|
|
16
|
+
#
|
|
17
|
+
# This response includes predicates for common checks and deconstructs for
|
|
18
|
+
# pattern matching. Include Predicates for safe predicate calls on any message.
|
|
19
|
+
#
|
|
20
|
+
# Use it to handle +Command.http+ completions.
|
|
21
|
+
#
|
|
22
|
+
# === Example
|
|
23
|
+
#
|
|
24
|
+
# case msg
|
|
25
|
+
# in { type: :http, envelope: :users, status:, body: }
|
|
26
|
+
# model.with(users: JSON.parse(body))
|
|
27
|
+
# in { type: :http, envelope: :users, error: }
|
|
28
|
+
# model.with(error:)
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
HttpResponse = Data.define(:envelope, :status, :body, :headers, :error) do
|
|
32
|
+
include Predicates
|
|
33
|
+
|
|
34
|
+
# Returns +true+ for HTTP responses.
|
|
35
|
+
def http?
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns +true+ if status is 2xx.
|
|
40
|
+
def success?
|
|
41
|
+
status&.between?(200, 299)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns +true+ if an error occurred.
|
|
45
|
+
def error?
|
|
46
|
+
!!error
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Deconstructs for pattern matching.
|
|
50
|
+
#
|
|
51
|
+
# Returns a hash with <tt>:type</tt>, <tt>:envelope</tt>, and either
|
|
52
|
+
# <tt>:error</tt> or <tt>:status</tt>, <tt>:body</tt>, <tt>:headers</tt>.
|
|
53
|
+
def deconstruct_keys(_keys)
|
|
54
|
+
if error
|
|
55
|
+
{ type: :http, envelope:, error: }
|
|
56
|
+
else
|
|
57
|
+
{ type: :http, envelope:, status:, body:, headers: }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|