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.
data/lib/rich/text.rb ADDED
@@ -0,0 +1,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "style"
4
+ require_relative "segment"
5
+ require_relative "cells"
6
+
7
+ module Rich
8
+ # A span of styled text within a Text object
9
+ class Span
10
+ # @return [Integer] Start position (inclusive)
11
+ attr_reader :start
12
+
13
+ # @return [Integer] End position (exclusive)
14
+ attr_reader :end
15
+
16
+ # @return [Style] Style for this span
17
+ attr_reader :style
18
+
19
+ def initialize(start_pos, end_pos, style)
20
+ @start = start_pos
21
+ @end = end_pos
22
+ @style = style.is_a?(String) ? Style.parse(style) : style
23
+ freeze
24
+ end
25
+
26
+ # @return [Integer] Length of the span
27
+ def length
28
+ @end - @start
29
+ end
30
+
31
+ # Check if span overlaps with a range
32
+ def overlaps?(start_pos, end_pos)
33
+ @start < end_pos && @end > start_pos
34
+ end
35
+
36
+ # Adjust span after insertion at position
37
+ def adjust_insert(position, length)
38
+ if position <= @start
39
+ Span.new(@start + length, @end + length, @style)
40
+ elsif position < @end
41
+ Span.new(@start, @end + length, @style)
42
+ else
43
+ self
44
+ end
45
+ end
46
+
47
+ # Adjust span after deletion at position
48
+ def adjust_delete(position, length)
49
+ delete_end = position + length
50
+
51
+ if delete_end <= @start
52
+ Span.new(@start - length, @end - length, @style)
53
+ elsif position >= @end
54
+ self
55
+ elsif position <= @start && delete_end >= @end
56
+ nil # Span completely deleted
57
+ elsif position <= @start
58
+ Span.new(position, @end - length, @style)
59
+ elsif delete_end >= @end
60
+ Span.new(@start, position, @style)
61
+ else
62
+ Span.new(@start, @end - length, @style)
63
+ end
64
+ end
65
+
66
+ def inspect
67
+ "#<Rich::Span [#{@start}:#{@end}] #{@style}>"
68
+ end
69
+ end
70
+
71
+ # Rich text with style spans.
72
+ # Text objects contain plain text plus a list of style spans that define
73
+ # how different portions of the text should be rendered.
74
+ class Text
75
+ # @return [String] Plain text content
76
+ attr_reader :plain
77
+
78
+ # @return [Array<Span>] Style spans
79
+ attr_reader :spans
80
+
81
+ # @return [Style, nil] Base style for entire text
82
+ attr_reader :style
83
+
84
+ # @return [Symbol] Justification (:left, :center, :right, :full)
85
+ attr_reader :justify
86
+
87
+ # @return [Symbol] Overflow handling (:fold, :crop, :ellipsis)
88
+ attr_reader :overflow
89
+
90
+ # @return [Boolean] No wrap
91
+ attr_reader :no_wrap
92
+
93
+ # @return [Boolean] End with newline
94
+ attr_reader :end
95
+
96
+ # Create new Text
97
+ # @param text [String] Initial text
98
+ # @param style [Style, String, nil] Base style
99
+ # @param justify [Symbol] Text justification
100
+ # @param overflow [Symbol] Overflow handling
101
+ # @param no_wrap [Boolean] Disable wrapping
102
+ def initialize(
103
+ text = "",
104
+ style: nil,
105
+ justify: :left,
106
+ overflow: :fold,
107
+ no_wrap: false,
108
+ end_str: "\n"
109
+ )
110
+ @plain = +text.to_s
111
+ @spans = []
112
+ @style = style.is_a?(String) ? Style.parse(style) : style
113
+ @justify = justify
114
+ @overflow = overflow
115
+ @no_wrap = no_wrap
116
+ @end = end_str
117
+ end
118
+
119
+ # @return [Integer] Length of text
120
+ def length
121
+ @plain.length
122
+ end
123
+
124
+ # @return [Integer] Cell width of text
125
+ def cell_length
126
+ Cells.cached_cell_len(@plain)
127
+ end
128
+
129
+ # @return [Boolean] True if text is empty
130
+ def empty?
131
+ @plain.empty?
132
+ end
133
+
134
+ # Append text with optional style
135
+ # @param text [String, Text] Text to append
136
+ # @param style [Style, String, nil] Style for appended text
137
+ # @return [self]
138
+ def append(text, style: nil)
139
+ if text.is_a?(Text)
140
+ append_text(text)
141
+ else
142
+ start_pos = @plain.length
143
+ @plain << text.to_s
144
+
145
+ if style
146
+ parsed_style = style.is_a?(String) ? Style.parse(style) : style
147
+ @spans << Span.new(start_pos, @plain.length, parsed_style)
148
+ end
149
+ end
150
+ self
151
+ end
152
+
153
+ alias << append
154
+
155
+ # Append a Text object
156
+ # @param other [Text] Text to append
157
+ # @return [self]
158
+ def append_text(other)
159
+ offset = @plain.length
160
+ @plain << other.plain
161
+
162
+ other.spans.each do |span|
163
+ @spans << Span.new(span.start + offset, span.end + offset, span.style)
164
+ end
165
+
166
+ self
167
+ end
168
+
169
+ # Apply a style to a range
170
+ # @param style [Style, String] Style to apply
171
+ # @param start_pos [Integer] Start position
172
+ # @param end_pos [Integer, nil] End position (nil = end of text)
173
+ # @return [self]
174
+ def stylize(style, start_pos = 0, end_pos = nil)
175
+ end_pos ||= @plain.length
176
+ return self if start_pos >= end_pos
177
+
178
+ parsed_style = style.is_a?(String) ? Style.parse(style) : style
179
+ @spans << Span.new(start_pos, end_pos, parsed_style)
180
+ self
181
+ end
182
+
183
+ # Apply a style to the entire text
184
+ # @param style [Style, String] Style to apply
185
+ # @return [self]
186
+ def stylize_all(style)
187
+ stylize(style, 0, @plain.length)
188
+ end
189
+
190
+ # Get a substring as a new Text object
191
+ # @param start_pos [Integer] Start position
192
+ # @param length [Integer, nil] Length (nil = to end)
193
+ # @return [Text]
194
+ def slice(start_pos, length = nil)
195
+ end_pos = length ? start_pos + length : @plain.length
196
+ new_text = Text.new(@plain[start_pos...end_pos], style: @style)
197
+
198
+ @spans.each do |span|
199
+ next unless span.overlaps?(start_pos, end_pos)
200
+
201
+ new_start = [span.start - start_pos, 0].max
202
+ new_end = [span.end - start_pos, end_pos - start_pos].min
203
+ new_text.spans << Span.new(new_start, new_end, span.style)
204
+ end
205
+
206
+ new_text
207
+ end
208
+
209
+ # Split text by a delimiter
210
+ # @param delimiter [String] Delimiter to split on
211
+ # @return [Array<Text>]
212
+ def split(delimiter = "\n")
213
+ parts = []
214
+ pos = 0
215
+
216
+ @plain.split(delimiter, -1).each do |part|
217
+ end_pos = pos + part.length
218
+ parts << slice(pos, part.length)
219
+ pos = end_pos + delimiter.length
220
+ end
221
+
222
+ parts
223
+ end
224
+
225
+ # Wrap text to a given width
226
+ # @param width [Integer] Maximum width
227
+ # @return [Array<Text>]
228
+ def wrap(width)
229
+ return [self.dup] if width <= 0 || cell_length <= width
230
+
231
+ lines = []
232
+ current_line = Text.new(style: @style)
233
+ current_width = 0
234
+
235
+ words = @plain.split(/(\s+)/)
236
+ word_pos = 0
237
+
238
+ words.each do |word|
239
+ word_width = Cells.cell_len(word)
240
+ word_end = word_pos + word.length
241
+
242
+ if current_width + word_width <= width
243
+ # Word fits
244
+ current_line.append(word)
245
+ # Copy spans for this word
246
+ @spans.each do |span|
247
+ if span.overlaps?(word_pos, word_end)
248
+ new_start = [span.start - word_pos, 0].max + current_line.length - word.length
249
+ new_end = [span.end - word_pos, word.length].min + current_line.length - word.length
250
+ current_line.spans << Span.new(new_start, new_end, span.style)
251
+ end
252
+ end
253
+ current_width += word_width
254
+ elsif word_width > width
255
+ # Word is too long, need to break it
256
+ unless current_line.empty?
257
+ lines << current_line
258
+ current_line = Text.new(style: @style)
259
+ current_width = 0
260
+ end
261
+
262
+ # Break long word
263
+ word.each_char do |char|
264
+ char_width = Cells.char_width(char)
265
+ if current_width + char_width > width
266
+ lines << current_line
267
+ current_line = Text.new(style: @style)
268
+ current_width = 0
269
+ end
270
+ current_line.append(char)
271
+ current_width += char_width
272
+ end
273
+ else
274
+ # Start new line
275
+ lines << current_line unless current_line.empty?
276
+ current_line = Text.new(style: @style)
277
+ current_line.append(word.lstrip)
278
+ current_width = Cells.cell_len(word.lstrip)
279
+ end
280
+
281
+ word_pos = word_end
282
+ end
283
+
284
+ lines << current_line unless current_line.empty?
285
+ lines
286
+ end
287
+
288
+ # Highlight occurrences of words
289
+ def highlight_words(words, style:)
290
+ words.each do |word|
291
+ pos = 0
292
+ while (pos = @plain.index(word, pos))
293
+ stylize(style, pos, pos + word.length)
294
+ pos += word.length
295
+ end
296
+ end
297
+ self
298
+ end
299
+
300
+ # Highlight occurrences matching a regex
301
+ def highlight_regex(re, style:)
302
+ @plain.scan(re) do
303
+ match = Regexp.last_match
304
+ stylize(style, match.begin(0), match.end(0))
305
+ end
306
+ self
307
+ end
308
+
309
+ # Copy the text object
310
+ def copy
311
+ dup
312
+ end
313
+
314
+ # Convert to segments for rendering
315
+ # @return [Array<Segment>]
316
+ def to_segments
317
+ return [Segment.new(@plain, style: @style)] if @spans.empty?
318
+
319
+ # Build a list of style changes
320
+ changes = []
321
+ @spans.each do |span|
322
+ changes << [span.start, :start, span.style]
323
+ changes << [span.end, :end, span.style]
324
+ end
325
+ changes.sort_by! { |c| [c[0], c[1] == :end ? 0 : 1] }
326
+
327
+ segments = []
328
+ active_styles = []
329
+ pos = 0
330
+
331
+ changes.each do |change_pos, change_type, style|
332
+ if change_pos > pos
333
+ # Emit segment for text between pos and change_pos
334
+ combined_style = combine_styles(active_styles)
335
+ combined_style = @style + combined_style if @style && combined_style
336
+ combined_style ||= @style
337
+
338
+ segments << Segment.new(@plain[pos...change_pos], style: combined_style)
339
+ end
340
+
341
+ if change_type == :start
342
+ active_styles << style
343
+ else
344
+ active_styles.delete_at(active_styles.rindex(style) || active_styles.length)
345
+ end
346
+
347
+ pos = change_pos
348
+ end
349
+
350
+ # Emit remaining text
351
+ if pos < @plain.length
352
+ combined_style = combine_styles(active_styles)
353
+ combined_style = @style + combined_style if @style && combined_style
354
+ combined_style ||= @style
355
+
356
+ segments << Segment.new(@plain[pos..], style: combined_style)
357
+ end
358
+
359
+ segments
360
+ end
361
+
362
+ # Render text to string with ANSI codes
363
+ # @param color_system [Symbol] Color system
364
+ # @return [String]
365
+ def render(color_system: ColorSystem::TRUECOLOR)
366
+ Segment.render(to_segments, color_system: color_system)
367
+ end
368
+
369
+ # @return [String]
370
+ def to_s
371
+ @plain
372
+ end
373
+
374
+ def inspect
375
+ "#<Rich::Text #{@plain.inspect} spans=#{@spans.length}>"
376
+ end
377
+
378
+ def dup
379
+ new_text = Text.new(@plain.dup, style: @style)
380
+ @spans.each { |span| new_text.spans << span }
381
+ new_text
382
+ end
383
+
384
+ class << self
385
+ # Assemble text from multiple parts
386
+ # @param parts [Array] Alternating text and style pairs
387
+ # @return [Text]
388
+ def assemble(*parts)
389
+ text = Text.new
390
+
391
+ parts.each do |part|
392
+ case part
393
+ when String
394
+ text.append(part)
395
+ when Array
396
+ content, style = part
397
+ text.append(content, style: style)
398
+ when Text
399
+ text.append_text(part)
400
+ end
401
+ end
402
+
403
+ text
404
+ end
405
+
406
+ # Create styled text
407
+ # @param content [String] Text content
408
+ # @param style [String] Style definition
409
+ # @return [Text]
410
+ def styled(content, style)
411
+ text = Text.new(content)
412
+ text.stylize_all(style)
413
+ text
414
+ end
415
+
416
+ # Create from markup
417
+ # @param markup [String] Markup text
418
+ # @return [Text]
419
+ def from_markup(markup)
420
+ Markup.parse(markup)
421
+ end
422
+ end
423
+
424
+ private
425
+
426
+ def combine_styles(styles)
427
+ return nil if styles.empty?
428
+ return styles.first if styles.length == 1
429
+
430
+ styles.reduce { |combined, style| combined + style }
431
+ end
432
+ end
433
+ end
data/lib/rich/tree.rb ADDED
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "style"
4
+ require_relative "segment"
5
+ require_relative "cells"
6
+
7
+ module Rich
8
+ # Tree guide characters for different styles
9
+ module TreeGuide
10
+ ASCII = {
11
+ vertical: "| ",
12
+ branch: "+-- ",
13
+ last: "`-- ",
14
+ space: " "
15
+ }.freeze
16
+
17
+ UNICODE = {
18
+ vertical: "│ ",
19
+ branch: "├── ",
20
+ last: "└── ",
21
+ space: " "
22
+ }.freeze
23
+
24
+ ROUNDED = {
25
+ vertical: "│ ",
26
+ branch: "├── ",
27
+ last: "╰── ",
28
+ space: " "
29
+ }.freeze
30
+
31
+ BOLD = {
32
+ vertical: "┃ ",
33
+ branch: "┣━━ ",
34
+ last: "┗━━ ",
35
+ space: " "
36
+ }.freeze
37
+
38
+ DOUBLE = {
39
+ vertical: "║ ",
40
+ branch: "╠══ ",
41
+ last: "╚══ ",
42
+ space: " "
43
+ }.freeze
44
+ end
45
+
46
+ # A node in a tree structure
47
+ class TreeNode
48
+ # @return [String] Node label
49
+ attr_reader :label
50
+
51
+ # @return [Style, nil] Label style
52
+ attr_reader :style
53
+
54
+ # @return [Array<TreeNode>] Child nodes
55
+ attr_reader :children
56
+
57
+ # @return [Object, nil] Associated data
58
+ attr_reader :data
59
+
60
+ # @return [Boolean] Expanded state
61
+ attr_accessor :expanded
62
+
63
+ def initialize(label, style: nil, data: nil, expanded: true)
64
+ @label = label.to_s
65
+ @style = style.is_a?(String) ? Style.parse(style) : style
66
+ @children = []
67
+ @data = data
68
+ @expanded = expanded
69
+ end
70
+
71
+ # Add a child node
72
+ # @param label [String] Child label
73
+ # @param kwargs [Hash] Node options
74
+ # @return [TreeNode] The new child node
75
+ def add(label, **kwargs)
76
+ child = TreeNode.new(label, **kwargs)
77
+ @children << child
78
+ child
79
+ end
80
+
81
+ # @return [Boolean] True if node has children
82
+ def leaf?
83
+ @children.empty?
84
+ end
85
+
86
+ # @return [Integer] Number of children
87
+ def child_count
88
+ @children.length
89
+ end
90
+
91
+ # @return [Integer] Total descendant count
92
+ def descendant_count
93
+ @children.sum { |c| 1 + c.descendant_count }
94
+ end
95
+
96
+ # Iterate through all descendants
97
+ # @yield [TreeNode, Integer] Each node and its depth
98
+ def each_descendant(depth = 0, &block)
99
+ yield(self, depth)
100
+ @children.each do |child|
101
+ child.each_descendant(depth + 1, &block)
102
+ end
103
+ end
104
+ end
105
+
106
+ # A tree display for hierarchical data
107
+ class Tree
108
+ # @return [TreeNode] Root node
109
+ attr_reader :root
110
+
111
+ # @return [Hash] Guide characters
112
+ attr_reader :guide
113
+
114
+ # @return [Style, nil] Guide style
115
+ attr_reader :guide_style
116
+
117
+ # @return [Boolean] Hide root node
118
+ attr_reader :hide_root
119
+
120
+ def initialize(
121
+ label,
122
+ style: nil,
123
+ guide: TreeGuide::UNICODE,
124
+ guide_style: nil,
125
+ hide_root: false
126
+ )
127
+ @root = TreeNode.new(label, style: style)
128
+ @guide = guide
129
+ @guide_style = guide_style.is_a?(String) ? Style.parse(guide_style) : guide_style
130
+ @hide_root = hide_root
131
+ end
132
+
133
+ # Add a child to root
134
+ # @param label [String] Child label
135
+ # @param kwargs [Hash] Node options
136
+ # @return [TreeNode]
137
+ def add(label, **kwargs)
138
+ @root.add(label, **kwargs)
139
+ end
140
+
141
+ # Render tree to segments
142
+ # @return [Array<Segment>]
143
+ def to_segments
144
+ segments = []
145
+
146
+ unless @hide_root
147
+ segments << Segment.new(@root.label, style: @root.style)
148
+ segments << Segment.new("\n")
149
+ end
150
+
151
+ if @root.expanded
152
+ render_children(@root.children, [], segments)
153
+ end
154
+
155
+ segments
156
+ end
157
+
158
+ # Render to string
159
+ # @param color_system [Symbol] Color system
160
+ # @return [String]
161
+ def render(color_system: ColorSystem::TRUECOLOR)
162
+ Segment.render(to_segments, color_system: color_system)
163
+ end
164
+
165
+ # Build tree from nested hash/array structure
166
+ # @param data [Hash, Array] Nested data
167
+ # @param label [String] Root label
168
+ # @return [Tree]
169
+ def self.from_data(data, label: "root", **kwargs)
170
+ tree = new(label, **kwargs)
171
+ add_data_to_node(tree.root, data)
172
+ tree
173
+ end
174
+
175
+ private
176
+
177
+ def render_children(children, prefix_parts, segments)
178
+ children.each_with_index do |child, index|
179
+ is_last = index == children.length - 1
180
+
181
+ # Build prefix
182
+ prefix = prefix_parts.join
183
+
184
+ # Add guide character
185
+ guide_char = is_last ? @guide[:last] : @guide[:branch]
186
+ segments << Segment.new(prefix, style: @guide_style)
187
+ segments << Segment.new(guide_char, style: @guide_style)
188
+ segments << Segment.new(child.label, style: child.style)
189
+ segments << Segment.new("\n")
190
+
191
+ # Recurse for children
192
+ if child.expanded && !child.children.empty?
193
+ new_part = is_last ? @guide[:space] : @guide[:vertical]
194
+ render_children(child.children, prefix_parts + [new_part], segments)
195
+ end
196
+ end
197
+ end
198
+
199
+ def self.add_data_to_node(node, data)
200
+ case data
201
+ when Hash
202
+ data.each do |key, value|
203
+ child = node.add(key.to_s)
204
+ add_data_to_node(child, value)
205
+ end
206
+ when Array
207
+ data.each_with_index do |item, index|
208
+ if item.is_a?(Hash) || item.is_a?(Array)
209
+ child = node.add("[#{index}]")
210
+ add_data_to_node(child, item)
211
+ else
212
+ node.add(item.to_s)
213
+ end
214
+ end
215
+ else
216
+ node.add(data.to_s) unless data.nil?
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rich
4
+ VERSION = "1.0.0"
5
+ end