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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +79 -0
  4. data/lib/r2ui/app.rb +311 -34
  5. data/lib/r2ui/canvas.rb +135 -14
  6. data/lib/r2ui/cli/builder.rb +72 -0
  7. data/lib/r2ui/cli/context.rb +45 -0
  8. data/lib/r2ui/cli/definition.rb +174 -0
  9. data/lib/r2ui/cli/ext/ask.rb +133 -0
  10. data/lib/r2ui/cli/ext/box.rb +112 -0
  11. data/lib/r2ui/cli/ext/choose.rb +162 -0
  12. data/lib/r2ui/cli/ext/choose_many.rb +241 -0
  13. data/lib/r2ui/cli/ext/color_option.rb +64 -0
  14. data/lib/r2ui/cli/ext/completion.rb +520 -0
  15. data/lib/r2ui/cli/ext/config.rb +179 -0
  16. data/lib/r2ui/cli/ext/confirm.rb +110 -0
  17. data/lib/r2ui/cli/ext/dashboard.rb +105 -0
  18. data/lib/r2ui/cli/ext/diff.rb +195 -0
  19. data/lib/r2ui/cli/ext/editor.rb +94 -0
  20. data/lib/r2ui/cli/ext/env.rb +108 -0
  21. data/lib/r2ui/cli/ext/examples.rb +62 -0
  22. data/lib/r2ui/cli/ext/filter.rb +269 -0
  23. data/lib/r2ui/cli/ext/format.rb +104 -0
  24. data/lib/r2ui/cli/ext/list.rb +117 -0
  25. data/lib/r2ui/cli/ext/log.rb +89 -0
  26. data/lib/r2ui/cli/ext/pager.rb +97 -0
  27. data/lib/r2ui/cli/ext/pairs.rb +62 -0
  28. data/lib/r2ui/cli/ext/parallel.rb +274 -0
  29. data/lib/r2ui/cli/ext/password.rb +158 -0
  30. data/lib/r2ui/cli/ext/progress.rb +330 -0
  31. data/lib/r2ui/cli/ext/reference.rb +129 -0
  32. data/lib/r2ui/cli/ext/spinner.rb +165 -0
  33. data/lib/r2ui/cli/ext/summary.rb +63 -0
  34. data/lib/r2ui/cli/ext/table.rb +141 -0
  35. data/lib/r2ui/cli/ext/tasks.rb +216 -0
  36. data/lib/r2ui/cli/ext/trace.rb +64 -0
  37. data/lib/r2ui/cli/ext/tree.rb +114 -0
  38. data/lib/r2ui/cli/ext/version.rb +67 -0
  39. data/lib/r2ui/cli/extension.rb +129 -0
  40. data/lib/r2ui/cli/help.rb +115 -0
  41. data/lib/r2ui/cli/helpers.rb +20 -0
  42. data/lib/r2ui/cli/live.rb +150 -0
  43. data/lib/r2ui/cli/parser.rb +165 -0
  44. data/lib/r2ui/cli/prompt.rb +133 -0
  45. data/lib/r2ui/cli/runner.rb +86 -0
  46. data/lib/r2ui/cli/shell.rb +185 -0
  47. data/lib/r2ui/cli/testing.rb +49 -0
  48. data/lib/r2ui/cli.rb +129 -0
  49. data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
  50. data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
  51. data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
  52. data/lib/r2ui/compat/bubbletea/input.rb +269 -0
  53. data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
  54. data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
  55. data/lib/r2ui/compat/bubbletea/model.rb +65 -0
  56. data/lib/r2ui/compat/bubbletea/native.rb +254 -0
  57. data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
  58. data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
  59. data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
  60. data/lib/r2ui/compat/bubbletea/version.rb +8 -0
  61. data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
  62. data/lib/r2ui/compat/bubbletea.rb +16 -0
  63. data/lib/r2ui/compat/lipgloss/border.rb +17 -0
  64. data/lib/r2ui/compat/lipgloss/color.rb +78 -0
  65. data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
  66. data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
  67. data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
  68. data/lib/r2ui/compat/lipgloss/list.rb +85 -0
  69. data/lib/r2ui/compat/lipgloss/position.rb +30 -0
  70. data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
  71. data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
  72. data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
  73. data/lib/r2ui/compat/lipgloss/table.rb +185 -0
  74. data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
  75. data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
  76. data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
  77. data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
  78. data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
  79. data/lib/r2ui/compat/lipgloss/version.rb +15 -0
  80. data/lib/r2ui/compat/lipgloss.rb +64 -0
  81. data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
  82. data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
  83. data/lib/r2ui/component.rb +67 -0
  84. data/lib/r2ui/context.rb +87 -0
  85. data/lib/r2ui/drop_in.rb +16 -0
  86. data/lib/r2ui/dsl/column.rb +11 -3
  87. data/lib/r2ui/dsl/dashboard.rb +45 -9
  88. data/lib/r2ui/dsl/resource.rb +12 -0
  89. data/lib/r2ui/ext/after.rb +54 -0
  90. data/lib/r2ui/ext/async.rb +81 -0
  91. data/lib/r2ui/ext/batch_sequence.rb +54 -0
  92. data/lib/r2ui/ext/countdown.rb +52 -0
  93. data/lib/r2ui/ext/data_table.rb +71 -0
  94. data/lib/r2ui/ext/every.rb +66 -0
  95. data/lib/r2ui/ext/exec.rb +116 -0
  96. data/lib/r2ui/ext/file_picker.rb +78 -0
  97. data/lib/r2ui/ext/focus_report.rb +69 -0
  98. data/lib/r2ui/ext/help.rb +62 -0
  99. data/lib/r2ui/ext/keys.rb +64 -0
  100. data/lib/r2ui/ext/list.rb +145 -0
  101. data/lib/r2ui/ext/messages.rb +87 -0
  102. data/lib/r2ui/ext/mouse.rb +134 -0
  103. data/lib/r2ui/ext/paginator.rb +86 -0
  104. data/lib/r2ui/ext/paste.rb +86 -0
  105. data/lib/r2ui/ext/println.rb +21 -0
  106. data/lib/r2ui/ext/progress.rb +85 -0
  107. data/lib/r2ui/ext/refresh.rb +56 -0
  108. data/lib/r2ui/ext/resize.rb +62 -0
  109. data/lib/r2ui/ext/screen.rb +47 -0
  110. data/lib/r2ui/ext/screen_mode.rb +63 -0
  111. data/lib/r2ui/ext/spinner.rb +59 -0
  112. data/lib/r2ui/ext/state.rb +45 -0
  113. data/lib/r2ui/ext/stopwatch.rb +67 -0
  114. data/lib/r2ui/ext/suspend.rb +56 -0
  115. data/lib/r2ui/ext/text_area.rb +73 -0
  116. data/lib/r2ui/ext/text_input.rb +83 -0
  117. data/lib/r2ui/ext/theme.rb +112 -0
  118. data/lib/r2ui/ext/view.rb +37 -0
  119. data/lib/r2ui/ext/viewport.rb +92 -0
  120. data/lib/r2ui/ext/window_title.rb +51 -0
  121. data/lib/r2ui/extension.rb +199 -0
  122. data/lib/r2ui/history.rb +4 -1
  123. data/lib/r2ui/keys.rb +35 -8
  124. data/lib/r2ui/motion.rb +169 -0
  125. data/lib/r2ui/panel_state.rb +46 -2
  126. data/lib/r2ui/renderer.rb +87 -22
  127. data/lib/r2ui/version.rb +1 -1
  128. data/lib/r2ui/widgets/box.rb +6 -3
  129. data/lib/r2ui/widgets/glyphs.rb +149 -0
  130. data/lib/r2ui/widgets/table.rb +109 -25
  131. data/lib/r2ui.rb +12 -1
  132. metadata +119 -4
  133. 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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copied unchanged from bubbletea 0.1.4 (https://github.com/marcoroth/bubbletea-ruby).
4
+ # Copyright (c) 2025 Marco Roth. MIT License; see LICENSE-bubbletea.txt in this directory.
5
+
6
+ module Bubbletea
7
+ VERSION = "0.1.4"
8
+ end