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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -2
  3. data/README.md +248 -228
  4. data/lib/shellfie/animation_frame_builder.rb +202 -0
  5. data/lib/shellfie/animation_scroll_easing.rb +77 -0
  6. data/lib/shellfie/animation_timeline.rb +28 -0
  7. data/lib/shellfie/ansi_colors.rb +94 -0
  8. data/lib/shellfie/ansi_line_buffer.rb +103 -0
  9. data/lib/shellfie/ansi_normalizer.rb +61 -0
  10. data/lib/shellfie/ansi_parser.rb +162 -83
  11. data/lib/shellfie/cassette.rb +76 -0
  12. data/lib/shellfie/cli.rb +75 -163
  13. data/lib/shellfie/cli_authoring.rb +233 -0
  14. data/lib/shellfie/cli_generate.rb +401 -0
  15. data/lib/shellfie/cli_info.rb +239 -0
  16. data/lib/shellfie/cli_run.rb +167 -0
  17. data/lib/shellfie/config.rb +112 -25
  18. data/lib/shellfie/config_defaults.rb +83 -0
  19. data/lib/shellfie/config_validation.rb +289 -0
  20. data/lib/shellfie/dependency_checker.rb +147 -0
  21. data/lib/shellfie/errors.rb +12 -1
  22. data/lib/shellfie/ffmpeg_encoder.rb +46 -0
  23. data/lib/shellfie/font_resolver.rb +68 -0
  24. data/lib/shellfie/format_resolver.rb +15 -0
  25. data/lib/shellfie/gif_generator.rb +231 -89
  26. data/lib/shellfie/gif_palette.rb +105 -0
  27. data/lib/shellfie/headless_theme_registry.rb +42 -0
  28. data/lib/shellfie/html_renderer.rb +54 -0
  29. data/lib/shellfie/image_magick_command_builder.rb +75 -0
  30. data/lib/shellfie/line_layout.rb +146 -0
  31. data/lib/shellfie/output_writer.rb +46 -0
  32. data/lib/shellfie/parser.rb +183 -30
  33. data/lib/shellfie/parser_validation.rb +178 -0
  34. data/lib/shellfie/raster_painter.rb +157 -0
  35. data/lib/shellfie/render_chrome_cache.rb +40 -0
  36. data/lib/shellfie/render_geometry.rb +115 -0
  37. data/lib/shellfie/render_segment.rb +71 -0
  38. data/lib/shellfie/renderer.rb +96 -149
  39. data/lib/shellfie/rendering/shape_helpers.rb +42 -0
  40. data/lib/shellfie/rendering/text_painter.rb +195 -0
  41. data/lib/shellfie/rendering/window_chrome.rb +196 -0
  42. data/lib/shellfie/reproducibility_manifest.rb +41 -0
  43. data/lib/shellfie/session.rb +111 -0
  44. data/lib/shellfie/session_config.rb +562 -0
  45. data/lib/shellfie/session_runner.rb +689 -0
  46. data/lib/shellfie/svg_raster_wrapper.rb +35 -0
  47. data/lib/shellfie/svg_renderer.rb +222 -0
  48. data/lib/shellfie/terminal_screen.rb +389 -0
  49. data/lib/shellfie/text_metrics.rb +155 -0
  50. data/lib/shellfie/theme_data.rb +80 -0
  51. data/lib/shellfie/theme_registry.rb +131 -0
  52. data/lib/shellfie/themes/base.rb +10 -1
  53. data/lib/shellfie/themes/configured.rb +61 -0
  54. data/lib/shellfie/themes/macos.rb +3 -1
  55. data/lib/shellfie/themes/ubuntu.rb +2 -1
  56. data/lib/shellfie/themes/windows_terminal.rb +7 -1
  57. data/lib/shellfie/transcript_renderer.rb +92 -0
  58. data/lib/shellfie/version.rb +1 -1
  59. data/lib/shellfie/yaml_safety.rb +147 -0
  60. data/lib/shellfie.rb +44 -3
  61. data/schema/shellfie-v1.schema.json +153 -0
  62. data/schema/shellfie-v2.schema.json +262 -0
  63. metadata +58 -20
  64. data/.rspec +0 -3
  65. data/Rakefile +0 -8
  66. data/examples/animation.yml +0 -33
  67. data/examples/colored.yml +0 -20
  68. data/examples/demo.gif +0 -0
  69. data/examples/demo.png +0 -0
  70. data/examples/demo_animation.yml +0 -31
  71. data/examples/headless.png +0 -0
  72. data/examples/headless.yml +0 -16
  73. data/examples/scrolling.yml +0 -48
  74. data/examples/simple.yml +0 -21
  75. data/examples/theme_macos.png +0 -0
  76. data/examples/theme_ubuntu.png +0 -0
  77. data/examples/theme_windows.png +0 -0
  78. data/shellfie.gemspec +0 -32
