asciinema_win 0.1.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 +7 -0
- data/README.md +575 -0
- data/exe/asciinema_win +17 -0
- data/lib/asciinema_win/ansi_parser.rb +437 -0
- data/lib/asciinema_win/asciicast.rb +537 -0
- data/lib/asciinema_win/cli.rb +591 -0
- data/lib/asciinema_win/export.rb +780 -0
- data/lib/asciinema_win/output_organizer.rb +276 -0
- data/lib/asciinema_win/player.rb +348 -0
- data/lib/asciinema_win/recorder.rb +480 -0
- data/lib/asciinema_win/screen_buffer.rb +375 -0
- data/lib/asciinema_win/themes.rb +334 -0
- data/lib/asciinema_win/version.rb +6 -0
- data/lib/asciinema_win.rb +153 -0
- data/lib/rich/_palettes.rb +148 -0
- data/lib/rich/box.rb +342 -0
- data/lib/rich/cells.rb +512 -0
- data/lib/rich/color.rb +628 -0
- data/lib/rich/color_triplet.rb +220 -0
- data/lib/rich/console.rb +549 -0
- data/lib/rich/control.rb +332 -0
- data/lib/rich/json.rb +254 -0
- data/lib/rich/layout.rb +314 -0
- data/lib/rich/markdown.rb +509 -0
- data/lib/rich/markup.rb +175 -0
- data/lib/rich/panel.rb +311 -0
- data/lib/rich/progress.rb +430 -0
- data/lib/rich/segment.rb +387 -0
- data/lib/rich/style.rb +433 -0
- data/lib/rich/syntax.rb +1145 -0
- data/lib/rich/table.rb +525 -0
- data/lib/rich/terminal_theme.rb +126 -0
- data/lib/rich/text.rb +433 -0
- data/lib/rich/tree.rb +220 -0
- data/lib/rich/version.rb +5 -0
- data/lib/rich/win32_console.rb +859 -0
- data/lib/rich.rb +108 -0
- metadata +123 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module AsciinemaWin
|
|
6
|
+
# Terminal session recorder for Windows
|
|
7
|
+
#
|
|
8
|
+
# Captures terminal output by periodically sampling the screen buffer
|
|
9
|
+
# and detecting changes between frames. Produces asciicast v2 compatible
|
|
10
|
+
# recordings that can be played back or uploaded to asciinema.org.
|
|
11
|
+
#
|
|
12
|
+
# @example Record an interactive session
|
|
13
|
+
# recorder = AsciinemaWin::Recorder.new(title: "Demo")
|
|
14
|
+
# recorder.record("session.cast") do
|
|
15
|
+
# # Recording runs until Ctrl+D or block exits
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Record a command
|
|
19
|
+
# recorder = AsciinemaWin::Recorder.new(command: "dir /s")
|
|
20
|
+
# recorder.record("command.cast")
|
|
21
|
+
class Recorder
|
|
22
|
+
# Default maximum idle time between events (seconds)
|
|
23
|
+
DEFAULT_IDLE_TIME_LIMIT = 2.0
|
|
24
|
+
|
|
25
|
+
# Default interval between screen captures (seconds)
|
|
26
|
+
DEFAULT_CAPTURE_INTERVAL = 0.1
|
|
27
|
+
|
|
28
|
+
# Minimum interval between captures to avoid CPU thrashing
|
|
29
|
+
MIN_CAPTURE_INTERVAL = 0.033 # ~30 FPS max
|
|
30
|
+
|
|
31
|
+
# @return [String, nil] Recording title
|
|
32
|
+
attr_reader :title
|
|
33
|
+
|
|
34
|
+
# @return [String, nil] Command to record
|
|
35
|
+
attr_reader :command
|
|
36
|
+
|
|
37
|
+
# @return [Float] Maximum idle time between events
|
|
38
|
+
attr_reader :idle_time_limit
|
|
39
|
+
|
|
40
|
+
# @return [Float] Interval between screen captures
|
|
41
|
+
attr_reader :capture_interval
|
|
42
|
+
|
|
43
|
+
# @return [Array<String>] Environment variables to capture
|
|
44
|
+
attr_reader :env_vars
|
|
45
|
+
|
|
46
|
+
# @return [Symbol] Current recording state (:idle, :recording, :paused, :stopped)
|
|
47
|
+
attr_reader :state
|
|
48
|
+
|
|
49
|
+
# Create a new recorder
|
|
50
|
+
#
|
|
51
|
+
# @param title [String, nil] Recording title
|
|
52
|
+
# @param command [String, nil] Command to record in subprocess
|
|
53
|
+
# @param idle_time_limit [Float] Maximum idle time (capped in output)
|
|
54
|
+
# @param capture_interval [Float] Time between screen captures
|
|
55
|
+
# @param env_vars [Array<String>] Environment variable names to capture
|
|
56
|
+
def initialize(
|
|
57
|
+
title: nil,
|
|
58
|
+
command: nil,
|
|
59
|
+
idle_time_limit: DEFAULT_IDLE_TIME_LIMIT,
|
|
60
|
+
capture_interval: DEFAULT_CAPTURE_INTERVAL,
|
|
61
|
+
env_vars: %w[SHELL TERM COMSPEC]
|
|
62
|
+
)
|
|
63
|
+
@title = title
|
|
64
|
+
@command = command
|
|
65
|
+
@idle_time_limit = idle_time_limit.to_f
|
|
66
|
+
@capture_interval = [capture_interval.to_f, MIN_CAPTURE_INTERVAL].max
|
|
67
|
+
@env_vars = env_vars
|
|
68
|
+
|
|
69
|
+
@state = :idle
|
|
70
|
+
@writer = nil
|
|
71
|
+
@start_time = nil
|
|
72
|
+
@last_buffer = nil
|
|
73
|
+
@capture_thread = nil
|
|
74
|
+
@stop_requested = false
|
|
75
|
+
@markers = []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Start recording to a file
|
|
79
|
+
#
|
|
80
|
+
# @param output_path [String] Path to save the recording
|
|
81
|
+
# @yield [Recorder] Block for interactive recording
|
|
82
|
+
# @return [Hash] Recording statistics
|
|
83
|
+
# @raise [RecordingError] If recording fails
|
|
84
|
+
def record(output_path, &block)
|
|
85
|
+
raise RecordingError, "Already recording" if @state == :recording
|
|
86
|
+
|
|
87
|
+
@state = :recording
|
|
88
|
+
@stop_requested = false
|
|
89
|
+
@markers = []
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
if @command
|
|
93
|
+
record_command(output_path)
|
|
94
|
+
else
|
|
95
|
+
record_interactive(output_path, &block)
|
|
96
|
+
end
|
|
97
|
+
ensure
|
|
98
|
+
@state = :stopped
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Pause recording (stops capturing but keeps file open)
|
|
103
|
+
#
|
|
104
|
+
# @return [void]
|
|
105
|
+
def pause
|
|
106
|
+
return unless @state == :recording
|
|
107
|
+
|
|
108
|
+
@state = :paused
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Resume recording after pause
|
|
112
|
+
#
|
|
113
|
+
# @return [void]
|
|
114
|
+
def resume
|
|
115
|
+
return unless @state == :paused
|
|
116
|
+
|
|
117
|
+
@state = :recording
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Stop recording
|
|
121
|
+
#
|
|
122
|
+
# @return [void]
|
|
123
|
+
def stop
|
|
124
|
+
@stop_requested = true
|
|
125
|
+
@state = :stopped
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Add a marker at the current position
|
|
129
|
+
#
|
|
130
|
+
# @param label [String] Marker label
|
|
131
|
+
# @return [void]
|
|
132
|
+
def add_marker(label = "")
|
|
133
|
+
return unless @writer && @start_time
|
|
134
|
+
|
|
135
|
+
time = current_time
|
|
136
|
+
@writer.write_marker(time, label)
|
|
137
|
+
@markers << { time: time, label: label }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Record an interactive terminal session
|
|
143
|
+
#
|
|
144
|
+
# @param output_path [String] Output file path
|
|
145
|
+
# @yield [Recorder] Block for custom recording logic
|
|
146
|
+
# @return [Hash] Recording statistics
|
|
147
|
+
def record_interactive(output_path)
|
|
148
|
+
# Get initial terminal size
|
|
149
|
+
size = get_terminal_size
|
|
150
|
+
width = size[0]
|
|
151
|
+
height = size[1]
|
|
152
|
+
|
|
153
|
+
# Build header
|
|
154
|
+
header = Asciicast::Header.new(
|
|
155
|
+
width: width,
|
|
156
|
+
height: height,
|
|
157
|
+
title: @title,
|
|
158
|
+
idle_time_limit: @idle_time_limit,
|
|
159
|
+
env: capture_environment
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Open output file and create writer
|
|
163
|
+
File.open(output_path, "w", encoding: "UTF-8") do |file|
|
|
164
|
+
@writer = Asciicast::Writer.new(file, header)
|
|
165
|
+
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
166
|
+
@last_buffer = nil
|
|
167
|
+
@last_event_time = 0.0
|
|
168
|
+
|
|
169
|
+
# Print recording message
|
|
170
|
+
print_recording_started(output_path)
|
|
171
|
+
|
|
172
|
+
begin
|
|
173
|
+
if block_given?
|
|
174
|
+
# Start capture thread
|
|
175
|
+
start_capture_thread
|
|
176
|
+
|
|
177
|
+
# Yield control to block
|
|
178
|
+
yield self
|
|
179
|
+
|
|
180
|
+
# Wait a bit for final captures
|
|
181
|
+
sleep(@capture_interval * 2)
|
|
182
|
+
else
|
|
183
|
+
# Interactive recording with keyboard input
|
|
184
|
+
run_interactive_loop
|
|
185
|
+
end
|
|
186
|
+
ensure
|
|
187
|
+
stop_capture_thread
|
|
188
|
+
@writer.close
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Return statistics
|
|
192
|
+
{
|
|
193
|
+
path: output_path,
|
|
194
|
+
duration: @writer.last_event_time,
|
|
195
|
+
event_count: @writer.event_count,
|
|
196
|
+
width: width,
|
|
197
|
+
height: height,
|
|
198
|
+
markers: @markers.length
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Record a command execution
|
|
204
|
+
#
|
|
205
|
+
# @param output_path [String] Output file path
|
|
206
|
+
# @return [Hash] Recording statistics
|
|
207
|
+
def record_command(output_path)
|
|
208
|
+
# Get initial terminal size
|
|
209
|
+
size = get_terminal_size
|
|
210
|
+
width = size[0]
|
|
211
|
+
height = size[1]
|
|
212
|
+
|
|
213
|
+
# Build header
|
|
214
|
+
header = Asciicast::Header.new(
|
|
215
|
+
width: width,
|
|
216
|
+
height: height,
|
|
217
|
+
title: @title || @command,
|
|
218
|
+
command: @command,
|
|
219
|
+
idle_time_limit: @idle_time_limit,
|
|
220
|
+
env: capture_environment
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
File.open(output_path, "w", encoding: "UTF-8") do |file|
|
|
224
|
+
@writer = Asciicast::Writer.new(file, header)
|
|
225
|
+
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
226
|
+
@last_buffer = nil
|
|
227
|
+
@last_event_time = 0.0
|
|
228
|
+
|
|
229
|
+
print_recording_started(output_path)
|
|
230
|
+
|
|
231
|
+
begin
|
|
232
|
+
# Start capture thread
|
|
233
|
+
start_capture_thread
|
|
234
|
+
|
|
235
|
+
# Execute command and wait for it
|
|
236
|
+
system(@command)
|
|
237
|
+
|
|
238
|
+
# Wait for final output to be captured
|
|
239
|
+
sleep(@capture_interval * 3)
|
|
240
|
+
ensure
|
|
241
|
+
stop_capture_thread
|
|
242
|
+
@writer.close
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
print_recording_finished
|
|
246
|
+
|
|
247
|
+
{
|
|
248
|
+
path: output_path,
|
|
249
|
+
duration: @writer.last_event_time,
|
|
250
|
+
event_count: @writer.event_count,
|
|
251
|
+
width: width,
|
|
252
|
+
height: height,
|
|
253
|
+
markers: @markers.length
|
|
254
|
+
}
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Run interactive recording loop
|
|
259
|
+
#
|
|
260
|
+
# @return [void]
|
|
261
|
+
def run_interactive_loop
|
|
262
|
+
# Start capture thread
|
|
263
|
+
start_capture_thread
|
|
264
|
+
|
|
265
|
+
# Set up raw mode for input detection
|
|
266
|
+
puts "\e[33mRecording... Press Ctrl+D to stop, Ctrl+M for marker.\e[0m\n"
|
|
267
|
+
|
|
268
|
+
begin
|
|
269
|
+
loop do
|
|
270
|
+
break if @stop_requested
|
|
271
|
+
|
|
272
|
+
# Check for input (non-blocking)
|
|
273
|
+
if $stdin.respond_to?(:ready?) && $stdin.ready?
|
|
274
|
+
char = $stdin.getc
|
|
275
|
+
handle_input_char(char)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Also check for Ctrl+D or EOF
|
|
279
|
+
if $stdin.eof?
|
|
280
|
+
break
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
sleep(0.05) # Small delay to avoid busy waiting
|
|
284
|
+
end
|
|
285
|
+
rescue Interrupt
|
|
286
|
+
# Ctrl+C pressed
|
|
287
|
+
@stop_requested = true
|
|
288
|
+
rescue EOFError
|
|
289
|
+
# End of input
|
|
290
|
+
@stop_requested = true
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Handle a single input character during recording
|
|
295
|
+
#
|
|
296
|
+
# @param char [String] Character pressed
|
|
297
|
+
# @return [void]
|
|
298
|
+
def handle_input_char(char)
|
|
299
|
+
case char
|
|
300
|
+
when "\x04" # Ctrl+D
|
|
301
|
+
@stop_requested = true
|
|
302
|
+
when "\r" # Ctrl+M (same as Enter)
|
|
303
|
+
add_marker
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Start the screen capture background thread
|
|
308
|
+
#
|
|
309
|
+
# @return [void]
|
|
310
|
+
def start_capture_thread
|
|
311
|
+
@capture_thread = Thread.new do
|
|
312
|
+
capture_loop
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Stop the capture thread
|
|
317
|
+
#
|
|
318
|
+
# @return [void]
|
|
319
|
+
def stop_capture_thread
|
|
320
|
+
@stop_requested = true
|
|
321
|
+
|
|
322
|
+
if @capture_thread
|
|
323
|
+
@capture_thread.join(1.0) # Wait up to 1 second
|
|
324
|
+
@capture_thread.kill if @capture_thread.alive?
|
|
325
|
+
@capture_thread = nil
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Main capture loop running in background thread
|
|
330
|
+
#
|
|
331
|
+
# @return [void]
|
|
332
|
+
def capture_loop
|
|
333
|
+
last_width = nil
|
|
334
|
+
last_height = nil
|
|
335
|
+
|
|
336
|
+
until @stop_requested
|
|
337
|
+
begin
|
|
338
|
+
# Skip if paused
|
|
339
|
+
if @state == :paused
|
|
340
|
+
sleep(@capture_interval)
|
|
341
|
+
next
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Capture current screen
|
|
345
|
+
buffer = ScreenBuffer.capture
|
|
346
|
+
next unless buffer
|
|
347
|
+
|
|
348
|
+
# Check for resize
|
|
349
|
+
if last_width && last_height
|
|
350
|
+
if buffer.width != last_width || buffer.height != last_height
|
|
351
|
+
emit_resize_event(buffer.width, buffer.height)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
last_width = buffer.width
|
|
356
|
+
last_height = buffer.height
|
|
357
|
+
|
|
358
|
+
# Generate diff from previous buffer
|
|
359
|
+
if @last_buffer
|
|
360
|
+
diff = buffer.diff(@last_buffer)
|
|
361
|
+
emit_output_event(diff) unless diff.empty?
|
|
362
|
+
else
|
|
363
|
+
# First capture - emit full screen
|
|
364
|
+
output = buffer.to_ansi
|
|
365
|
+
emit_output_event(output) unless output.empty?
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
@last_buffer = buffer
|
|
369
|
+
|
|
370
|
+
sleep(@capture_interval)
|
|
371
|
+
rescue StandardError => e
|
|
372
|
+
# Log error but keep running
|
|
373
|
+
warn "Capture error: #{e.message}" if ENV["DEBUG"]
|
|
374
|
+
sleep(@capture_interval)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Emit an output event with idle time limiting
|
|
380
|
+
#
|
|
381
|
+
# @param data [String] Output data
|
|
382
|
+
# @return [void]
|
|
383
|
+
def emit_output_event(data)
|
|
384
|
+
return if data.nil? || data.empty?
|
|
385
|
+
return unless @writer
|
|
386
|
+
|
|
387
|
+
time = current_time
|
|
388
|
+
|
|
389
|
+
# Apply idle time limit
|
|
390
|
+
if @idle_time_limit > 0 && @last_event_time
|
|
391
|
+
gap = time - @last_event_time
|
|
392
|
+
if gap > @idle_time_limit
|
|
393
|
+
# Adjust time to cap the idle period
|
|
394
|
+
time = @last_event_time + @idle_time_limit
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
@writer.write_output(time, data)
|
|
399
|
+
@last_event_time = time
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Emit a resize event
|
|
403
|
+
#
|
|
404
|
+
# @param width [Integer] New width
|
|
405
|
+
# @param height [Integer] New height
|
|
406
|
+
# @return [void]
|
|
407
|
+
def emit_resize_event(width, height)
|
|
408
|
+
return unless @writer
|
|
409
|
+
|
|
410
|
+
time = current_time
|
|
411
|
+
@writer.write_resize(time, width, height)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Get current recording time offset
|
|
415
|
+
#
|
|
416
|
+
# @return [Float] Seconds since recording started
|
|
417
|
+
def current_time
|
|
418
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Get terminal dimensions
|
|
422
|
+
#
|
|
423
|
+
# @return [Array<Integer>] [width, height]
|
|
424
|
+
def get_terminal_size
|
|
425
|
+
if Gem.win_platform? && defined?(Rich::Win32Console)
|
|
426
|
+
size = Rich::Win32Console.get_size
|
|
427
|
+
return size if size
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Try IO#winsize
|
|
431
|
+
if $stdout.respond_to?(:winsize)
|
|
432
|
+
height, width = $stdout.winsize
|
|
433
|
+
return [width, height] if width > 0 && height > 0
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Default
|
|
437
|
+
[80, 24]
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Capture environment variables
|
|
441
|
+
#
|
|
442
|
+
# @return [Hash<String, String>] Environment variable map
|
|
443
|
+
def capture_environment
|
|
444
|
+
result = {}
|
|
445
|
+
|
|
446
|
+
@env_vars.each do |var|
|
|
447
|
+
value = ENV[var]
|
|
448
|
+
result[var] = value if value
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Add Windows-specific variables
|
|
452
|
+
result["SHELL"] ||= ENV["COMSPEC"] || "cmd.exe"
|
|
453
|
+
result["TERM"] ||= "xterm-256color"
|
|
454
|
+
|
|
455
|
+
result
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Print recording started message
|
|
459
|
+
#
|
|
460
|
+
# @param path [String] Output path
|
|
461
|
+
# @return [void]
|
|
462
|
+
def print_recording_started(path)
|
|
463
|
+
puts "\e[32masciinema-win: Recording started → #{path}\e[0m"
|
|
464
|
+
puts "\e[33mPress Ctrl+D to finish recording.\e[0m"
|
|
465
|
+
puts
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Print recording finished message
|
|
469
|
+
#
|
|
470
|
+
# @return [void]
|
|
471
|
+
def print_recording_finished
|
|
472
|
+
puts
|
|
473
|
+
puts "\e[32masciinema-win: Recording finished.\e[0m"
|
|
474
|
+
if @writer
|
|
475
|
+
puts "Duration: #{format("%.2f", @writer.last_event_time)}s"
|
|
476
|
+
puts "Events: #{@writer.event_count}"
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|