ratatui_ruby-tea 0.3.1 → 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 +42 -2
- data/CHANGELOG.md +76 -0
- data/README.md +8 -5
- 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 +11 -1
- 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 +1 -1
- 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 +59 -27
- data/lib/ratatui_ruby/tea/command/wait.rb +82 -0
- data/lib/ratatui_ruby/tea/command.rb +245 -64
- 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 +11 -11
- data/lib/ratatui_ruby/tea/runtime.rb +320 -185
- 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 +1 -17
- data/sig/concurrent.rbs +72 -0
- data/sig/ratatui_ruby/tea/command.rbs +141 -37
- data/sig/ratatui_ruby/tea/message.rbs +123 -0
- data/sig/ratatui_ruby/tea/router.rbs +1 -1
- data/sig/ratatui_ruby/tea/runtime.rbs +39 -6
- data/sig/ratatui_ruby/tea/test_helper.rbs +12 -0
- data/sig/ratatui_ruby/tea.rbs +24 -4
- metadata +63 -11
- data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +0 -73
- data/lib/ratatui_ruby/tea/command/cancellation_token.rb +0 -135
|
@@ -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,9 +5,14 @@
|
|
|
5
5
|
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
6
|
#++
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
require "concurrent-edge"
|
|
9
9
|
require_relative "command/custom"
|
|
10
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"
|
|
11
16
|
|
|
12
17
|
module RatatuiRuby
|
|
13
18
|
module Tea
|
|
@@ -34,7 +39,14 @@ module RatatuiRuby
|
|
|
34
39
|
# Sentinel value for application termination.
|
|
35
40
|
#
|
|
36
41
|
# The runtime detects this before dispatching. It breaks the loop immediately.
|
|
37
|
-
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
|
|
38
50
|
|
|
39
51
|
# Creates a quit command.
|
|
40
52
|
#
|
|
@@ -54,6 +66,25 @@ module RatatuiRuby
|
|
|
54
66
|
Exit.new
|
|
55
67
|
end
|
|
56
68
|
|
|
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
|
+
|
|
57
88
|
# Sentinel value for command cancellation.
|
|
58
89
|
#
|
|
59
90
|
# Long-running commands (WebSocket listeners, database pollers) run until stopped.
|
|
@@ -62,7 +93,14 @@ module RatatuiRuby
|
|
|
62
93
|
#
|
|
63
94
|
# This type carries the handle (command object) to cancel. The runtime pattern-matches
|
|
64
95
|
# on <tt>Command::Cancel</tt> and signals the token.
|
|
65
|
-
Cancel
|
|
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
|
|
66
104
|
|
|
67
105
|
# Request cancellation of a running command.
|
|
68
106
|
#
|
|
@@ -84,16 +122,33 @@ module RatatuiRuby
|
|
|
84
122
|
Cancel.new(handle:)
|
|
85
123
|
end
|
|
86
124
|
|
|
87
|
-
#
|
|
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.
|
|
88
132
|
#
|
|
89
|
-
#
|
|
90
|
-
# never sees them, and backtraces in STDERR corrupt the TUI display.
|
|
133
|
+
# Use it to surface failures from HTTP requests, file I/O, or external processes.
|
|
91
134
|
#
|
|
92
|
-
#
|
|
93
|
-
# Pattern match on it in your update function.
|
|
135
|
+
# === Examples
|
|
94
136
|
#
|
|
95
|
-
#
|
|
96
|
-
|
|
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
|
|
97
152
|
|
|
98
153
|
# Creates an error sentinel.
|
|
99
154
|
#
|
|
@@ -140,16 +195,8 @@ module RatatuiRuby
|
|
|
140
195
|
# - <tt>[tag, :error, {message:}]</tt> if the command cannot start
|
|
141
196
|
#
|
|
142
197
|
# The <tt>status</tt> is the integer exit code (0 = success).
|
|
143
|
-
System
|
|
144
|
-
|
|
145
|
-
def tea_command?
|
|
146
|
-
true
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# Grace period for cleanup after cancellation.
|
|
150
|
-
def tea_cancellation_grace_period
|
|
151
|
-
0.1
|
|
152
|
-
end
|
|
198
|
+
class System < Data.define(:command, :envelope, :stream)
|
|
199
|
+
include Custom
|
|
153
200
|
|
|
154
201
|
# Returns true if streaming mode is enabled.
|
|
155
202
|
def stream?
|
|
@@ -173,7 +220,13 @@ module RatatuiRuby
|
|
|
173
220
|
|
|
174
221
|
private def batch_execution(out)
|
|
175
222
|
stdout, stderr, status = Open3.capture3(command)
|
|
176
|
-
|
|
223
|
+
message = Message::System::Batch.new(
|
|
224
|
+
envelope:,
|
|
225
|
+
stdout:,
|
|
226
|
+
stderr:,
|
|
227
|
+
status: status.exitstatus
|
|
228
|
+
)
|
|
229
|
+
out.put(Ractor.make_shareable(message))
|
|
177
230
|
end
|
|
178
231
|
|
|
179
232
|
private def stream_execution(out, token)
|
|
@@ -181,46 +234,74 @@ module RatatuiRuby
|
|
|
181
234
|
stdin.close
|
|
182
235
|
pid = wait_thr.pid
|
|
183
236
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
rescue IOError
|
|
187
|
-
# Stream closed - SIGKILL the child if still alive (forcible cleanup)
|
|
188
|
-
begin
|
|
189
|
-
Process.kill("KILL", pid) if wait_thr.alive?
|
|
190
|
-
rescue Errno::ESRCH
|
|
191
|
-
# Already dead
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
stderr_thread = Thread.new do
|
|
195
|
-
stderr.each_line { |line| out.put(tag, :stderr, line.freeze) }
|
|
196
|
-
rescue IOError
|
|
197
|
-
# Stream closed
|
|
198
|
-
end
|
|
237
|
+
# Track which streams are still open
|
|
238
|
+
streams = { stdout => :stdout, stderr => :stderr }
|
|
199
239
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if token.cancelled? && wait_thr.alive?
|
|
240
|
+
until streams.empty?
|
|
241
|
+
# Check cancellation before blocking on IO.select
|
|
242
|
+
if token.canceled? && wait_thr.alive?
|
|
204
243
|
begin
|
|
205
244
|
Process.kill("TERM", pid)
|
|
206
245
|
rescue Errno::ESRCH
|
|
207
246
|
# Already dead
|
|
208
247
|
end
|
|
209
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
|
|
210
283
|
end
|
|
211
284
|
|
|
285
|
+
# Wait for process to finish
|
|
212
286
|
wait_thr.join
|
|
213
287
|
|
|
214
|
-
# Child exited; clean up threads
|
|
215
|
-
stdout_thread.kill
|
|
216
|
-
stderr_thread.kill
|
|
217
|
-
cancellation_watcher.kill
|
|
218
|
-
|
|
219
288
|
status = wait_thr.value.exitstatus
|
|
220
|
-
|
|
289
|
+
msg = Message::System::Stream.new(
|
|
290
|
+
envelope:,
|
|
291
|
+
stream: :complete,
|
|
292
|
+
content: nil,
|
|
293
|
+
status:
|
|
294
|
+
)
|
|
295
|
+
out.put(Ractor.make_shareable(msg))
|
|
221
296
|
end
|
|
222
297
|
rescue Errno::ENOENT, Errno::EACCES => e
|
|
223
|
-
|
|
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))
|
|
224
305
|
end
|
|
225
306
|
end
|
|
226
307
|
|
|
@@ -265,25 +346,24 @@ module RatatuiRuby
|
|
|
265
346
|
# [model.with(loading: false, error: message), nil]
|
|
266
347
|
# end
|
|
267
348
|
# end
|
|
268
|
-
def self.system(command,
|
|
269
|
-
System.new(command:,
|
|
349
|
+
def self.system(command, envelope, stream: false)
|
|
350
|
+
System.new(command:, envelope:, stream:)
|
|
270
351
|
end
|
|
271
352
|
|
|
272
353
|
# Wraps another command's result with a transformation.
|
|
273
354
|
#
|
|
274
|
-
# Fractal Architecture requires composition. Child
|
|
275
|
-
# with their own tags. Parent
|
|
355
|
+
# Fractal Architecture requires composition. Child fragments produce commands
|
|
356
|
+
# with their own tags. Parent fragments need those results routed back with
|
|
276
357
|
# a parent prefix. Without transformation, update functions become
|
|
277
358
|
# monolithic "God Reducers" that know about every child's internals.
|
|
278
359
|
#
|
|
279
360
|
# This command wraps an inner command and transforms its result message.
|
|
280
|
-
# The parent
|
|
361
|
+
# The parent fragment delegates to the child, then intercepts the result and
|
|
281
362
|
# adds its routing prefix. Clean separation. No coupling.
|
|
282
363
|
#
|
|
283
|
-
# Use it to compose child
|
|
284
|
-
Mapped
|
|
285
|
-
|
|
286
|
-
def tea_command? = true
|
|
364
|
+
# Use it to compose child fragments that return their own commands.
|
|
365
|
+
class Mapped < Data.define(:inner_command, :mapper)
|
|
366
|
+
include Custom
|
|
287
367
|
|
|
288
368
|
# Grace period delegates to inner command.
|
|
289
369
|
def tea_cancellation_grace_period
|
|
@@ -293,8 +373,8 @@ module RatatuiRuby
|
|
|
293
373
|
|
|
294
374
|
# Executes the inner command, waits for result, and transforms it.
|
|
295
375
|
def call(out, token)
|
|
296
|
-
|
|
297
|
-
inner_outlet = Outlet.new(
|
|
376
|
+
inner_channel = Concurrent::Promises::Channel.new
|
|
377
|
+
inner_outlet = Outlet.new(inner_channel, lifecycle: out.live)
|
|
298
378
|
|
|
299
379
|
# Dispatch inner command
|
|
300
380
|
if inner_command.respond_to?(:call)
|
|
@@ -304,7 +384,7 @@ module RatatuiRuby
|
|
|
304
384
|
end
|
|
305
385
|
|
|
306
386
|
# Transform result and send
|
|
307
|
-
inner_message =
|
|
387
|
+
inner_message = inner_channel.pop
|
|
308
388
|
transformed = mapper.call(inner_message)
|
|
309
389
|
out.put(*transformed)
|
|
310
390
|
end
|
|
@@ -335,6 +415,10 @@ module RatatuiRuby
|
|
|
335
415
|
# Reusable procs and lambdas share identity. Dispatch them twice, and
|
|
336
416
|
# +Command.cancel+ would cancel both. Wrap them to get distinct handles.
|
|
337
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
|
+
#
|
|
338
422
|
# [callable] Proc, lambda, or any object responding to +call(out, token)+.
|
|
339
423
|
# If omitted, the block is used.
|
|
340
424
|
# [grace_period] Cleanup time override. Default: 2.0 seconds.
|
|
@@ -346,20 +430,117 @@ module RatatuiRuby
|
|
|
346
430
|
#
|
|
347
431
|
# # With block
|
|
348
432
|
# cmd = Command.custom(grace_period: 5.0) do |out, token|
|
|
349
|
-
#
|
|
433
|
+
# until token.canceled?
|
|
350
434
|
# out.put(:tick, Time.now)
|
|
351
435
|
# sleep 1
|
|
352
436
|
# end
|
|
353
437
|
# end
|
|
354
438
|
def self.custom(callable = nil, grace_period: nil, &block)
|
|
355
|
-
|
|
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(*, **)
|
|
356
531
|
end
|
|
357
532
|
|
|
358
533
|
# :nodoc:
|
|
359
|
-
Wrapped
|
|
534
|
+
class Wrapped < Data.define(:callable, :grace_period)
|
|
360
535
|
include Custom
|
|
361
|
-
def tea_cancellation_grace_period
|
|
362
|
-
|
|
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
|
|
363
544
|
end
|
|
364
545
|
private_constant :Wrapped
|
|
365
546
|
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
|
|
@@ -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
|
+
# System command response types.
|
|
12
|
+
#
|
|
13
|
+
# Contains message types for system command results.
|
|
14
|
+
module System
|
|
15
|
+
# Response from a system command (batch mode).
|
|
16
|
+
#
|
|
17
|
+
# Shell commands capture output and status. Without structured responses,
|
|
18
|
+
# handling success vs error requires manual parsing of arrays.
|
|
19
|
+
#
|
|
20
|
+
# This response includes predicates for common checks and deconstructs for
|
|
21
|
+
# pattern matching. Include Predicates for safe predicate calls.
|
|
22
|
+
#
|
|
23
|
+
# Use it to handle <tt>Command.system</tt> completions (batch mode).
|
|
24
|
+
#
|
|
25
|
+
# === Example
|
|
26
|
+
#
|
|
27
|
+
# case msg
|
|
28
|
+
# in { type: :system, envelope: :build, status: 0, stdout: }
|
|
29
|
+
# model.with(output: stdout)
|
|
30
|
+
# in { type: :system, envelope: :build, status: }
|
|
31
|
+
# model.with(error: "Build failed with exit #{status}")
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
Batch = Data.define(:envelope, :stdout, :stderr, :status) do
|
|
35
|
+
include Predicates
|
|
36
|
+
|
|
37
|
+
# Returns <tt>true</tt> for system responses.
|
|
38
|
+
def system?
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns <tt>true</tt> if status is 0.
|
|
43
|
+
def success?
|
|
44
|
+
status == 0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns <tt>true</tt> if status is non-zero.
|
|
48
|
+
def error?
|
|
49
|
+
status != 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Deconstructs for pattern matching.
|
|
53
|
+
#
|
|
54
|
+
# Returns a hash with <tt>:type</tt>, <tt>:envelope</tt>, <tt>:stdout</tt>,
|
|
55
|
+
# <tt>:stderr</tt>, and <tt>:status</tt>.
|
|
56
|
+
def deconstruct_keys(_keys)
|
|
57
|
+
{ type: :system, envelope:, stdout:, stderr:, status: }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|