ratatui_ruby-tea 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +8 -0
  3. data/CHANGELOG.md +41 -0
  4. data/README.md +1 -1
  5. data/doc/concepts/application_architecture.md +182 -3
  6. data/examples/app_fractal_dashboard/README.md +60 -0
  7. data/examples/app_fractal_dashboard/app.rb +67 -0
  8. data/examples/app_fractal_dashboard/bags/custom_shell_input.rb +77 -0
  9. data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +73 -0
  10. data/examples/app_fractal_dashboard/bags/custom_shell_output.rb +86 -0
  11. data/examples/app_fractal_dashboard/bags/disk_usage.rb +44 -0
  12. data/examples/app_fractal_dashboard/bags/network_panel.rb +45 -0
  13. data/examples/app_fractal_dashboard/bags/ping.rb +43 -0
  14. data/examples/app_fractal_dashboard/bags/stats_panel.rb +45 -0
  15. data/examples/app_fractal_dashboard/bags/system_info.rb +43 -0
  16. data/examples/app_fractal_dashboard/bags/uptime.rb +43 -0
  17. data/examples/app_fractal_dashboard/dashboard/base.rb +74 -0
  18. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +86 -0
  19. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +87 -0
  20. data/examples/app_fractal_dashboard/dashboard/update_router.rb +43 -0
  21. data/examples/verify_readme_usage/README.md +1 -1
  22. data/examples/verify_readme_usage/app.rb +1 -1
  23. data/examples/{widget_cmd_exec → widget_command_system}/app.rb +18 -18
  24. data/lib/ratatui_ruby/tea/command.rb +145 -0
  25. data/lib/ratatui_ruby/tea/router.rb +337 -0
  26. data/lib/ratatui_ruby/tea/runtime.rb +99 -39
  27. data/lib/ratatui_ruby/tea/shortcuts.rb +51 -0
  28. data/lib/ratatui_ruby/tea/version.rb +1 -1
  29. data/lib/ratatui_ruby/tea.rb +59 -1
  30. data/sig/ratatui_ruby/tea/command.rbs +47 -0
  31. data/sig/ratatui_ruby/tea/router.rbs +99 -0
  32. metadata +26 -8
  33. data/lib/ratatui_ruby/tea/cmd.rb +0 -88
  34. data/sig/ratatui_ruby/tea/cmd.rbs +0 -32
  35. /data/examples/{widget_cmd_exec → widget_command_system}/README.md +0 -0
  36. /data/sig/examples/{widget_cmd_exec → widget_command_system}/app.rbs +0 -0
