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
@@ -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
@@ -2,192 +2,139 @@
2
2
 
3
3
  require "mini_magick"
4
4
  require_relative "ansi_parser"
5
- require_relative "themes/base"
6
- require_relative "themes/macos"
7
- require_relative "themes/ubuntu"
8
- require_relative "themes/windows_terminal"
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
- THEMES = {
13
- "macos" => Themes::MacOS,
14
- "ubuntu" => Themes::Ubuntu,
15
- "windows" => Themes::WindowsTerminal
16
- }.freeze
20
+ attr_reader :config, :theme, :font_resolver
17
21
 
18
- attr_reader :config, :theme
19
-
20
- def initialize(config)
22
+ def initialize(config, chrome_cache: nil)
21
23
  @config = config
22
- @theme = load_theme(config.theme)
23
- @ansi_parser = AnsiParser.new
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
- check_dependencies!
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
- create_image(lines, output_path, scale: scale, shadow: shadow, transparent: transparent)
31
- output_path
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
- private
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 load_theme(name)
37
- klass = THEMES[name] || THEMES["macos"]
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
- result = `which magick 2>/dev/null || which convert 2>/dev/null`.strip
43
- if result.empty?
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 build_lines
53
- config.lines.flat_map do |line|
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 create_image(lines, output_path, scale:, shadow:, transparent:)
70
- decoration = theme.window_decoration
71
- font_config = config.font.is_a?(Hash) ? config.font : theme.font
72
- padding = config.window[:padding] || 20
73
- width = config.window[:width] || 600
74
- line_height = (font_config[:size] || 14) * (font_config[:line_height] || 1.4)
75
- title_bar_height = config.headless ? 0 : decoration[:title_bar_height]
76
- visible_lines = config.window[:visible_lines]
77
-
78
- display_line_count = visible_lines || lines.size
79
- content_height = display_line_count * line_height + padding * 2
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
- convert.fill theme.colors[:background]
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
- content_y = margin + scaled_title_bar + scaled_padding
146
- lines.each_with_index do |line, idx|
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 button_positions(margin, title_bar_height, scale)
157
- button_size = (theme.window_decoration[:button_size] * scale).to_i
158
- scaled_width = ((config.window[:width] || 600) * scale).to_i
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
- y = margin + title_bar_height / 2
93
+ end
166
94
 
167
- [x, y]
95
+ def coalesce_segments(segments)
96
+ RenderSegment.coalesce(segments)
168
97
  end
169
98
 
170
- def draw_line_segments(convert, segments, x, y, font_size, font_config)
171
- current_x = x
99
+ def create_image(lines, output_path, scale:, shadow:, transparent:)
100
+ geometry = build_geometry(lines, scale: scale, shadow: shadow)
172
101
 
173
- segments.each do |segment|
174
- color = segment.foreground ? theme.color_for(segment.foreground) : theme.colors[:foreground]
175
- text = escape_text(segment.text)
102
+ raster_painter.paint(geometry, output_path, transparent: transparent)
103
+ end
176
104
 
177
- next if text.empty?
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
- convert.fill color
180
- convert.pointsize font_size
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
- convert.draw "text #{current_x},#{y} '#{text}'"
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
- char_width = font_size * 0.6
185
- current_x += (segment.text.length * char_width).to_i
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
- text.to_s.gsub("'", "\\\\'").gsub("\\", "\\\\\\\\")
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