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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -2
- data/README.md +258 -97
- data/lib/shellfie/animation_frame_builder.rb +31 -7
- data/lib/shellfie/animation_timeline.rb +2 -1
- data/lib/shellfie/ansi_line_buffer.rb +20 -4
- data/lib/shellfie/ansi_normalizer.rb +18 -8
- data/lib/shellfie/ansi_parser.rb +120 -7
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +65 -2
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +244 -40
- data/lib/shellfie/cli_info.rb +106 -6
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +5 -1
- data/lib/shellfie/config_defaults.rb +21 -2
- data/lib/shellfie/config_validation.rb +93 -4
- data/lib/shellfie/dependency_checker.rb +74 -3
- data/lib/shellfie/errors.rb +1 -0
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +11 -1
- data/lib/shellfie/gif_generator.rb +157 -11
- data/lib/shellfie/gif_palette.rb +8 -4
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/line_layout.rb +19 -10
- data/lib/shellfie/output_writer.rb +7 -2
- data/lib/shellfie/parser.rb +82 -19
- data/lib/shellfie/parser_validation.rb +48 -15
- data/lib/shellfie/render_geometry.rb +2 -1
- data/lib/shellfie/render_segment.rb +17 -5
- data/lib/shellfie/renderer.rb +28 -11
- data/lib/shellfie/rendering/text_painter.rb +23 -15
- data/lib/shellfie/rendering/window_chrome.rb +2 -2
- data/lib/shellfie/reproducibility_manifest.rb +41 -0
- data/lib/shellfie/session.rb +111 -0
- data/lib/shellfie/session_config.rb +562 -0
- data/lib/shellfie/session_runner.rb +689 -0
- data/lib/shellfie/svg_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +74 -15
- data/lib/shellfie/transcript_renderer.rb +92 -0
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie/yaml_safety.rb +147 -0
- data/lib/shellfie.rb +8 -1
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +27 -24
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/docs/.nojekyll +0 -0
- data/docs/index.html +0 -205
- data/docs/scripts.js +0 -85
- data/docs/styles.css +0 -507
- data/examples/animation.yml +0 -33
- data/examples/colored.yml +0 -20
- data/examples/demo.gif +0 -0
- data/examples/demo.png +0 -0
- data/examples/demo_animation.yml +0 -31
- data/examples/headless.png +0 -0
- data/examples/headless.yml +0 -16
- data/examples/scrolling.yml +0 -48
- data/examples/simple.yml +0 -21
- data/examples/theme_macos.png +0 -0
- data/examples/theme_ubuntu.png +0 -0
- data/examples/theme_windows.png +0 -0
- 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
|