@@ -0,0 +1,145 @@
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
+ # Commands represent side effects.
11
+ #
12
+ # The MVU pattern separates logic from effects. Your update function returns a pure
13
+ # model transformation. Side effects go in commands. The runtime executes them.
14
+ #
15
+ # Commands produce **messages**, not callbacks. The +tag+ argument names the message
16
+ # so your update function can pattern-match on it. This keeps all logic in +update+
17
+ # and ensures messages are Ractor-shareable.
18
+ #
19
+ # === Examples
20
+ #
21
+ # # Terminate the application
22
+ # [model, Command.exit]
23
+ #
24
+ # # Run a shell command; produces [:got_files, {stdout:, stderr:, status:}]
25
+ # [model, Command.system("ls -la", :got_files)]
26
+ #
27
+ # # No side effect
28
+ # [model, nil]
29
+ module Command
30
+ # Sentinel value for application termination.
31
+ #
32
+ # The runtime detects this before dispatching. It breaks the loop immediately.
33
+ Exit = Data.define
34
+
35
+ # Creates a quit command.
36
+ #
37
+ # Returns a sentinel the runtime detects to terminate the application.
38
+ #
39
+ # === Example
40
+ #
41
+ # def update(message, model)
42
+ # case message
43
+ # in { type: :key, code: "q" }
44
+ # [model, Command.exit]
45
+ # else
46
+ # [model, nil]
47
+ # end
48
+ # end
49
+ def self.exit
50
+ Exit.new
51
+ end
52
+
53
+ # Command to run a shell command via Open3.
54
+ #
55
+ # The runtime executes the command and produces messages. In batch mode
56
+ # (default), a single message arrives: <tt>[tag, {stdout:, stderr:, status:}]</tt>.
57
+ #
58
+ # In streaming mode, messages arrive incrementally:
59
+ # - <tt>[tag, :stdout, line]</tt> for each stdout line
60
+ # - <tt>[tag, :stderr, line]</tt> for each stderr line
61
+ # - <tt>[tag, :complete, {status:}]</tt> when the command finishes
62
+ # - <tt>[tag, :error, {message:}]</tt> if the command cannot start
63
+ #
64
+ # The <tt>status</tt> is the integer exit code (0 = success).
65
+ System = Data.define(:command, :tag, :stream) do
66
+ # Returns true if streaming mode is enabled.
67
+ def stream?
68
+ stream
69
+ end
70
+ end
71
+
72
+ # Creates a shell execution command.
73
+ #
74
+ # [command] Shell command string to execute.
75
+ # [tag] Symbol or class to tag the result message.
76
+ # [stream] If <tt>true</tt>, the runtime sends incremental stdout/stderr
77
+ # messages as they arrive. If <tt>false</tt> (default), waits for
78
+ # completion and sends a single message with all output.
79
+ #
80
+ # === Example (Batch Mode)
81
+ #
82
+ # # Return this from update:
83
+ # [model.with(loading: true), Command.system("ls -la", :got_files)]
84
+ #
85
+ # # Then handle it later:
86
+ # def update(message, model)
87
+ # case message
88
+ # in [:got_files, {stdout:, status: 0}]
89
+ # [model.with(files: stdout.lines), nil]
90
+ # in [:got_files, {stderr:, status:}]
91
+ # [model.with(error: stderr), nil]
92
+ # end
93
+ # end
94
+ #
95
+ # === Example (Streaming Mode)
96
+ #
97
+ # # Return this from update:
98
+ # [model.with(loading: true), Command.system("tail -f log.txt", :log, stream: true)]
99
+ #
100
+ # # Then handle incremental messages:
101
+ # def update(message, model)
102
+ # case message
103
+ # in [:log, :stdout, line]
104
+ # [model.with(lines: [*model.lines, line]), nil]
105
+ # in [:log, :stderr, line]
106
+ # [model.with(errors: [*model.errors, line]), nil]
107
+ # in [:log, :complete, {status:}]
108
+ # [model.with(loading: false, exit_status: status), nil]
109
+ # in [:log, :error, {message:}]
110
+ # [model.with(loading: false, error: message), nil]
111
+ # end
112
+ # end
113
+ def self.system(command, tag, stream: false)
114
+ System.new(command:, tag:, stream:)
115
+ end
116
+
117
+ # Command that wraps another command's result with a transformation.
118
+ #
119
+ # Fractal Architecture requires composition. Child bags produce commands.
120
+ # Parent bags route child results back to themselves. +Mapped+ wraps a
121
+ # child bag's command and transforms its result message into a parent message.
122
+ Mapped = Data.define(:inner_command, :mapper)
123
+
124
+ # Creates a mapped command for Fractal Architecture composition.
125
+ #
126
+ # Wraps an inner command. When the inner command completes, the +mapper+ block
127
+ # transforms the result into a parent message. This prevents monolithic update
128
+ # functions (the "God Reducer" anti-pattern).
129
+ #
130
+ # [inner_command] The child command to wrap.
131
+ # [mapper] Block that transforms child message to parent message.
132
+ #
133
+ # === Example
134
+ #
135
+ # # Child returns Command.execute that produces [:got_files, {...}]
136
+ # child_command = Command.system("ls", :got_files)
137
+ #
138
+ # # Parent wraps to route as [:sidebar, :got_files, {...}]
139
+ # parent_command = Command.map(child_command) { |child_result| [:sidebar, *child_result] }
140
+ def self.map(inner_command, &mapper)
141
+ Mapped.new(inner_command:, mapper:)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,337 @@
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
+ # Declarative DSL for Fractal Architecture.
11
+ #
12
+ # Large applications decompose into bags. Each bag has its own Model,
13
+ # UPDATE, and VIEW. Parent bags route messages to child bags and compose views.
14
+ # Writing this routing logic by hand is tedious and error-prone.
15
+ #
16
+ # Include this module to declare routes and keymaps. Call +from_router+ to
17
+ # generate an UPDATE lambda that handles routing automatically.
18
+ #
19
+ # A *bag* is a module containing <tt>Model</tt>, <tt>INITIAL</tt>,
20
+ # <tt>UPDATE</tt>, and <tt>VIEW</tt> constants. Bags compose: parent bags
21
+ # delegate to child bags.
22
+ #
23
+ # === Example
24
+ #
25
+ # class Dashboard
26
+ # include Tea::Router
27
+ #
28
+ # route :stats, to: StatsPanel
29
+ # route :network, to: NetworkPanel
30
+ #
31
+ # keymap do
32
+ # key "s", -> { SystemInfo.fetch_command }, route: :stats
33
+ # key "q", -> { Command.exit }
34
+ # end
35
+ #
36
+ # Model = Data.define(:stats, :network)
37
+ # INITIAL = Model.new(stats: StatsPanel::INITIAL, network: NetworkPanel::INITIAL)
38
+ # VIEW = ->(model, tui) { ... }
39
+ # UPDATE = from_router
40
+ # end
41
+ module Router
42
+ # :nodoc:
43
+ def self.included(base)
44
+ base.extend(ClassMethods)
45
+ end
46
+
47
+ # Class methods added when Router is included.
48
+ module ClassMethods
49
+ # Declares a route to a child.
50
+ #
51
+ # [prefix] Symbol or String identifying the route (normalized via +.to_s.to_sym+).
52
+ # [to] The child module (must have UPDATE and INITIAL constants).
53
+ def route(prefix, to:)
54
+ routes[prefix.to_s.to_sym] = to
55
+ end
56
+
57
+ # Returns the registered routes hash.
58
+ def routes
59
+ @routes ||= {}
60
+ end
61
+
62
+ # Declares a named action.
63
+ #
64
+ # Actions are shared handlers that keymap and mousemap can reference.
65
+ # This avoids duplicating logic for keys and mouse events that do
66
+ # the same thing.
67
+ #
68
+ # [name] Symbol or String identifying the action (normalized via +.to_s.to_sym+).
69
+ # [handler] Callable that returns a command or message.
70
+ def action(name, handler)
71
+ actions[name.to_s.to_sym] = handler
72
+ end
73
+
74
+ # Returns the registered actions hash.
75
+ def actions
76
+ @actions ||= {}
77
+ end
78
+
79
+ # Declares key handlers in a block.
80
+ #
81
+ # === Example
82
+ #
83
+ # keymap do
84
+ # key "q", -> { Command.exit }
85
+ # key :up, :scroll_up # Delegate to action
86
+ # end
87
+ def keymap(&)
88
+ builder = KeymapBuilder.new
89
+ builder.instance_eval(&)
90
+ @key_handlers = builder.handlers
91
+ end
92
+
93
+ # Returns the registered key handlers hash.
94
+ def key_handlers
95
+ @key_handlers ||= {}
96
+ end
97
+
98
+ # Declares mouse handlers in a block.
99
+ #
100
+ # === Example
101
+ #
102
+ # mousemap do
103
+ # click -> (x, y) { [:clicked, x, y] }
104
+ # scroll :up, :scroll_up # Delegate to action
105
+ # end
106
+ def mousemap(&)
107
+ builder = MousemapBuilder.new
108
+ builder.instance_eval(&)
109
+ @mouse_handlers = builder.handlers
110
+ end
111
+
112
+ # Returns the registered mouse handlers hash.
113
+ def mouse_handlers
114
+ @mouse_handlers ||= {}
115
+ end
116
+
117
+ # Generates an UPDATE lambda from routes, keymap, and mousemap.
118
+ #
119
+ # The generated UPDATE:
120
+ # 1. Routes prefixed messages to child UPDATEs
121
+ # 2. Handles keyboard events via keymap
122
+ # 3. Handles mouse events via mousemap
123
+ # 4. Returns model unchanged for unhandled messages
124
+ def from_router
125
+ my_routes = routes
126
+ my_actions = actions
127
+ my_key_handlers = key_handlers
128
+ my_mouse_handlers = mouse_handlers
129
+
130
+ lambda do |message, model|
131
+ # 1. Try routing prefixed messages to children
132
+ my_routes.each do |prefix, child|
133
+ result = Tea.delegate(message, prefix, child::UPDATE, model.public_send(prefix))
134
+ if result
135
+ new_child, command = result
136
+ return [model.with(prefix => new_child), command]
137
+ end
138
+ end
139
+
140
+ # 2. Try keymap handlers (message is an Event)
141
+ if message.respond_to?(:key?) && message.key?
142
+ my_key_handlers.each do |key_name, config|
143
+ predicate = :"#{key_name}?"
144
+ next unless message.respond_to?(predicate) && message.public_send(predicate)
145
+
146
+ # Check guard if present
147
+ if (config[:guard]) && !config[:guard].call(model)
148
+ next
149
+ end
150
+
151
+ handler = config[:handler] || my_actions[config[:action]]
152
+ command = handler.call
153
+ if config[:route]
154
+ command = Tea.route(command, config[:route])
155
+ end
156
+ return [model, command]
157
+ end
158
+ end
159
+
160
+ # 3. Try mousemap handlers
161
+ if message.respond_to?(:mouse?) && message.mouse?
162
+ # Scroll events
163
+ if message.respond_to?(:scroll_up?) && message.scroll_up?
164
+ config = my_mouse_handlers[:scroll_up]
165
+ if config
166
+ handler = config[:handler] || my_actions[config[:action]]
167
+ return [model, handler.call]
168
+ end
169
+ end
170
+ if message.respond_to?(:scroll_down?) && message.scroll_down?
171
+ config = my_mouse_handlers[:scroll_down]
172
+ if config
173
+ handler = config[:handler] || my_actions[config[:action]]
174
+ return [model, handler.call]
175
+ end
176
+ end
177
+ # Click events
178
+ if message.respond_to?(:click?) && message.click?
179
+ config = my_mouse_handlers[:click]
180
+ if config
181
+ handler = config[:handler] || my_actions[config[:action]]
182
+ return [model, handler.call(message.x, message.y)]
183
+ end
184
+ end
185
+ end
186
+
187
+ # 4. Unhandled - return model unchanged
188
+ [model, nil]
189
+ end
190
+ end
191
+ end
192
+
193
+ # Builder for keymap DSL.
194
+ class KeymapBuilder
195
+ # Returns the registered handlers hash.
196
+ attr_reader :handlers
197
+
198
+ # :nodoc:
199
+ def initialize
200
+ @handlers = {}
201
+ @guard_stack = []
202
+ end
203
+
204
+ # Registers a key handler.
205
+ #
206
+ # [key_name] String or Symbol for the key (normalized via +.to_s+).
207
+ # [handler_or_action] Callable or Symbol (action name).
208
+ # [route] Optional route prefix for the command result.
209
+ # [when/if/only/guard] Guard that runs if truthy (aliases).
210
+ # [unless/except/skip] Guard that runs if falsy (negative aliases).
211
+ def key(key_name, handler_or_action, route: nil, when: nil, if: nil, only: nil, guard: nil, unless: nil, except: nil, skip: nil)
212
+ entry = {}
213
+ if handler_or_action.is_a?(Symbol)
214
+ entry[:action] = handler_or_action
215
+ else
216
+ entry[:handler] = handler_or_action
217
+ end
218
+ entry[:route] = route if route
219
+
220
+ guards = @guard_stack.dup
221
+
222
+ # Positive guards (when, if, only, guard)
223
+ positive = binding.local_variable_get(:when) ||
224
+ binding.local_variable_get(:if) ||
225
+ only ||
226
+ guard
227
+ guards << positive if positive
228
+
229
+ # Negative guards (unless, except, skip) - wrap to invert
230
+ negative = binding.local_variable_get(:unless) || except || skip
231
+ if negative
232
+ guards << -> (model) { !negative.call(model) }
233
+ end
234
+
235
+ if guards.any?
236
+ entry[:guard] = -> (model) { guards.all? { |g| g.call(model) } }
237
+ end
238
+
239
+ @handlers[key_name.to_s] = entry
240
+ end
241
+
242
+ # Applies a guard to all keys in the block.
243
+ #
244
+ # [when/if/only/guard] Guard that runs if truthy.
245
+ def only(when: nil, if: nil, only: nil, guard: nil, &)
246
+ arg_count = 0
247
+ arg_count += 1 if binding.local_variable_get(:when)
248
+ arg_count += 1 if binding.local_variable_get(:if)
249
+ arg_count += 1 if only
250
+ arg_count += 1 if guard
251
+
252
+ if arg_count > 1
253
+ raise ArgumentError, "only accepts exactly one of: when, if, only, guard"
254
+ end
255
+
256
+ positive = binding.local_variable_get(:when) ||
257
+ binding.local_variable_get(:if) ||
258
+ only ||
259
+ guard
260
+ with_guard(positive, &)
261
+ end
262
+
263
+ # Skips all keys in the block when the guard is true.
264
+ #
265
+ # [when/if/skip/guard] Guard that skips if truthy.
266
+ def skip(when: nil, if: nil, skip: nil, guard: nil, &)
267
+ arg_count = 0
268
+ arg_count += 1 if binding.local_variable_get(:when)
269
+ arg_count += 1 if binding.local_variable_get(:if)
270
+ arg_count += 1 if skip
271
+ arg_count += 1 if guard
272
+
273
+ if arg_count > 1
274
+ raise ArgumentError, "skip accepts exactly one of: when, if, skip, guard"
275
+ end
276
+
277
+ skip_guard = binding.local_variable_get(:when) ||
278
+ binding.local_variable_get(:if) ||
279
+ skip ||
280
+ guard
281
+
282
+ # Invert the guard: skip when true means run when false
283
+ inverted = skip_guard ? -> (model) { !skip_guard.call(model) } : nil
284
+ with_guard(inverted, &)
285
+ end
286
+ private def with_guard(guard, &block)
287
+ if guard
288
+ @guard_stack << guard
289
+ begin
290
+ block.call
291
+ ensure
292
+ @guard_stack.pop
293
+ end
294
+ else
295
+ block.call
296
+ end
297
+ end
298
+ end
299
+
300
+ # Builder for mousemap DSL.
301
+ class MousemapBuilder
302
+ # Returns the registered handlers hash.
303
+ attr_reader :handlers
304
+
305
+ # :nodoc:
306
+ def initialize
307
+ @handlers = {}
308
+ end
309
+
310
+ # Registers a click handler.
311
+ #
312
+ # [handler_or_action] Callable or Symbol (action name).
313
+ def click(handler_or_action)
314
+ register(:click, handler_or_action)
315
+ end
316
+
317
+ # Registers a scroll handler.
318
+ #
319
+ # [direction] <tt>:up</tt> or <tt>:down</tt>.
320
+ # [handler_or_action] Callable or Symbol (action name).
321
+ def scroll(direction, handler_or_action)
322
+ register(:"scroll_#{direction}", handler_or_action)
323
+ end
324
+
325
+ private def register(key, handler_or_action)
326
+ entry = {}
327
+ if handler_or_action.is_a?(Symbol)
328
+ entry[:action] = handler_or_action
329
+ else
330
+ entry[:handler] = handler_or_action
331
+ end
332
+ @handlers[key] = entry
333
+ end
334
+ end
335
+ end
336
+ end
337
+ end
@@ -27,8 +27,8 @@ module RatatuiRuby
27
27
  #++
