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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
#++
|
|
7
7
|
|
|
8
8
|
require "ratatui_ruby"
|
|
9
|
+
require "concurrent-edge"
|
|
9
10
|
|
|
10
11
|
module RatatuiRuby
|
|
11
12
|
module Tea
|
|
@@ -38,224 +39,358 @@ module RatatuiRuby
|
|
|
38
39
|
#
|
|
39
40
|
# Runs until the update function returns a <tt>Command.exit</tt> command.
|
|
40
41
|
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
42
|
+
# == Root Fragment with Init
|
|
43
|
+
#
|
|
44
|
+
# Pass a fragment module with <tt>Init</tt>, <tt>Update</tt>, and <tt>View</tt> constants.
|
|
45
|
+
# This allows your application to do work at startup, and your Update will be called with the result.
|
|
46
|
+
# It also allows you to create a more complex model with access to <tt>ARGV</tt> and <tt>ENV</tt>.
|
|
47
|
+
#
|
|
48
|
+
# module MyApp
|
|
49
|
+
# # Init is any callable, and returns an immutable Model and/or Command, according to your application's needs.
|
|
50
|
+
# # The Model is your application's initial state, and the Command is any command to run at startup.
|
|
51
|
+
# Init = -> () {
|
|
52
|
+
# # To do work at startup:
|
|
53
|
+
# return [Data.define(:count).new(count: 0), Command.http("https://api.example.com/data")]
|
|
54
|
+
# # To start idle:
|
|
55
|
+
# return Data.define(:count).new(count: 0)
|
|
56
|
+
# }
|
|
57
|
+
#
|
|
58
|
+
# # Update has access to a single Message, and your fragment's latest immutable Model.
|
|
59
|
+
# # It returns a new Model and/or a Command, according to your application's needs.
|
|
60
|
+
# Update = ->(message, model) {
|
|
61
|
+
# [model, Command.exit]
|
|
62
|
+
# }
|
|
63
|
+
#
|
|
64
|
+
# # View has access to your fragment's latest immutable Model, and a RatatuiRuby::TUI.
|
|
65
|
+
# # It returns a tree of RatatuiRuby::Widget and/or Custom Widgets.
|
|
66
|
+
# View = ->(model, tui) {
|
|
67
|
+
# tui.paragraph(text: model.count.to_s)
|
|
68
|
+
# }
|
|
69
|
+
# end
|
|
70
|
+
#
|
|
71
|
+
# Tea.run(MyApp)
|
|
72
|
+
#
|
|
73
|
+
# == Root Fragment with auto-Init
|
|
74
|
+
#
|
|
75
|
+
# Pass a fragment module with a <tt>Model</tt> class and <tt>Update</tt> and <tt>View</tt> constants.
|
|
76
|
+
# Your application will be idle until a RatatuiRuby::Event message is sent to your Update.
|
|
77
|
+
#
|
|
78
|
+
# module MyApp
|
|
79
|
+
# # Model is anything that responds to <tt>new</tt>.
|
|
80
|
+
# Model = Data.define(:count).new(count: 0)
|
|
81
|
+
#
|
|
82
|
+
# # Update has access to a single Message, and your fragment's latest immutable Model.
|
|
83
|
+
# # It returns a new Model and/or a Command, according to your application's needs.
|
|
84
|
+
# Update = ->(message, model) {
|
|
85
|
+
# [model, Command.exit]
|
|
86
|
+
# }
|
|
87
|
+
#
|
|
88
|
+
# # View has access to your fragment's latest immutable Model, and a RatatuiRuby::TUI.
|
|
89
|
+
# # It returns a tree of RatatuiRuby::Widget and/or Custom Widgets.
|
|
90
|
+
# View = ->(model, tui) {
|
|
91
|
+
# tui.paragraph(text: model.count.to_s)
|
|
92
|
+
# }
|
|
93
|
+
# end
|
|
94
|
+
#
|
|
95
|
+
# Tea.run(MyApp)
|
|
96
|
+
#
|
|
97
|
+
# == Explicit Parameters API
|
|
98
|
+
#
|
|
99
|
+
# A root fragment is not required. You can pass individual parameters:
|
|
100
|
+
#
|
|
101
|
+
# Tea.run(
|
|
102
|
+
# model: MyApp::Model.new(count: 0),
|
|
103
|
+
# view: MyApp::View,
|
|
104
|
+
# update: MyApp::Update,
|
|
105
|
+
# command: Command.http("https://api.example.com/data")
|
|
106
|
+
# )
|
|
107
|
+
#
|
|
108
|
+
# == Parameters
|
|
109
|
+
#
|
|
110
|
+
# [root_fragment] Module with Model, Init, Update, View constants. *Mutually exclusive with model/view/update.*
|
|
111
|
+
# [fps] Target frames per second for the application. Higher values feel more responsive, but may spike CPU usage.
|
|
112
|
+
# [model] Initial application state (immutable). *Required if fragment not provided.*
|
|
113
|
+
# [view] Callable receiving <tt>(model, tui)</tt>, returns a widget. *Required if fragment not provided.*
|
|
114
|
+
# [update] Callable receiving <tt>(message, model)</tt>, returns <tt>[new_model, command]</tt> or just <tt>new_model</tt>. *Required if fragment not provided.*
|
|
115
|
+
# [command] Optional callable to run at startup. Returns a message for update.
|
|
116
|
+
#
|
|
117
|
+
# == Raises
|
|
118
|
+
#
|
|
119
|
+
# [RatatuiRuby::Error::Invariant] If both fragment and any of (model, view, update, command) are provided.
|
|
120
|
+
def self.run(root_fragment = nil, fps: 60, model: nil, view: nil, update: nil, command: nil)
|
|
121
|
+
@fragment = fragment_from_kwargs(root_fragment, model:, view:, update:, command:)
|
|
122
|
+
@view = @fragment::View
|
|
123
|
+
@update = @fragment::Update
|
|
124
|
+
@model, @command = init_callable.call
|
|
125
|
+
@timeout = 1 / fps
|
|
55
126
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
RatatuiRuby.run do |tui|
|
|
62
|
-
loop do
|
|
63
|
-
tui.draw do |frame|
|
|
64
|
-
widget = view.call(model, tui)
|
|
65
|
-
validate_view_result!(widget)
|
|
66
|
-
frame.render_widget(widget, frame.area)
|
|
67
|
-
end
|
|
127
|
+
# commands do significant work, so they run off the main thread
|
|
128
|
+
validate_ractor_shareable!(@command, "command")
|
|
129
|
+
# models get passed to and from commands on other threads
|
|
130
|
+
validate_ractor_shareable!(@model, "model")
|
|
131
|
+
# views and updates run on the main thread, so they don't need to be shareable
|
|
68
132
|
|
|
69
|
-
|
|
70
|
-
|
|
133
|
+
start_runtime
|
|
134
|
+
end
|
|
71
135
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
136
|
+
# Normalizes Init callable return value to <tt>[model, command]</tt> tuple.
|
|
137
|
+
#
|
|
138
|
+
# Init callables return initial state and optional startup command. They can use
|
|
139
|
+
# DWIM (Do What I Mean) syntax: return just a model, just a command, or a full tuple.
|
|
140
|
+
#
|
|
141
|
+
# This method handles all formats. Use it when composing child fragment Inits.
|
|
142
|
+
#
|
|
143
|
+
# [result] The Init return value (model, command, or <tt>[model, command]</tt> tuple).
|
|
144
|
+
#
|
|
145
|
+
# === Examples
|
|
146
|
+
#
|
|
147
|
+
#--
|
|
148
|
+
# SPDX-SnippetBegin
|
|
149
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
150
|
+
# SPDX-License-Identifier: MIT-0
|
|
151
|
+
#++
|
|
152
|
+
# # Just model
|
|
153
|
+
# model, cmd = Tea.normalize_init(Model.new(...))
|
|
154
|
+
# # => [Model.new(...), nil]
|
|
155
|
+
#
|
|
156
|
+
# # Just command
|
|
157
|
+
# model, cmd = Tea.normalize_init(Command.http(...))
|
|
158
|
+
# # => [nil, Command.http(...)]
|
|
159
|
+
#
|
|
160
|
+
# # Tuple (already normalized)
|
|
161
|
+
# model, cmd = Tea.normalize_init([Model.new(...), Command.http(...)])
|
|
162
|
+
# # => [Model.new(...), Command.http(...)]
|
|
163
|
+
#--
|
|
164
|
+
# SPDX-SnippetEnd
|
|
165
|
+
#++
|
|
166
|
+
def self.normalize_init(result)
|
|
167
|
+
normalize_update_return(result, nil)
|
|
168
|
+
end
|
|
78
169
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
end
|
|
170
|
+
# Sentinel value avoids accidentally quitting from application exceptions.
|
|
171
|
+
QUIT = Object.new.freeze
|
|
82
172
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if synthetic&.sync?
|
|
89
|
-
# Wait for all pending threads to complete
|
|
90
|
-
pending_threads.each(&:join)
|
|
91
|
-
pending_threads.clear
|
|
92
|
-
|
|
93
|
-
# Yield to ensure any final queue writes are visible
|
|
94
|
-
Thread.pass
|
|
95
|
-
|
|
96
|
-
# Process all pending queue items
|
|
97
|
-
until queue.empty?
|
|
98
|
-
begin
|
|
99
|
-
background_message = queue.pop(true)
|
|
100
|
-
result = update.call(background_message, model)
|
|
101
|
-
model, command = normalize_update_result(result, model)
|
|
102
|
-
validate_ractor_shareable!(model, "model")
|
|
103
|
-
throw :quit if command.is_a?(Command::Exit)
|
|
104
|
-
|
|
105
|
-
thread = dispatch(command, queue, active_commands) if command
|
|
106
|
-
pending_threads << thread if thread
|
|
107
|
-
rescue ThreadError
|
|
108
|
-
break
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
end
|
|
173
|
+
class << self
|
|
174
|
+
private def start_runtime
|
|
175
|
+
@message_queue = Concurrent::Promises::Channel.new
|
|
176
|
+
@pending_futures = [] #: Array[Concurrent::Promises::Future[void]]
|
|
177
|
+
@lifecycle = Command::Lifecycle.new
|
|
113
178
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
thread = dispatch(command, queue, active_commands) if command
|
|
124
|
-
pending_threads << thread if thread
|
|
125
|
-
rescue ThreadError
|
|
126
|
-
break
|
|
127
|
-
end
|
|
179
|
+
catch(QUIT) do
|
|
180
|
+
dispatch_command
|
|
181
|
+
RatatuiRuby.run do |tui|
|
|
182
|
+
@tui = tui
|
|
183
|
+
loop do
|
|
184
|
+
draw_view
|
|
185
|
+
handle_ratatui_event
|
|
186
|
+
handle_sync
|
|
187
|
+
send_pending_messages
|
|
128
188
|
end
|
|
129
189
|
end
|
|
130
190
|
end
|
|
191
|
+
|
|
192
|
+
# Shutdown: signal all, wait grace periods (cooperative cancellation)
|
|
193
|
+
@lifecycle.shutdown
|
|
194
|
+
|
|
195
|
+
# Process any final messages from completed commands
|
|
196
|
+
send_pending_messages(dispatch: false)
|
|
197
|
+
|
|
198
|
+
@model
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private def draw_view
|
|
202
|
+
@tui.draw do |frame|
|
|
203
|
+
widget = @view.call(@model, @tui)
|
|
204
|
+
validate_view_return!(widget)
|
|
205
|
+
frame.render_widget(widget, frame.area)
|
|
206
|
+
end
|
|
131
207
|
end
|
|
132
208
|
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
209
|
+
# Enforces invariants
|
|
210
|
+
private def fragment_from_kwargs(root_fragment, model: nil, view: nil, update: nil, command: nil)
|
|
211
|
+
if root_fragment
|
|
212
|
+
fragment_invariant!("model") if model
|
|
213
|
+
fragment_invariant!("view") if view
|
|
214
|
+
fragment_invariant!("update") if update
|
|
215
|
+
fragment_invariant!("command") if command
|
|
216
|
+
root_fragment
|
|
141
217
|
else
|
|
142
|
-
|
|
218
|
+
fragment = Module.new
|
|
219
|
+
fragment.const_set(:Model, model)
|
|
220
|
+
fragment.const_set(:View, view)
|
|
221
|
+
fragment.const_set(:Update, update)
|
|
222
|
+
fragment.const_set(:Init, -> { [model, command] })
|
|
223
|
+
fragment
|
|
143
224
|
end
|
|
144
225
|
end
|
|
145
226
|
|
|
146
|
-
#
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
227
|
+
# Helps app developers understand invariants
|
|
228
|
+
private def fragment_invariant!(param)
|
|
229
|
+
raise RatatuiRuby::Error::Invariant, "Cannot provide both fragment: and #{param}: parameters. Use fragment-first API (fragment:) OR explicit parameters (model:, view:, update:, command:), not both."
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private def init_callable
|
|
233
|
+
if @fragment.const_defined?(:Init)
|
|
234
|
+
if @fragment::Init.respond_to?(:call)
|
|
235
|
+
if Proc === @fragment::Init or Method === @fragment::Init
|
|
236
|
+
@fragment::Init
|
|
237
|
+
else
|
|
238
|
+
@fragment::Init.method(:call)
|
|
239
|
+
end
|
|
240
|
+
else
|
|
241
|
+
raise RatatuiRuby::Error::Invariant, "Fragment::Init must respond to :call"
|
|
242
|
+
end
|
|
243
|
+
else
|
|
244
|
+
if @fragment.const_defined?(:Model)
|
|
245
|
+
if @fragment::Model.respond_to?(:new)
|
|
246
|
+
-> { @fragment::Model.new }
|
|
247
|
+
else
|
|
248
|
+
raise RatatuiRuby::Error::Invariant, "Fragment::Model must respond to :new; or pass Fragment::Init instead"
|
|
249
|
+
end
|
|
250
|
+
else
|
|
251
|
+
raise RatatuiRuby::Error::Invariant, "Fragment must define a Model class or an Init callable"
|
|
252
|
+
end
|
|
154
253
|
end
|
|
155
254
|
end
|
|
156
255
|
|
|
157
|
-
|
|
158
|
-
end
|
|
256
|
+
private
|
|
159
257
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
258
|
+
# Validates the view returned a widget.
|
|
259
|
+
#
|
|
260
|
+
# Views return widget trees. Returning +nil+ is a bug—you forgot to
|
|
261
|
+
# return something. For an intentionally empty screen, use TUI#clear.
|
|
262
|
+
private def validate_view_return!(widget)
|
|
263
|
+
return unless widget.nil?
|
|
166
264
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
265
|
+
raise RatatuiRuby::Error::Invariant,
|
|
266
|
+
"View returned nil. Return a widget, or use TUI#clear for an empty screen."
|
|
267
|
+
end
|
|
170
268
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
269
|
+
# Extracts [model, command] from Update return value.
|
|
270
|
+
private def normalize_update_return(result, previous_model)
|
|
271
|
+
# Case 0: Nil result - preserve previous model
|
|
272
|
+
return [previous_model, nil] if result.nil?
|
|
273
|
+
|
|
274
|
+
# Case 1: Already a [model, command] tuple
|
|
275
|
+
if result.is_a?(Array) && (result.size == 2)
|
|
276
|
+
model, command = result
|
|
277
|
+
# Verify the second element is a valid command
|
|
278
|
+
if command.nil? ||
|
|
279
|
+
(command.respond_to?(:tea_command?) && command.tea_command?)
|
|
280
|
+
|
|
281
|
+
return [model, command]
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Debug-mode heuristic: warn about suspicious command-like objects
|
|
285
|
+
if RatatuiRuby::Debug.enabled? &&
|
|
286
|
+
command.respond_to?(:call) &&
|
|
287
|
+
!command.respond_to?(:tea_command?) &&
|
|
288
|
+
!Ractor.shareable?(result)
|
|
289
|
+
|
|
290
|
+
warn "WARNING: Update returned [model, #{command.class}] but #{command.class} " \
|
|
291
|
+
"responds to #call without #tea_command?. Did you forget to include Command::Custom? " \
|
|
292
|
+
"The tuple will be treated as the model, not as [model, command]. " \
|
|
293
|
+
"To suppress this warning if the array is your model, use Ractor.make_shareable on it. " \
|
|
294
|
+
"(#{caller.first})"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Case 2: Result is a Command - use previous model
|
|
300
|
+
if result.respond_to?(:tea_command?) && result.tea_command?
|
|
301
|
+
command = result #: RatatuiRuby::Tea::Command::execution
|
|
302
|
+
return [previous_model, command]
|
|
193
303
|
end
|
|
304
|
+
|
|
305
|
+
# Case 3: Result is the new model
|
|
306
|
+
[result, nil]
|
|
194
307
|
end
|
|
195
308
|
|
|
196
|
-
#
|
|
197
|
-
|
|
198
|
-
|
|
309
|
+
# Validates an object is Ractor-shareable (deeply frozen).
|
|
310
|
+
#
|
|
311
|
+
# Models and messages must be shareable for future Ractor support.
|
|
312
|
+
# Mutable objects cause race conditions. Freeze your data.
|
|
313
|
+
#
|
|
314
|
+
# Only enforced in debug mode (and tests). Production skips this check
|
|
315
|
+
# for performance; mutable objects will still cause bugs, but silently.
|
|
316
|
+
private def validate_ractor_shareable!(object, name)
|
|
317
|
+
return unless RatatuiRuby::Debug.enabled?
|
|
318
|
+
return if Ractor.shareable?(object)
|
|
319
|
+
|
|
320
|
+
raise RatatuiRuby::Error::Invariant,
|
|
321
|
+
"#{name.capitalize} is not Ractor-shareable. Use Ractor.make_shareable or Object#freeze."
|
|
199
322
|
end
|
|
200
|
-
|
|
201
|
-
|
|
323
|
+
|
|
324
|
+
private def handle_ratatui_event
|
|
325
|
+
message = @tui.poll_event(timeout: @timeout)
|
|
326
|
+
return if message.none?
|
|
327
|
+
|
|
328
|
+
@model, @command = normalize_update_return(@update.call(message, @model), @model)
|
|
329
|
+
validate_ractor_shareable!(@model, "model")
|
|
330
|
+
throw QUIT if Command::Exit === @command
|
|
331
|
+
dispatch_command
|
|
202
332
|
end
|
|
203
333
|
|
|
204
|
-
#
|
|
205
|
-
|
|
206
|
-
|
|
334
|
+
# This must come *after* handle_ratatui_event so Sync waits for commands
|
|
335
|
+
# dispatched by the preceding event. For example, in a test:
|
|
336
|
+
#
|
|
337
|
+
# inject_key("a")
|
|
338
|
+
# inject_sync
|
|
339
|
+
#
|
|
340
|
+
# We need <kbd>a</kbd> to call the Update and queue its message before
|
|
341
|
+
# processing the Sync.
|
|
342
|
+
private def handle_sync
|
|
343
|
+
if RatatuiRuby::SyntheticEvents.pending?
|
|
344
|
+
synthetic = RatatuiRuby::SyntheticEvents.pop
|
|
345
|
+
if synthetic&.sync?
|
|
346
|
+
# Wait for all pending futures to complete
|
|
347
|
+
@pending_futures.each(&:wait)
|
|
348
|
+
@pending_futures.clear
|
|
207
349
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
# Models and messages must be shareable for future Ractor support.
|
|
211
|
-
# Mutable objects cause race conditions. Freeze your data.
|
|
212
|
-
#
|
|
213
|
-
# Only enforced in debug mode (and tests). Production skips this check
|
|
214
|
-
# for performance; mutable objects will still cause bugs, but silently.
|
|
215
|
-
private_class_method def self.validate_ractor_shareable!(object, name)
|
|
216
|
-
return unless RatatuiRuby::Debug.enabled?
|
|
217
|
-
return if Ractor.shareable?(object)
|
|
218
|
-
|
|
219
|
-
raise RatatuiRuby::Error::Invariant,
|
|
220
|
-
"#{name.capitalize} is not Ractor-shareable. Use Ractor.make_shareable or Object#freeze."
|
|
221
|
-
end
|
|
350
|
+
# Yield to ensure any final queue writes are visible
|
|
351
|
+
Thread.pass
|
|
222
352
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
# Spawns a background thread and pushes results to the message queue.
|
|
226
|
-
# See Command.system for message formats.
|
|
227
|
-
private_class_method def self.dispatch(command, queue, active_commands = {})
|
|
228
|
-
case command
|
|
229
|
-
when Command::Cancel
|
|
230
|
-
entry = active_commands[command.handle]
|
|
231
|
-
if entry && entry[:thread].alive?
|
|
232
|
-
entry[:token].cancel!
|
|
233
|
-
grace = command.handle.tea_cancellation_grace_period
|
|
234
|
-
if grace.finite?
|
|
235
|
-
deadline = Time.now + grace
|
|
236
|
-
sleep 0.02 while entry[:thread].alive? && Time.now < deadline
|
|
237
|
-
entry[:thread].kill if entry[:thread].alive?
|
|
238
|
-
else
|
|
239
|
-
entry[:thread].join
|
|
353
|
+
# Process all pending message queue items
|
|
354
|
+
send_pending_messages
|
|
240
355
|
end
|
|
241
356
|
end
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
QUEUE_EMPTY = Object.new.freeze
|
|
360
|
+
private_constant :QUEUE_EMPTY
|
|
361
|
+
|
|
362
|
+
private def send_pending_messages(dispatch: true)
|
|
363
|
+
loop do
|
|
364
|
+
background_message = @message_queue.try_pop(QUEUE_EMPTY)
|
|
365
|
+
break if background_message == QUEUE_EMPTY
|
|
366
|
+
|
|
367
|
+
result = @update.call(background_message, @model)
|
|
368
|
+
@model, @command = normalize_update_return(result, @model)
|
|
369
|
+
return unless dispatch
|
|
255
370
|
|
|
256
|
-
|
|
257
|
-
|
|
371
|
+
validate_ractor_shareable!(@model, "model")
|
|
372
|
+
throw QUIT if Command::Exit === @command
|
|
373
|
+
|
|
374
|
+
dispatch_command
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Spawns a future and pushes results to the message queue.
|
|
379
|
+
# See Command.system for message formats.
|
|
380
|
+
private def dispatch_command
|
|
381
|
+
future = if @command.nil?
|
|
382
|
+
nil
|
|
383
|
+
elsif Command::Cancel === @command
|
|
384
|
+
@lifecycle.cancel(@command.handle)
|
|
385
|
+
nil
|
|
386
|
+
elsif @command.respond_to?(:tea_command?) && @command.tea_command?
|
|
387
|
+
entry = @lifecycle.run_async(@command, @message_queue)
|
|
388
|
+
entry.future
|
|
389
|
+
else
|
|
390
|
+
raise RatatuiRuby::Error::Invariant,
|
|
391
|
+
"#{@command.inspect} is not a valid Tea command."
|
|
258
392
|
end
|
|
393
|
+
@pending_futures << future if future
|
|
259
394
|
end
|
|
260
395
|
end
|
|
261
396
|
end
|
|
@@ -36,8 +36,8 @@ module RatatuiRuby
|
|
|
36
36
|
|
|
37
37
|
# Creates a shell execution command.
|
|
38
38
|
# Short alias for +Command.system+.
|
|
39
|
-
def self.sh(command,
|
|
40
|
-
Command.system(command,
|
|
39
|
+
def self.sh(command, envelope)
|
|
40
|
+
Command.system(command, envelope)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
# Creates a mapped command.
|
|
@@ -0,0 +1,58 @@
|
|
|
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 "ratatui_ruby/test_helper"
|
|
9
|
+
|
|
10
|
+
module RatatuiRuby
|
|
11
|
+
module Tea
|
|
12
|
+
# Test helpers for Tea command validation.
|
|
13
|
+
#
|
|
14
|
+
# This module extends RatatuiRuby::TestHelper with Tea-specific assertions
|
|
15
|
+
# for verifying custom commands implement the proper protocol.
|
|
16
|
+
module TestHelper
|
|
17
|
+
# Validates a command implements the Tea command protocol.
|
|
18
|
+
#
|
|
19
|
+
# Custom commands run in background threads. They dispatch work and send messages.
|
|
20
|
+
# Forgetting to include \<tt>Command::Custom\</tt> breaks dispatch. The runtime
|
|
21
|
+
# treats \<tt>[model, bad_command]\</tt> as a model, not a tuple. Tests fail with
|
|
22
|
+
# confusing Ractor shareability errors.
|
|
23
|
+
#
|
|
24
|
+
# This method checks the protocol. Call it in tests to catch mistakes early.
|
|
25
|
+
#
|
|
26
|
+
# [command] The command object to validate.
|
|
27
|
+
#
|
|
28
|
+
# === Example
|
|
29
|
+
#
|
|
30
|
+
# def test_websocket_command_protocol
|
|
31
|
+
# cmd = WebSocketCommand.new("wss://example.com")
|
|
32
|
+
# validate_tea_command!(cmd)
|
|
33
|
+
# end
|
|
34
|
+
def validate_tea_command!(command)
|
|
35
|
+
unless command.respond_to?(:tea_command?)
|
|
36
|
+
raise RatatuiRuby::Error::Invariant,
|
|
37
|
+
"#{command.class} does not respond to #tea_command?. " \
|
|
38
|
+
"Include Command::Custom or implement the tea_command? predicate."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
unless command.respond_to?(:call)
|
|
42
|
+
raise RatatuiRuby::Error::Invariant,
|
|
43
|
+
"#{command.class} does not respond to #call. " \
|
|
44
|
+
"Implement call(out, token) to execute the command."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
unless command.respond_to?(:tea_cancellation_grace_period)
|
|
48
|
+
raise RatatuiRuby::Error::Invariant,
|
|
49
|
+
"#{command.class} does not respond to #tea_cancellation_grace_period. " \
|
|
50
|
+
"Include Command::Custom or implement this method."
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Attach Tea test helpers to RatatuiRuby::TestHelper
|
|
58
|
+
RatatuiRuby::TestHelper.include(RatatuiRuby::Tea::TestHelper)
|