@@ -1,6 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "mini_magick"
4
+ require "fileutils"
5
+ require "json"
6
+ require "tmpdir"
7
+ require_relative "animation_frame_builder"
8
+ require_relative "dependency_checker"
9
+ require_relative "format_resolver"
10
+ require_relative "ffmpeg_encoder"
11
+ require_relative "gif_palette"
12
+ require_relative "image_magick_command_builder"
13
+ require_relative "output_writer"
14
+ require_relative "render_chrome_cache"
4
15
  require_relative "renderer"
5
16
 
6
17
  module Shellfie
@@ -11,141 +22,272 @@ module Shellfie
11
22
  @config = config
12
23
  @renderer = Renderer.new(config)
13
24
  @theme = @renderer.theme
25
+ @frame_builder = AnimationFrameBuilder.new(config)
14
26
  end
15
27
 
16
- def generate(output_path, scale: 1, shadow: true)
28
+ def generate(output_path, scale: 1, shadow: true, transparent: false, format: nil, io: nil)
17
29
  check_dependencies!
18
30
 
19
- frames = build_animation_frames
20
- images = render_frames(frames, scale: scale, shadow: shadow)
21
- combine_to_gif(images, output_path)
22
- cleanup_temp_files(images)
23
- output_path
31
+ images = []
32
+ chrome_cache = RenderChromeCache.new
33
+ begin
34
+ frames = playback_frames(coalesce_frames(build_animation_frames))
35
+ validate_frame_limit!(frames)
36
+ validate_workload!(frames, scale: scale, shadow: shadow)
37
+ warn_frame_count(frames)
38
+ images = render_frames(frames, scale: scale, shadow: shadow, transparent: transparent, chrome_cache: chrome_cache)
39
+ extension = FormatResolver.resolve(output_path, explicit: format, default: "gif")
40
+ return write_png_sequence(images, output_path) if extension == "png-sequence"
41
+
42
+ OutputWriter.write(output_path, extension: extension, io: io) do |temporary_path|
43
+ combine_to_animation(images, temporary_path, format: extension)
44
+ end
45
+ ensure
46
+ cleanup_temp_files(images)
47
+ chrome_cache.cleanup
48
+ end
49
+ rescue MiniMagick::Error => e
50
+ raise RenderError.new("ImageMagick animation render failed: #{e.message}", category: :render)
24
51
  end
25
52
 
26
53
  private
27
54
 
28
55
  def check_dependencies!
29
- result = `which magick 2>/dev/null || which convert 2>/dev/null`.strip
30
- if result.empty?
31
- raise DependencyError, <<~MSG
32
- ImageMagick not found
33
- → Please install ImageMagick: brew install imagemagick
34
- → Or visit: https://imagemagick.org/script/download.php
35
- MSG
36
- end
56
+ DependencyChecker.configure_mini_magick!
57
+ DependencyChecker.ensure_imagemagick!
37
58
  end
38
59
 
39
60
  def build_animation_frames