28
28
  # RatatuiRuby::Tea.run(
29
29
  # model: { count: 0 }.freeze,
30
- # view: ->(m, tui) { tui.paragraph(text: m[:count].to_s) },
31
- # update: ->(msg, m) { msg.q? ? [m, Cmd.quit] : [m, nil] }
30
+ # view: ->(model, tui) { tui.paragraph(text: model[:count].to_s) },
31
+ # update: ->(message, model) { message.q? ? [model, Command.exit] : [model, nil] }
32
32
  # )
33
33
  #--
34
34
  # SPDX-SnippetEnd
@@ -36,24 +36,25 @@ module RatatuiRuby
36
36
  class Runtime
37
37
  # Starts the MVU event loop.
38
38
  #
39
- # Runs until the update function returns a <tt>Cmd.quit</tt> command.
39
+ # Runs until the update function returns a <tt>Command.exit</tt> command.
40
40
  #
41
41
  # [model] Initial application state (immutable).
42
42
  # [view] Callable receiving <tt>(model, tui)</tt>, returns a widget.
43
- # [update] Callable receiving <tt>(msg, model)</tt>, returns <tt>[new_model, cmd]</tt> or just <tt>new_model</tt>.
43
+ # [update] Callable receiving <tt>(message, model)</tt>, returns <tt>[new_model, command]</tt> or just <tt>new_model</tt>.
44
44
  # [init] Optional callable to run at startup. Returns a message for update.
