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,387 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "style"
4
+ require_relative "cells"
5
+ require_relative "control"
6
+
7
+ module Rich
8
+ # A piece of text with associated style.
9
+ # Segments are the fundamental unit produced by the rendering process
10
+ # and are ultimately converted to strings for terminal output.
11
+ class Segment
12
+ # @return [String] Text content
13
+ attr_reader :text
14
+
15
+ # @return [Style, nil] Style for the text
16
+ attr_reader :style
17
+
18
+ # @return [Array, nil] Control codes (non-printable)
19
+ attr_reader :control
20
+
21
+ # Create a new segment
22
+ # @param text [String] Text content
23
+ # @param style [Style, nil] Style to apply
24
+ # @param control [Array, nil] Control codes
25
+ def initialize(text = "", style: nil, control: nil)
26
+ @text = text.freeze
27
+ @style = style
28
+ @control = control&.freeze
29
+ freeze
30
+ end
31
+
32
+ # @return [Integer] Display width in terminal cells
33
+ def cell_length
34
+ return 0 if control?
35
+
36
+ Cells.cached_cell_len(@text)
37
+ end
38
+
39
+ # @return [Boolean] True if segment has text content
40
+ def present?
41
+ !@text.empty?
42
+ end
43
+
44
+ # @return [Boolean] True if segment is empty
45
+ def empty?
46
+ @text.empty?
47
+ end
48
+
49
+ # @return [Boolean] True if this is a control segment
50
+ def control?
51
+ !@control.nil?
52
+ end
53
+
54
+ # @return [Boolean] True if segment has text (used for truthiness)
55
+ def to_bool
56
+ present?
57
+ end
58
+
59
+ # Split segment at a cell position
60
+ # @param cut [Integer] Cell position to split at
61
+ # @return [Array<Segment>] Two segments [before, after]
62
+ def split_cells(cut)
63
+ return [self.class.new("", style: @style), self] if cut <= 0
64
+ return [self, self.class.new("", style: @style)] if cut >= cell_length
65
+
66
+ self.class.split_at_cell(@text, cut, @style)
67
+ end
68
+
69
+ # Get segment text with ANSI reset if needed
70
+ # @return [String]
71
+ def to_s
72
+ @text
73
+ end
74
+
75
+ def inspect
76
+ if control?
77
+ "#<Rich::Segment control=#{@control.inspect}>"
78
+ elsif @style
79
+ "#<Rich::Segment #{@text.inspect} style=#{@style}>"
80
+ else
81
+ "#<Rich::Segment #{@text.inspect}>"
82
+ end
83
+ end
84
+
85
+ def ==(other)
86
+ return false unless other.is_a?(Segment)
87
+
88
+ @text == other.text && @style == other.style && @control == other.control
89
+ end
90
+
91
+ alias eql? ==
92
+
93
+ def hash
94
+ [@text, @style, @control].hash
95
+ end
96
+
97
+ class << self
98
+ # Create a newline segment
99
+ # @return [Segment]
100
+ def line
101
+ @line ||= new("\n")
102
+ end
103
+
104
+ # Create a blank segment with specified cell width
105
+ # @param cell_count [Integer] Width in cells
106
+ # @param style [Style, nil] Optional style
107
+ # @return [Segment]
108
+ def blank(cell_count, style: nil)
109
+ new(" " * cell_count, style: style)
110
+ end
111
+
112
+ # Create a control segment
113
+ # @param control [Array] Control codes
114
+ # @return [Segment]
115
+ def control(control_codes)
116
+ new("", control: control_codes)
117
+ end
118
+
119
+ # Apply style to an iterable of segments
120
+ # @param segments [Enumerable<Segment>] Segments to style
121
+ # @param style [Style, nil] Style to apply
122
+ # @param post_style [Style, nil] Style to apply after segment style
123
+ # @return [Enumerable<Segment>]
124
+ def apply_style(segments, style: nil, post_style: nil)
125
+ return segments if style.nil? && post_style.nil?
126
+
127
+ segments.map do |segment|
128
+ next segment if segment.control?
129
+
130
+ new_style = if segment.style
131
+ if style && post_style
132
+ style + segment.style + post_style
133
+ elsif style
134
+ style + segment.style
135
+ else
136
+ segment.style + post_style
137
+ end
138
+ else
139
+ style || post_style
140
+ end
141
+
142
+ new(segment.text, style: new_style, control: segment.control)
143
+ end
144
+ end
145
+
146
+ # Filter segments by control status
147
+ # @param segments [Enumerable<Segment>] Segments to filter
148
+ # @param is_control [Boolean] Filter for control segments
149
+ # @return [Enumerable<Segment>]
150
+ def filter_control(segments, is_control: false)
151
+ segments.select { |s| s.control? == is_control }
152
+ end
153
+
154
+ # Split segments into lines
155
+ # @param segments [Enumerable<Segment>] Segments to split
156
+ # @return [Array<Array<Segment>>] Array of lines
157
+ def split_lines(segments)
158
+ lines = []
159
+ current_line = []
160
+
161
+ segments.each do |segment|
162
+ if segment.text.include?("\n")
163
+ parts = segment.text.split("\n", -1)
164
+ parts.each_with_index do |part, index|
165
+ current_line << new(part, style: segment.style) unless part.empty?
166
+
167
+ if index < parts.length - 1
168
+ lines << current_line
169
+ current_line = []
170
+ end
171
+ end
172
+ else
173
+ current_line << segment
174
+ end
175
+ end
176
+
177
+ lines << current_line unless current_line.empty?
178
+ lines
179
+ end
180
+
181
+ # Split and crop segments to a given width
182
+ # @param segments [Enumerable<Segment>] Segments to process
183
+ # @param width [Integer] Maximum width
184
+ # @param style [Style, nil] Fill style
185
+ # @param pad [Boolean] Pad lines to width
186
+ # @param include_new_lines [Boolean] Include newline segments
187
+ # @return [Array<Array<Segment>>]
188
+ def split_and_crop_lines(segments, width, style: nil, pad: true, include_new_lines: true)
189
+ lines = split_lines(segments)
190
+
191
+ lines.map do |line|
192
+ cropped = adjust_line_length(line, width, style: style, pad: pad)
193
+ if include_new_lines
194
+ cropped + [new("\n")]
195
+ else
196
+ cropped
197
+ end
198
+ end
199
+ end
200
+
201
+ # Adjust line length by cropping or padding
202
+ # @param line [Array<Segment>] Line segments
203
+ # @param length [Integer] Target length
204
+ # @param style [Style, nil] Style for padding
205
+ # @param pad [Boolean] Whether to pad short lines
206
+ # @return [Array<Segment>]
207
+ def adjust_line_length(line, length, style: nil, pad: true)
208
+ current_length = get_line_length(line)
209
+
210
+ if current_length < length
211
+ # Pad if needed
212
+ if pad
213
+ pad_size = length - current_length
214
+ line + [blank(pad_size, style: style)]
215
+ else
216
+ line
217
+ end
218
+ elsif current_length > length
219
+ # Crop
220
+ crop_line(line, length)
221
+ else
222
+ line
223
+ end
224
+ end
225
+
226
+ # Get total cell length of a line
227
+ # @param line [Array<Segment>] Line segments
228
+ # @return [Integer]
229
+ def get_line_length(line)
230
+ line.sum(&:cell_length)
231
+ end
232
+
233
+ # Get dimensions of lines
234
+ # @param lines [Array<Array<Segment>>] Lines
235
+ # @return [Array<Integer>] [width, height]
236
+ def get_shape(lines)
237
+ height = lines.length
238
+ width = lines.map { |line| get_line_length(line) }.max || 0
239
+ [width, height]
240
+ end
241
+
242
+ # Crop a line to a maximum width
243
+ # @param line [Array<Segment>] Line segments
244
+ # @param max_width [Integer] Maximum width
245
+ # @return [Array<Segment>]
246
+ def crop_line(line, max_width)
247
+ result = []
248
+ remaining = max_width
249
+
250
+ line.each do |segment|
251
+ break if remaining <= 0
252
+
253
+ segment_width = segment.cell_length
254
+
255
+ if segment_width <= remaining
256
+ result << segment
257
+ remaining -= segment_width
258
+ else
259
+ # Need to split segment
260
+ before, _after = segment.split_cells(remaining)
261
+ result << before
262
+ remaining = 0
263
+ end
264
+ end
265
+
266
+ result
267
+ end
268
+
269
+ # Simplify consecutive segments with the same style
270
+ # @param segments [Array<Segment>] Segments to simplify
271
+ # @return [Array<Segment>]
272
+ def simplify(segments)
273
+ return segments if segments.empty?
274
+
275
+ result = []
276
+ current_text = +""
277
+ current_style = nil
278
+ current_control = nil
279
+
280
+ segments.each do |segment|
281
+ if segment.control?
282
+ # Flush text if any
283
+ unless current_text.empty?
284
+ result << new(current_text, style: current_style, control: current_control)
285
+ current_text = +""
286
+ end
287
+ result << segment
288
+ current_style = nil
289
+ current_control = nil
290
+ elsif segment.style == current_style
291
+ current_text << segment.text
292
+ else
293
+ unless current_text.empty?
294
+ result << new(current_text, style: current_style, control: current_control)
295
+ end
296
+ current_text = +segment.text
297
+ current_style = segment.style
298
+ current_control = nil
299
+ end
300
+ end
301
+
302
+ result << new(current_text, style: current_style) unless current_text.empty?
303
+
304
+ result
305
+ end
306
+
307
+ # Render segments to string with ANSI codes
308
+ # @param segments [Enumerable<Segment>] Segments to render
309
+ # @param color_system [Symbol] Color system to use
310
+ # @return [String]
311
+ def render(segments, color_system: ColorSystem::TRUECOLOR)
312
+ output = +""
313
+ last_style = nil
314
+
315
+ segments.each do |segment|
316
+ if segment.control?
317
+ # Handle control codes
318
+ segment.control.each do |control_code|
319
+ output << Control.generate(*control_code)
320
+ end
321
+ else
322
+ style = segment.style
323
+
324
+ if style != last_style
325
+ # Reset if needed
326
+ output << "\e[0m" if last_style
327
+ # Apply new style
328
+ output << style.render(color_system: color_system) if style
329
+ last_style = style
330
+ end
331
+
332
+ output << segment.text
333
+ end
334
+ end
335
+
336
+ # Reset at end if we had any style
337
+ output << "\e[0m" if last_style
338
+
339
+ output
340
+ end
341
+
342
+ # Split text at a cell position
343
+ # @param text [String] Text to split
344
+ # @param cut [Integer] Cell position
345
+ # @param style [Style, nil] Style to apply
346
+ # @return [Array<Segment>] Two segments
347
+ def split_at_cell(text, cut, style)
348
+ position = 0
349
+ cell_count = 0
350
+ insert_spaces = 0
351
+
352
+ text.each_char do |char|
353
+ char_width = Cells.char_width(char)
354
+ new_cell_count = cell_count + char_width
355
+
356
+ if new_cell_count > cut
357
+ # Split happens in the middle of a wide character
358
+ if char_width == 2 && cell_count + 1 == cut
359
+ insert_spaces = 2
360
+ end
361
+ break
362
+ end
363
+
364
+ cell_count = new_cell_count
365
+ position += 1
366
+ break if cell_count == cut
367
+ end
368
+
369
+ if insert_spaces > 0
370
+ [
371
+ new(text[0...position] + " " * (cut - cell_count), style: style),
372
+ new(" " * (insert_spaces - (cut - cell_count)) + text[position..], style: style)
373
+ ]
374
+ else
375
+ [
376
+ new(text[0...position], style: style),
377
+ new(text[position..] || "", style: style)
378
+ ]
379
+ end
380
+ end
381
+
382
+ private
383
+
384
+ # No private methods currently
385
+ end
386
+ end
387
+ end