shellfie 0.1.1 → 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 +248 -228
- data/lib/shellfie/animation_frame_builder.rb +202 -0
- data/lib/shellfie/animation_scroll_easing.rb +77 -0
- data/lib/shellfie/animation_timeline.rb +28 -0
- data/lib/shellfie/ansi_colors.rb +94 -0
- data/lib/shellfie/ansi_line_buffer.rb +103 -0
- data/lib/shellfie/ansi_normalizer.rb +61 -0
- data/lib/shellfie/ansi_parser.rb +162 -83
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +75 -163
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +401 -0
- data/lib/shellfie/cli_info.rb +239 -0
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +112 -25
- data/lib/shellfie/config_defaults.rb +83 -0
- data/lib/shellfie/config_validation.rb +289 -0
- data/lib/shellfie/dependency_checker.rb +147 -0
- data/lib/shellfie/errors.rb +12 -1
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +68 -0
- data/lib/shellfie/format_resolver.rb +15 -0
- data/lib/shellfie/gif_generator.rb +231 -89
- data/lib/shellfie/gif_palette.rb +105 -0
- data/lib/shellfie/headless_theme_registry.rb +42 -0
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/image_magick_command_builder.rb +75 -0
- data/lib/shellfie/line_layout.rb +146 -0
- data/lib/shellfie/output_writer.rb +46 -0
- data/lib/shellfie/parser.rb +183 -30
- data/lib/shellfie/parser_validation.rb +178 -0
- data/lib/shellfie/raster_painter.rb +157 -0
- data/lib/shellfie/render_chrome_cache.rb +40 -0
- data/lib/shellfie/render_geometry.rb +115 -0
- data/lib/shellfie/render_segment.rb +71 -0
- data/lib/shellfie/renderer.rb +96 -149
- data/lib/shellfie/rendering/shape_helpers.rb +42 -0
- data/lib/shellfie/rendering/text_painter.rb +195 -0
- data/lib/shellfie/rendering/window_chrome.rb +196 -0
- 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_raster_wrapper.rb +35 -0
- data/lib/shellfie/svg_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +155 -0
- data/lib/shellfie/theme_data.rb +80 -0
- data/lib/shellfie/theme_registry.rb +131 -0
- data/lib/shellfie/themes/base.rb +10 -1
- data/lib/shellfie/themes/configured.rb +61 -0
- data/lib/shellfie/themes/macos.rb +3 -1
- data/lib/shellfie/themes/ubuntu.rb +2 -1
- data/lib/shellfie/themes/windows_terminal.rb +7 -1
- 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 +44 -3
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +58 -20
- data/.rspec +0 -3
- data/Rakefile +0 -8
- 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,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "animation_scroll_easing"
|
|
4
|
+
require_relative "animation_timeline"
|
|
5
|
+
require_relative "text_metrics"
|
|
6
|
+
|
|
7
|
+
module Shellfie
|
|
8
|
+
class AnimationFrameBuilder
|
|
9
|
+
def initialize(config)
|
|
10
|
+
@config = config
|
|
11
|
+
@random = Random.new(config.animation[:seed])
|
|
12
|
+
@scroll_easing = AnimationScrollEasing.new(config)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build
|
|
16
|
+
return [{ lines: @config.lines, delay: @config.animation[:final_delay] }] if @config.frames.empty?
|
|
17
|
+
|
|
18
|
+
frames = []
|
|
19
|
+
current_lines = @config.lines.flat_map { |line| line_data(line) }
|
|
20
|
+
AnimationTimeline.new(@config).each do |event|
|
|
21
|
+
case event.kind
|
|
22
|
+
when :screen
|
|
23
|
+
current_lines.replace(event.frame.screen.map { |line| { output: line } })
|
|
24
|
+
frames << { lines: build_display_lines(current_lines), delay: [event.frame.delay, 1].max }
|
|
25
|
+
when :command
|
|
26
|
+
frames.concat(command_frames(current_lines, event.frame))
|
|
27
|
+
when :output
|
|
28
|
+
frames.concat(output_frames(current_lines, event.frame))
|
|
29
|
+
when :pause
|
|
30
|
+
frames << { lines: build_display_lines(current_lines), delay: event.frame.delay }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if @config.animation[:final_delay].positive?
|
|
35
|
+
frames << { lines: build_display_lines(current_lines), delay: @config.animation[:final_delay] }
|
|
36
|
+
end
|
|
37
|
+
frames
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cursor_text
|
|
41
|
+
glyph = case @config.cursor[:style]
|
|
42
|
+
when "bar"
|
|
43
|
+
"|"
|
|
44
|
+
when "underline"
|
|
45
|
+
"_"
|
|
46
|
+
else
|
|
47
|
+
"█"
|
|
48
|
+
end
|
|
49
|
+
color = @config.cursor[:color]
|
|
50
|
+
return glyph unless color
|
|
51
|
+
|
|
52
|
+
"#{ansi_color(color)}#{glyph}\e[0m"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def command_frames(current_lines, frame)
|
|
58
|
+
prompt = frame.prompt || ""
|
|
59
|
+
frames = build_typing_frames(current_lines.dup, frame)
|
|
60
|
+
current_lines << command_line(prompt, frame.type, prompt_color: frame.prompt_color, command_color: frame.command_color)
|
|
61
|
+
frames.concat(command_pause_frames(current_lines, frame))
|
|
62
|
+
frames
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_typing_frames(base_lines, frame)
|
|
66
|
+
frames = []
|
|
67
|
+
prompt = frame.prompt || ""
|
|
68
|
+
command = frame.type
|
|
69
|
+
chars = TextMetrics.graphemes(command)
|
|
70
|
+
chunk_size = @config.animation[:typing_chunk_size]
|
|
71
|
+
|
|
72
|
+
(chunk_size..chars.length).step(chunk_size).each do |index|
|
|
73
|
+
typed = chars.first(index).join
|
|
74
|
+
frames << typing_frame(base_lines, frame, typed)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
frames << typing_frame(base_lines, frame, command) if chars.length % chunk_size != 0 || frames.empty?
|
|
78
|
+
final_lines = base_lines.dup
|
|
79
|
+
final_lines << command_line(prompt, command, prompt_color: frame.prompt_color, command_color: frame.command_color)
|
|
80
|
+
frames << { lines: build_display_lines(final_lines), delay: @config.animation[:typing_speed] }
|
|
81
|
+
frames
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def typing_frame(base_lines, frame, typed)
|
|
85
|
+
lines = base_lines.dup
|
|
86
|
+
lines << command_line(
|
|
87
|
+
frame.prompt || "",
|
|
88
|
+
typed,
|
|
89
|
+
cursor: true,
|
|
90
|
+
prompt_color: frame.prompt_color,
|
|
91
|
+
command_color: frame.command_color
|
|
92
|
+
)
|
|
93
|
+
{
|
|
94
|
+
lines: build_display_lines(lines),
|
|
95
|
+
delay: jittered_delay(@config.animation[:typing_speed], @config.animation[:typing_jitter])
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def command_pause_frames(current_lines, frame)
|
|
100
|
+
delay = if frame.output
|
|
101
|
+
@config.animation[:command_delay]
|
|
102
|
+
else
|
|
103
|
+
frame.delay.positive? ? frame.delay : @config.animation[:command_delay]
|
|
104
|
+
end
|
|
105
|
+
return [] unless delay.positive?
|
|
106
|
+
return [{ lines: build_display_lines(current_lines), delay: delay }] unless @config.animation[:cursor_blink]
|
|
107
|
+
|
|
108
|
+
half_delay = [delay / 2, 1].max
|
|
109
|
+
[
|
|
110
|
+
{ lines: build_display_lines(current_lines[0...-1] + [cursor_command_line(frame)]), delay: half_delay },
|
|
111
|
+
{ lines: build_display_lines(current_lines), delay: delay - half_delay }
|
|
112
|
+
]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def output_frames(current_lines, frame)
|
|
116
|
+
output_lines = frame.output.to_s.split("\n", -1)
|
|
117
|
+
output_delay = @config.animation[:output_delay]
|
|
118
|
+
|
|
119
|
+
if output_delay.positive?
|
|
120
|
+
frames = output_lines.each_with_index.each_with_object([]) do |(line, index), result|
|
|
121
|
+
previous_count = current_lines.size
|
|
122
|
+
current_lines << { output: line, output_color: frame.output_color }
|
|
123
|
+
delay = @scroll_easing.output_delay(output_delay, index, output_lines.size)
|
|
124
|
+
result.concat(
|
|
125
|
+
@scroll_easing.transition_frames(
|
|
126
|
+
build_display_lines(current_lines),
|
|
127
|
+
delay: delay,
|
|
128
|
+
previous_count: previous_count
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
frames << { lines: build_display_lines(current_lines), delay: frame.delay } if frame.delay.positive?
|
|
133
|
+
frames
|
|
134
|
+
else
|
|
135
|
+
output_lines.each { |line| current_lines << { output: line, output_color: frame.output_color } }
|
|
136
|
+
[{ lines: build_display_lines(current_lines), delay: [frame.delay, 1].max }]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def command_line(prompt, command, cursor: false, prompt_color: nil, command_color: nil)
|
|
141
|
+
{ prompt: prompt, command: command, cursor: cursor, prompt_color: prompt_color, command_color: command_color }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def line_data(line)
|
|
145
|
+
data = []
|
|
146
|
+
if line.prompt || line.command
|
|
147
|
+
data << command_line(
|
|
148
|
+
line.prompt,
|
|
149
|
+
line.command,
|
|
150
|
+
prompt_color: line.prompt_color,
|
|
151
|
+
command_color: line.command_color
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
data.concat(line.output.to_s.split("\n", -1).map { |output| { output: output, output_color: line.output_color } }) if line.output
|
|
155
|
+
data
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def cursor_command_line(frame)
|
|
159
|
+
command_line(
|
|
160
|
+
frame.prompt || "",
|
|
161
|
+
frame.type,
|
|
162
|
+
cursor: true,
|
|
163
|
+
prompt_color: frame.prompt_color,
|
|
164
|
+
command_color: frame.command_color
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def build_display_lines(lines_data)
|
|
169
|
+
lines_data.map do |line_data|
|
|
170
|
+
if line_data[:prompt]
|
|
171
|
+
command = line_data[:command].to_s
|
|
172
|
+
command += cursor_text if line_data[:cursor]
|
|
173
|
+
Line.new(
|
|
174
|
+
prompt: line_data[:prompt],
|
|
175
|
+
command: command,
|
|
176
|
+
output: nil,
|
|
177
|
+
prompt_color: line_data[:prompt_color],
|
|
178
|
+
command_color: line_data[:command_color]
|
|
179
|
+
)
|
|
180
|
+
else
|
|
181
|
+
Line.new(prompt: nil, command: nil, output: line_data[:output], output_color: line_data[:output_color])
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def ansi_color(color)
|
|
187
|
+
return "" unless color.to_s.match?(/\A#[0-9a-fA-F]{6}\z/)
|
|
188
|
+
|
|
189
|
+
r = color[1, 2].to_i(16)
|
|
190
|
+
g = color[3, 2].to_i(16)
|
|
191
|
+
b = color[5, 2].to_i(16)
|
|
192
|
+
"\e[38;2;#{r};#{g};#{b}m"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def jittered_delay(base_delay, jitter)
|
|
196
|
+
return base_delay unless jitter.positive?
|
|
197
|
+
|
|
198
|
+
factor = 1.0 + @random.rand(-jitter..jitter)
|
|
199
|
+
[(base_delay * factor).round, 1].max
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shellfie
|
|
4
|
+
class AnimationScrollEasing
|
|
5
|
+
def initialize(config)
|
|
6
|
+
@config = config
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def output_delay(base_delay, index, total)
|
|
10
|
+
return base_delay if total <= 1 || easing == "linear"
|
|
11
|
+
|
|
12
|
+
progress = index.to_f / (total - 1)
|
|
13
|
+
[(base_delay * delay_factor(progress)).round, 1].max
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def transition_frames(lines, delay:, previous_count:)
|
|
17
|
+
return [{ lines: lines, delay: delay }] unless scroll_transition?(delay, previous_count)
|
|
18
|
+
|
|
19
|
+
delays = split_delay(delay, scroll_frame_count(delay))
|
|
20
|
+
delays.each_with_index.map do |frame_delay, index|
|
|
21
|
+
progress = (index + 1).to_f / delays.size
|
|
22
|
+
{
|
|
23
|
+
lines: lines,
|
|
24
|
+
delay: frame_delay,
|
|
25
|
+
window: { scroll_offset: scroll_progress(progress) }
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def easing
|
|
33
|
+
@config.animation[:scroll_easing]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def scroll_transition?(delay, previous_count)
|
|
37
|
+
visible_lines = @config.window[:visible_lines]
|
|
38
|
+
visible_lines && previous_count >= visible_lines && delay.positive?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def scroll_frame_count(delay)
|
|
42
|
+
[[[(delay / 40.0).ceil, 1].max, 4].min, delay].min
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def split_delay(delay, count)
|
|
46
|
+
base_delay = delay / count
|
|
47
|
+
remainder = delay % count
|
|
48
|
+
Array.new(count) { |index| base_delay + (index < remainder ? 1 : 0) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def delay_factor(progress)
|
|
52
|
+
case easing
|
|
53
|
+
when "ease_in"
|
|
54
|
+
0.75 + progress * 0.5
|
|
55
|
+
when "ease_out"
|
|
56
|
+
1.25 - progress * 0.5
|
|
57
|
+
when "ease_in_out"
|
|
58
|
+
0.75 + (0.5 - (progress - 0.5).abs) * 1.0
|
|
59
|
+
else
|
|
60
|
+
1.0
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def scroll_progress(progress)
|
|
65
|
+
case easing
|
|
66
|
+
when "ease_in"
|
|
67
|
+
progress**2
|
|
68
|
+
when "ease_out"
|
|
69
|
+
1.0 - ((1.0 - progress)**2)
|
|
70
|
+
when "ease_in_out"
|
|
71
|
+
0.5 - Math.cos(progress * Math::PI) / 2.0
|
|
72
|
+
else
|
|
73
|
+
progress
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shellfie
|
|
4
|
+
class AnimationTimeline
|
|
5
|
+
Event = Struct.new(:kind, :frame, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
def initialize(config)
|
|
8
|
+
@config = config
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def each
|
|
12
|
+
return enum_for(:each) unless block_given?
|
|
13
|
+
|
|
14
|
+
@config.frames.each do |frame|
|
|
15
|
+
yield Event.new(kind: :screen, frame: frame) if frame.screen
|
|
16
|
+
yield Event.new(kind: :command, frame: frame) if frame.type
|
|
17
|
+
yield Event.new(kind: :output, frame: frame) if frame.output
|
|
18
|
+
yield Event.new(kind: :pause, frame: frame) if pause_frame?(frame)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def pause_frame?(frame)
|
|
25
|
+
frame.delay&.positive? && frame.output.nil? && frame.type.nil? && frame.screen.nil?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shellfie
|
|
4
|
+
module AnsiColors
|
|
5
|
+
COLORS = {
|
|
6
|
+
30 => :black,
|
|
7
|
+
31 => :red,
|
|
8
|
+
32 => :green,
|
|
9
|
+
33 => :yellow,
|
|
10
|
+
34 => :blue,
|
|
11
|
+
35 => :magenta,
|
|
12
|
+
36 => :cyan,
|
|
13
|
+
37 => :white,
|
|
14
|
+
90 => :bright_black,
|
|
15
|
+
91 => :bright_red,
|
|
16
|
+
92 => :bright_green,
|
|
17
|
+
93 => :bright_yellow,
|
|
18
|
+
94 => :bright_blue,
|
|
19
|
+
95 => :bright_magenta,
|
|
20
|
+
96 => :bright_cyan,
|
|
21
|
+
97 => :bright_white
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
BG_COLORS = {
|
|
25
|
+
40 => :black,
|
|
26
|
+
41 => :red,
|
|
27
|
+
42 => :green,
|
|
28
|
+
43 => :yellow,
|
|
29
|
+
44 => :blue,
|
|
30
|
+
45 => :magenta,
|
|
31
|
+
46 => :cyan,
|
|
32
|
+
47 => :white,
|
|
33
|
+
100 => :bright_black,
|
|
34
|
+
101 => :bright_red,
|
|
35
|
+
102 => :bright_green,
|
|
36
|
+
103 => :bright_yellow,
|
|
37
|
+
104 => :bright_blue,
|
|
38
|
+
105 => :bright_magenta,
|
|
39
|
+
106 => :bright_cyan,
|
|
40
|
+
107 => :bright_white
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
XTERM_COLOR_STEPS = [0, 95, 135, 175, 215, 255].freeze
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
def parse_extended_color(codes, index)
|
|
48
|
+
return [index, nil] if codes[index + 1].nil?
|
|
49
|
+
|
|
50
|
+
case codes[index + 1]
|
|
51
|
+
when 5
|
|
52
|
+
color_index = codes[index + 2]
|
|
53
|
+
return [index, nil] unless color_index
|
|
54
|
+
|
|
55
|
+
[index + 2, color_256(color_index)]
|
|
56
|
+
when 2
|
|
57
|
+
rgb = codes.values_at(index + 2, index + 3, index + 4)
|
|
58
|
+
return [index, nil] unless rgb.all? { |value| value.is_a?(Integer) && value.between?(0, 255) }
|
|
59
|
+
|
|
60
|
+
[index + 4, format("#%02x%02x%02x", *rgb)]
|
|
61
|
+
else
|
|
62
|
+
[index, nil]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def color_256(index)
|
|
67
|
+
return nil unless index.is_a?(Integer) && index.between?(0, 255)
|
|
68
|
+
|
|
69
|
+
if index < 16
|
|
70
|
+
standard_colors[index]
|
|
71
|
+
elsif index < 232
|
|
72
|
+
color_cube(index - 16)
|
|
73
|
+
else
|
|
74
|
+
gray = (index - 232) * 10 + 8
|
|
75
|
+
format("#%02x%02x%02x", gray, gray, gray)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def standard_colors
|
|
80
|
+
%i[
|
|
81
|
+
black red green yellow blue magenta cyan white
|
|
82
|
+
bright_black bright_red bright_green bright_yellow
|
|
83
|
+
bright_blue bright_magenta bright_cyan bright_white
|
|
84
|
+
]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def color_cube(index)
|
|
88
|
+
r = XTERM_COLOR_STEPS[index / 36]
|
|
89
|
+
g = XTERM_COLOR_STEPS[(index % 36) / 6]
|
|
90
|
+
b = XTERM_COLOR_STEPS[index % 6]
|
|
91
|
+
format("#%02x%02x%02x", r, g, b)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "text_metrics"
|
|
4
|
+
|
|
5
|
+
module Shellfie
|
|
6
|
+
class AnsiLineBuffer
|
|
7
|
+
CONTINUATION = Object.new.freeze
|
|
8
|
+
|
|
9
|
+
def initialize(tab_width: 8)
|
|
10
|
+
@cells = []
|
|
11
|
+
@column = 0
|
|
12
|
+
@pending_escape = +""
|
|
13
|
+
@tab_width = tab_width
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def write_escape(sequence)
|
|
17
|
+
@pending_escape << sequence
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write_character(char)
|
|
21
|
+
case char
|
|
22
|
+
when "\r"
|
|
23
|
+
@column = 0
|
|
24
|
+
when "\b"
|
|
25
|
+
@column = [@column - 1, 0].max
|
|
26
|
+
when "\t"
|
|
27
|
+
@column += @tab_width - (@column % @tab_width)
|
|
28
|
+
when "\a"
|
|
29
|
+
nil
|
|
30
|
+
else
|
|
31
|
+
width = TextMetrics.grapheme_width(char)
|
|
32
|
+
return if width.zero?
|
|
33
|
+
|
|
34
|
+
clear_wide_cell(@column)
|
|
35
|
+
@cells[@column] = "#{@pending_escape}#{char}"
|
|
36
|
+
@cells[@column + 1] = CONTINUATION if width == 2
|
|
37
|
+
@pending_escape = +""
|
|
38
|
+
@column += width
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def move(command, params)
|
|
43
|
+
amount = first_param(params, default: 1)
|
|
44
|
+
case command
|
|
45
|
+
when "C"
|
|
46
|
+
@column += amount
|
|
47
|
+
when "D"
|
|
48
|
+
@column = [@column - amount, 0].max
|
|
49
|
+
when "G"
|
|
50
|
+
@column = [amount - 1, 0].max
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def position(params)
|
|
55
|
+
values = params.to_s.split(";")
|
|
56
|
+
column = values.length >= 2 ? Integer(values[1], exception: false) : 1
|
|
57
|
+
@column = [[column || 1, 1].max - 1, 0].max
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def clear(command, params)
|
|
61
|
+
mode = first_param(params, default: 0)
|
|
62
|
+
command == "K" ? clear_line(mode) : clear_screen(mode)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_s
|
|
66
|
+
last = @cells.rindex { |cell| !cell.nil? }
|
|
67
|
+
return @pending_escape unless last
|
|
68
|
+
|
|
69
|
+
@cells[0..last].map { |cell| cell.equal?(CONTINUATION) ? "" : (cell || " ") }.join + @pending_escape
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def first_param(params, default:)
|
|
75
|
+
value = Integer(params.to_s.split(";").first, exception: false)
|
|
76
|
+
value && value.positive? ? value : default
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def clear_line(mode)
|
|
80
|
+
case mode
|
|
81
|
+
when 1
|
|
82
|
+
clear_range(0..@column)
|
|
83
|
+
when 2, 3
|
|
84
|
+
@cells.clear
|
|
85
|
+
else
|
|
86
|
+
@cells.slice!(@column..)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def clear_screen(mode)
|
|
91
|
+
mode.zero? ? clear_line(0) : clear_line(2)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def clear_range(range)
|
|
95
|
+
range.each { |index| @cells[index] = nil if index < @cells.length }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def clear_wide_cell(column)
|
|
99
|
+
@cells[column - 1] = nil if column.positive? && @cells[column].equal?(CONTINUATION)
|
|
100
|
+
@cells[column + 1] = nil if @cells[column + 1].equal?(CONTINUATION)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
require_relative "ansi_line_buffer"
|
|
5
|
+
|
|
6
|
+
module Shellfie
|
|
7
|
+
module AnsiNormalizer
|
|
8
|
+
ANSI_REGEX = /\e\[([0-9;:]*)m/
|
|
9
|
+
OSC_REGEX = /\e\].*?(?:\a|\e\\)/
|
|
10
|
+
STRING_CONTROL_REGEX = /\e(?:P|_|\^).*?(?:\a|\e\\)/m
|
|
11
|
+
INCOMPLETE_STRING_CONTROL_REGEX = /\e(?:P|_|\^).*\z/m
|
|
12
|
+
GRAPHICS_CONTROL_PREFIX = /\e(?:_G|P[0-9;?]*q|\]1337;File=)/
|
|
13
|
+
CSI_CONTROL_REGEX = /\e\[[0-9;:?]*[A-Za-ln-z]/
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def normalize(text, tab_width: 8, osc_policy: "ignore")
|
|
18
|
+
text = text.gsub(STRING_CONTROL_REGEX, "").gsub(INCOMPLETE_STRING_CONTROL_REGEX, "")
|
|
19
|
+
text = TextMetrics.graphemes(text).join
|
|
20
|
+
text = text.gsub(OSC_REGEX) { |sequence| osc_policy == "ignore" || !sequence.start_with?("\e]8;") ? "" : sequence }
|
|
21
|
+
text = apply_line_controls(text, tab_width: tab_width)
|
|
22
|
+
text.gsub(CSI_CONTROL_REGEX, "")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def apply_line_controls(text, tab_width: 8)
|
|
26
|
+
buffer = AnsiLineBuffer.new(tab_width: tab_width)
|
|
27
|
+
scanner = StringScanner.new(text)
|
|
28
|
+
|
|
29
|
+
until scanner.eos?
|
|
30
|
+
if scanner.scan(ANSI_REGEX)
|
|
31
|
+
buffer.write_escape(scanner.matched)
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if scanner.scan(OSC_REGEX)
|
|
36
|
+
buffer.write_escape(scanner.matched)
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if scanner.scan(/\e\[([0-9;?]*)[JK]/)
|
|
41
|
+
buffer.clear(scanner.matched[-1], scanner[1])
|
|
42
|
+
next
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if scanner.scan(/\e\[([0-9;?]*)[CDG]/)
|
|
46
|
+
buffer.move(scanner.matched[-1], scanner[1])
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if scanner.scan(/\e\[([0-9;?]*)[Hf]/)
|
|
51
|
+
buffer.position(scanner[1])
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
buffer.write_character(scanner.scan(/\X/))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
buffer.to_s
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|