45
45
  def self.run(model:, view:, update:, init: nil)
46
46
  validate_ractor_shareable!(model, "model")
47
47
 
48
48
  # Execute init command synchronously if provided
49
49
  if init
50
- init_msg = init.call
51
- result = update.call(init_msg, model)
52
- model, _cmd = normalize_update_result(result, model)
50
+ init_message = init.call
51
+ result = update.call(init_message, model)
52
+ model, _command = normalize_update_result(result, model)
53
53
  validate_ractor_shareable!(model, "model")
54
54
  end
55
55
 
56
56
  queue = Queue.new
57
+ pending_threads = []
57
58
 
58
59
  catch(:quit) do
59
60
  RatatuiRuby.run do |tui|
@@ -65,28 +66,58 @@ module RatatuiRuby
65
66
  end
66
67
 
67
68
  # 1. Handle user input (blocks up to 16ms)
68
- msg = tui.poll_event
69
+ message = tui.poll_event
69
70
 
70
71
  # If provided, handle the event
71
- unless msg.is_a?(RatatuiRuby::Event::None)
72
- result = update.call(msg, model)
73
- model, cmd = normalize_update_result(result, model)
72
+ unless message.is_a?(RatatuiRuby::Event::None)
73
+ result = update.call(message, model)
74
+ model, command = normalize_update_result(result, model)
74
75
  validate_ractor_shareable!(model, "model")