40
- frames = []
41
- current_lines = []
42
- animation_settings = config.animation
43
-
44
- config.frames.each do |frame|
45
- if frame.type
46
- typing_frames = build_typing_frames(
47
- current_lines.dup,
48
- frame.prompt || "",
49
- frame.type,
50
- animation_settings[:typing_speed] || 80
51
- )
52
- frames.concat(typing_frames)
53
- current_lines << { prompt: frame.prompt, command: frame.type }
54
- end
55
-
56
- if frame.output
57
- frame.output.split("\n").each do |line|
58
- current_lines << { output: line }
59
- end
60
- frames << { lines: build_display_lines(current_lines), delay: frame.delay || 100 }
61
- end
62
-
63
- if frame.delay && frame.delay > 0 && !frame.output
64
- frames << { lines: build_display_lines(current_lines), delay: frame.delay }
65
- end
66
- end
67
-
68
- frames
69
- end
70
-
71
- def build_typing_frames(base_lines, prompt, command, typing_speed)
72
- frames = []
73
- chars = command.chars
74
-
75
- chars.each_with_index do |_char, i|
76
- typed = command[0..i]
77
- lines = base_lines.dup
78
- lines << { prompt: prompt, command: typed, cursor: true }
79
- frames << { lines: build_display_lines(lines), delay: typing_speed }
80
- end
81
-
82
- final_lines = base_lines.dup
83
- final_lines << { prompt: prompt, command: command }
84
- frames << { lines: build_display_lines(final_lines), delay: typing_speed }
85
-
86
- frames
61
+ @frame_builder.build
87
62
  end
88
63
 
89
- def build_display_lines(lines_data)
90
- lines_data.map do |line_data|
91
- if line_data[:prompt]
92
- text = "#{line_data[:prompt]}#{line_data[:command]}"
93
- text += "█" if line_data[:cursor]
94
- Line.new(prompt: text, command: nil, output: nil)
95
- else
96
- Line.new(prompt: nil, command: nil, output: line_data[:output])
97
- end
98
- end
64
+ def cursor_text
65
+ @frame_builder.cursor_text
99
66
  end
100
67
 
101
- def render_frames(frames, scale:, shadow:)
68
+ def render_frames(frames, scale:, shadow:, transparent:, chrome_cache:)
102
69
  temp_dir = Dir.mktmpdir("shellfie")
103
70
  images = []
71
+ complete = false
72
+ visible_lines = fixed_visible_lines(frames)
104
73
 
105
74
  frames.each_with_index do |frame, idx|
106
- frame_config = create_frame_config(frame[:lines])
107
- renderer = Renderer.new(frame_config)
75
+ frame_config = create_frame_config(
76
+ frame[:lines],
77
+ window_overrides: { visible_lines: visible_lines }.merge(frame[:window] || {})
78
+ )
79
+ renderer = Renderer.new(frame_config, chrome_cache: chrome_cache)
108
80
  output_path = File.join(temp_dir, "frame_#{format("%04d", idx)}.png")
109
- renderer.render(output_path, scale: scale, shadow: shadow)
81
+ renderer.render(output_path, scale: scale, shadow: shadow, transparent: transparent)
110
82
  images << { path: output_path, delay: frame[:delay] }
111
83
  end
112
84
 
85
+ complete = true
113
86
  images
87
+ ensure
88
+ FileUtils.rm_rf(temp_dir) if temp_dir && !complete
114
89
  end
115
90
 
116
- def create_frame_config(lines)
91
+ def create_frame_config(lines, window_overrides: {})
117
92
  Config.new(
118
93
  theme: config.theme,
94
+ window_theme: config.window_theme,
95
+ color_scheme: config.color_scheme,
96
+ colors: config.colors,
97
+ window_decoration: config.window_decoration,
119
98
  title: config.title,
120
- window: config.window,
99
+ window: config.window.merge(window_overrides),
121
100
  font: config.font,
122
101
  lines: lines,
102
+ animation: config.animation,
103
+ cursor: config.cursor,
104
+ limits: config.limits,
123
105
  headless: config.headless
124
106
  )
125
107
  end
126
108
 
127
- def combine_to_gif(images, output_path)
128
- MiniMagick.convert do |convert|
129
- convert.dispose "none"
130
- convert.loop config.animation[:loop] ? 0 : 1
109
+ def combine_to_animation(images, output_path, format:)
110
+ if %w[mp4 webm apng].include?(format)
111
+ DependencyChecker.ensure_ffmpeg!
112
+ return FfmpegEncoder.encode(
113
+ images,
114
+ output_path,
115
+ format: format,
116
+ command: DependencyChecker.ffmpeg_path,
117
+ framerate: config.animation[:framerate],
118
+ playback_speed: config.animation[:playback_speed],
119
+ loop: config.animation[:loop],
120
+ loop_count: config.animation[:loop_count],
121
+ apng_prediction: config.animation[:apng_prediction]
122
+ )
123
+ end
131
124
 
