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.
- checksums.yaml +4 -4
- data/AGENTS.md +8 -0
- data/CHANGELOG.md +41 -0
- data/README.md +1 -1
- data/doc/concepts/application_architecture.md +182 -3
- data/examples/app_fractal_dashboard/README.md +60 -0
- data/examples/app_fractal_dashboard/app.rb +67 -0
- data/examples/app_fractal_dashboard/bags/custom_shell_input.rb +77 -0
- data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +73 -0
- data/examples/app_fractal_dashboard/bags/custom_shell_output.rb +86 -0
- data/examples/app_fractal_dashboard/bags/disk_usage.rb +44 -0
- data/examples/app_fractal_dashboard/bags/network_panel.rb +45 -0
- data/examples/app_fractal_dashboard/bags/ping.rb +43 -0
- data/examples/app_fractal_dashboard/bags/stats_panel.rb +45 -0
- data/examples/app_fractal_dashboard/bags/system_info.rb +43 -0
- data/examples/app_fractal_dashboard/bags/uptime.rb +43 -0
- data/examples/app_fractal_dashboard/dashboard/base.rb +74 -0
- data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +86 -0
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +87 -0
- data/examples/app_fractal_dashboard/dashboard/update_router.rb +43 -0
- data/examples/verify_readme_usage/README.md +1 -1
- data/examples/verify_readme_usage/app.rb +1 -1
- data/examples/{widget_cmd_exec → widget_command_system}/app.rb +18 -18
- data/lib/ratatui_ruby/tea/command.rb +145 -0
- data/lib/ratatui_ruby/tea/router.rb +337 -0
- data/lib/ratatui_ruby/tea/runtime.rb +99 -39
- data/lib/ratatui_ruby/tea/shortcuts.rb +51 -0
- data/lib/ratatui_ruby/tea/version.rb +1 -1
- data/lib/ratatui_ruby/tea.rb +59 -1
- data/sig/ratatui_ruby/tea/command.rbs +47 -0
- data/sig/ratatui_ruby/tea/router.rbs +99 -0
- metadata +26 -8
- data/lib/ratatui_ruby/tea/cmd.rb +0 -88
- data/sig/ratatui_ruby/tea/cmd.rbs +0 -32
- /data/examples/{widget_cmd_exec → widget_command_system}/README.md +0 -0
- /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: ->(
|
|
31
|
-
# update: ->(
|
|
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>
|
|
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>(
|
|
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
|
-
|
|
51
|
-
result = update.call(
|
|
52
|
-
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
|
-
|
|
69
|
+
message = tui.poll_event
|
|
69
70
|
|
|
70
71
|
# If provided, handle the event
|
|
71
|
-
unless
|
|
72
|
-
result = update.call(
|
|
73
|
-
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
|
|
76
|
+
throw :quit if command.is_a?(Command::Exit)
|
|
76
77
|
|
|
77
|
-
dispatch(
|
|
78
|
+
thread = dispatch(command, queue) if command
|
|
79
|
+
pending_threads << thread if thread
|
|
78
80
|
end
|
|
79
81
|
|
|
80
|
-
# 2. Check for
|
|
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
|
-
|
|
84
|
-
result = update.call(
|
|
85
|
-
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
|
|
117
|
+
throw :quit if command.is_a?(Command::Exit)
|
|
88
118
|
|
|
89
|
-
dispatch(
|
|
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,
|
|
143
|
+
# Detects whether +result+ is a +[model, command]+ tuple, a plain model, or a Command alone.
|
|
113
144
|
#
|
|
114
|
-
# Returns +[model,
|
|
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 &&
|
|
117
|
-
return [previous_model, result] if
|
|
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 +
|
|
123
|
-
private_class_method def self.
|
|
124
|
-
value.nil? || value.class.name&.start_with?("RatatuiRuby::Tea::
|
|
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
|
|
169
|
+
# Dispatches a command asynchronously. :nodoc:
|
|
139
170
|
#
|
|
140
|
-
# Spawns a thread
|
|
141
|
-
#
|
|
142
|
-
private_class_method def self.dispatch(
|
|
143
|
-
case
|
|
144
|
-
when
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|