75
- throw :quit if cmd.is_a?(Cmd::Quit)
76
+ throw :quit if command.is_a?(Command::Exit)
76
77
 
77
- dispatch(cmd, queue) if cmd
78
+ thread = dispatch(command, queue) if command
79
+ pending_threads << thread if thread
78
80
  end
79
81
 
80
- # 2. Check for background outcomes
82
+ # 2. Check for synthetic events (Sync)
83
+ # This comes AFTER poll_event so Sync waits for commands dispatched
84
+ # by the preceding event (e.g., inject_key("a"); inject_sync)
85
+ if RatatuiRuby::SyntheticEvents.pending?
86
+ synthetic = RatatuiRuby::SyntheticEvents.pop
87
+ if synthetic&.sync?
88
+ # Wait for all pending threads to complete
89
+ pending_threads.each(&:join)
90
+ pending_threads.clear
91
+
92
+ # Process all pending queue items
93
+ until queue.empty?
94
+ begin
95
+ background_message = queue.pop(true)
96
+ result = update.call(background_message, model)
97
+ model, command = normalize_update_result(result, model)
98
+ validate_ractor_shareable!(model, "model")
99
+ throw :quit if command.is_a?(Command::Exit)
100
+
101
+ thread = dispatch(command, queue) if command
102
+ pending_threads << thread if thread
103
+ rescue ThreadError
104
+ break
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ # 3. Check for background outcomes (non-blocking)
81
111
  until queue.empty?