132
- images.each do |img|
133
- delay = (img[:delay] / 10.0).round
125
+ palette = GifPalette.new(config: config, theme: theme) if format == "gif"
126
+ ImageMagickCommandBuilder.convert do |convert|
127
+ convert.dispose "none" if format == "gif"
128
+ convert.loop animation_loop_count
129
+
130
+ animation_entries(images).each do |delay, img|
134
131
  convert.delay delay
135
132
  convert << img[:path]
136
133
  end
137
134
 
138
- convert.dither "FloydSteinberg"
139
- convert.colors 256
140
- convert.layers "optimize"
141
- convert << output_path
135
+ configure_animation_format(convert, format, images: images, palette: palette)
136
+ ImageMagickCommandBuilder.output(convert, output_path, format: format)
137
+ end
138
+ ensure
139
+ palette&.cleanup
140
+ end
141
+
142
+ def configure_animation_format(convert, format, images:, palette:)
143
+ case format
144
+ when "gif"
145
+ palette.apply(convert, images: images)
146
+ convert.layers "optimize" if config.animation[:gif_optimize]
147
+ when "webp"
148
+ convert.define "webp:lossless=#{config.animation[:webp_lossless]}"
149
+ convert.define "webp:method=#{config.animation[:webp_method]}"
150
+ convert.define "webp:near-lossless=#{config.animation[:webp_near_lossless]}"
151
+ convert.quality config.animation[:webp_quality]
142
152
  end
143
153
  end
144
154
 
155
+ def animation_loop_count
156
+ config.animation[:loop_count] || (config.animation[:loop] ? 0 : 1)
157
+ end
158
+
145
159
  def cleanup_temp_files(images)
146
- images.each { |img| File.delete(img[:path]) if File.exist?(img[:path]) }
147
160
  temp_dir = File.dirname(images.first[:path]) if images.any?
