shellfie 1.0.0 → 1.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -2
  3. data/README.md +258 -97
  4. data/lib/shellfie/animation_frame_builder.rb +31 -7
  5. data/lib/shellfie/animation_timeline.rb +2 -1
  6. data/lib/shellfie/ansi_line_buffer.rb +20 -4
  7. data/lib/shellfie/ansi_normalizer.rb +18 -8
  8. data/lib/shellfie/ansi_parser.rb +120 -7
  9. data/lib/shellfie/cassette.rb +76 -0
  10. data/lib/shellfie/cli.rb +65 -2
  11. data/lib/shellfie/cli_authoring.rb +233 -0
  12. data/lib/shellfie/cli_generate.rb +244 -40
  13. data/lib/shellfie/cli_info.rb +106 -6
  14. data/lib/shellfie/cli_run.rb +167 -0
  15. data/lib/shellfie/config.rb +5 -1
  16. data/lib/shellfie/config_defaults.rb +21 -2
  17. data/lib/shellfie/config_validation.rb +93 -4
  18. data/lib/shellfie/dependency_checker.rb +74 -3
  19. data/lib/shellfie/errors.rb +1 -0
  20. data/lib/shellfie/ffmpeg_encoder.rb +46 -0
  21. data/lib/shellfie/font_resolver.rb +11 -1
  22. data/lib/shellfie/gif_generator.rb +157 -11
  23. data/lib/shellfie/gif_palette.rb +8 -4
  24. data/lib/shellfie/html_renderer.rb +54 -0
  25. data/lib/shellfie/line_layout.rb +19 -10
  26. data/lib/shellfie/output_writer.rb +7 -2
  27. data/lib/shellfie/parser.rb +82 -19
  28. data/lib/shellfie/parser_validation.rb +48 -15
  29. data/lib/shellfie/render_geometry.rb +2 -1
  30. data/lib/shellfie/render_segment.rb +17 -5
  31. data/lib/shellfie/renderer.rb +28 -11
  32. data/lib/shellfie/rendering/text_painter.rb +23 -15
  33. data/lib/shellfie/rendering/window_chrome.rb +2 -2
  34. data/lib/shellfie/reproducibility_manifest.rb +41 -0
  35. data/lib/shellfie/session.rb +111 -0
  36. data/lib/shellfie/session_config.rb +562 -0
  37. data/lib/shellfie/session_runner.rb +689 -0
  38. data/lib/shellfie/svg_renderer.rb +222 -0
  39. data/lib/shellfie/terminal_screen.rb +389 -0
  40. data/lib/shellfie/text_metrics.rb +74 -15
  41. data/lib/shellfie/transcript_renderer.rb +92 -0
  42. data/lib/shellfie/version.rb +1 -1
  43. data/lib/shellfie/yaml_safety.rb +147 -0
  44. data/lib/shellfie.rb +8 -1
  45. data/schema/shellfie-v1.schema.json +153 -0
  46. data/schema/shellfie-v2.schema.json +262 -0
  47. metadata +27 -24
  48. data/.rspec +0 -3
  49. data/Rakefile +0 -8
  50. data/docs/.nojekyll +0 -0
  51. data/docs/index.html +0 -205
  52. data/docs/scripts.js +0 -85
  53. data/docs/styles.css +0 -507
  54. data/examples/animation.yml +0 -33
  55. data/examples/colored.yml +0 -20
  56. data/examples/demo.gif +0 -0
  57. data/examples/demo.png +0 -0
  58. data/examples/demo_animation.yml +0 -31
  59. data/examples/headless.png +0 -0
  60. data/examples/headless.yml +0 -16
  61. data/examples/scrolling.yml +0 -48
  62. data/examples/simple.yml +0 -21
  63. data/examples/theme_macos.png +0 -0
  64. data/examples/theme_ubuntu.png +0 -0
  65. data/examples/theme_windows.png +0 -0
  66. data/shellfie.gemspec +0 -32
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+ require_relative "text_metrics"
5
+
6
+ module Shellfie
7
+ class SvgRenderer
8
+ def initialize(config:, theme:)
9
+ @config = config
10
+ @theme = theme
11
+ end
12
+
13
+ def render(geometry, output_path, transparent: false)
14
+ File.write(output_path, to_svg(geometry, transparent: transparent))
15
+ end
16
+
17
+ def to_svg(geometry, transparent: false)
18
+ document(geometry, transparent: transparent)
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :config, :theme
24
+
25
+ def document(geometry, transparent:)
26
+ <<~SVG
27
+ <?xml version="1.0" encoding="UTF-8"?>
28
+ <svg xmlns="http://www.w3.org/2000/svg" width="#{geometry[:canvas_width]}" height="#{geometry[:canvas_height]}" viewBox="0 0 #{geometry[:canvas_width]} #{geometry[:canvas_height]}" role="img" aria-labelledby="shellfie-title shellfie-description">
29
+ <title id="shellfie-title">#{escape(config.title)}</title>
30
+ <desc id="shellfie-description">Terminal session rendered by Shellfie</desc>
31
+ #{definitions(geometry)}
32
+ #{canvas(geometry, transparent)}
33
+ #{window(geometry)}
34
+ #{title_bar(geometry) unless config.headless}
35
+ #{content(geometry)}
36
+ </svg>
37
+ SVG
38
+ end
39
+
40
+ def definitions(geometry)
41
+ gradient = config.window[:background_gradient]
42
+ gradient_definition = if gradient
43
+ <<~SVG
44
+ <linearGradient id="background" x1="0" y1="0" x2="0" y2="1">
45
+ <stop offset="0" stop-color="#{escape(gradient[0])}"/>
46
+ <stop offset="1" stop-color="#{escape(gradient[1])}"/>
47
+ </linearGradient>
48
+ SVG
49
+ end
50
+ shadow = theme.window_decoration[:shadow]
51
+ shadow_definition = if geometry[:shadow]
52
+ <<~SVG
53
+ <filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
54
+ <feDropShadow dx="#{shadow[:offset_x] * geometry[:scale]}" dy="#{shadow[:offset_y] * geometry[:scale]}" stdDeviation="#{shadow[:blur] * geometry[:scale] / 2.0}" flood-color="#{escape(shadow[:color])}"/>
55
+ </filter>
56
+ SVG
57
+ end
58
+ <<~SVG
59
+ <defs>
60
+ <style>@keyframes shellfie-blink { 50% { opacity: 0; } } .blink { animation: shellfie-blink 1s step-end infinite; } @media (prefers-reduced-motion: reduce) { .blink { animation: none; } }</style>
61
+ #{gradient_definition}
62
+ #{shadow_definition}
63
+ <clipPath id="content-clip"><rect x="#{content_x(geometry)}" y="#{content_y(geometry)}" width="#{content_width(geometry)}" height="#{content_height(geometry)}"/></clipPath>
64
+ </defs>
65
+ SVG
66
+ end
67
+
68
+ def canvas(geometry, transparent)
69
+ return "" if transparent
70
+
71
+ fill = config.window[:background_gradient] ? "url(#background)" : theme.colors[:background]
72
+ %(<rect width="100%" height="100%" fill="#{escape(fill)}"/>)
73
+ end
74
+
75
+ def window(geometry)
76
+ attrs = [
77
+ %(x="#{geometry[:margin]}"), %(y="#{geometry[:margin]}"), %(width="#{geometry[:scaled_width]}"),
78
+ %(height="#{geometry[:scaled_height]}"), %(rx="#{geometry[:scaled_radius]}"),
79
+ %(fill="#{escape(theme.colors[:background])}"), %(fill-opacity="#{config.window[:opacity]}")
80
+ ]
81
+ attrs << %(stroke="#{escape(theme.colors[:border])}") if theme.colors[:border]
82
+ attrs << %(filter="url(#shadow)") if geometry[:shadow]
83
+ "<rect #{attrs.join(" ")}/>"
84
+ end
85
+
86
+ def title_bar(geometry)
87
+ margin = geometry[:margin]
88
+ width = geometry[:scaled_width]
89
+ height = geometry[:scaled_title_bar]
90
+ radius = geometry[:scaled_radius]
91
+ separator = theme.colors[:title_bar_border]
92
+ <<~SVG
93
+ <g>
94
+ <path d="M #{margin + radius} #{margin} H #{margin + width - radius} Q #{margin + width} #{margin} #{margin + width} #{margin + radius} V #{margin + height} H #{margin} V #{margin + radius} Q #{margin} #{margin} #{margin + radius} #{margin} Z" fill="#{escape(theme.colors[:title_bar])}"/>
95
+ #{%(<line x1="#{margin}" y1="#{margin + height}" x2="#{margin + width}" y2="#{margin + height}" stroke="#{escape(separator)}"/>) if separator}
96
+ #{buttons(geometry)}
97
+ #{title(geometry)}
98
+ </g>
99
+ SVG
100
+ end
101
+
102
+ def buttons(geometry)
103
+ return windows_buttons(geometry) if theme.button_style == :icons
104
+
105
+ scale = geometry[:scale]
106
+ size = theme.window_decoration[:button_size] * scale
107
+ spacing = (theme.window_decoration[:button_spacing] + theme.window_decoration[:button_size]) * scale
108
+ y = geometry[:margin] + geometry[:scaled_title_bar] / 2
109
+ start_x = theme.buttons_position == :left ? geometry[:margin] + 16 * scale : geometry[:margin] + geometry[:scaled_width] - 16 * scale - size * 2 - spacing * 2
110
+ theme.button_colors.each_with_index.map do |color, index|
111
+ %(<circle cx="#{start_x + index * spacing}" cy="#{y}" r="#{size / 2.0}" fill="#{escape(color)}" stroke="rgba(0,0,0,0.18)"/>)
112
+ end.join("\n")
113
+ end
114
+
115
+ def windows_buttons(geometry)
116
+ scale = geometry[:scale]
117
+ button_width = (theme.window_decoration[:button_width] || 46) * scale
118
+ start_x = geometry[:margin] + geometry[:scaled_width] - button_width * 3
119
+ y = geometry[:margin] + geometry[:scaled_title_bar] / 2
120
+ size = 5 * scale
121
+ color = escape(theme.colors[:title_text])
122
+ [
123
+ %(<line x1="#{start_x + button_width / 2 - size}" y1="#{y}" x2="#{start_x + button_width / 2 + size}" y2="#{y}" stroke="#{color}"/>),
124
+ %(<rect x="#{start_x + button_width * 1.5 - size}" y="#{y - size}" width="#{size * 2}" height="#{size * 2}" fill="none" stroke="#{color}"/>),
125
+ %(<path d="M #{start_x + button_width * 2.5 - size} #{y - size} L #{start_x + button_width * 2.5 + size} #{y + size} M #{start_x + button_width * 2.5 + size} #{y - size} L #{start_x + button_width * 2.5 - size} #{y + size}" stroke="#{color}"/>)
126
+ ].join("\n")
127
+ end
128
+
129
+ def title(geometry)
130
+ font_size = geometry[:scaled_font_size]
131
+ text = escape(config.title)
132
+ x = geometry[:margin] + geometry[:scaled_width] / 2
133
+ y = geometry[:margin] + geometry[:scaled_title_bar] / 2 + font_size * 0.35
134
+ anchor = "middle"
135
+ if theme.title_alignment == :left
136
+ x = geometry[:margin] + 16 * geometry[:scale]
137
+ anchor = "start"
138
+ elsif theme.title_alignment == :right
139
+ x = geometry[:margin] + geometry[:scaled_width] - 16 * geometry[:scale]
140
+ anchor = "end"
141
+ end
142
+ %(<text x="#{x}" y="#{y}" text-anchor="#{anchor}" #{font_attributes(geometry[:font_config], font_size)} fill="#{escape(theme.colors[:title_text])}">#{text}</text>)
143
+ end
144
+
145
+ def content(geometry)
146
+ y = content_y(geometry) - (geometry[:scroll_offset] * geometry[:scaled_line_height]).round
147
+ lines = geometry[:lines].each_with_index.map do |line, index|
148
+ line_svg(line, geometry, y + index * geometry[:scaled_line_height])
149
+ end
150
+ %(<g clip-path="url(#content-clip)">#{lines.join("\n")}</g>)
151
+ end
152
+
153
+ def line_svg(line, geometry, top)
154
+ selected = if line[:selected]
155
+ %(<rect x="#{content_x(geometry)}" y="#{top}" width="#{content_width(geometry)}" height="#{geometry[:scaled_line_height]}" fill="#{escape(theme.colors[:selection])}"/>)
156
+ end
157
+ x = content_x(geometry)
158
+ segments = line[:segments].map do |segment|
159
+ width = TextMetrics.pixel_width(
160
+ segment.text, geometry[:scaled_font_size], ambiguous_width: geometry[:ambiguous_width]
161
+ )
162
+ svg = segment_svg(segment, x, top, width, geometry)
163
+ x += width
164
+ svg
165
+ end
166
+ [selected, *segments].compact.join("\n")
167
+ end
168
+
169
+ def segment_svg(segment, x, top, width, geometry)
170
+ foreground = segment.foreground ? theme.color_for(segment.foreground) : theme.colors[:foreground]
171
+ background = segment.background ? theme.color_for(segment.background) : nil
172
+ foreground, background = background || theme.colors[:background], foreground if segment.reverse
173
+ opacity = if segment.conceal
174
+ %( fill-opacity="0")
175
+ elsif segment.dim
176
+ %( fill-opacity="0.6")
177
+ else
178
+ ""
179
+ end
180
+ decoration = []
181
+ decoration << "underline" if segment.underline
182
+ decoration << "line-through" if segment.strikethrough
183
+ decoration << "overline" if segment.overline
184
+ background_svg = %(<rect x="#{x}" y="#{top}" width="#{width}" height="#{geometry[:scaled_line_height]}" fill="#{escape(background)}"/>) if background
185
+ underline_style = segment.underline_style == :curly ? :wavy : segment.underline_style
186
+ decoration_style = %( text-decoration-style="#{underline_style}") if underline_style && underline_style != :single
187
+ decoration_color = %( text-decoration-color="#{escape(theme.color_for(segment.underline_color))}") if segment.underline_color
188
+ blink = segment.blink ? %( class="blink") : ""
189
+ text = %(<text x="#{x}" y="#{top + geometry[:scaled_font_size]}" xml:space="preserve" #{font_attributes(geometry[:font_config], geometry[:scaled_font_size], segment)} fill="#{escape(foreground)}"#{opacity}#{blink}#{%( text-decoration="#{decoration.join(" ")}") unless decoration.empty?}#{decoration_style}#{decoration_color}>#{escape(segment.text)}</text>)
190
+ text = %(<a href="#{escape(segment.link)}">#{text}</a>) if segment.link
191
+ [background_svg, text].compact.join("\n")
192
+ end
193
+
194
+ def font_attributes(font, size, segment = nil)
195
+ family = [font[:family], font[:fallback_family], font[:emoji_family]].compact.join(", ")
196
+ attrs = [%(font-family="#{escape(family)}"), %(font-size="#{size}")]
197
+ attrs << %(font-weight="700") if segment&.bold
198
+ attrs << %(font-style="italic") if segment&.italic
199
+ attrs.join(" ")
200
+ end
201
+
202
+ def content_x(geometry)
203
+ geometry[:margin] + geometry[:scaled_padding]
204
+ end
205
+
206
+ def content_y(geometry)
207
+ geometry[:margin] + geometry[:scaled_title_bar] + geometry[:scaled_padding]
208
+ end
209
+
210
+ def content_width(geometry)
211
+ [geometry[:scaled_width] - geometry[:scaled_padding] * 2, 1].max
212
+ end
213
+
214
+ def content_height(geometry)
215
+ [geometry[:scaled_height] - geometry[:scaled_title_bar] - geometry[:scaled_padding] * 2, 1].max
216
+ end
217
+
218
+ def escape(value)
219
+ CGI.escapeHTML(value.to_s)
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,389 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "text_metrics"
4
+ require_relative "ansi_parser"
5
+
6
+ module Shellfie
7
+ class TerminalScreen
8
+ CSI = /\A\e\[([0-9;:?]*)([ -\/]*)?([@-~])\z/
9
+ STRING_CONTROL = /\e(?:\]|P|_|\^).*?(?:\a|\e\\)/m
10
+ TOKENS = /#{STRING_CONTROL}|\e\[[0-9;:?]*[ -\/]*[@-~]|\e[78DEM]|[\r\n\b\t\a]|\X/m
11
+ MAX_PENDING_CONTROL_BYTES = 1_048_576
12
+ CONTINUATION = Object.new.freeze
13
+
14
+ attr_reader :row, :column, :rows, :columns
15
+
16
+ def initialize(columns: 80, rows: 24, tab_width: 8, graphics_policy: "ignore")
17
+ @columns = columns
18
+ @rows = rows
19
+ @tab_width = tab_width
20
+ @graphics_policy = graphics_policy
21
+ @cells = Array.new(rows) { Array.new(columns) }
22
+ @row = 0
23
+ @column = 0
24
+ @saved_cursor = [0, 0]
25
+ @scroll_top = 0
26
+ @scroll_bottom = rows - 1
27
+ @primary_state = nil
28
+ @pending_control = +""
29
+ @discarding_control = nil
30
+ @discard_control_tail = +""
31
+ @wrap_pending = false
32
+ @ansi_parser = AnsiParser.new
33
+ end
34
+
35
+ def feed(text)
36
+ input = text.to_s.dup.force_encoding(Encoding::UTF_8).scrub
37
+ input = discard_control_prefix(input)
38
+ return self if input.empty? && @discarding_control
39
+
40
+ input = @pending_control << input
41
+ if @graphics_policy == "error" && input.match?(AnsiNormalizer::GRAPHICS_CONTROL_PREFIX)
42
+ raise ValidationError, "Terminal graphics are not supported; use window.graphics_policy: ignore to discard them"
43
+ end
44
+ input, @pending_control = split_incomplete_control(input)
45
+ input.scan(TOKENS).each { |token| process(token) }
46
+ self
47
+ end
48
+
49
+ def lines
50
+ visible_rows.map { |cells| cells.reject { |cell| cell.equal?(CONTINUATION) }.map { |cell| cell&.text || " " }.join.rstrip }
51
+ end
52
+
53
+ def render_lines
54
+ visible_rows.map do |cells|
55
+ visible = cells.reject { |cell| cell.equal?(CONTINUATION) }
56
+ last = visible.rindex { |cell| cell && cell.text != " " }
57
+ next "" unless last
58
+
59
+ visible[0..last].chunk { |cell| style_key(cell) }.map do |_style, group|
60
+ cells_in_style = group.to_a
61
+ text = cells_in_style.map { |cell| cell&.text || " " }.join
62
+ styled_cell(cells_in_style.first, text)
63
+ end.join
64
+ end
65
+ end
66
+
67
+ def to_s
68
+ lines.join("\n")
69
+ end
70
+
71
+ private
72
+
73
+ def process(token)
74
+ return if token.match?(/\A\e(?:\]|P|_|\^)/)
75
+ match = CSI.match(token)
76
+ @ansi_parser.parse(token) if match && match[3] == "m"
77
+ if match
78
+ @wrap_pending = false unless match[3] == "m"
79
+ return process_csi(match[1], match[3])
80
+ end
81
+
82
+ case token
83
+ when "\r" then @column = 0; @wrap_pending = false
84
+ when "\n" then newline(reset_column: false)
85
+ when "\b" then @column = [@column - 1, 0].max; @wrap_pending = false
86
+ when "\t"
87
+ @column = [@column + @tab_width - (@column % @tab_width), @columns - 1].min
88
+ @wrap_pending = false
89
+ when "\a" then nil
90
+ when "\e7" then @saved_cursor = [@row, @column]
91
+ when "\e8" then @row, @column = @saved_cursor
92
+ when "\eD" then newline(reset_column: false)
93
+ when "\eE" then newline
94
+ when "\eM" then reverse_index
95
+ else write(token)
96
+ end
97
+ end
98
+
99
+ def process_csi(params, command)
100
+ private_mode = params.start_with?("?")
101
+ values = params.delete_prefix("?").split(";").map { |value| Integer(value, exception: false) || 0 }
102
+ amount = values.first.to_i.positive? ? values.first : 1
103
+ case command
104
+ when "A" then @row = [@row - amount, 0].max
105
+ when "B" then @row = [@row + amount, @rows - 1].min
106
+ when "C" then @column = [@column + amount, @columns - 1].min
107
+ when "D" then @column = [@column - amount, 0].max
108
+ when "E" then @row = [@row + amount, @rows - 1].min; @column = 0
109
+ when "F" then @row = [@row - amount, 0].max; @column = 0
110
+ when "G" then @column = [[amount - 1, 0].max, @columns - 1].min
111
+ when "H", "f" then position(values)
112
+ when "J" then erase_display(values.first || 0)
113
+ when "K" then erase_line(values.first || 0)
114
+ when "@" then insert_characters(amount)
115
+ when "P" then delete_characters(amount)
116
+ when "L" then insert_lines(amount)
117
+ when "M" then delete_lines(amount)
118
+ when "s" then @saved_cursor = [@row, @column]
119
+ when "u" then @row, @column = @saved_cursor
120
+ when "r" then set_scroll_region(values)
121
+ when "h" then enter_alternate_screen if private_mode && (values & [47, 1047, 1049]).any?
122
+ when "l" then leave_alternate_screen if private_mode && (values & [47, 1047, 1049]).any?
123
+ end
124
+ end
125
+
126
+ def write(grapheme)
127
+ return if extend_previous_grapheme(grapheme)
128
+
129
+ width = TextMetrics.grapheme_width(grapheme)
130
+ return if width.zero?
131
+ newline if @wrap_pending
132
+ newline if @column + width > @columns
133
+
134
+ clear_wide_cell(@row, @column)
135
+ @cells[@row][@column] = @ansi_parser.parse(grapheme).first
136
+ @cells[@row][@column + 1] = CONTINUATION if width == 2 && @column + 1 < @columns
137
+ @column += width
138
+ if @column >= @columns
139
+ @column = @columns - 1
140
+ @wrap_pending = true
141
+ end
142
+ end
143
+
144
+ def extend_previous_grapheme(grapheme)
145
+ return false if @column.zero?
146
+
147
+ owner_column = @wrap_pending ? @column : @column - 1
148
+ owner_column -= 1 if @cells[@row][owner_column].equal?(CONTINUATION)
149
+ owner = @cells[@row][owner_column]
150
+ return false unless owner && TextMetrics.graphemes(owner.text + grapheme).size == 1
151
+
152
+ old_width = TextMetrics.grapheme_width(owner.text)
153
+ owner.text << grapheme
154
+ new_width = TextMetrics.grapheme_width(owner.text)
155
+ if old_width == 1 && new_width == 2 && owner_column + 1 < @columns
156
+ @cells[@row][owner_column + 1] = CONTINUATION
157
+ @column += 1
158
+ if @column >= @columns
159
+ @column = @columns - 1
160
+ @wrap_pending = true
161
+ end
162
+ end
163
+ true
164
+ end
165
+
166
+ def newline(reset_column: true)
167
+ @wrap_pending = false
168
+ if @row == @scroll_bottom
169
+ @cells.delete_at(@scroll_top)
170
+ @cells.insert(@scroll_bottom, Array.new(@columns))
171
+ @column = 0 if reset_column
172
+ return
173
+ end
174
+
175
+ if @row == @rows - 1
176
+ @column = 0 if reset_column
177
+ return
178
+ end
179
+
180
+ @row += 1
181
+ @column = 0 if reset_column
182
+ end
183
+
184
+ def reverse_index
185
+ if @row == @scroll_top
186
+ @cells.insert(@scroll_top, Array.new(@columns))
187
+ @cells.delete_at(@scroll_bottom + 1)
188
+ else
189
+ @row = [@row - 1, 0].max
190
+ end
191
+ end
192
+
193
+ def set_scroll_region(values)
194
+ top = (values[0].to_i.nonzero? || 1) - 1
195
+ bottom = (values[1].to_i.nonzero? || @rows) - 1
196
+ return unless top.between?(0, @rows - 1) && bottom.between?(0, @rows - 1) && top < bottom
197
+
198
+ @scroll_top = top
199
+ @scroll_bottom = bottom
200
+ @row = 0
201
+ @column = 0
202
+ end
203
+
204
+ def enter_alternate_screen
205
+ return if @primary_state
206
+
207
+ @primary_state = [@cells, @row, @column, @saved_cursor, @scroll_top, @scroll_bottom, @wrap_pending]
208
+ @cells = Array.new(@rows) { Array.new(@columns) }
209
+ @row = @column = @scroll_top = 0
210
+ @wrap_pending = false
211
+ @scroll_bottom = @rows - 1
212
+ @saved_cursor = [0, 0]
213
+ end
214
+
215
+ def leave_alternate_screen
216
+ return unless @primary_state
217
+
218
+ @cells, @row, @column, @saved_cursor, @scroll_top, @scroll_bottom, @wrap_pending = @primary_state
219
+ @primary_state = nil
220
+ end
221
+
222
+ def position(values)
223
+ @row = [[(values[0].to_i.nonzero? || 1) - 1, 0].max, @rows - 1].min
224
+ @column = [[(values[1].to_i.nonzero? || 1) - 1, 0].max, @columns - 1].min
225
+ end
226
+
227
+ def erase_display(mode)
228
+ case mode
229
+ when 1
230
+ (0...@row).each { |index| @cells[index] = Array.new(@columns) }
231
+ erase_line(1)
232
+ when 2, 3
233
+ @cells = Array.new(@rows) { Array.new(@columns) }
234
+ else
235
+ erase_line(0)
236
+ (@row + 1...@rows).each { |index| @cells[index] = Array.new(@columns) }
237
+ end
238
+ end
239
+
240
+ def erase_line(mode)
241
+ range = case mode
242
+ when 1 then 0..@column
243
+ when 2 then 0...@columns
244
+ else @column...@columns
245
+ end
246
+ range.each do |index|
247
+ clear_wide_cell(@row, index)
248
+ @cells[@row][index] = nil
249
+ end
250
+ end
251
+
252
+ def insert_characters(amount)
253
+ clear_wide_cell(@row, @column)
254
+ amount.times { @cells[@row].insert(@column, nil) }
255
+ @cells[@row] = @cells[@row].first(@columns)
256
+ repair_wide_cells(@row)
257
+ end
258
+
259
+ def delete_characters(amount)
260
+ (@column...[@column + amount, @columns].min).each { |index| clear_wide_cell(@row, index) }
261
+ @cells[@row].slice!(@column, amount)
262
+ @cells[@row].concat(Array.new(amount)).slice!(@columns..)
263
+ repair_wide_cells(@row)
264
+ end
265
+
266
+ def insert_lines(amount)
267
+ return unless @row.between?(@scroll_top, @scroll_bottom)
268
+
269
+ [amount, @scroll_bottom - @row + 1].min.times do
270
+ @cells.insert(@row, Array.new(@columns))
271
+ @cells.delete_at(@scroll_bottom + 1)
272
+ end
273
+ end
274
+
275
+ def delete_lines(amount)
276
+ return unless @row.between?(@scroll_top, @scroll_bottom)
277
+
278
+ [amount, @scroll_bottom - @row + 1].min.times do
279
+ @cells.delete_at(@row)
280
+ @cells.insert(@scroll_bottom, Array.new(@columns))
281
+ end
282
+ end
283
+
284
+ def clear_wide_cell(row, column)
285
+ @cells[row][column - 1] = nil if column.positive? && @cells[row][column].equal?(CONTINUATION)
286
+ @cells[row][column + 1] = nil if @cells[row][column + 1].equal?(CONTINUATION)
287
+ end
288
+
289
+ def repair_wide_cells(row)
290
+ @cells[row].each_with_index do |cell, column|
291
+ if cell.equal?(CONTINUATION)
292
+ owner = column.positive? && @cells[row][column - 1]
293
+ @cells[row][column] = nil unless owner && TextMetrics.grapheme_width(owner.text) == 2
294
+ elsif cell && TextMetrics.grapheme_width(cell.text) == 2 && !@cells[row][column + 1].equal?(CONTINUATION)
295
+ @cells[row][column] = nil
296
+ end
297
+ end
298
+ end
299
+
300
+ def split_incomplete_control(text)
301
+ starts = ["\e]", "\eP", "\e_", "\e^"].filter_map { |prefix| text.rindex(prefix) }
302
+ if (start = starts.max) && !text[start..].match?(/\A#{STRING_CONTROL}/)
303
+ tail = text[start..]
304
+ if tail.bytesize > MAX_PENDING_CONTROL_BYTES
305
+ @discarding_control = :string
306
+ return [text[0...start], +""]
307
+ end
308
+ return [text[0...start], tail]
309
+ end
310
+ if (start = text.rindex("\e[")) && text[start..].match?(/\A\e\[[0-9;:?]*[ -\/]*\z/)
311
+ tail = text[start..]
312
+ if tail.bytesize > MAX_PENDING_CONTROL_BYTES
313
+ @discarding_control = :csi
314
+ return [text[0...start], +""]
315
+ end
316
+ return [text[0...start], tail]
317
+ end
318
+ return [text[0...-1], +"\e"] if text.end_with?("\e")
319
+
320
+ [text, +""]
321
+ end
322
+
323
+ def discard_control_prefix(text)
324
+ return text unless @discarding_control
325
+
326
+ text = @discard_control_tail << text
327
+ @discard_control_tail = +""
328
+ finish = if @discarding_control == :csi
329
+ final = text.index(/[@-~]/)
330
+ final && final + 1
331
+ else
332
+ bell = text.index("\a")
333
+ st = text.index("\e\\")
334
+ [bell && bell + 1, st && st + 2].compact.min
335
+ end
336
+ unless finish
337
+ @discard_control_tail = +"\e" if @discarding_control == :string && text.end_with?("\e")
338
+ return ""
339
+ end
340
+
341
+ @discarding_control = nil
342
+ text[finish..].to_s
343
+ end
344
+
345
+ def visible_rows
346
+ last = @cells.rindex { |cells| cells.any? { |cell| cell && !cell.equal?(CONTINUATION) } } || 0
347
+ @cells[0..last]
348
+ end
349
+
350
+ def styled_cell(cell, text = cell&.text || " ")
351
+ return text unless cell
352
+
353
+ codes = []
354
+ codes << 1 if cell.bold
355
+ codes << 2 if cell.dim
356
+ codes << 3 if cell.italic
357
+ if cell.underline
358
+ underline_code = { double: 21, curly: "4:3", dotted: "4:4", dashed: "4:5" }.fetch(cell.underline_style, 4)
359
+ codes << underline_code
360
+ end
361
+ codes.concat(color_codes(cell.underline_color, 58)) if cell.underline_color
362
+ codes << 5 if cell.blink
363
+ codes << 8 if cell.conceal
364
+ codes << 7 if cell.reverse
365
+ codes << 9 if cell.strikethrough
366
+ codes << 53 if cell.overline
367
+ codes.concat(color_codes(cell.foreground, 38)) if cell.foreground
368
+ codes.concat(color_codes(cell.background, 48)) if cell.background
369
+ return text if codes.empty?
370
+
371
+ "\e[#{codes.join(";")}m#{text}\e[0m"
372
+ end
373
+
374
+ def style_key(cell)
375
+ return nil unless cell
376
+
377
+ [cell.foreground, cell.background, cell.bold, cell.italic, cell.underline, cell.underline_style,
378
+ cell.underline_color, cell.dim, cell.reverse, cell.strikethrough, cell.overline, cell.blink, cell.conceal]
379
+ end
380
+
381
+ def color_codes(color, prefix)
382
+ standard = prefix == 38 ? AnsiColors::COLORS.key(color) : AnsiColors::BG_COLORS.key(color)
383
+ return [standard] if standard
384
+ return [] unless color.match?(/\A#[0-9a-fA-F]{6}\z/)
385
+
386
+ [prefix, 2, color[1, 2].to_i(16), color[3, 2].to_i(16), color[5, 2].to_i(16)]
387
+ end
388
+ end
389
+ end