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,437 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciinemaWin
|
|
4
|
+
# ANSI escape sequence parser for rendering colored output
|
|
5
|
+
#
|
|
6
|
+
# Parses ANSI SGR (Select Graphic Rendition) codes and converts
|
|
7
|
+
# terminal output into structured data for SVG/HTML rendering.
|
|
8
|
+
class AnsiParser
|
|
9
|
+
# Character with style information
|
|
10
|
+
StyledChar = Data.define(:char, :fg, :bg, :bold, :italic, :underline, :strikethrough) do
|
|
11
|
+
# Check if this character has default styling
|
|
12
|
+
#
|
|
13
|
+
# @return [Boolean] True if no special styling applied
|
|
14
|
+
def default_style?
|
|
15
|
+
fg.nil? && bg.nil? && !bold && !italic && !underline && !strikethrough
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Check if style matches another character
|
|
19
|
+
#
|
|
20
|
+
# @param other [StyledChar] Other character to compare
|
|
21
|
+
# @return [Boolean] True if styles match
|
|
22
|
+
def same_style?(other)
|
|
23
|
+
fg == other.fg &&
|
|
24
|
+
bg == other.bg &&
|
|
25
|
+
bold == other.bold &&
|
|
26
|
+
italic == other.italic &&
|
|
27
|
+
underline == other.underline &&
|
|
28
|
+
strikethrough == other.strikethrough
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Current text style state
|
|
33
|
+
class StyleState
|
|
34
|
+
# @return [Integer, nil] Foreground color (ANSI code or 256-color index)
|
|
35
|
+
attr_accessor :fg
|
|
36
|
+
|
|
37
|
+
# @return [Integer, nil] Background color (ANSI code or 256-color index)
|
|
38
|
+
attr_accessor :bg
|
|
39
|
+
|
|
40
|
+
# @return [Boolean] Bold/bright text
|
|
41
|
+
attr_accessor :bold
|
|
42
|
+
|
|
43
|
+
# @return [Boolean] Italic text
|
|
44
|
+
attr_accessor :italic
|
|
45
|
+
|
|
46
|
+
# @return [Boolean] Underlined text
|
|
47
|
+
attr_accessor :underline
|
|
48
|
+
|
|
49
|
+
# @return [Boolean] Strikethrough text
|
|
50
|
+
attr_accessor :strikethrough
|
|
51
|
+
|
|
52
|
+
# @return [String, nil] RGB foreground (#rrggbb)
|
|
53
|
+
attr_accessor :fg_rgb
|
|
54
|
+
|
|
55
|
+
# @return [String, nil] RGB background (#rrggbb)
|
|
56
|
+
attr_accessor :bg_rgb
|
|
57
|
+
|
|
58
|
+
def initialize
|
|
59
|
+
reset
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Reset all attributes to default
|
|
63
|
+
#
|
|
64
|
+
# @return [void]
|
|
65
|
+
def reset
|
|
66
|
+
@fg = nil
|
|
67
|
+
@bg = nil
|
|
68
|
+
@bold = false
|
|
69
|
+
@italic = false
|
|
70
|
+
@underline = false
|
|
71
|
+
@strikethrough = false
|
|
72
|
+
@fg_rgb = nil
|
|
73
|
+
@bg_rgb = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get effective foreground color
|
|
77
|
+
#
|
|
78
|
+
# @return [Integer, String, nil] Color value
|
|
79
|
+
def effective_fg
|
|
80
|
+
fg_rgb || fg
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Get effective background color
|
|
84
|
+
#
|
|
85
|
+
# @return [Integer, String, nil] Color value
|
|
86
|
+
def effective_bg
|
|
87
|
+
bg_rgb || bg
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Create a StyledChar from current state
|
|
91
|
+
#
|
|
92
|
+
# @param char [String] Character
|
|
93
|
+
# @return [StyledChar] Styled character
|
|
94
|
+
def to_styled_char(char)
|
|
95
|
+
StyledChar.new(
|
|
96
|
+
char: char,
|
|
97
|
+
fg: effective_fg,
|
|
98
|
+
bg: effective_bg,
|
|
99
|
+
bold: @bold,
|
|
100
|
+
italic: @italic,
|
|
101
|
+
underline: @underline,
|
|
102
|
+
strikethrough: @strikethrough
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Parsed line of styled characters
|
|
108
|
+
ParsedLine = Data.define(:chars, :line_number)
|
|
109
|
+
|
|
110
|
+
# @return [Array<ParsedLine>] Parsed lines
|
|
111
|
+
attr_reader :lines
|
|
112
|
+
|
|
113
|
+
# @return [Integer] Terminal width
|
|
114
|
+
attr_reader :width
|
|
115
|
+
|
|
116
|
+
# @return [Integer] Terminal height
|
|
117
|
+
attr_reader :height
|
|
118
|
+
|
|
119
|
+
# Initialize parser with terminal dimensions
|
|
120
|
+
#
|
|
121
|
+
# @param width [Integer] Terminal width in characters
|
|
122
|
+
# @param height [Integer] Terminal height in lines
|
|
123
|
+
def initialize(width:, height:)
|
|
124
|
+
@width = width
|
|
125
|
+
@height = height
|
|
126
|
+
@lines = []
|
|
127
|
+
@current_line = []
|
|
128
|
+
@cursor_x = 0
|
|
129
|
+
@cursor_y = 0
|
|
130
|
+
@state = StyleState.new
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Parse ANSI content and build styled character grid
|
|
134
|
+
#
|
|
135
|
+
# @param content [String] Raw ANSI content
|
|
136
|
+
# @return [Array<ParsedLine>] Parsed lines
|
|
137
|
+
def parse(content)
|
|
138
|
+
# Initialize empty grid
|
|
139
|
+
@height.times do |y|
|
|
140
|
+
@lines[y] = ParsedLine.new(
|
|
141
|
+
chars: Array.new(@width) { @state.to_styled_char(" ") },
|
|
142
|
+
line_number: y
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
pos = 0
|
|
147
|
+
while pos < content.length
|
|
148
|
+
if content[pos] == "\e" && content[pos + 1] == "["
|
|
149
|
+
# ANSI escape sequence
|
|
150
|
+
end_pos = find_sequence_end(content, pos + 2)
|
|
151
|
+
if end_pos
|
|
152
|
+
sequence = content[pos + 2...end_pos]
|
|
153
|
+
command = content[end_pos]
|
|
154
|
+
process_escape(sequence, command)
|
|
155
|
+
pos = end_pos + 1
|
|
156
|
+
else
|
|
157
|
+
pos += 1
|
|
158
|
+
end
|
|
159
|
+
elsif content[pos] == "\r"
|
|
160
|
+
@cursor_x = 0
|
|
161
|
+
pos += 1
|
|
162
|
+
elsif content[pos] == "\n"
|
|
163
|
+
@cursor_x = 0
|
|
164
|
+
@cursor_y += 1
|
|
165
|
+
scroll_if_needed
|
|
166
|
+
pos += 1
|
|
167
|
+
elsif content[pos] == "\t"
|
|
168
|
+
# Tab - move to next 8-column boundary
|
|
169
|
+
spaces = 8 - (@cursor_x % 8)
|
|
170
|
+
spaces.times { write_char(" ") }
|
|
171
|
+
pos += 1
|
|
172
|
+
elsif content[pos] == "\b"
|
|
173
|
+
# Backspace
|
|
174
|
+
@cursor_x = [@cursor_x - 1, 0].max
|
|
175
|
+
pos += 1
|
|
176
|
+
elsif content[pos].ord >= 32 || content[pos].ord == 0
|
|
177
|
+
write_char(content[pos])
|
|
178
|
+
pos += 1
|
|
179
|
+
else
|
|
180
|
+
pos += 1
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
@lines
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
# Find end of ANSI escape sequence
|
|
190
|
+
#
|
|
191
|
+
# @param content [String] Content string
|
|
192
|
+
# @param start [Integer] Start position
|
|
193
|
+
# @return [Integer, nil] End position or nil
|
|
194
|
+
def find_sequence_end(content, start)
|
|
195
|
+
pos = start
|
|
196
|
+
while pos < content.length
|
|
197
|
+
char = content[pos]
|
|
198
|
+
if char.match?(/[A-Za-z]/)
|
|
199
|
+
return pos
|
|
200
|
+
elsif char.match?(/[0-9;:?]/)
|
|
201
|
+
pos += 1
|
|
202
|
+
else
|
|
203
|
+
return nil
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Process ANSI escape sequence
|
|
210
|
+
#
|
|
211
|
+
# @param sequence [String] Sequence parameters
|
|
212
|
+
# @param command [String] Command character
|
|
213
|
+
# @return [void]
|
|
214
|
+
def process_escape(sequence, command)
|
|
215
|
+
case command
|
|
216
|
+
when "m"
|
|
217
|
+
process_sgr(sequence)
|
|
218
|
+
when "H", "f"
|
|
219
|
+
# Cursor position
|
|
220
|
+
parts = sequence.split(";")
|
|
221
|
+
row = (parts[0] || "1").to_i - 1
|
|
222
|
+
col = (parts[1] || "1").to_i - 1
|
|
223
|
+
@cursor_y = [[row, 0].max, @height - 1].min
|
|
224
|
+
@cursor_x = [[col, 0].max, @width - 1].min
|
|
225
|
+
when "A"
|
|
226
|
+
# Cursor up
|
|
227
|
+
n = (sequence.empty? ? 1 : sequence.to_i)
|
|
228
|
+
@cursor_y = [@cursor_y - n, 0].max
|
|
229
|
+
when "B"
|
|
230
|
+
# Cursor down
|
|
231
|
+
n = (sequence.empty? ? 1 : sequence.to_i)
|
|
232
|
+
@cursor_y = [@cursor_y + n, @height - 1].min
|
|
233
|
+
when "C"
|
|
234
|
+
# Cursor forward
|
|
235
|
+
n = (sequence.empty? ? 1 : sequence.to_i)
|
|
236
|
+
@cursor_x = [@cursor_x + n, @width - 1].min
|
|
237
|
+
when "D"
|
|
238
|
+
# Cursor back
|
|
239
|
+
n = (sequence.empty? ? 1 : sequence.to_i)
|
|
240
|
+
@cursor_x = [@cursor_x - n, 0].max
|
|
241
|
+
when "J"
|
|
242
|
+
# Erase in display
|
|
243
|
+
n = sequence.empty? ? 0 : sequence.to_i
|
|
244
|
+
erase_display(n)
|
|
245
|
+
when "K"
|
|
246
|
+
# Erase in line
|
|
247
|
+
n = sequence.empty? ? 0 : sequence.to_i
|
|
248
|
+
erase_line(n)
|
|
249
|
+
when "G"
|
|
250
|
+
# Cursor horizontal absolute
|
|
251
|
+
col = (sequence.empty? ? 1 : sequence.to_i) - 1
|
|
252
|
+
@cursor_x = [[col, 0].max, @width - 1].min
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Process SGR (Select Graphic Rendition) codes
|
|
257
|
+
#
|
|
258
|
+
# @param sequence [String] Semicolon-separated codes
|
|
259
|
+
# @return [void]
|
|
260
|
+
def process_sgr(sequence)
|
|
261
|
+
return @state.reset if sequence.empty?
|
|
262
|
+
|
|
263
|
+
codes = sequence.split(";").map(&:to_i)
|
|
264
|
+
i = 0
|
|
265
|
+
|
|
266
|
+
while i < codes.length
|
|
267
|
+
code = codes[i]
|
|
268
|
+
|
|
269
|
+
case code
|
|
270
|
+
when 0
|
|
271
|
+
@state.reset
|
|
272
|
+
when 1
|
|
273
|
+
@state.bold = true
|
|
274
|
+
when 3
|
|
275
|
+
@state.italic = true
|
|
276
|
+
when 4
|
|
277
|
+
@state.underline = true
|
|
278
|
+
when 9
|
|
279
|
+
@state.strikethrough = true
|
|
280
|
+
when 22
|
|
281
|
+
@state.bold = false
|
|
282
|
+
when 23
|
|
283
|
+
@state.italic = false
|
|
284
|
+
when 24
|
|
285
|
+
@state.underline = false
|
|
286
|
+
when 29
|
|
287
|
+
@state.strikethrough = false
|
|
288
|
+
when 30..37
|
|
289
|
+
@state.fg = code
|
|
290
|
+
@state.fg_rgb = nil
|
|
291
|
+
when 38
|
|
292
|
+
# Extended foreground color
|
|
293
|
+
if codes[i + 1] == 5 && codes[i + 2]
|
|
294
|
+
# 256 color
|
|
295
|
+
@state.fg = codes[i + 2]
|
|
296
|
+
@state.fg_rgb = nil
|
|
297
|
+
i += 2
|
|
298
|
+
elsif codes[i + 1] == 2 && codes[i + 4]
|
|
299
|
+
# 24-bit RGB
|
|
300
|
+
r = codes[i + 2]
|
|
301
|
+
g = codes[i + 3]
|
|
302
|
+
b = codes[i + 4]
|
|
303
|
+
@state.fg_rgb = format("#%02x%02x%02x", r, g, b)
|
|
304
|
+
@state.fg = nil
|
|
305
|
+
i += 4
|
|
306
|
+
end
|
|
307
|
+
when 39
|
|
308
|
+
@state.fg = nil
|
|
309
|
+
@state.fg_rgb = nil
|
|
310
|
+
when 40..47
|
|
311
|
+
@state.bg = code
|
|
312
|
+
@state.bg_rgb = nil
|
|
313
|
+
when 48
|
|
314
|
+
# Extended background color
|
|
315
|
+
if codes[i + 1] == 5 && codes[i + 2]
|
|
316
|
+
# 256 color
|
|
317
|
+
@state.bg = codes[i + 2]
|
|
318
|
+
@state.bg_rgb = nil
|
|
319
|
+
i += 2
|
|
320
|
+
elsif codes[i + 1] == 2 && codes[i + 4]
|
|
321
|
+
# 24-bit RGB
|
|
322
|
+
r = codes[i + 2]
|
|
323
|
+
g = codes[i + 3]
|
|
324
|
+
b = codes[i + 4]
|
|
325
|
+
@state.bg_rgb = format("#%02x%02x%02x", r, g, b)
|
|
326
|
+
@state.bg = nil
|
|
327
|
+
i += 4
|
|
328
|
+
end
|
|
329
|
+
when 49
|
|
330
|
+
@state.bg = nil
|
|
331
|
+
@state.bg_rgb = nil
|
|
332
|
+
when 90..97
|
|
333
|
+
@state.fg = code
|
|
334
|
+
@state.fg_rgb = nil
|
|
335
|
+
when 100..107
|
|
336
|
+
@state.bg = code
|
|
337
|
+
@state.bg_rgb = nil
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
i += 1
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Write character at current cursor position
|
|
345
|
+
#
|
|
346
|
+
# @param char [String] Character to write
|
|
347
|
+
# @return [void]
|
|
348
|
+
def write_char(char)
|
|
349
|
+
return if @cursor_y >= @height
|
|
350
|
+
|
|
351
|
+
if @cursor_x < @width
|
|
352
|
+
# Update the character at current position
|
|
353
|
+
old_line = @lines[@cursor_y]
|
|
354
|
+
new_chars = old_line.chars.dup
|
|
355
|
+
new_chars[@cursor_x] = @state.to_styled_char(char)
|
|
356
|
+
@lines[@cursor_y] = ParsedLine.new(chars: new_chars, line_number: @cursor_y)
|
|
357
|
+
@cursor_x += 1
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Handle line wrap
|
|
361
|
+
if @cursor_x >= @width
|
|
362
|
+
@cursor_x = 0
|
|
363
|
+
@cursor_y += 1
|
|
364
|
+
scroll_if_needed
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Scroll screen if cursor goes past bottom
|
|
369
|
+
#
|
|
370
|
+
# @return [void]
|
|
371
|
+
def scroll_if_needed
|
|
372
|
+
return unless @cursor_y >= @height
|
|
373
|
+
|
|
374
|
+
# Scroll up by one line
|
|
375
|
+
@lines.shift
|
|
376
|
+
@lines << ParsedLine.new(
|
|
377
|
+
chars: Array.new(@width) { @state.to_styled_char(" ") },
|
|
378
|
+
line_number: @height - 1
|
|
379
|
+
)
|
|
380
|
+
@cursor_y = @height - 1
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Erase in display
|
|
384
|
+
#
|
|
385
|
+
# @param mode [Integer] 0=cursor to end, 1=start to cursor, 2=entire
|
|
386
|
+
# @return [void]
|
|
387
|
+
def erase_display(mode)
|
|
388
|
+
blank = @state.to_styled_char(" ")
|
|
389
|
+
|
|
390
|
+
case mode
|
|
391
|
+
when 0
|
|
392
|
+
# Cursor to end
|
|
393
|
+
erase_line(0)
|
|
394
|
+
((@cursor_y + 1)...@height).each do |y|
|
|
395
|
+
@lines[y] = ParsedLine.new(chars: Array.new(@width) { blank }, line_number: y)
|
|
396
|
+
end
|
|
397
|
+
when 1
|
|
398
|
+
# Start to cursor
|
|
399
|
+
(0...@cursor_y).each do |y|
|
|
400
|
+
@lines[y] = ParsedLine.new(chars: Array.new(@width) { blank }, line_number: y)
|
|
401
|
+
end
|
|
402
|
+
erase_line(1)
|
|
403
|
+
when 2, 3
|
|
404
|
+
# Entire screen
|
|
405
|
+
@height.times do |y|
|
|
406
|
+
@lines[y] = ParsedLine.new(chars: Array.new(@width) { blank }, line_number: y)
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Erase in line
|
|
412
|
+
#
|
|
413
|
+
# @param mode [Integer] 0=cursor to end, 1=start to cursor, 2=entire line
|
|
414
|
+
# @return [void]
|
|
415
|
+
def erase_line(mode)
|
|
416
|
+
return if @cursor_y >= @height
|
|
417
|
+
|
|
418
|
+
blank = @state.to_styled_char(" ")
|
|
419
|
+
old_line = @lines[@cursor_y]
|
|
420
|
+
new_chars = old_line.chars.dup
|
|
421
|
+
|
|
422
|
+
case mode
|
|
423
|
+
when 0
|
|
424
|
+
# Cursor to end of line
|
|
425
|
+
(@cursor_x...@width).each { |x| new_chars[x] = blank }
|
|
426
|
+
when 1
|
|
427
|
+
# Start of line to cursor
|
|
428
|
+
(0..@cursor_x).each { |x| new_chars[x] = blank }
|
|
429
|
+
when 2
|
|
430
|
+
# Entire line
|
|
431
|
+
@width.times { |x| new_chars[x] = blank }
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
@lines[@cursor_y] = ParsedLine.new(chars: new_chars, line_number: @cursor_y)
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|