148
- Dir.rmdir(temp_dir) if temp_dir && Dir.exist?(temp_dir) && Dir.empty?(temp_dir)
161
+ FileUtils.rm_rf(temp_dir) if temp_dir && Dir.exist?(temp_dir)
162
+ end
163
+
164
+ def write_png_sequence(images, output_path)
165
+ if Dir.exist?(output_path) && !replaceable_sequence_directory?(output_path)
166
+ raise FileSystemError, "Refusing to replace a non-Shellfie directory: #{output_path}"
167
+ end
168
+
169
+ parent = File.dirname(File.expand_path(output_path))
170
+ FileUtils.mkdir_p(parent)
171
+ temporary = Dir.mktmpdir(".shellfie-sequence-", parent)
172
+ frames = images.each_with_index.map do |image, index|
173
+ filename = "frame_#{format("%04d", index)}.png"
174
+ FileUtils.cp(image[:path], File.join(temporary, filename))
175
+ { file: filename, delay_ms: image[:delay] / config.animation[:playback_speed] }
176
+ end
177
+ File.write(File.join(temporary, "timeline.json"), JSON.pretty_generate(version: 1, frames: frames))
178
+ backup = "#{temporary}-previous"
179
+ File.rename(output_path, backup) if File.exist?(output_path)
180
+ File.rename(temporary, output_path)
181
+ FileUtils.rm_rf(backup)
182
+ output_path
183
+ rescue StandardError
184
+ File.rename(backup, output_path) if backup && File.exist?(backup) && !File.exist?(output_path)
185
+ raise
186
+ ensure
187
+ FileUtils.rm_rf(temporary) if temporary && Dir.exist?(temporary)
188
+ FileUtils.rm_rf(backup) if backup && File.exist?(backup)
189
+ end
190
+
191
+ def replaceable_sequence_directory?(path)
192
+ entries = Dir.children(path)
193
+ return true if entries.empty?
194
+ return false unless entries.include?("timeline.json")
195
+
196
+ entries.all? { |entry| entry == "timeline.json" || entry.match?(/\Aframe_\d{4}\.png\z/) }
197
+ end
198
+
199
+ def animation_delays(images)
200
+ elapsed = 0.0
201
+ emitted = 0
202
+ images.map do |image|
203
+ elapsed += image[:delay] / config.animation[:playback_speed]
204
+ target = [(elapsed / 10.0).round, emitted + 1].max
205
+ (target - emitted).tap { emitted = target }
206
+ end
207
+ end
208
+
209
+ def animation_entries(images)
210
+ target_ticks = [(images.sum { |image| image[:delay] } / config.animation[:playback_speed] / 10.0).round, 1].max
211
+ return animation_delays(images).zip(images) if target_ticks >= images.size
212
+
213
+ elapsed = 0.0
214
+ ends = images.map { |image| elapsed += image[:delay] / config.animation[:playback_speed] }
215
+ indexes = (1..target_ticks).map do |tick|
216
+ ends.index { |finish| finish >= tick * 10 } || images.size - 1
217
+ end
218
+ indexes[-1] = images.size - 1
219
+ indexes.chunk(&:itself).map { |index, ticks| [ticks.size, images[index]] }
220
+ end
221
+
222
+ def warn_frame_count(frames)
223
+ max_frames = config.animation[:max_frames]
224
+ return unless max_frames && frames.size > max_frames
225
+
226
+ $stderr.puts "Warning: animation will generate #{frames.size} frames (max_frames is #{max_frames})"
227
+ end
228
+
229
+ def validate_frame_limit!(frames)
230
+ return if frames.size <= config.limits[:max_render_frames]
231
+
232
+ raise ResourceLimitError, "Animation would generate #{frames.size} frames (max #{config.limits[:max_render_frames]})"
233
+ end
234
+
235
+ def coalesce_frames(frames)
236
+ frames.each_with_object([]) do |frame, result|
237
+ if result.last && frame_key(result.last) == frame_key(frame)
238
+ result.last[:delay] += frame[:delay]
239
+ else
240
+ result << frame.merge(lines: frame[:lines].dup)
241
+ end
242
+ end
243
+ end
244
+
245
+ def frame_key(frame)
246
+ [frame[:lines].map(&:to_h), frame[:window]]
247
+ end
248
+
249
+ def playback_frames(frames)
250
+ frames = frames.reverse if config.animation[:direction] == "reverse"
251
+ offset = config.animation[:loop_offset]
252
+ if offset >= frames.size
253
+ raise ValidationError, "animation.loop_offset must be less than the #{frames.size} rendered frames"
254
+ end
255
+ frames = frames.rotate(offset)
256
+ return frames unless config.animation[:direction] == "ping_pong" && frames.size > 2
257
+
258
+ frames + frames[1...-1].reverse
259
+ end
260
+
261
+ def validate_workload!(frames, scale:, shadow:)
262
+ speed = Rational(config.animation[:playback_speed].to_s)
263
+ encoded_frames = (frames.sum { |frame| frame[:delay] } * config.animation[:framerate] / (1_000 * speed)).ceil
264
+ if encoded_frames > config.limits[:max_render_frames]
265
+ raise ResourceLimitError,
266
+ "Animation timeline would encode #{encoded_frames} frames (max #{config.limits[:max_render_frames]})"
267
+ end
268
+
269
+ visible_lines = fixed_visible_lines(frames)
270
+ max_pixels = frames.map do |frame|
271
+ frame_config = create_frame_config(frame[:lines], window_overrides: { visible_lines: visible_lines }.merge(frame[:window] || {}))
272
+ geometry = Renderer.new(frame_config).estimate(scale: scale, shadow: shadow)
273
+ geometry[:canvas_width] * geometry[:canvas_height]
274
+ end.max || 0
275
+ total_pixels = max_pixels * frames.size
276
+ if total_pixels > config.limits[:max_total_pixels]
277
+ raise ResourceLimitError,
278
+ "Animation workload is too large (#{total_pixels} pixels, max #{config.limits[:max_total_pixels]})"
279
+ end
280
+
281
+ estimated_bytes = total_pixels * 4
282
+ return if estimated_bytes <= config.limits[:max_temp_bytes]
283
+
284
+ raise ResourceLimitError,
285
+ "Animation temporary data is too large (#{estimated_bytes} bytes, max #{config.limits[:max_temp_bytes]})"
286
+ end
287
+
288
+ def fixed_visible_lines(frames)
289
+ config.window[:visible_lines] || [frames.map { |frame| frame[:lines].size }.max.to_i, 1].max
149
290
  end
291
+
150
292
  end
