r2ui 0.1.0 → 0.2.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/CHANGELOG.md +64 -0
- data/README.md +79 -0
- data/lib/r2ui/app.rb +311 -34
- data/lib/r2ui/canvas.rb +135 -14
- data/lib/r2ui/cli/builder.rb +72 -0
- data/lib/r2ui/cli/context.rb +45 -0
- data/lib/r2ui/cli/definition.rb +174 -0
- data/lib/r2ui/cli/ext/ask.rb +133 -0
- data/lib/r2ui/cli/ext/box.rb +112 -0
- data/lib/r2ui/cli/ext/choose.rb +162 -0
- data/lib/r2ui/cli/ext/choose_many.rb +241 -0
- data/lib/r2ui/cli/ext/color_option.rb +64 -0
- data/lib/r2ui/cli/ext/completion.rb +520 -0
- data/lib/r2ui/cli/ext/config.rb +179 -0
- data/lib/r2ui/cli/ext/confirm.rb +110 -0
- data/lib/r2ui/cli/ext/dashboard.rb +105 -0
- data/lib/r2ui/cli/ext/diff.rb +195 -0
- data/lib/r2ui/cli/ext/editor.rb +94 -0
- data/lib/r2ui/cli/ext/env.rb +108 -0
- data/lib/r2ui/cli/ext/examples.rb +62 -0
- data/lib/r2ui/cli/ext/filter.rb +269 -0
- data/lib/r2ui/cli/ext/format.rb +104 -0
- data/lib/r2ui/cli/ext/list.rb +117 -0
- data/lib/r2ui/cli/ext/log.rb +89 -0
- data/lib/r2ui/cli/ext/pager.rb +97 -0
- data/lib/r2ui/cli/ext/pairs.rb +62 -0
- data/lib/r2ui/cli/ext/parallel.rb +274 -0
- data/lib/r2ui/cli/ext/password.rb +158 -0
- data/lib/r2ui/cli/ext/progress.rb +330 -0
- data/lib/r2ui/cli/ext/reference.rb +129 -0
- data/lib/r2ui/cli/ext/spinner.rb +165 -0
- data/lib/r2ui/cli/ext/summary.rb +63 -0
- data/lib/r2ui/cli/ext/table.rb +141 -0
- data/lib/r2ui/cli/ext/tasks.rb +216 -0
- data/lib/r2ui/cli/ext/trace.rb +64 -0
- data/lib/r2ui/cli/ext/tree.rb +114 -0
- data/lib/r2ui/cli/ext/version.rb +67 -0
- data/lib/r2ui/cli/extension.rb +129 -0
- data/lib/r2ui/cli/help.rb +115 -0
- data/lib/r2ui/cli/helpers.rb +20 -0
- data/lib/r2ui/cli/live.rb +150 -0
- data/lib/r2ui/cli/parser.rb +165 -0
- data/lib/r2ui/cli/prompt.rb +133 -0
- data/lib/r2ui/cli/runner.rb +86 -0
- data/lib/r2ui/cli/shell.rb +185 -0
- data/lib/r2ui/cli/testing.rb +49 -0
- data/lib/r2ui/cli.rb +129 -0
- data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
- data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
- data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
- data/lib/r2ui/compat/bubbletea/input.rb +269 -0
- data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
- data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
- data/lib/r2ui/compat/bubbletea/model.rb +65 -0
- data/lib/r2ui/compat/bubbletea/native.rb +254 -0
- data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
- data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
- data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
- data/lib/r2ui/compat/bubbletea/version.rb +8 -0
- data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
- data/lib/r2ui/compat/bubbletea.rb +16 -0
- data/lib/r2ui/compat/lipgloss/border.rb +17 -0
- data/lib/r2ui/compat/lipgloss/color.rb +78 -0
- data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
- data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
- data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
- data/lib/r2ui/compat/lipgloss/list.rb +85 -0
- data/lib/r2ui/compat/lipgloss/position.rb +30 -0
- data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
- data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
- data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
- data/lib/r2ui/compat/lipgloss/table.rb +185 -0
- data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
- data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
- data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
- data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
- data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
- data/lib/r2ui/compat/lipgloss/version.rb +15 -0
- data/lib/r2ui/compat/lipgloss.rb +64 -0
- data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
- data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
- data/lib/r2ui/component.rb +67 -0
- data/lib/r2ui/context.rb +87 -0
- data/lib/r2ui/drop_in.rb +16 -0
- data/lib/r2ui/dsl/column.rb +11 -3
- data/lib/r2ui/dsl/dashboard.rb +45 -9
- data/lib/r2ui/dsl/resource.rb +12 -0
- data/lib/r2ui/ext/after.rb +54 -0
- data/lib/r2ui/ext/async.rb +81 -0
- data/lib/r2ui/ext/batch_sequence.rb +54 -0
- data/lib/r2ui/ext/countdown.rb +52 -0
- data/lib/r2ui/ext/data_table.rb +71 -0
- data/lib/r2ui/ext/every.rb +66 -0
- data/lib/r2ui/ext/exec.rb +116 -0
- data/lib/r2ui/ext/file_picker.rb +78 -0
- data/lib/r2ui/ext/focus_report.rb +69 -0
- data/lib/r2ui/ext/help.rb +62 -0
- data/lib/r2ui/ext/keys.rb +64 -0
- data/lib/r2ui/ext/list.rb +145 -0
- data/lib/r2ui/ext/messages.rb +87 -0
- data/lib/r2ui/ext/mouse.rb +134 -0
- data/lib/r2ui/ext/paginator.rb +86 -0
- data/lib/r2ui/ext/paste.rb +86 -0
- data/lib/r2ui/ext/println.rb +21 -0
- data/lib/r2ui/ext/progress.rb +85 -0
- data/lib/r2ui/ext/refresh.rb +56 -0
- data/lib/r2ui/ext/resize.rb +62 -0
- data/lib/r2ui/ext/screen.rb +47 -0
- data/lib/r2ui/ext/screen_mode.rb +63 -0
- data/lib/r2ui/ext/spinner.rb +59 -0
- data/lib/r2ui/ext/state.rb +45 -0
- data/lib/r2ui/ext/stopwatch.rb +67 -0
- data/lib/r2ui/ext/suspend.rb +56 -0
- data/lib/r2ui/ext/text_area.rb +73 -0
- data/lib/r2ui/ext/text_input.rb +83 -0
- data/lib/r2ui/ext/theme.rb +112 -0
- data/lib/r2ui/ext/view.rb +37 -0
- data/lib/r2ui/ext/viewport.rb +92 -0
- data/lib/r2ui/ext/window_title.rb +51 -0
- data/lib/r2ui/extension.rb +199 -0
- data/lib/r2ui/history.rb +4 -1
- data/lib/r2ui/keys.rb +35 -8
- data/lib/r2ui/motion.rb +169 -0
- data/lib/r2ui/panel_state.rb +46 -2
- data/lib/r2ui/renderer.rb +87 -22
- data/lib/r2ui/version.rb +1 -1
- data/lib/r2ui/widgets/box.rb +6 -3
- data/lib/r2ui/widgets/glyphs.rb +149 -0
- data/lib/r2ui/widgets/table.rb +109 -25
- data/lib/r2ui.rb +12 -1
- metadata +119 -4
- data/lib/r2ui/terminal.rb +0 -53
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copied from bubbletea 0.1.4 (https://github.com/marcoroth/bubbletea-ruby); r2ui's changes are
|
|
4
|
+
# marked "r2ui:".
|
|
5
|
+
# Copyright (c) 2025 Marco Roth. MIT License; see LICENSE-bubbletea.txt in this directory.
|
|
6
|
+
# Bubbletea::Program, which this drives, is r2ui's pure-Ruby replacement (native.rb).
|
|
7
|
+
|
|
8
|
+
module Bubbletea
|
|
9
|
+
# Runner manages the event loop and coordinates between the model and the terminal
|
|
10
|
+
class Runner
|
|
11
|
+
attr_reader :options
|
|
12
|
+
|
|
13
|
+
DEFAULT_OPTIONS = {
|
|
14
|
+
alt_screen: false,
|
|
15
|
+
mouse_cell_motion: false,
|
|
16
|
+
mouse_all_motion: false,
|
|
17
|
+
bracketed_paste: false,
|
|
18
|
+
report_focus: false,
|
|
19
|
+
fps: 60,
|
|
20
|
+
input_timeout: 10,
|
|
21
|
+
without_renderer: false,
|
|
22
|
+
# r2ui: wrap each frame in DEC 2026 synchronized output (not an upstream option).
|
|
23
|
+
synchronized: false,
|
|
24
|
+
# r2ui: in the alt screen, write only the lines that changed since the last frame (not an
|
|
25
|
+
# upstream option).
|
|
26
|
+
line_diff: false,
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def initialize(model, **options)
|
|
30
|
+
@model = model
|
|
31
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
|
32
|
+
@program = Program.new
|
|
33
|
+
@renderer_id = nil
|
|
34
|
+
@running = false
|
|
35
|
+
@pending_ticks = []
|
|
36
|
+
@pending_ticks_lock = Mutex.new
|
|
37
|
+
@width = 80
|
|
38
|
+
@height = 24
|
|
39
|
+
@resize_pending = false
|
|
40
|
+
@repaint = false
|
|
41
|
+
@previous_winch_handler = nil
|
|
42
|
+
@in_alt_screen = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run
|
|
46
|
+
setup_terminal
|
|
47
|
+
@renderer_id = @program.create_renderer unless @options[:without_renderer]
|
|
48
|
+
if @renderer_id && @options[:synchronized]
|
|
49
|
+
R2UI::Compat::Tea.renderer(@renderer_id)&.synchronized = true
|
|
50
|
+
end
|
|
51
|
+
# r2ui: line diffing, the same way. setup_terminal entered the alt screen before the renderer
|
|
52
|
+
# existed, so upstream's renderer never learns it (it draws inline-style there); diffing
|
|
53
|
+
# needs to know, so tell it.
|
|
54
|
+
if @renderer_id && @options[:line_diff]
|
|
55
|
+
renderer = R2UI::Compat::Tea.renderer(@renderer_id)
|
|
56
|
+
renderer&.line_diff = true
|
|
57
|
+
@program.renderer_set_alt_screen(@renderer_id, true) if @in_alt_screen
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
update_terminal_size
|
|
61
|
+
@running = true
|
|
62
|
+
|
|
63
|
+
new_model, command = @model.init
|
|
64
|
+
@model = new_model if new_model
|
|
65
|
+
process_command(command)
|
|
66
|
+
|
|
67
|
+
return unless @running
|
|
68
|
+
|
|
69
|
+
handle_message(WindowSizeMessage.new(width: @width, height: @height))
|
|
70
|
+
|
|
71
|
+
render
|
|
72
|
+
run_loop
|
|
73
|
+
render
|
|
74
|
+
ensure
|
|
75
|
+
cleanup_terminal
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def send(message)
|
|
79
|
+
@pending_messages ||= []
|
|
80
|
+
@pending_messages << message
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def setup_terminal
|
|
86
|
+
@program.enter_raw_mode
|
|
87
|
+
@program.hide_cursor
|
|
88
|
+
@program.start_input_reader
|
|
89
|
+
|
|
90
|
+
if @options[:alt_screen]
|
|
91
|
+
@program.enter_alt_screen
|
|
92
|
+
@program.renderer_set_alt_screen(@renderer_id, true) if @renderer_id
|
|
93
|
+
@in_alt_screen = true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
@program.enable_mouse_cell_motion if @options[:mouse_cell_motion]
|
|
97
|
+
@program.enable_mouse_all_motion if @options[:mouse_all_motion]
|
|
98
|
+
@program.enable_bracketed_paste if @options[:bracketed_paste]
|
|
99
|
+
@program.enable_report_focus if @options[:report_focus]
|
|
100
|
+
|
|
101
|
+
setup_resize_handler
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def cleanup_terminal
|
|
105
|
+
restore_resize_handler
|
|
106
|
+
|
|
107
|
+
@program.disable_mouse if @options[:mouse_cell_motion] || @options[:mouse_all_motion]
|
|
108
|
+
@program.disable_bracketed_paste if @options[:bracketed_paste]
|
|
109
|
+
@program.disable_report_focus if @options[:report_focus]
|
|
110
|
+
|
|
111
|
+
if @in_alt_screen
|
|
112
|
+
@program.exit_alt_screen
|
|
113
|
+
else
|
|
114
|
+
print "\r\n"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
@program.show_cursor
|
|
118
|
+
@program.stop_input_reader
|
|
119
|
+
@program.exit_raw_mode
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def setup_resize_handler
|
|
123
|
+
@previous_winch_handler = Signal.trap("WINCH") do
|
|
124
|
+
@resize_pending = true
|
|
125
|
+
@previous_winch_handler.call if @previous_winch_handler.is_a?(Proc)
|
|
126
|
+
end
|
|
127
|
+
rescue ArgumentError
|
|
128
|
+
# SIGWINCH not supported on this platform
|
|
129
|
+
@previous_winch_handler = nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def restore_resize_handler
|
|
133
|
+
if @previous_winch_handler
|
|
134
|
+
Signal.trap("WINCH", @previous_winch_handler)
|
|
135
|
+
else
|
|
136
|
+
Signal.trap("WINCH", "DEFAULT")
|
|
137
|
+
end
|
|
138
|
+
rescue ArgumentError
|
|
139
|
+
# SIGWINCH not supported on this platform
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def update_terminal_size
|
|
143
|
+
size = @program.terminal_size
|
|
144
|
+
return unless size
|
|
145
|
+
|
|
146
|
+
@width, @height = size
|
|
147
|
+
@program.renderer_set_size(@renderer_id, @width, @height) if @renderer_id
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def run_loop
|
|
151
|
+
frame_duration = 1.0 / @options[:fps]
|
|
152
|
+
last_frame = Time.now
|
|
153
|
+
|
|
154
|
+
while @running
|
|
155
|
+
check_resize
|
|
156
|
+
process_pending_messages
|
|
157
|
+
|
|
158
|
+
event = @program.poll_event(@options[:input_timeout])
|
|
159
|
+
|
|
160
|
+
if event
|
|
161
|
+
message = Bubbletea.parse_event(event)
|
|
162
|
+
handle_message(message) if message
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
process_ticks
|
|
166
|
+
|
|
167
|
+
now = Time.now
|
|
168
|
+
|
|
169
|
+
if now - last_frame >= frame_duration
|
|
170
|
+
# r2ui: a model with `frame_due?` (R2UI::App) skips frame slots where nothing changed;
|
|
171
|
+
# a resize always repaints.
|
|
172
|
+
render if @repaint || !@model.respond_to?(:frame_due?) || @model.frame_due?
|
|
173
|
+
@repaint = false
|
|
174
|
+
last_frame = now
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def check_resize
|
|
180
|
+
return unless @resize_pending
|
|
181
|
+
|
|
182
|
+
@resize_pending = false
|
|
183
|
+
|
|
184
|
+
size = @program.terminal_size
|
|
185
|
+
return unless size
|
|
186
|
+
|
|
187
|
+
new_width, new_height = size
|
|
188
|
+
|
|
189
|
+
return if new_width == @width && new_height == @height
|
|
190
|
+
|
|
191
|
+
@width = new_width
|
|
192
|
+
@height = new_height
|
|
193
|
+
|
|
194
|
+
@program.renderer_set_size(@renderer_id, @width, @height) if @renderer_id
|
|
195
|
+
@repaint = true
|
|
196
|
+
|
|
197
|
+
handle_message(WindowSizeMessage.new(width: @width, height: @height))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def process_pending_messages
|
|
201
|
+
return unless @pending_messages&.any?
|
|
202
|
+
|
|
203
|
+
messages = @pending_messages
|
|
204
|
+
@pending_messages = []
|
|
205
|
+
|
|
206
|
+
messages.each { |message| handle_message(message) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def handle_message(message)
|
|
210
|
+
return unless @running
|
|
211
|
+
|
|
212
|
+
new_model, command = @model.update(message)
|
|
213
|
+
@model = new_model if new_model
|
|
214
|
+
process_command(command)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def process_command(command)
|
|
218
|
+
return if command.nil?
|
|
219
|
+
|
|
220
|
+
case command
|
|
221
|
+
when QuitCommand
|
|
222
|
+
@running = false
|
|
223
|
+
|
|
224
|
+
when BatchCommand
|
|
225
|
+
Thread.new do
|
|
226
|
+
execute_batch_sync(command.commands)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
when SequenceCommand
|
|
230
|
+
Thread.new do
|
|
231
|
+
execute_sequence_sync(command.commands)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
when TickCommand
|
|
235
|
+
schedule_tick(command)
|
|
236
|
+
|
|
237
|
+
when SendMessage
|
|
238
|
+
if command.delay.positive?
|
|
239
|
+
schedule_delayed_message(command)
|
|
240
|
+
else
|
|
241
|
+
handle_message(command.message)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
when EnterAltScreenCommand
|
|
245
|
+
@program.enter_alt_screen
|
|
246
|
+
@program.renderer_set_alt_screen(@renderer_id, true) if @renderer_id
|
|
247
|
+
@in_alt_screen = true
|
|
248
|
+
|
|
249
|
+
when ExitAltScreenCommand
|
|
250
|
+
@program.exit_alt_screen
|
|
251
|
+
@program.renderer_set_alt_screen(@renderer_id, false) if @renderer_id
|
|
252
|
+
@in_alt_screen = false
|
|
253
|
+
|
|
254
|
+
when SetWindowTitleCommand
|
|
255
|
+
Bubbletea._set_window_title(command.title)
|
|
256
|
+
|
|
257
|
+
when PutsCommand
|
|
258
|
+
warn "\r#{command.text}\r"
|
|
259
|
+
|
|
260
|
+
when SuspendCommand
|
|
261
|
+
suspend_process
|
|
262
|
+
|
|
263
|
+
when ExecCommand
|
|
264
|
+
exec_process(command)
|
|
265
|
+
|
|
266
|
+
when Proc
|
|
267
|
+
Thread.new do
|
|
268
|
+
result = command.call
|
|
269
|
+
next unless result
|
|
270
|
+
|
|
271
|
+
if result.is_a?(Message)
|
|
272
|
+
send(result)
|
|
273
|
+
else
|
|
274
|
+
process_command(result)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def execute_command_sync(command)
|
|
281
|
+
return if command.nil?
|
|
282
|
+
|
|
283
|
+
case command
|
|
284
|
+
when QuitCommand
|
|
285
|
+
@running = false
|
|
286
|
+
|
|
287
|
+
when BatchCommand
|
|
288
|
+
execute_batch_sync(command.commands)
|
|
289
|
+
|
|
290
|
+
when SequenceCommand
|
|
291
|
+
execute_sequence_sync(command.commands)
|
|
292
|
+
|
|
293
|
+
when TickCommand
|
|
294
|
+
schedule_tick(command)
|
|
295
|
+
|
|
296
|
+
when SendMessage
|
|
297
|
+
sleep(command.delay) if command.delay.positive?
|
|
298
|
+
handle_message(command.message)
|
|
299
|
+
|
|
300
|
+
when EnterAltScreenCommand
|
|
301
|
+
@program.enter_alt_screen
|
|
302
|
+
@program.renderer_set_alt_screen(@renderer_id, true) if @renderer_id
|
|
303
|
+
@in_alt_screen = true
|
|
304
|
+
|
|
305
|
+
when ExitAltScreenCommand
|
|
306
|
+
@program.exit_alt_screen
|
|
307
|
+
@program.renderer_set_alt_screen(@renderer_id, false) if @renderer_id
|
|
308
|
+
@in_alt_screen = false
|
|
309
|
+
|
|
310
|
+
when SetWindowTitleCommand
|
|
311
|
+
Bubbletea._set_window_title(command.title)
|
|
312
|
+
|
|
313
|
+
when PutsCommand
|
|
314
|
+
warn "\r#{command.text}\r"
|
|
315
|
+
|
|
316
|
+
when SuspendCommand
|
|
317
|
+
suspend_process
|
|
318
|
+
|
|
319
|
+
when ExecCommand
|
|
320
|
+
exec_process(command)
|
|
321
|
+
|
|
322
|
+
when Proc
|
|
323
|
+
result = command.call
|
|
324
|
+
return unless result
|
|
325
|
+
|
|
326
|
+
if result.is_a?(Message)
|
|
327
|
+
send(result)
|
|
328
|
+
else
|
|
329
|
+
execute_command_sync(result)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def execute_sequence_sync(commands)
|
|
335
|
+
commands.each do |cmd|
|
|
336
|
+
break unless @running
|
|
337
|
+
|
|
338
|
+
execute_command_sync(cmd)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def execute_batch_sync(commands)
|
|
343
|
+
threads = commands.map do |cmd|
|
|
344
|
+
Thread.new { execute_command_sync(cmd) }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
threads.each(&:join)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def schedule_tick(tick_command)
|
|
351
|
+
tick = { at: Time.now + tick_command.duration, callback: tick_command.callback }
|
|
352
|
+
@pending_ticks_lock.synchronize { @pending_ticks << tick }
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def schedule_delayed_message(send_command)
|
|
356
|
+
tick = { at: Time.now + send_command.delay, message: send_command.message }
|
|
357
|
+
@pending_ticks_lock.synchronize { @pending_ticks << tick }
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def process_ticks
|
|
361
|
+
now = Time.now
|
|
362
|
+
ready = @pending_ticks_lock.synchronize do
|
|
363
|
+
ready, @pending_ticks = @pending_ticks.partition { |tick| tick[:at] <= now }
|
|
364
|
+
ready
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
ready.each do |tick|
|
|
368
|
+
if tick[:callback]
|
|
369
|
+
result = tick[:callback].call
|
|
370
|
+
handle_message(result) if result
|
|
371
|
+
elsif tick[:message]
|
|
372
|
+
handle_message(tick[:message])
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def suspend_process
|
|
378
|
+
@program.disable_mouse if @options[:mouse_cell_motion] || @options[:mouse_all_motion]
|
|
379
|
+
@program.show_cursor
|
|
380
|
+
@program.stop_input_reader
|
|
381
|
+
@program.exit_raw_mode
|
|
382
|
+
|
|
383
|
+
Process.kill("TSTP", Process.pid)
|
|
384
|
+
|
|
385
|
+
# When we get here, we've been resumed (SIGCONT was received)
|
|
386
|
+
@program.enter_raw_mode
|
|
387
|
+
@program.hide_cursor
|
|
388
|
+
@program.start_input_reader
|
|
389
|
+
@program.enable_mouse_cell_motion if @options[:mouse_cell_motion]
|
|
390
|
+
@program.enable_mouse_all_motion if @options[:mouse_all_motion]
|
|
391
|
+
|
|
392
|
+
handle_message(ResumeMessage.new)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def exec_process(command)
|
|
396
|
+
@program.disable_mouse if @options[:mouse_cell_motion] || @options[:mouse_all_motion]
|
|
397
|
+
@program.show_cursor
|
|
398
|
+
@program.stop_input_reader
|
|
399
|
+
@program.exit_raw_mode
|
|
400
|
+
|
|
401
|
+
command.callable.call
|
|
402
|
+
|
|
403
|
+
@program.enter_raw_mode
|
|
404
|
+
@program.hide_cursor
|
|
405
|
+
@program.start_input_reader
|
|
406
|
+
@program.enable_mouse_cell_motion if @options[:mouse_cell_motion]
|
|
407
|
+
@program.enable_mouse_all_motion if @options[:mouse_all_motion]
|
|
408
|
+
|
|
409
|
+
handle_message(command.message) if command.message
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def render
|
|
413
|
+
return if @options[:without_renderer]
|
|
414
|
+
return unless @renderer_id
|
|
415
|
+
|
|
416
|
+
view = @model.view
|
|
417
|
+
@program.render(@renderer_id, view)
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def self.run(model, **options)
|
|
422
|
+
runner = Runner.new(model, **options)
|
|
423
|
+
runner.run
|
|
424
|
+
end
|
|
425
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module R2UI
|
|
6
|
+
module Compat
|
|
7
|
+
module Tea
|
|
8
|
+
# The terminal half of upstream's Go extension (go/terminal.go): raw mode, mode toggles and
|
|
9
|
+
# size, with the same idempotence flags and the exact bytes charmbracelet/x/ansi v0.8.0 writes.
|
|
10
|
+
# Upstream ignores write errors; so do we. Every write is flushed.
|
|
11
|
+
class Terminal
|
|
12
|
+
SET_ALT_SCREEN = "\e[?1049h"
|
|
13
|
+
RESET_ALT_SCREEN = "\e[?1049l"
|
|
14
|
+
ERASE_ENTIRE_SCREEN = "\e[2J"
|
|
15
|
+
CURSOR_HOME = "\e[H"
|
|
16
|
+
HIDE_CURSOR = "\e[?25l"
|
|
17
|
+
SHOW_CURSOR = "\e[?25h"
|
|
18
|
+
SET_BUTTON_EVENT_MOUSE = "\e[?1002h"
|
|
19
|
+
RESET_BUTTON_EVENT_MOUSE = "\e[?1002l"
|
|
20
|
+
SET_ANY_EVENT_MOUSE = "\e[?1003h"
|
|
21
|
+
RESET_ANY_EVENT_MOUSE = "\e[?1003l"
|
|
22
|
+
SET_SGR_EXT_MOUSE = "\e[?1006h"
|
|
23
|
+
RESET_SGR_EXT_MOUSE = "\e[?1006l"
|
|
24
|
+
SET_BRACKETED_PASTE = "\e[?2004h"
|
|
25
|
+
RESET_BRACKETED_PASTE = "\e[?2004l"
|
|
26
|
+
SET_FOCUS_EVENT = "\e[?1004h"
|
|
27
|
+
RESET_FOCUS_EVENT = "\e[?1004l"
|
|
28
|
+
|
|
29
|
+
def self.window_title(title) = "\e]2;#{title}\a"
|
|
30
|
+
|
|
31
|
+
def self.write(output, string)
|
|
32
|
+
output.write(string)
|
|
33
|
+
output.flush
|
|
34
|
+
nil
|
|
35
|
+
rescue IOError, SystemCallError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Safety net: terminals left with any mode on are restored at exit, as Go's tea_free_program
|
|
40
|
+
# restores raw mode. Only dirty terminals are held, so a clean one is never written twice.
|
|
41
|
+
@dirty = {}.compare_by_identity
|
|
42
|
+
@lock = Mutex.new
|
|
43
|
+
@hook_installed = false
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
def track(terminal)
|
|
47
|
+
@lock.synchronize do
|
|
48
|
+
if terminal.dirty?
|
|
49
|
+
@dirty[terminal] = true
|
|
50
|
+
install_hook
|
|
51
|
+
else
|
|
52
|
+
@dirty.delete(terminal)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def restore_all
|
|
58
|
+
terminals = @lock.synchronize { @dirty.keys }
|
|
59
|
+
terminals.each(&:restore)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Installed lazily (on the first dirty terminal) so it runs after any at_exit hook that was
|
|
65
|
+
# running the program, such as a test runner's.
|
|
66
|
+
def install_hook
|
|
67
|
+
return if @hook_installed
|
|
68
|
+
|
|
69
|
+
@hook_installed = true
|
|
70
|
+
at_exit { Terminal.restore_all }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
attr_reader :input, :output
|
|
75
|
+
|
|
76
|
+
def initialize(input, output)
|
|
77
|
+
@input = input
|
|
78
|
+
@output = output
|
|
79
|
+
@raw_mode = false
|
|
80
|
+
@previous_mode = nil
|
|
81
|
+
@alt_screen = false
|
|
82
|
+
@cursor_hidden = false
|
|
83
|
+
@mouse_enabled = false
|
|
84
|
+
@bracketed_paste = false
|
|
85
|
+
@report_focus = false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def dirty?
|
|
89
|
+
@raw_mode || @alt_screen || @cursor_hidden || @mouse_enabled || @bracketed_paste || @report_focus
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Like x/term MakeRaw on the input: true on success or when already raw, false when the input
|
|
93
|
+
# is not a terminal.
|
|
94
|
+
def enter_raw_mode
|
|
95
|
+
return true if @raw_mode
|
|
96
|
+
|
|
97
|
+
mode = @input.console_mode
|
|
98
|
+
@input.raw!
|
|
99
|
+
@previous_mode = mode
|
|
100
|
+
@raw_mode = true
|
|
101
|
+
Terminal.track(self)
|
|
102
|
+
true
|
|
103
|
+
rescue StandardError
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def exit_raw_mode
|
|
108
|
+
return true unless @raw_mode
|
|
109
|
+
|
|
110
|
+
@input.console_mode = @previous_mode if @previous_mode
|
|
111
|
+
@raw_mode = false
|
|
112
|
+
Terminal.track(self)
|
|
113
|
+
true
|
|
114
|
+
rescue StandardError
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def enter_alt_screen
|
|
119
|
+
return if @alt_screen
|
|
120
|
+
|
|
121
|
+
write(SET_ALT_SCREEN + ERASE_ENTIRE_SCREEN + CURSOR_HOME)
|
|
122
|
+
@alt_screen = true
|
|
123
|
+
Terminal.track(self)
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def exit_alt_screen
|
|
128
|
+
return unless @alt_screen
|
|
129
|
+
|
|
130
|
+
write(RESET_ALT_SCREEN)
|
|
131
|
+
@alt_screen = false
|
|
132
|
+
Terminal.track(self)
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def hide_cursor
|
|
137
|
+
return if @cursor_hidden
|
|
138
|
+
|
|
139
|
+
write(HIDE_CURSOR)
|
|
140
|
+
@cursor_hidden = true
|
|
141
|
+
Terminal.track(self)
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def show_cursor
|
|
146
|
+
return unless @cursor_hidden
|
|
147
|
+
|
|
148
|
+
write(SHOW_CURSOR)
|
|
149
|
+
@cursor_hidden = false
|
|
150
|
+
Terminal.track(self)
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def enable_mouse_cell_motion
|
|
155
|
+
write(SET_BUTTON_EVENT_MOUSE + SET_SGR_EXT_MOUSE)
|
|
156
|
+
@mouse_enabled = true
|
|
157
|
+
Terminal.track(self)
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def enable_mouse_all_motion
|
|
162
|
+
write(SET_ANY_EVENT_MOUSE + SET_SGR_EXT_MOUSE)
|
|
163
|
+
@mouse_enabled = true
|
|
164
|
+
Terminal.track(self)
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def disable_mouse
|
|
169
|
+
return unless @mouse_enabled
|
|
170
|
+
|
|
171
|
+
write(RESET_BUTTON_EVENT_MOUSE + RESET_ANY_EVENT_MOUSE + RESET_SGR_EXT_MOUSE)
|
|
172
|
+
@mouse_enabled = false
|
|
173
|
+
Terminal.track(self)
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Upstream writes paste and focus toggles unconditionally; the flags only feed the exit restore.
|
|
178
|
+
def enable_bracketed_paste
|
|
179
|
+
write(SET_BRACKETED_PASTE)
|
|
180
|
+
@bracketed_paste = true
|
|
181
|
+
Terminal.track(self)
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def disable_bracketed_paste
|
|
186
|
+
write(RESET_BRACKETED_PASTE)
|
|
187
|
+
@bracketed_paste = false
|
|
188
|
+
Terminal.track(self)
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def enable_report_focus
|
|
193
|
+
write(SET_FOCUS_EVENT)
|
|
194
|
+
@report_focus = true
|
|
195
|
+
Terminal.track(self)
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def disable_report_focus
|
|
200
|
+
write(RESET_FOCUS_EVENT)
|
|
201
|
+
@report_focus = false
|
|
202
|
+
Terminal.track(self)
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# [width, height] of the output (Go asks stdout), or nil when it is not a terminal.
|
|
207
|
+
def size
|
|
208
|
+
rows, cols = @output.winsize
|
|
209
|
+
[cols, rows]
|
|
210
|
+
rescue StandardError
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Undo whatever is still on, in the runner's cleanup order. Each step is guarded by its flag.
|
|
215
|
+
def restore
|
|
216
|
+
disable_mouse
|
|
217
|
+
disable_bracketed_paste if @bracketed_paste
|
|
218
|
+
disable_report_focus if @report_focus
|
|
219
|
+
exit_alt_screen
|
|
220
|
+
show_cursor
|
|
221
|
+
exit_raw_mode
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
def write(string) = Terminal.write(@output, string)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|