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,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../image_magick_command_builder"
|
|
4
|
+
|
|
5
|
+
module Shellfie
|
|
6
|
+
module Rendering
|
|
7
|
+
module ShapeHelpers
|
|
8
|
+
def draw_roundrect(convert, x1, y1, x2, y2, radius)
|
|
9
|
+
if radius.positive?
|
|
10
|
+
ImageMagickCommandBuilder.round_rectangle(convert, x1, y1, x2, y2, radius)
|
|
11
|
+
else
|
|
12
|
+
ImageMagickCommandBuilder.rectangle(convert, x1, y1, x2, y2)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def draw_windows_icon(convert, index, center_x, center_y, icon_size)
|
|
17
|
+
case index
|
|
18
|
+
when 0
|
|
19
|
+
ImageMagickCommandBuilder.line(convert, center_x - icon_size / 2, center_y, center_x + icon_size / 2, center_y)
|
|
20
|
+
when 1
|
|
21
|
+
ImageMagickCommandBuilder.rectangle(
|
|
22
|
+
convert,
|
|
23
|
+
center_x - icon_size / 2,
|
|
24
|
+
center_y - icon_size / 2,
|
|
25
|
+
center_x + icon_size / 2,
|
|
26
|
+
center_y + icon_size / 2
|
|
27
|
+
)
|
|
28
|
+
when 2
|
|
29
|
+
ImageMagickCommandBuilder.lines(
|
|
30
|
+
convert,
|
|
31
|
+
[
|
|
32
|
+
{ x1: center_x - icon_size / 2, y1: center_y - icon_size / 2,
|
|
33
|
+
x2: center_x + icon_size / 2, y2: center_y + icon_size / 2 },
|
|
34
|
+
{ x1: center_x + icon_size / 2, y1: center_y - icon_size / 2,
|
|
35
|
+
x2: center_x - icon_size / 2, y2: center_y + icon_size / 2 }
|
|
36
|
+
]
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../image_magick_command_builder"
|
|
4
|
+
require_relative "../text_metrics"
|
|
5
|
+
|
|
6
|
+
module Shellfie
|
|
7
|
+
module Rendering
|
|
8
|
+
module TextPainter
|
|
9
|
+
def draw_content(convert, geometry)
|
|
10
|
+
content_y = content_origin_y(geometry)
|
|
11
|
+
draw_selected_backgrounds(convert, geometry, content_y)
|
|
12
|
+
|
|
13
|
+
geometry[:lines].each_with_index do |line, index|
|
|
14
|
+
top = content_y + index * geometry[:scaled_line_height]
|
|
15
|
+
baseline = top + geometry[:scaled_font_size]
|
|
16
|
+
x = geometry[:margin] + geometry[:scaled_padding]
|
|
17
|
+
|
|
18
|
+
draw_line_segments(convert, line[:segments], x, baseline, geometry)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def draw_line_segments(convert, segments, x, baseline, geometry)
|
|
23
|
+
positioned_segments = position_segments(segments, x, baseline, geometry)
|
|
24
|
+
draw_segment_backgrounds(convert, positioned_segments)
|
|
25
|
+
draw_positioned_segments(convert, positioned_segments, geometry)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def position_segments(segments, x, baseline, geometry)
|
|
29
|
+
current_x = x
|
|
30
|
+
segments.each_with_object([]) do |segment, result|
|
|
31
|
+
text = segment.text.to_s
|
|
32
|
+
next if text.empty?
|
|
33
|
+
|
|
34
|
+
width = TextMetrics.pixel_width(text, geometry[:scaled_font_size], ambiguous_width: geometry[:ambiguous_width])
|
|
35
|
+
top = baseline - geometry[:scaled_font_size]
|
|
36
|
+
foreground, background = segment_colors(segment)
|
|
37
|
+
result << {
|
|
38
|
+
segment: segment,
|
|
39
|
+
text: text,
|
|
40
|
+
x: current_x,
|
|
41
|
+
width: width,
|
|
42
|
+
top: top,
|
|
43
|
+
bottom: top + geometry[:scaled_line_height],
|
|
44
|
+
foreground: foreground,
|
|
45
|
+
background: background
|
|
46
|
+
}
|
|
47
|
+
current_x += width
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def draw_segment_backgrounds(convert, positioned_segments)
|
|
52
|
+
positioned_segments
|
|
53
|
+
.select { |item| item[:background] }
|
|
54
|
+
.group_by { |item| item[:background] }
|
|
55
|
+
.each do |background, items|
|
|
56
|
+
convert.fill background
|
|
57
|
+
ImageMagickCommandBuilder.rectangles(
|
|
58
|
+
convert,
|
|
59
|
+
items.map { |item| { x1: item[:x], y1: item[:top], x2: item[:x] + item[:width], y2: item[:bottom] } }
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def draw_positioned_segments(convert, positioned_segments, geometry)
|
|
65
|
+
positioned_segments.each do |item|
|
|
66
|
+
unless item[:segment].conceal
|
|
67
|
+
draw_text(
|
|
68
|
+
convert,
|
|
69
|
+
item[:text],
|
|
70
|
+
item[:x],
|
|
71
|
+
item[:top],
|
|
72
|
+
item[:foreground],
|
|
73
|
+
geometry[:scaled_font_size],
|
|
74
|
+
geometry[:font_config],
|
|
75
|
+
bold: item[:segment].bold,
|
|
76
|
+
italic: item[:segment].italic
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
draw_text_decoration(
|
|
80
|
+
convert,
|
|
81
|
+
item[:segment],
|
|
82
|
+
item[:x],
|
|
83
|
+
item[:width],
|
|
84
|
+
item[:top] + geometry[:scaled_font_size],
|
|
85
|
+
geometry
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def segment_colors(segment)
|
|
91
|
+
foreground = segment.foreground ? theme.color_for(segment.foreground) : theme.colors[:foreground]
|
|
92
|
+
background = segment.background ? theme.color_for(segment.background) : nil
|
|
93
|
+
|
|
94
|
+
if segment.reverse
|
|
95
|
+
foreground, background = background || theme.colors[:background], foreground
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
foreground = color_with_opacity(foreground, 0.6, true) if segment.dim
|
|
99
|
+
[foreground, background]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def draw_text(convert, text, x, y, color, font_size, font_config, bold: false, italic: false)
|
|
103
|
+
convert.gravity "NorthWest"
|
|
104
|
+
convert.fill color
|
|
105
|
+
convert.stroke "none"
|
|
106
|
+
font = font_resolver.resolve(font_config, italic: italic)
|
|
107
|
+
convert.font font if font
|
|
108
|
+
convert.pointsize font_size
|
|
109
|
+
convert.weight(bold ? 700 : 400)
|
|
110
|
+
convert.style(italic ? "Italic" : "Normal")
|
|
111
|
+
convert.annotate "+#{x}+#{y}", escape_text(text)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def draw_text_decoration(convert, segment, x, width, baseline, geometry)
|
|
115
|
+
return if segment.conceal
|
|
116
|
+
return unless segment.underline || segment.strikethrough || segment.overline
|
|
117
|
+
|
|
118
|
+
line_width = [(geometry[:scaled_font_size] / 12.0).ceil, 1].max
|
|
119
|
+
decoration_color = segment.underline_color ? theme.color_for(segment.underline_color) : segment_colors(segment).first
|
|
120
|
+
convert.stroke decoration_color
|
|
121
|
+
convert.strokewidth line_width
|
|
122
|
+
convert.stroke_dasharray "#{line_width},#{line_width * 2}" if segment.underline_style == :dotted
|
|
123
|
+
convert.stroke_dasharray "#{line_width * 3},#{line_width * 2}" if segment.underline_style == :dashed
|
|
124
|
+
|
|
125
|
+
if segment.underline
|
|
126
|
+
y = baseline + (geometry[:scaled_font_size] * 0.12).ceil
|
|
127
|
+
ImageMagickCommandBuilder.line(convert, x, y, x + width, y)
|
|
128
|
+
ImageMagickCommandBuilder.line(convert, x, y + line_width * 2, x + width, y + line_width * 2) if segment.underline_style == :double
|
|
129
|
+
end
|
|
130
|
+
if segment.strikethrough
|
|
131
|
+
y = baseline - (geometry[:scaled_font_size] * 0.35).ceil
|
|
132
|
+
ImageMagickCommandBuilder.line(convert, x, y, x + width, y)
|
|
133
|
+
end
|
|
134
|
+
if segment.overline
|
|
135
|
+
y = baseline - geometry[:scaled_font_size]
|
|
136
|
+
ImageMagickCommandBuilder.line(convert, x, y, x + width, y)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
convert.stroke "none"
|
|
140
|
+
convert.stroke_dasharray "none" if %i[dotted dashed].include?(segment.underline_style)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def draw_selected_backgrounds(convert, geometry, content_y)
|
|
144
|
+
rectangles = geometry[:lines].each_with_index.each_with_object([]) do |(line, index), result|
|
|
145
|
+
next unless line[:selected]
|
|
146
|
+
|
|
147
|
+
x = geometry[:margin] + geometry[:scaled_padding]
|
|
148
|
+
top = content_y + index * geometry[:scaled_line_height]
|
|
149
|
+
result << {
|
|
150
|
+
x1: x,
|
|
151
|
+
y1: top,
|
|
152
|
+
x2: x + geometry[:scaled_width] - geometry[:scaled_padding] * 2,
|
|
153
|
+
y2: top + geometry[:scaled_line_height]
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
return if rectangles.empty?
|
|
157
|
+
|
|
158
|
+
convert.fill theme.colors[:selection]
|
|
159
|
+
ImageMagickCommandBuilder.rectangles(convert, rectangles)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def content_origin_y(geometry)
|
|
163
|
+
base_y = geometry[:margin] + geometry[:scaled_title_bar] + geometry[:scaled_padding]
|
|
164
|
+
base_y - (geometry[:scroll_offset].to_f * geometry[:scaled_line_height]).round
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def fit_text(text, max_width, font_size)
|
|
168
|
+
return "" if max_width <= 0
|
|
169
|
+
return text if TextMetrics.pixel_width(text, font_size, ambiguous_width: config.window[:ambiguous_width]) <= max_width
|
|
170
|
+
|
|
171
|
+
max_cells = [(max_width / (font_size * 0.6)).floor - 3, 0].max
|
|
172
|
+
"#{TextMetrics.take_cells(text, max_cells, ambiguous_width: config.window[:ambiguous_width])}..."
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def color_with_opacity(color, opacity, allow_rgba)
|
|
176
|
+
return color unless allow_rgba && opacity < 1.0
|
|
177
|
+
|
|
178
|
+
if color.to_s.match?(/\A#[0-9a-fA-F]{6}\z/)
|
|
179
|
+
r = color[1, 2].to_i(16)
|
|
180
|
+
g = color[3, 2].to_i(16)
|
|
181
|
+
b = color[5, 2].to_i(16)
|
|
182
|
+
"rgba(#{r},#{g},#{b},#{opacity})"
|
|
183
|
+
else
|
|
184
|
+
color
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def escape_text(text)
|
|
189
|
+
text.to_s
|
|
190
|
+
.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
|
|
191
|
+
.gsub(/[\u0000-\u001f\u007f]/, " ")
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "shape_helpers"
|
|
4
|
+
require_relative "../image_magick_command_builder"
|
|
5
|
+
require_relative "../text_metrics"
|
|
6
|
+
|
|
7
|
+
module Shellfie
|
|
8
|
+
module Rendering
|
|
9
|
+
module WindowChrome
|
|
10
|
+
include ShapeHelpers
|
|
11
|
+
|
|
12
|
+
def draw_shadow(convert, geometry)
|
|
13
|
+
shadow = theme.window_decoration[:shadow]
|
|
14
|
+
scale = geometry[:scale]
|
|
15
|
+
offset_x = (shadow[:offset_x] * scale).to_i
|
|
16
|
+
offset_y = (shadow[:offset_y] * scale).to_i
|
|
17
|
+
radius = geometry[:scaled_radius]
|
|
18
|
+
margin = geometry[:margin]
|
|
19
|
+
|
|
20
|
+
convert.fill shadow[:color]
|
|
21
|
+
draw_roundrect(
|
|
22
|
+
convert,
|
|
23
|
+
margin + offset_x,
|
|
24
|
+
margin + offset_y,
|
|
25
|
+
margin + geometry[:scaled_width] - 1 + offset_x,
|
|
26
|
+
margin + geometry[:scaled_height] - 1 + offset_y,
|
|
27
|
+
radius
|
|
28
|
+
)
|
|
29
|
+
convert.blur "0x#{(shadow[:blur] * scale).to_i}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def draw_window(convert, geometry, transparent:)
|
|
33
|
+
margin = geometry[:margin]
|
|
34
|
+
background = color_with_opacity(theme.colors[:background], config.window[:opacity], true)
|
|
35
|
+
|
|
36
|
+
convert.fill background
|
|
37
|
+
draw_roundrect(
|
|
38
|
+
convert,
|
|
39
|
+
margin,
|
|
40
|
+
margin,
|
|
41
|
+
margin + geometry[:scaled_width] - 1,
|
|
42
|
+
margin + geometry[:scaled_height] - 1,
|
|
43
|
+
geometry[:scaled_radius]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
draw_border(convert, geometry)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def draw_title_bar(convert, geometry)
|
|
50
|
+
margin = geometry[:margin]
|
|
51
|
+
title_y2 = margin + geometry[:scaled_title_bar] - 1
|
|
52
|
+
|
|
53
|
+
convert.fill theme.colors[:title_bar]
|
|
54
|
+
draw_roundrect(
|
|
55
|
+
convert,
|
|
56
|
+
margin,
|
|
57
|
+
margin,
|
|
58
|
+
margin + geometry[:scaled_width] - 1,
|
|
59
|
+
title_y2,
|
|
60
|
+
geometry[:scaled_radius]
|
|
61
|
+
)
|
|
62
|
+
convert.fill theme.colors[:title_bar]
|
|
63
|
+
ImageMagickCommandBuilder.rectangle(
|
|
64
|
+
convert,
|
|
65
|
+
margin,
|
|
66
|
+
margin + geometry[:scaled_radius],
|
|
67
|
+
margin + geometry[:scaled_width] - 1,
|
|
68
|
+
title_y2
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
draw_title_separator(convert, geometry, title_y2)
|
|
72
|
+
draw_buttons(convert, geometry)
|
|
73
|
+
draw_title(convert, geometry)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def draw_title_separator(convert, geometry, y)
|
|
77
|
+
color = theme.colors[:title_bar_border]
|
|
78
|
+
return unless color
|
|
79
|
+
|
|
80
|
+
convert.stroke color
|
|
81
|
+
convert.strokewidth 1
|
|
82
|
+
ImageMagickCommandBuilder.line(convert, geometry[:margin], y, geometry[:margin] + geometry[:scaled_width] - 1, y)
|
|
83
|
+
convert.stroke "none"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def draw_border(convert, geometry)
|
|
87
|
+
color = theme.colors[:border]
|
|
88
|
+
return unless color
|
|
89
|
+
|
|
90
|
+
convert.fill "none"
|
|
91
|
+
convert.stroke color
|
|
92
|
+
convert.strokewidth [geometry[:scale].to_i, 1].max
|
|
93
|
+
draw_roundrect(
|
|
94
|
+
convert,
|
|
95
|
+
geometry[:margin],
|
|
96
|
+
geometry[:margin],
|
|
97
|
+
geometry[:margin] + geometry[:scaled_width] - 1,
|
|
98
|
+
geometry[:margin] + geometry[:scaled_height] - 1,
|
|
99
|
+
geometry[:scaled_radius]
|
|
100
|
+
)
|
|
101
|
+
convert.stroke "none"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def draw_buttons(convert, geometry)
|
|
105
|
+
return draw_windows_buttons(convert, geometry) if theme.button_style == :icons
|
|
106
|
+
|
|
107
|
+
button_radius = ((theme.window_decoration[:button_size] / 2.0) * geometry[:scale]).to_i
|
|
108
|
+
centers = circle_button_centers(geometry)
|
|
109
|
+
|
|
110
|
+
centers.each_with_index do |(x, y), index|
|
|
111
|
+
convert.fill theme.button_colors[index]
|
|
112
|
+
convert.stroke "rgba(0,0,0,0.18)"
|
|
113
|
+
convert.strokewidth [geometry[:scale].to_i, 1].max
|
|
114
|
+
ImageMagickCommandBuilder.circle(convert, x, y, button_radius)
|
|
115
|
+
end
|
|
116
|
+
convert.stroke "none"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def circle_button_centers(geometry)
|
|
120
|
+
scale = geometry[:scale]
|
|
121
|
+
size = (theme.window_decoration[:button_size] * scale).to_i
|
|
122
|
+
spacing = ((theme.window_decoration[:button_spacing] + theme.window_decoration[:button_size]) * scale).to_i
|
|
123
|
+
y = geometry[:margin] + geometry[:scaled_title_bar] / 2
|
|
124
|
+
|
|
125
|
+
start_x = if theme.buttons_position == :left
|
|
126
|
+
geometry[:margin] + (16 * scale).to_i
|
|
127
|
+
else
|
|
128
|
+
group_width = size * 3 + (theme.window_decoration[:button_spacing] * scale).to_i * 2
|
|
129
|
+
geometry[:margin] + geometry[:scaled_width] - (16 * scale).to_i - group_width + size / 2
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
3.times.map { |index| [start_x + index * spacing, y] }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def draw_windows_buttons(convert, geometry)
|
|
136
|
+
scale = geometry[:scale]
|
|
137
|
+
button_width = ((theme.window_decoration[:button_width] || 46) * scale).to_i
|
|
138
|
+
icon_size = (10 * scale).to_i
|
|
139
|
+
start_x = geometry[:margin] + geometry[:scaled_width] - button_width * 3
|
|
140
|
+
center_y = geometry[:margin] + geometry[:scaled_title_bar] / 2
|
|
141
|
+
color = theme.colors[:title_text]
|
|
142
|
+
|
|
143
|
+
convert.stroke color
|
|
144
|
+
convert.strokewidth [scale.to_i, 1].max
|
|
145
|
+
convert.fill "none"
|
|
146
|
+
|
|
147
|
+
3.times do |index|
|
|
148
|
+
center_x = start_x + button_width * index + button_width / 2
|
|
149
|
+
draw_windows_icon(convert, index, center_x, center_y, icon_size)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
convert.stroke "none"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def draw_title(convert, geometry)
|
|
156
|
+
scaled_font_size = geometry[:scaled_font_size]
|
|
157
|
+
group_width = button_group_width(geometry)
|
|
158
|
+
reserve_left = theme.buttons_position == :left ? group_width + (32 * geometry[:scale]).to_i : (12 * geometry[:scale]).to_i
|
|
159
|
+
reserve_right = theme.buttons_position == :right ? group_width + (12 * geometry[:scale]).to_i : (12 * geometry[:scale]).to_i
|
|
160
|
+
available_width = geometry[:scaled_width] - reserve_left - reserve_right
|
|
161
|
+
title = fit_text(config.title.to_s, available_width, scaled_font_size)
|
|
162
|
+
title_width = TextMetrics.pixel_width(title, scaled_font_size, ambiguous_width: config.window[:ambiguous_width])
|
|
163
|
+
min_x = geometry[:margin] + reserve_left
|
|
164
|
+
max_x = geometry[:margin] + geometry[:scaled_width] - reserve_right - title_width
|
|
165
|
+
centered_x = geometry[:margin] + (geometry[:scaled_width] - title_width) / 2
|
|
166
|
+
x = title_x(min_x, max_x, centered_x)
|
|
167
|
+
y = geometry[:margin] + geometry[:scaled_title_bar] / 2 + (scaled_font_size * 0.6).round
|
|
168
|
+
|
|
169
|
+
draw_text(convert, title, x, y - scaled_font_size, theme.colors[:title_text], scaled_font_size, geometry[:font_config])
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def title_x(min_x, max_x, centered_x)
|
|
173
|
+
case theme.title_alignment
|
|
174
|
+
when :left
|
|
175
|
+
min_x
|
|
176
|
+
when :right
|
|
177
|
+
max_x
|
|
178
|
+
else
|
|
179
|
+
[[centered_x, min_x].max, max_x].min
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def button_group_width(geometry)
|
|
184
|
+
scale = geometry[:scale]
|
|
185
|
+
if theme.button_style == :icons
|
|
186
|
+
((theme.window_decoration[:button_width] || 46) * 3 * scale).to_i
|
|
187
|
+
else
|
|
188
|
+
size = theme.window_decoration[:button_size]
|
|
189
|
+
spacing = theme.window_decoration[:button_spacing]
|
|
190
|
+
((size * 3 + spacing * 2) * scale).to_i
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
|
|
7
|
+
module Shellfie
|
|
8
|
+
class ReproducibilityManifest
|
|
9
|
+
def self.build(config, output_path:, format:)
|
|
10
|
+
renderer = Renderer.new(config)
|
|
11
|
+
{
|
|
12
|
+
schema: 1,
|
|
13
|
+
config_sha256: Digest::SHA256.hexdigest(JSON.generate(config.to_h)),
|
|
14
|
+
output: output_path,
|
|
15
|
+
output_sha256: output_digest(output_path),
|
|
16
|
+
format: format,
|
|
17
|
+
ruby: RUBY_DESCRIPTION,
|
|
18
|
+
platform: RbConfig::CONFIG["host_os"],
|
|
19
|
+
unicode: {
|
|
20
|
+
version: TextMetrics::UNICODE_VERSION,
|
|
21
|
+
width_table: TextMetrics::WIDTH_TABLE_VERSION,
|
|
22
|
+
ambiguous_width: config.window[:ambiguous_width]
|
|
23
|
+
},
|
|
24
|
+
imagemagick: DependencyChecker.imagemagick_details[:version],
|
|
25
|
+
ffmpeg: DependencyChecker.ffmpeg_version,
|
|
26
|
+
fonts: renderer.font_info
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.output_digest(path)
|
|
31
|
+
return Digest::SHA256.file(path).hexdigest if File.file?(path)
|
|
32
|
+
return unless File.directory?(path)
|
|
33
|
+
|
|
34
|
+
digest = Digest::SHA256.new
|
|
35
|
+
Dir.glob(File.join(path, "**", "*"), File::FNM_DOTMATCH).select { |entry| File.file?(entry) }.sort.each do |entry|
|
|
36
|
+
digest << entry.delete_prefix("#{path}#{File::SEPARATOR}") << "\0" << Digest::SHA256.file(entry).digest
|
|
37
|
+
end
|
|
38
|
+
digest.hexdigest
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "parser"
|
|
5
|
+
require_relative "terminal_screen"
|
|
6
|
+
|
|
7
|
+
module Shellfie
|
|
8
|
+
class Session
|
|
9
|
+
attr_reader :events, :captures, :exit_status, :screen
|
|
10
|
+
|
|
11
|
+
def initialize(columns:, rows:, title: "Terminal Session", events: [], captures: {}, exit_status: nil)
|
|
12
|
+
unless columns.is_a?(Integer) && columns.between?(1, 500) && rows.is_a?(Integer) && rows.between?(1, 200)
|
|
13
|
+
raise ParseError, "Invalid session dimensions"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@title = title
|
|
17
|
+
@events = events
|
|
18
|
+
@captures = captures
|
|
19
|
+
@exit_status = exit_status
|
|
20
|
+
@screen = TerminalScreen.new(columns: columns, rows: rows)
|
|
21
|
+
events.each { |event| @screen.feed(event[:text].to_s) if event.fetch(:visible, true) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def record(text, delay: 0, visible: true, status: nil)
|
|
25
|
+
screen.feed(text) if visible
|
|
26
|
+
event = { text: visible ? text : "", delay: delay, visible: visible, status: status }
|
|
27
|
+
events << event
|
|
28
|
+
@exit_status = status unless status.nil?
|
|
29
|
+
event
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def capture(name)
|
|
33
|
+
captures[name] = screen.render_lines
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def render_config(theme:, options: {}, animated: false, lines: nil)
|
|
37
|
+
base = {
|
|
38
|
+
theme: theme,
|
|
39
|
+
title: @title,
|
|
40
|
+
window: options.fetch(:window, {}),
|
|
41
|
+
font: options.fetch(:font, {}),
|
|
42
|
+
animation: options.fetch(:animation, {}),
|
|
43
|
+
headless: options.fetch(:headless, false)
|
|
44
|
+
}
|
|
45
|
+
if animated
|
|
46
|
+
raise ValidationError, "Captured screens cannot be rendered as animations" if lines
|
|
47
|
+
|
|
48
|
+
frames = []
|
|
49
|
+
each_snapshot do |event, snapshot|
|
|
50
|
+
frame = Frame.new(screen: snapshot, delay: [(event[:delay].to_f * 1_000).round, 1].max)
|
|
51
|
+
frames << frame
|
|
52
|
+
end
|
|
53
|
+
Config.new(**base, frames: frames)
|
|
54
|
+
else
|
|
55
|
+
Config.new(**base, lines: (lines || screen.render_lines).map { |line| Line.new(output: line) })
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def compose_hash
|
|
60
|
+
frames = []
|
|
61
|
+
each_snapshot do |event, snapshot|
|
|
62
|
+
frames << {
|
|
63
|
+
"screen" => snapshot,
|
|
64
|
+
"delay" => [(event[:delay].to_f * 1_000).round, 1].max
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
{
|
|
68
|
+
"version" => 1,
|
|
69
|
+
"title" => @title,
|
|
70
|
+
"window" => { "width" => screen.columns * 8, "visible_lines" => screen.rows },
|
|
71
|
+
"frames" => frames
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
{
|
|
77
|
+
version: 1,
|
|
78
|
+
title: @title,
|
|
79
|
+
columns: screen.columns,
|
|
80
|
+
rows: screen.rows,
|
|
81
|
+
events: events,
|
|
82
|
+
captures: captures,
|
|
83
|
+
exit_status: exit_status
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def each_snapshot
|
|
90
|
+
replay = TerminalScreen.new(columns: screen.columns, rows: screen.rows)
|
|
91
|
+
count = characters = 0
|
|
92
|
+
events.each do |event|
|
|
93
|
+
next unless event[:visible]
|
|
94
|
+
|
|
95
|
+
text = event[:text].to_s
|
|
96
|
+
next if text.empty? && !event[:delay].to_f.positive?
|
|
97
|
+
|
|
98
|
+
count += 1
|
|
99
|
+
raise ResourceLimitError, "Too many session frames (max #{Config::DEFAULTS[:limits][:max_frames]})" if count > Config::DEFAULTS[:limits][:max_frames]
|
|
100
|
+
|
|
101
|
+
replay.feed(text) unless text.empty?
|
|
102
|
+
snapshot = event[:screen] || replay.render_lines
|
|
103
|
+
characters += snapshot.sum(&:length)
|
|
104
|
+
if characters > Config::DEFAULTS[:limits][:max_characters]
|
|
105
|
+
raise ResourceLimitError, "Session snapshots are too large (max #{Config::DEFAULTS[:limits][:max_characters]} characters)"
|
|
106
|
+
end
|
|
107
|
+
yield event, snapshot
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|