151
293
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require_relative "image_magick_command_builder"
5
+
6
+ module Shellfie
7
+ class GifPalette
8
+ def initialize(config:, theme:, command_builder: ImageMagickCommandBuilder)
9
+ @config = config
10
+ @theme = theme
11
+ @command_builder = command_builder
12
+ @temporary_files = []
13
+ end
14
+
15
+ def apply(convert, images: [])
16
+ convert.dither(dither_mode)
17
+
18
+ case @config.animation[:palette]
19
+ when "global"
20
+ apply_global_palette(convert, images)
21
+ when "theme"
22
+ apply_theme_palette(convert)
23
+ else
24
+ convert.colors color_count
25
+ end
26
+ end
27
+
28
+ def cleanup
29
+ @temporary_files.each { |file| file.close! if file.respond_to?(:close!) }
30
+ @temporary_files.clear
31
+ end
32
+
33
+ private
34
+
35
+ def apply_global_palette(convert, images)
36
+ palette_path = build_global_palette(images)
37
+ convert.remap palette_path if palette_path
38
+ convert.colors color_count
39
+ end
40
+
41
+ def apply_theme_palette(convert)
42
+ palette_path = build_theme_palette
43
+ convert.remap palette_path if palette_path
44
+ convert.colors(theme_color_count)
45
+ end
46
+
47
+ def build_global_palette(images)
48
+ return nil if images.empty?
49
+
50
+ path = palette_path
51
+ @command_builder.convert do |convert|
52
+ images.each { |image| convert << image[:path] }
53
+ convert.append
54
+ convert.colors 256
55
+ convert.unique_colors
56
+ @command_builder.output(convert, path, format: "png")
57
+ end
58
+ path
59
+ end
60
+
61
+ def build_theme_palette
62
+ colors = theme_colors.first(color_count)
63
+ return nil if colors.empty?
64
+
65
+ path = palette_path
66
+ @command_builder.convert do |convert|
67
+ @command_builder.canvas(convert, width: colors.size, height: 1, background: "xc:transparent")
68
+ colors.each_with_index do |color, index|
69
+ convert.fill color
70
+ @command_builder.point(convert, index, 0)
71
+ end
72
+ @command_builder.output(convert, path, format: "png")
73
+ end
74
+ path
75
+ end
76
+
77
+ def palette_path
78
+ file = Tempfile.new(["shellfie-palette", ".png"])
79
+ @temporary_files << file
80
+ file.close
81
+ file.path
82
+ end
83
+
84
+ def dither_mode
85
+ @config.animation[:dither] ? "FloydSteinberg" : "None"
86
+ end
87
+
88
+ def theme_color_count
89
+ [[theme_colors.size, [16, color_count].min].max, color_count].min
90
+ end
91
+
92
+ def theme_colors
93
+ @theme_colors ||= [
94
+ @theme.colors.values,
95
+ @theme.button_colors,
96
+ @theme.window_decoration.dig(:shadow, :color),
97
+ @theme.window_decoration[:border]
98
+ ].flatten.compact.uniq
99
+ end
100
+
101
+ def color_count
102
+ @config.animation[:gif_colors]
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "theme_data"
4
+
5
+ module Shellfie
6
+ class HeadlessThemeRegistry
7
+ VARIANTS = {
8
+ "plain" => {
9
+ window_decoration: {
10
+ title_bar_height: 0,
11
+ corner_radius: 0,
12
+ button_size: 0,
13
+ button_spacing: 0
14
+ },
15
+ button_colors: [],
16
+ button_style: :none,
17
+ buttons_position: :left,
18
+ title_alignment: :left
19
+ }
20
+ }.freeze
21
+
22
+ class << self
23
+ def build(theme, variant: "plain")
24
+ settings = VARIANTS.fetch(variant)
25
+ ThemeData.new(
26
+ name: theme.name,
27
+ colors: theme.colors,
28
+ window_decoration: ThemeData.deep_merge(theme.window_decoration, settings[:window_decoration]),
29
+ button_colors: settings[:button_colors],
30
+ buttons_position: settings[:buttons_position],
31
+ button_style: settings[:button_style],
32
+ font: theme.font,
33
+ title_alignment: settings[:title_alignment]
34
+ )
35
+ end
36
+
37
+ def available_variants
38
+ VARIANTS.keys
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+ require_relative "svg_renderer"
5
+
6
+ module Shellfie
7
+ class HtmlRenderer
8
+ def initialize(config:, theme:)
9
+ @config = config
10
+ @svg_renderer = SvgRenderer.new(config: config, theme: theme)
11
+ end
12
+
13
+ def render(geometry, output_path, transparent: false)
14
+ svg = @svg_renderer.to_svg(geometry, transparent: transparent).sub(/\A<\?xml[^>]+>\s*/, "")
15
+ transcript = geometry[:lines].map { |line| line[:segments].map(&:text).join }.join("\n")
16
+ File.write(output_path, <<~HTML)
17
+ <!doctype html>
18
+ <html lang="en">
19
+ <head>
20
+ <meta charset="utf-8">
21
+ <meta name="viewport" content="width=device-width, initial-scale=1">
22
+ <title>#{escape(@config.title)}</title>
23
+ <style>
24
+ :root { color-scheme: light dark; }
25
+ :root[data-theme="light"] { color-scheme: light; }
26
+ :root[data-theme="dark"] { color-scheme: dark; }
27
+ body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: Canvas; }
28
+ main { max-width: 100%; overflow: auto; }
29
+ button { position: fixed; inset: 1rem 1rem auto auto; font: inherit; }
30
+ svg { display: block; max-width: 100%; height: auto; }
31
+ .transcript { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: pre; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <button type="button" aria-label="Toggle color theme">Theme</button>
36
+ <main aria-label="#{escape(@config.title)}">#{svg}<pre class="transcript">#{escape(transcript)}</pre></main>
37
+ <script>
38
+ document.querySelector('button').addEventListener('click', () => {
39
+ const root = document.documentElement;
40
+ root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark';
41
+ });
42
+ </script>
43
+ </body>
44
+ </html>
45
+ HTML
46
+ end
47
+
48
+ private
49
+
50
+ def escape(value)
51
+ CGI.escapeHTML(value.to_s)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mini_magick"
4
+
5
+ module Shellfie
6
+ class ImageMagickCommandBuilder
7
+ class << self
8
+ def convert(&block)
9
+ MiniMagick.convert(&block)
10
+ end
11
+
12
+ def canvas(convert, width:, height:, background:)
13
+ convert.size "#{width}x#{height}"
14
+ convert << background
15
+ end
16
+
17
+ def output(convert, path, format: nil)
18
+ convert << output_path(path, format: format || File.extname(path).delete_prefix("."))
19
+ end
20
+
21
+ def output_path(path, format:)
22
+ format == "apng" ? "apng:#{path}" : path
23
+ end
24
+
25
+ def draw(convert, command)
26
+ convert.draw command
27
+ end
28
+
29
+ def rectangle(convert, x1, y1, x2, y2)
30
+ draw(convert, "rectangle #{x1},#{y1} #{x2},#{y2}")
31
+ end
32
+
33
+ def rectangles(convert, rectangles)
34
+ return if rectangles.empty?
35
+
36
+ draw(convert, rectangles.map { |rect| "rectangle #{rect[:x1]},#{rect[:y1]} #{rect[:x2]},#{rect[:y2]}" }.join(" "))
37
+ end
38
+
39
+ def round_rectangle(convert, x1, y1, x2, y2, radius)
40
+ draw(convert, "roundrectangle #{x1},#{y1} #{x2},#{y2} #{radius},#{radius}")
41
+ end
42
+
43
+ def line(convert, x1, y1, x2, y2)
44
+ draw(convert, "line #{x1},#{y1} #{x2},#{y2}")
45
+ end
46
+
47
+ def lines(convert, lines)
48
+ return if lines.empty?
49
+
50
+ draw(convert, lines.map { |line| "line #{line[:x1]},#{line[:y1]} #{line[:x2]},#{line[:y2]}" }.join(" "))
51
+ end
52
+
53
+ def circle(convert, center_x, center_y, radius)
54
+ draw(convert, "circle #{center_x},#{center_y} #{center_x + radius},#{center_y}")
55
+ end
56
+
57
+ def point(convert, x, y)
58
+ draw(convert, "point #{x},#{y}")
59
+ end
60
+
61
+ def region(convert, x:, y:, width:, height:)
62
+ convert.region "#{width}x#{height}+#{x}+#{y}"
63
+ end
64
+
65
+ def clear_region(convert)
66
+ convert << "+region"
67
+ end
68
+
69
+ def composite_over(convert)
70
+ convert.compose "over"
71
+ convert.composite
72
+ end
73
+ end
74
+ end
75
+ end