82
112
  begin
83
- bg_msg = queue.pop(true)
84
- result = update.call(bg_msg, model)
85
- model, cmd = normalize_update_result(result, model)
113
+ background_message = queue.pop(true)
114
+ result = update.call(background_message, model)
115
+ model, command = normalize_update_result(result, model)
86
116
  validate_ractor_shareable!(model, "model")
87
- throw :quit if cmd.is_a?(Cmd::Quit)
117
+ throw :quit if command.is_a?(Command::Exit)
88
118
 
89
- dispatch(cmd, queue) if cmd
119
+ thread = dispatch(command, queue) if command
120
+ pending_threads << thread if thread
90
121
  rescue ThreadError
91
122
  break
92
123
  end
@@ -109,19 +140,19 @@ module RatatuiRuby
109
140
  "View returned nil. Return a widget, or use TUI#clear for an empty screen."
110
141
  end
111
142
 
112
- # Detects whether +result+ is a +[model, cmd]+ tuple, a plain model, or a Cmd alone.
143
+ # Detects whether +result+ is a +[model, command]+ tuple, a plain model, or a Command alone.
113
144
  #
114
- # Returns +[model, cmd]+ in all cases.
145
+ # Returns +[model, command]+ in all cases.
115
146
  private_class_method def self.normalize_update_result(result, previous_model)
