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,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require_relative "image_magick_command_builder"
|
|
5
|
+
require_relative "rendering/text_painter"
|
|
6
|
+
require_relative "rendering/window_chrome"
|
|
7
|
+
|
|
8
|
+
module Shellfie
|
|
9
|
+
class RasterPainter
|
|
10
|
+
include Rendering::TextPainter
|
|
11
|
+
include Rendering::WindowChrome
|
|
12
|
+
|
|
13
|
+
attr_reader :config, :theme, :font_resolver
|
|
14
|
+
|
|
15
|
+
def initialize(config:, theme:, font_resolver:, chrome_cache: nil)
|
|
16
|
+
@config = config
|
|
17
|
+
@theme = theme
|
|
18
|
+
@font_resolver = font_resolver
|
|
19
|
+
@chrome_cache = chrome_cache
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def paint(geometry, output_path, transparent:)
|
|
23
|
+
return create_cached_image(geometry, output_path, transparent: transparent) if @chrome_cache
|
|
24
|
+
|
|
25
|
+
create_full_image(geometry, output_path, transparent: transparent)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def create_full_image(geometry, output_path, transparent:)
|
|
31
|
+
return create_direct_image(geometry, output_path, transparent: transparent) unless clip_content?(geometry)
|
|
32
|
+
|
|
33
|
+
with_temp_png do |base_path|
|
|
34
|
+
with_temp_png do |content_path|
|
|
35
|
+
create_chrome_image(geometry, base_path, transparent: transparent)
|
|
36
|
+
create_content_layer(geometry, content_path)
|
|
37
|
+
composite_layers(base_path, content_path, output_path)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def create_direct_image(geometry, output_path, transparent:)
|
|
43
|
+
ImageMagickCommandBuilder.convert do |convert|
|
|
44
|
+
ImageMagickCommandBuilder.canvas(
|
|
45
|
+
convert,
|
|
46
|
+
width: geometry[:canvas_width],
|
|
47
|
+
height: geometry[:canvas_height],
|
|
48
|
+
background: canvas_background(transparent)
|
|
49
|
+
)
|
|
50
|
+
draw_chrome(convert, geometry, transparent: transparent)
|
|
51
|
+
draw_content(convert, geometry)
|
|
52
|
+
finish_image(convert, output_path)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def create_cached_image(geometry, output_path, transparent:)
|
|
57
|
+
base_path = @chrome_cache.fetch(geometry, transparent: transparent) do |path|
|
|
58
|
+
create_chrome_image(geometry, path, transparent: transparent)
|
|
59
|
+
end
|
|
60
|
+
return create_cached_direct_image(base_path, geometry, output_path) unless clip_content?(geometry)
|
|
61
|
+
|
|
62
|
+
with_temp_png do |content_path|
|
|
63
|
+
create_content_layer(geometry, content_path)
|
|
64
|
+
composite_layers(base_path, content_path, output_path)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def create_cached_direct_image(base_path, geometry, output_path)
|
|
69
|
+
ImageMagickCommandBuilder.convert do |convert|
|
|
70
|
+
convert << base_path
|
|
71
|
+
draw_content(convert, geometry)
|
|
72
|
+
finish_image(convert, output_path)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def create_chrome_image(geometry, output_path, transparent:)
|
|
77
|
+
ImageMagickCommandBuilder.convert do |convert|
|
|
78
|
+
ImageMagickCommandBuilder.canvas(
|
|
79
|
+
convert,
|
|
80
|
+
width: geometry[:canvas_width],
|
|
81
|
+
height: geometry[:canvas_height],
|
|
82
|
+
background: canvas_background(transparent)
|
|
83
|
+
)
|
|
84
|
+
draw_chrome(convert, geometry, transparent: transparent)
|
|
85
|
+
ImageMagickCommandBuilder.output(convert, output_path, format: "png")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def create_content_layer(geometry, output_path)
|
|
90
|
+
ImageMagickCommandBuilder.convert do |convert|
|
|
91
|
+
ImageMagickCommandBuilder.canvas(
|
|
92
|
+
convert,
|
|
93
|
+
width: geometry[:canvas_width],
|
|
94
|
+
height: geometry[:canvas_height],
|
|
95
|
+
background: "xc:transparent"
|
|
96
|
+
)
|
|
97
|
+
ImageMagickCommandBuilder.region(convert, **content_region(geometry))
|
|
98
|
+
draw_content(convert, geometry)
|
|
99
|
+
ImageMagickCommandBuilder.clear_region(convert)
|
|
100
|
+
ImageMagickCommandBuilder.output(convert, output_path, format: "png")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def composite_layers(base_path, content_path, output_path)
|
|
105
|
+
ImageMagickCommandBuilder.convert do |convert|
|
|
106
|
+
convert << base_path
|
|
107
|
+
convert << content_path
|
|
108
|
+
ImageMagickCommandBuilder.composite_over(convert)
|
|
109
|
+
finish_image(convert, output_path)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def draw_chrome(convert, geometry, transparent:)
|
|
114
|
+
draw_shadow(convert, geometry) if geometry[:shadow]
|
|
115
|
+
draw_window(convert, geometry, transparent: transparent)
|
|
116
|
+
draw_title_bar(convert, geometry) unless config.headless
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def finish_image(convert, output_path)
|
|
120
|
+
if config.window[:trim]
|
|
121
|
+
convert.trim
|
|
122
|
+
convert << "+repage"
|
|
123
|
+
end
|
|
124
|
+
ImageMagickCommandBuilder.output(convert, output_path)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def canvas_background(transparent)
|
|
128
|
+
gradient = config.window[:background_gradient]
|
|
129
|
+
return "xc:transparent" if transparent
|
|
130
|
+
return "gradient:#{gradient[0]}-#{gradient[1]}" if gradient.is_a?(Array) && gradient.size == 2
|
|
131
|
+
|
|
132
|
+
"xc:#{theme.colors[:background]}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def clip_content?(geometry)
|
|
136
|
+
geometry[:scroll_offset].to_f.positive?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def content_region(geometry)
|
|
140
|
+
{
|
|
141
|
+
x: geometry[:margin] + geometry[:scaled_padding],
|
|
142
|
+
y: geometry[:margin] + geometry[:scaled_title_bar] + geometry[:scaled_padding],
|
|
143
|
+
width: [geometry[:scaled_width] - geometry[:scaled_padding] * 2, 1].max,
|
|
144
|
+
height: [geometry[:scaled_height] - geometry[:scaled_title_bar] - geometry[:scaled_padding] * 2, 1].max
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def with_temp_png
|
|
149
|
+
file = Tempfile.new(["shellfie-layer", ".png"])
|
|
150
|
+
path = file.path
|
|
151
|
+
file.close
|
|
152
|
+
yield path
|
|
153
|
+
ensure
|
|
154
|
+
File.delete(path) if path && File.exist?(path)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module Shellfie
|
|
6
|
+
class RenderChromeCache
|
|
7
|
+
def initialize
|
|
8
|
+
@entries = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def fetch(geometry, transparent:)
|
|
12
|
+
key = cache_key(geometry, transparent)
|
|
13
|
+
return @entries[key].path if @entries.key?(key)
|
|
14
|
+
|
|
15
|
+
temp = Tempfile.new(["shellfie-chrome", ".png"], binmode: true)
|
|
16
|
+
temp.close
|
|
17
|
+
yield temp.path
|
|
18
|
+
@entries[key] = temp
|
|
19
|
+
temp.path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def cleanup
|
|
23
|
+
@entries.each_value do |temp|
|
|
24
|
+
File.delete(temp.path) if File.exist?(temp.path)
|
|
25
|
+
temp.close unless temp.closed?
|
|
26
|
+
end
|
|
27
|
+
@entries.clear
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def cache_key(geometry, transparent)
|
|
33
|
+
[
|
|
34
|
+
transparent,
|
|
35
|
+
geometry.values_at(:canvas_width, :canvas_height, :scaled_width, :scaled_height, :scaled_title_bar, :margin,
|
|
36
|
+
:shadow, :scaled_radius)
|
|
37
|
+
]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "line_layout"
|
|
4
|
+
|
|
5
|
+
module Shellfie
|
|
6
|
+
class RenderGeometry
|
|
7
|
+
def initialize(config:, theme:)
|
|
8
|
+
@config = config
|
|
9
|
+
@theme = theme
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def build(lines, scale:, shadow:)
|
|
13
|
+
font_config = @theme.font
|
|
14
|
+
line_height = font_config[:size] * font_config[:line_height]
|
|
15
|
+
display_lines, visible_count = display_lines(lines, font_config, line_height)
|
|
16
|
+
total_height = @config.window[:height] || title_bar_height + [visible_count, 1].max * line_height + padding * 2
|
|
17
|
+
margin = canvas_margin(scale, shadow && !exact_size?)
|
|
18
|
+
geometry = geometry_hash(
|
|
19
|
+
display_lines,
|
|
20
|
+
font_config,
|
|
21
|
+
line_height,
|
|
22
|
+
total_height,
|
|
23
|
+
scale,
|
|
24
|
+
margin,
|
|
25
|
+
shadow && !exact_size?,
|
|
26
|
+
visible_count
|
|
27
|
+
)
|
|
28
|
+
validate_pixel_limit!(geometry)
|
|
29
|
+
geometry
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def display_lines(lines, font_config, line_height)
|
|
35
|
+
layout = LineLayout.new(@config)
|
|
36
|
+
display_lines = layout.prepare(
|
|
37
|
+
lines,
|
|
38
|
+
content_width: [width - (padding * 2), 1].max,
|
|
39
|
+
font_size: font_config[:size],
|
|
40
|
+
title_bar_height: title_bar_height,
|
|
41
|
+
padding: padding,
|
|
42
|
+
line_height: line_height
|
|
43
|
+
)
|
|
44
|
+
[display_lines, layout.visible_count]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def geometry_hash(lines, font_config, line_height, total_height, scale, margin, shadow, visible_count)
|
|
48
|
+
{
|
|
49
|
+
lines: lines,
|
|
50
|
+
visible_line_count: visible_count,
|
|
51
|
+
font_config: font_config,
|
|
52
|
+
width: width,
|
|
53
|
+
height: total_height,
|
|
54
|
+
padding: padding,
|
|
55
|
+
line_height: line_height,
|
|
56
|
+
font_size: font_config[:size],
|
|
57
|
+
title_bar_height: title_bar_height,
|
|
58
|
+
logical_width: width,
|
|
59
|
+
logical_height: total_height.ceil,
|
|
60
|
+
scale: scale,
|
|
61
|
+
scaled_width: (width * scale).to_i,
|
|
62
|
+
scaled_height: (total_height * scale).ceil,
|
|
63
|
+
scaled_padding: (padding * scale).to_i,
|
|
64
|
+
scaled_line_height: (line_height * scale).ceil,
|
|
65
|
+
scaled_font_size: (font_config[:size] * scale).to_i,
|
|
66
|
+
scaled_title_bar: (title_bar_height * scale).to_i,
|
|
67
|
+
scaled_radius: (corner_radius * scale).to_i,
|
|
68
|
+
scroll_offset: @config.window[:scroll_offset].to_f,
|
|
69
|
+
ambiguous_width: @config.window[:ambiguous_width],
|
|
70
|
+
margin: margin,
|
|
71
|
+
canvas_width: (width * scale).to_i + margin * 2,
|
|
72
|
+
canvas_height: (total_height * scale).ceil + margin * 2,
|
|
73
|
+
shadow: shadow
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def width
|
|
78
|
+
@config.window[:width]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def padding
|
|
82
|
+
@config.window[:padding]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def title_bar_height
|
|
86
|
+
@config.headless ? 0 : @theme.window_decoration[:title_bar_height]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def corner_radius
|
|
90
|
+
@config.headless ? 0 : @theme.window_decoration[:corner_radius]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def exact_size?
|
|
94
|
+
@config.window[:exact_size]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def canvas_margin(scale, shadow)
|
|
98
|
+
configured = @config.window[:margin]
|
|
99
|
+
return (configured * scale).to_i if configured
|
|
100
|
+
return 0 if @config.headless && !shadow
|
|
101
|
+
return 0 if exact_size?
|
|
102
|
+
return (10 * scale).to_i unless shadow
|
|
103
|
+
|
|
104
|
+
shadow_config = @theme.window_decoration[:shadow]
|
|
105
|
+
(([shadow_config[:blur].to_i, shadow_config[:offset_x].to_i.abs, shadow_config[:offset_y].to_i.abs].max + 10) * scale).to_i
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def validate_pixel_limit!(geometry)
|
|
109
|
+
pixels = geometry[:canvas_width] * geometry[:canvas_height]
|
|
110
|
+
return if pixels <= @config.limits[:max_pixels]
|
|
111
|
+
|
|
112
|
+
raise ResourceLimitError, "Estimated image is too large (#{pixels} pixels, max #{@config.limits[:max_pixels]})"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shellfie
|
|
4
|
+
class RenderSegment
|
|
5
|
+
ATTRIBUTES = %i[
|
|
6
|
+
foreground background bold italic underline underline_style underline_color dim reverse strikethrough overline blink conceal link
|
|
7
|
+
].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :text, :foreground, :background, :bold, :italic, :underline, :underline_style, :underline_color,
|
|
10
|
+
:dim, :reverse, :strikethrough, :overline, :blink, :conceal, :link
|
|
11
|
+
|
|
12
|
+
def self.from_segment(segment, default_color:)
|
|
13
|
+
new(
|
|
14
|
+
text: segment.text.to_s,
|
|
15
|
+
foreground: segment.foreground || default_color,
|
|
16
|
+
background: segment.background,
|
|
17
|
+
bold: segment.bold,
|
|
18
|
+
italic: segment.italic,
|
|
19
|
+
underline: segment.underline,
|
|
20
|
+
underline_style: segment.underline_style,
|
|
21
|
+
underline_color: segment.underline_color,
|
|
22
|
+
dim: segment.dim,
|
|
23
|
+
reverse: segment.reverse,
|
|
24
|
+
strikethrough: segment.strikethrough,
|
|
25
|
+
overline: segment.overline,
|
|
26
|
+
blink: segment.blink,
|
|
27
|
+
conceal: segment.conceal,
|
|
28
|
+
link: segment.link
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.copy(segment, text)
|
|
33
|
+
new(**ATTRIBUTES.each_with_object(text: text.dup) { |attribute, values| values[attribute] = segment.public_send(attribute) })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.coalesce(segments)
|
|
37
|
+
segments.each_with_object([]) do |segment, result|
|
|
38
|
+
if result.last&.same_style?(segment)
|
|
39
|
+
result[-1] = copy(result.last, result.last.text + segment.text)
|
|
40
|
+
else
|
|
41
|
+
result << segment
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(text:, foreground: nil, background: nil, bold: false, italic: false, underline: false,
|
|
47
|
+
underline_style: nil, underline_color: nil, dim: false, reverse: false, strikethrough: false,
|
|
48
|
+
overline: false, blink: false, conceal: false, link: nil)
|
|
49
|
+
@text = text
|
|
50
|
+
@foreground = foreground
|
|
51
|
+
@background = background
|
|
52
|
+
@bold = bold
|
|
53
|
+
@italic = italic
|
|
54
|
+
@underline = underline
|
|
55
|
+
@underline_style = underline_style
|
|
56
|
+
@underline_color = underline_color
|
|
57
|
+
@dim = dim
|
|
58
|
+
@reverse = reverse
|
|
59
|
+
@strikethrough = strikethrough
|
|
60
|
+
@overline = overline
|
|
61
|
+
@blink = blink
|
|
62
|
+
@conceal = conceal
|
|
63
|
+
@link = link
|
|
64
|
+
freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def same_style?(other)
|
|
68
|
+
ATTRIBUTES.all? { |attribute| public_send(attribute) == other.public_send(attribute) }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/shellfie/renderer.rb
CHANGED
|
@@ -2,192 +2,139 @@
|
|
|
2
2
|
|
|
3
3
|
require "mini_magick"
|
|
4
4
|
require_relative "ansi_parser"
|
|
5
|
-
require_relative "
|
|
6
|
-
require_relative "
|
|
7
|
-
require_relative "
|
|
8
|
-
require_relative "
|
|
5
|
+
require_relative "dependency_checker"
|
|
6
|
+
require_relative "font_resolver"
|
|
7
|
+
require_relative "format_resolver"
|
|
8
|
+
require_relative "html_renderer"
|
|
9
|
+
require_relative "output_writer"
|
|
10
|
+
require_relative "raster_painter"
|
|
11
|
+
require_relative "render_chrome_cache"
|
|
12
|
+
require_relative "render_geometry"
|
|
13
|
+
require_relative "render_segment"
|
|
14
|
+
require_relative "svg_raster_wrapper"
|
|
15
|
+
require_relative "svg_renderer"
|
|
16
|
+
require_relative "theme_registry"
|
|
9
17
|
|
|
10
18
|
module Shellfie
|
|
11
19
|
class Renderer
|
|
12
|
-
|
|
13
|
-
"macos" => Themes::MacOS,
|
|
14
|
-
"ubuntu" => Themes::Ubuntu,
|
|
15
|
-
"windows" => Themes::WindowsTerminal
|
|
16
|
-
}.freeze
|
|
20
|
+
attr_reader :config, :theme, :font_resolver
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def initialize(config)
|
|
22
|
+
def initialize(config, chrome_cache: nil)
|
|
21
23
|
@config = config
|
|
22
|
-
@
|
|
23
|
-
@
|
|
24
|
+
@chrome_cache = chrome_cache
|
|
25
|
+
@theme = ThemeRegistry.build(config)
|
|
26
|
+
@ansi_parser = AnsiParser.new(
|
|
27
|
+
state_mode: config.window[:ansi_state] || :persistent,
|
|
28
|
+
tab_width: config.window[:tab_width],
|
|
29
|
+
osc_policy: config.window[:osc_policy],
|
|
30
|
+
graphics_policy: config.window[:graphics_policy]
|
|
31
|
+
)
|
|
32
|
+
@font_resolver = FontResolver.new(-> { imagemagick_command })
|
|
24
33
|
end
|
|
25
34
|
|
|
26
|
-
def render(output_path, scale: 1, shadow: true, transparent: false)
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
def render(output_path, scale: 1, shadow: true, transparent: false, format: nil, io: nil)
|
|
36
|
+
extension = FormatResolver.resolve(output_path, explicit: format, default: "png")
|
|
37
|
+
check_dependencies! unless %w[svg html].include?(extension)
|
|
29
38
|
lines = build_lines
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
OutputWriter.write(output_path, extension: extension, io: io) do |temporary_path|
|
|
40
|
+
render_method = { "svg" => :create_svg_image, "svg-raster" => :create_svg_raster_image, "html" => :create_html }.fetch(extension, :create_image)
|
|
41
|
+
send(render_method, lines, temporary_path, scale: scale, shadow: shadow, transparent: transparent)
|
|
42
|
+
end
|
|
43
|
+
rescue MiniMagick::Error => e
|
|
44
|
+
raise RenderError.new("ImageMagick render failed: #{e.message}", category: :render)
|
|
32
45
|
end
|
|
33
46
|
|
|
34
|
-
|
|
47
|
+
def estimate(scale: 1, shadow: true)
|
|
48
|
+
geometry = build_geometry(build_lines, scale: scale, shadow: shadow)
|
|
49
|
+
geometry.slice(:canvas_width, :canvas_height, :scaled_width, :scaled_height, :logical_width, :logical_height, :scale)
|
|
50
|
+
end
|
|
35
51
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
klass.new
|
|
52
|
+
def font_info
|
|
53
|
+
font_resolver.details(theme.font)
|
|
39
54
|
end
|
|
40
55
|
|
|
56
|
+
private
|
|
57
|
+
|
|
41
58
|
def check_dependencies!
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
raise DependencyError, <<~MSG
|
|
45
|
-
ImageMagick not found
|
|
46
|
-
→ Please install ImageMagick: brew install imagemagick
|
|
47
|
-
→ Or visit: https://imagemagick.org/script/download.php
|
|
48
|
-
MSG
|
|
49
|
-
end
|
|
59
|
+
DependencyChecker.configure_mini_magick!
|
|
60
|
+
DependencyChecker.ensure_imagemagick!
|
|
50
61
|
end
|
|
51
62
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
result = []
|
|
55
|
-
if line.prompt
|
|
56
|
-
prompt_segments = @ansi_parser.parse(line.prompt)
|
|
57
|
-
command_segments = line.command ? @ansi_parser.parse(line.command) : []
|
|
58
|
-
result << { segments: prompt_segments + command_segments }
|
|
59
|
-
end
|
|
60
|
-
if line.output
|
|
61
|
-
line.output.split("\n").each do |output_line|
|
|
62
|
-
result << { segments: @ansi_parser.parse(output_line) }
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
result
|
|
66
|
-
end
|
|
63
|
+
def imagemagick_command
|
|
64
|
+
@imagemagick_command ||= DependencyChecker.imagemagick_path.to_s
|
|
67
65
|
end
|
|
68
66
|
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
total_height = title_bar_height + content_height
|
|
81
|
-
corner_radius = decoration[:corner_radius]
|
|
82
|
-
|
|
83
|
-
if visible_lines && lines.size > visible_lines
|
|
84
|
-
lines = lines.last(visible_lines)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
scaled_width = (width * scale).to_i
|
|
88
|
-
scaled_height = (total_height * scale).to_i
|
|
89
|
-
scaled_padding = (padding * scale).to_i
|
|
90
|
-
scaled_title_bar = (title_bar_height * scale).to_i
|
|
91
|
-
scaled_line_height = (line_height * scale).to_i
|
|
92
|
-
scaled_font_size = ((font_config[:size] || 14) * scale).to_i
|
|
93
|
-
scaled_radius = (corner_radius * scale).to_i
|
|
94
|
-
|
|
95
|
-
margin = shadow ? (50 * scale).to_i : (10 * scale).to_i
|
|
96
|
-
canvas_width = scaled_width + margin * 2
|
|
97
|
-
canvas_height = scaled_height + margin * 2
|
|
98
|
-
|
|
99
|
-
MiniMagick.convert do |convert|
|
|
100
|
-
convert.size "#{canvas_width}x#{canvas_height}"
|
|
101
|
-
convert << "xc:transparent"
|
|
102
|
-
|
|
103
|
-
if shadow
|
|
104
|
-
convert.fill "rgba(0,0,0,0.3)"
|
|
105
|
-
shadow_offset = (10 * scale).to_i
|
|
106
|
-
convert.draw "roundrectangle #{margin + shadow_offset},#{margin + shadow_offset} " \
|
|
107
|
-
"#{margin + scaled_width - 1 + shadow_offset},#{margin + scaled_height - 1 + shadow_offset} " \
|
|
108
|
-
"#{scaled_radius},#{scaled_radius}"
|
|
109
|
-
convert.blur "0x#{(15 * scale).to_i}"
|
|
67
|
+
def build_lines
|
|
68
|
+
config.lines.flat_map do |line|
|
|
69
|
+
rendered_lines = []
|
|
70
|
+
if line.prompt || line.command
|
|
71
|
+
rendered_lines << {
|
|
72
|
+
segments: coalesce_segments(
|
|
73
|
+
parse_with_default(line.prompt.to_s, line.prompt_color) +
|
|
74
|
+
parse_with_default(line.command.to_s, line.command_color)
|
|
75
|
+
),
|
|
76
|
+
selected: line.selected
|
|
77
|
+
}
|
|
110
78
|
end
|
|
111
79
|
|
|
112
|
-
|
|
113
|
-
convert.draw "roundrectangle #{margin},#{margin} " \
|
|
114
|
-
"#{margin + scaled_width - 1},#{margin + scaled_height - 1} " \
|
|
115
|
-
"#{scaled_radius},#{scaled_radius}"
|
|
116
|
-
|
|
117
|
-
unless config.headless
|
|
118
|
-
convert.fill theme.colors[:title_bar]
|
|
119
|
-
title_y2 = margin + scaled_title_bar - 1
|
|
120
|
-
convert.draw "roundrectangle #{margin},#{margin} " \
|
|
121
|
-
"#{margin + scaled_width - 1},#{title_y2} " \
|
|
122
|
-
"#{scaled_radius},#{scaled_radius}"
|
|
123
|
-
convert.fill theme.colors[:title_bar]
|
|
124
|
-
convert.draw "rectangle #{margin},#{margin + scaled_radius} " \
|
|
125
|
-
"#{margin + scaled_width - 1},#{title_y2}"
|
|
126
|
-
|
|
127
|
-
button_x, button_y = button_positions(margin, scaled_title_bar, scale)
|
|
128
|
-
button_radius = ((theme.window_decoration[:button_size] / 2) * scale).to_i
|
|
129
|
-
|
|
130
|
-
theme.button_colors.each_with_index do |color, i|
|
|
131
|
-
spacing = ((theme.window_decoration[:button_spacing] + theme.window_decoration[:button_size]) * scale).to_i
|
|
132
|
-
bx = button_x + i * spacing
|
|
133
|
-
convert.fill color
|
|
134
|
-
convert.draw "circle #{bx},#{button_y} #{bx + button_radius},#{button_y}"
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
convert.fill theme.colors[:title_text]
|
|
138
|
-
convert.pointsize scaled_font_size
|
|
139
|
-
title_y = margin + scaled_title_bar / 2 + scaled_font_size / 3
|
|
140
|
-
title_x = margin + scaled_width / 2
|
|
141
|
-
convert.gravity "NorthWest"
|
|
142
|
-
convert.draw "text #{title_x - config.title.to_s.length * scaled_font_size / 4},#{title_y - scaled_font_size} '#{escape_text(config.title.to_s)}'"
|
|
143
|
-
end
|
|
80
|
+
next rendered_lines unless line.output
|
|
144
81
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
y = content_y + idx * scaled_line_height + scaled_font_size
|
|
148
|
-
x = margin + scaled_padding
|
|
149
|
-
draw_line_segments(convert, line[:segments], x, y, scaled_font_size, font_config)
|
|
82
|
+
line.output.to_s.split("\n", -1).each do |output_line|
|
|
83
|
+
rendered_lines << { segments: coalesce_segments(parse_with_default(output_line, line.output_color)), selected: line.selected }
|
|
150
84
|
end
|
|
151
|
-
|
|
152
|
-
convert << output_path
|
|
85
|
+
rendered_lines
|
|
153
86
|
end
|
|
154
87
|
end
|
|
155
88
|
|
|
156
|
-
def
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if theme.buttons_position == :left
|
|
161
|
-
x = margin + (16 * scale).to_i
|
|
162
|
-
else
|
|
163
|
-
x = margin + scaled_width - (16 * scale).to_i - button_size * 3
|
|
89
|
+
def parse_with_default(text, default_color)
|
|
90
|
+
@ansi_parser.parse(text).map do |segment|
|
|
91
|
+
RenderSegment.from_segment(segment, default_color: default_color)
|
|
164
92
|
end
|
|
165
|
-
|
|
93
|
+
end
|
|
166
94
|
|
|
167
|
-
|
|
95
|
+
def coalesce_segments(segments)
|
|
96
|
+
RenderSegment.coalesce(segments)
|
|
168
97
|
end
|
|
169
98
|
|
|
170
|
-
def
|
|
171
|
-
|
|
99
|
+
def create_image(lines, output_path, scale:, shadow:, transparent:)
|
|
100
|
+
geometry = build_geometry(lines, scale: scale, shadow: shadow)
|
|
172
101
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
text = escape_text(segment.text)
|
|
102
|
+
raster_painter.paint(geometry, output_path, transparent: transparent)
|
|
103
|
+
end
|
|
176
104
|
|
|
177
|
-
|
|
105
|
+
def create_svg_image(lines, output_path, scale:, shadow:, transparent:)
|
|
106
|
+
geometry = build_geometry(lines, scale: scale, shadow: shadow)
|
|
107
|
+
SvgRenderer.new(config: config, theme: theme).render(geometry, output_path, transparent: transparent)
|
|
108
|
+
end
|
|
178
109
|
|
|
179
|
-
|
|
180
|
-
|
|
110
|
+
def create_svg_raster_image(lines, output_path, scale:, shadow:, transparent:)
|
|
111
|
+
SvgRasterWrapper.write(output_path) { |png_path| create_image(lines, png_path, scale: scale, shadow: shadow, transparent: transparent) }
|
|
112
|
+
end
|
|
181
113
|
|
|
182
|
-
|
|
114
|
+
def create_html(lines, output_path, scale:, shadow:, transparent:)
|
|
115
|
+
geometry = build_geometry(lines, scale: scale, shadow: shadow)
|
|
116
|
+
HtmlRenderer.new(config: config, theme: theme).render(geometry, output_path, transparent: transparent)
|
|
117
|
+
end
|
|
183
118
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
end
|
|
119
|
+
def build_geometry(lines, scale:, shadow:)
|
|
120
|
+
geometry_builder.build(lines, scale: scale, shadow: shadow)
|
|
187
121
|
end
|
|
188
122
|
|
|
189
123
|
def escape_text(text)
|
|
190
|
-
|
|
124
|
+
raster_painter.send(:escape_text, text)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def geometry_builder
|
|
128
|
+
@geometry_builder ||= RenderGeometry.new(config: config, theme: theme)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def raster_painter
|
|
132
|
+
@raster_painter ||= RasterPainter.new(
|
|
133
|
+
config: config,
|
|
134
|
+
theme: theme,
|
|
135
|
+
font_resolver: font_resolver,
|
|
136
|
+
chrome_cache: @chrome_cache
|
|
137
|
+
)
|
|
191
138
|
end
|
|
192
139
|
end
|
|
193
140
|
end
|