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.
@@ -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