116
- return result if result.is_a?(Array) && result.size == 2 && valid_cmd?(result[1])
117
- return [previous_model, result] if valid_cmd?(result)
147
+ return result if result.is_a?(Array) && result.size == 2 && valid_command?(result[1])
148
+ return [previous_model, result] if valid_command?(result)
118
149
 
119
150
  [result, nil]
120
151
  end
121
152
 
122
- # Returns +true+ if +value+ is a valid command (+nil+ or a +Cmd+ type).
123
- private_class_method def self.valid_cmd?(value)
124
- value.nil? || value.class.name&.start_with?("RatatuiRuby::Tea::Cmd::")
153
+ # Returns +true+ if +value+ is a valid command (+nil+ or a +Command+ type).
154
+ private_class_method def self.valid_command?(value)
155
+ value.nil? || value.class.name&.start_with?("RatatuiRuby::Tea::Command::")
125
156
  end
126
157
 
127
158
  # Validates an object is Ractor-shareable (deeply frozen).
@@ -135,23 +166,52 @@ module RatatuiRuby
135
166
  "#{name.capitalize} is not Ractor-shareable. Use Ractor.make_shareable or Object#freeze."
136
167
  end
137
168
 
138
- # Dispatches a command to the worker pool.
169
+ # Dispatches a command asynchronously. :nodoc:
139
170
  #
140
- # Spawns a thread for async commands. Pushes result to +queue+.
141
- # Handles nested commands (Batch, Sequence) recursively.
142
- private_class_method def self.dispatch(cmd, queue)
143
- case cmd
144
- when Cmd::Exec
171
+ # Spawns a background thread and pushes results to the message queue.
172
+ # See Command.system for message formats.
173
+ private_class_method def self.dispatch(command, queue)
174
+ case command
175
+ when Command::System
145
176
  Thread.new do
146
177
  require "open3"
147
- stdout, stderr, status = Open3.capture3(cmd.command)
148
- msg = [cmd.tag, { stdout:, stderr:, status: status.exitstatus }]
149
- queue << Ractor.make_shareable(msg)
150
- rescue => e
151
- # Should we send an error message? For now, crash in debug, ignore in prod?
152
- # Better to rely on Open3 not raising for standard execution.
178
+ if command.stream?
179
+ begin
180
+ Open3.popen3(command.command) do |stdin, stdout, stderr, wait_thr|
181
+ stdin.close
182
+ stdout_thread = Thread.new do
183
+ stdout.each_line do |line|
184
+ queue << Ractor.make_shareable([command.tag, :stdout, line])
185
+ end
186
+ end
187
+ stderr_thread = Thread.new do
188
+ stderr.each_line do |line|
189
+ queue << Ractor.make_shareable([command.tag, :stderr, line])
190
+ end
191
+ end
192
+ stdout_thread.join
193
+ stderr_thread.join
194
+ status = wait_thr.value.exitstatus
195
+ queue << Ractor.make_shareable([command.tag, :complete, { status: }])
196
+ end
197
+ rescue Errno::ENOENT, Errno::EACCES => e
198
+ queue << Ractor.make_shareable([command.tag, :error, { message: e.message }])
199
+ end
200
+ else
201
+ stdout, stderr, status = Open3.capture3(command.command)
202
+ message = [command.tag, { stdout:, stderr:, status: status.exitstatus }]
203
+ queue << Ractor.make_shareable(message)
204
+ end
205
+ end
206
+ when Command::Mapped
207
+ inner_queue = Queue.new
208
+ inner_thread = dispatch(command.inner_command, inner_queue)
209
+ Thread.new do
210
+ inner_thread&.join
211
+ inner_message = inner_queue.pop
212
+ transformed = command.mapper.call(inner_message)
213
+ queue << Ractor.make_shareable(transformed)
153
214
  end
154
- # TODO: Add Batch, Sequence, NetHttp
155
215
  end
156
216
  end
157
217
  end