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