shellfie 0.1.0 → 1.0.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +95 -236
  4. data/docs/.nojekyll +0 -0
  5. data/docs/index.html +205 -0
  6. data/docs/scripts.js +85 -0
  7. data/docs/styles.css +507 -0
  8. data/examples/simple.yml +3 -3
  9. data/lib/shellfie/animation_frame_builder.rb +178 -0
  10. data/lib/shellfie/animation_scroll_easing.rb +77 -0
  11. data/lib/shellfie/animation_timeline.rb +27 -0
  12. data/lib/shellfie/ansi_colors.rb +94 -0
  13. data/lib/shellfie/ansi_line_buffer.rb +87 -0
  14. data/lib/shellfie/ansi_normalizer.rb +51 -0
  15. data/lib/shellfie/ansi_parser.rb +50 -84
  16. data/lib/shellfie/cli.rb +22 -173
  17. data/lib/shellfie/cli_generate.rb +197 -0
  18. data/lib/shellfie/cli_info.rb +139 -0
  19. data/lib/shellfie/config.rb +108 -25
  20. data/lib/shellfie/config_defaults.rb +64 -0
  21. data/lib/shellfie/config_validation.rb +200 -0
  22. data/lib/shellfie/dependency_checker.rb +76 -0
  23. data/lib/shellfie/errors.rb +11 -1
  24. data/lib/shellfie/font_resolver.rb +58 -0
  25. data/lib/shellfie/format_resolver.rb +15 -0
  26. data/lib/shellfie/gif_generator.rb +83 -87
  27. data/lib/shellfie/gif_palette.rb +101 -0
  28. data/lib/shellfie/headless_theme_registry.rb +42 -0
  29. data/lib/shellfie/image_magick_command_builder.rb +75 -0
  30. data/lib/shellfie/line_layout.rb +137 -0
  31. data/lib/shellfie/output_writer.rb +41 -0
  32. data/lib/shellfie/parser.rb +113 -23
  33. data/lib/shellfie/parser_validation.rb +145 -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 +114 -0
  37. data/lib/shellfie/render_segment.rb +59 -0
  38. data/lib/shellfie/renderer.rb +79 -149
  39. data/lib/shellfie/rendering/shape_helpers.rb +42 -0
  40. data/lib/shellfie/rendering/text_painter.rb +187 -0
  41. data/lib/shellfie/rendering/window_chrome.rb +196 -0
  42. data/lib/shellfie/svg_raster_wrapper.rb +35 -0
  43. data/lib/shellfie/text_metrics.rb +96 -0
  44. data/lib/shellfie/theme_data.rb +80 -0
  45. data/lib/shellfie/theme_registry.rb +131 -0
  46. data/lib/shellfie/themes/base.rb +10 -1
  47. data/lib/shellfie/themes/configured.rb +61 -0
  48. data/lib/shellfie/themes/macos.rb +3 -1
  49. data/lib/shellfie/themes/ubuntu.rb +2 -1
  50. data/lib/shellfie/themes/windows_terminal.rb +7 -1
  51. data/lib/shellfie/version.rb +1 -1
  52. data/lib/shellfie.rb +37 -3
  53. metadata +37 -2
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tempfile"
5
+ require "tmpdir"
6
+
7
+ module Shellfie
8
+ class OutputWriter
9
+ class << self
10
+ def write(path, extension:)
11
+ FileUtils.mkdir_p(output_directory(path)) unless stdout?(path)
12
+ temp = Tempfile.new(["shellfie", ".#{extension}"], output_directory(path), binmode: true)
13
+ temp.close
14
+ yield temp.path
15
+
16
+ return File.binread(temp.path) if stdout?(path)
17
+
18
+ FileUtils.mv(temp.path, path)
19
+ path
20
+ ensure
21
+ if temp
22
+ temp.close unless temp.closed?
23
+ File.delete(temp.path) if File.exist?(temp.path)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def stdout?(path)
30
+ path == "-"
31
+ end
32
+
33
+ def output_directory(path)
34
+ return Dir.tmpdir if stdout?(path)
35
+
36
+ directory = File.dirname(path)
37
+ directory == "." ? Dir.pwd : directory
38
+ end
39
+ end
40
+ end
41
+ end
@@ -3,19 +3,24 @@
3
3
  require "yaml"
4
4
  require_relative "config"
5
5
  require_relative "errors"
6
+ require_relative "parser_validation"
6
7
 
7
8
  module Shellfie
8
9
  class Parser
9
10
  class << self
11
+ include ParserValidation
12
+
10
13
  def parse(path)
14
+ return parse_string($stdin.read, base_dir: Dir.pwd) if path == "-"
11
15
  raise ParseError, "Configuration file not found: #{path}" unless File.exist?(path)
12
16
 
13
17
  content = File.read(path)
14
- parse_string(content)
18
+ parse_string(content, base_dir: File.dirname(path))
15
19
  end
16
20
 
17
- def parse_string(content)
18
- raw = YAML.safe_load(content, symbolize_names: true)
21
+ def parse_string(content, base_dir: nil)
22
+ raw = YAML.safe_load(content, symbolize_names: true, aliases: true)
23
+ raw = apply_includes(raw, base_dir) if base_dir
19
24
  validate_config(raw)
20
25
  build_config(raw)
21
26
  rescue Psych::SyntaxError => e
@@ -24,27 +29,21 @@ module Shellfie
24
29
 
25
30
  private
26
31
 
27
- def validate_config(raw)
28
- raise ValidationError, "Empty configuration" if raw.nil? || raw.empty?
29
-
30
- valid_themes = %w[macos ubuntu windows]
31
- if raw[:theme] && !valid_themes.include?(raw[:theme])
32
- raise ValidationError, "Invalid theme '#{raw[:theme]}'\n → Available themes: #{valid_themes.join(", ")}"
33
- end
34
-
35
- if raw[:lines].nil? && raw[:frames].nil?
36
- raise ValidationError, "Configuration must have either 'lines' or 'frames'"
37
- end
38
- end
39
-
40
32
  def build_config(raw)
41
33
  options = {
34
+ version: raw[:version],
42
35
  theme: raw[:theme],
36
+ window_theme: raw[:window_theme],
37
+ color_scheme: raw[:color_scheme],
38
+ colors: symbolize_hash(raw[:colors]),
39
+ window_decoration: symbolize_hash(raw[:window_decoration]),
43
40
  title: raw[:title],
44
41
  window: symbolize_hash(raw[:window]),
45
42
  font: symbolize_hash(raw[:font]),
46
43
  lines: parse_lines(raw[:lines]),
47
44
  animation: symbolize_hash(raw[:animation]),
45
+ cursor: symbolize_hash(raw[:cursor]),
46
+ limits: symbolize_hash(raw[:limits]),
48
47
  frames: parse_frames(raw[:frames]),
49
48
  headless: raw[:headless] || false
50
49
  }.compact
@@ -52,39 +51,130 @@ module Shellfie
52
51
  Config.new(options)
53
52
  end
54
53
 
54
+ def apply_includes(raw, base_dir, depth: 0)
55
+ return raw unless raw.is_a?(Hash) && raw[:include]
56
+ raise ParseError, "YAML include depth exceeded" if depth > 5
57
+
58
+ includes = Array(raw[:include])
59
+ included_config = includes.reduce({}) do |merged, include_path|
60
+ include_file = File.expand_path(include_path, base_dir)
61
+ raise ParseError, "Included configuration file not found: #{include_path}" unless File.exist?(include_file)
62
+
63
+ included_raw = YAML.safe_load(File.read(include_file), symbolize_names: true, aliases: true)
64
+ included_raw = apply_includes(included_raw, File.dirname(include_file), depth: depth + 1)
65
+ deep_merge(merged, included_raw || {})
66
+ end
67
+
68
+ deep_merge(included_config, raw.reject { |key, _value| key == :include })
69
+ end
70
+
71
+ def deep_merge(base, overrides)
72
+ base.merge(overrides) do |_key, left, right|
73
+ left.is_a?(Hash) && right.is_a?(Hash) ? deep_merge(left, right) : right
74
+ end
75
+ end
76
+
55
77
  def symbolize_hash(hash)
56
78
  return nil unless hash.is_a?(Hash)
57
79
 
58
- hash.transform_keys(&:to_sym)
80
+ Config.normalize_keys(hash)
59
81
  end
60
82
 
61
83
  def parse_lines(lines)
62
- return [] unless lines.is_a?(Array)
84
+ return [] if lines.nil?
63
85
 
64
86
  lines.map do |line|
65
87
  Line.new(
66
88
  prompt: line[:prompt],
67
89
  command: line[:command],
68
- output: line[:output]
90
+ output: line[:output],
91
+ prompt_color: line[:prompt_color],
92
+ command_color: line[:command_color],
93
+ output_color: line[:output_color],
94
+ selected: line[:selected] || false
69
95
  )
70
96
  end
71
97
  end
72
98
 
73
99
  def parse_frames(frames)
74
- return [] unless frames.is_a?(Array)
100
+ return [] if frames.nil?
75
101
 
76
102
  frames.map do |frame|
77
103
  Frame.new(
78
104
  prompt: frame[:prompt],
79
105
  type: frame[:type],
80
106
  output: frame[:output],
81
- delay: frame[:delay] || 0
107
+ delay: frame[:delay] || 0,
108
+ prompt_color: frame[:prompt_color],
109
+ command_color: frame[:command_color],
110
+ output_color: frame[:output_color]
82
111
  )
83
112
  end
84
113
  end
85
114
  end
86
115
  end
87
116
 
88
- Line = Struct.new(:prompt, :command, :output, keyword_init: true)
89
- Frame = Struct.new(:prompt, :type, :output, :delay, keyword_init: true)
117
+ class Line
118
+ attr_reader :prompt, :command, :output, :prompt_color, :command_color, :output_color, :selected
119
+
120
+ def initialize(prompt: nil, command: nil, output: nil, prompt_color: nil, command_color: nil, output_color: nil,
121
+ selected: false)
122
+ @prompt = prompt
123
+ @command = command
124
+ @output = output
125
+ @prompt_color = prompt_color
126
+ @command_color = command_color
127
+ @output_color = output_color
128
+ @selected = selected
129
+ freeze
130
+ end
131
+
132
+ def to_h
133
+ {
134
+ prompt: prompt,
135
+ command: command,
136
+ output: output,
137
+ prompt_color: prompt_color,
138
+ command_color: command_color,
139
+ output_color: output_color,
140
+ selected: selected
141
+ }.compact
142
+ end
143
+
144
+ def to_s
145
+ [prompt, command, output].compact.join("\n")
146
+ end
147
+ end
148
+
149
+ class Frame
150
+ attr_reader :prompt, :type, :output, :delay, :prompt_color, :command_color, :output_color
151
+
152
+ def initialize(prompt: nil, type: nil, output: nil, delay: 0, prompt_color: nil, command_color: nil,
153
+ output_color: nil)
154
+ @prompt = prompt
155
+ @type = type
156
+ @output = output
157
+ @delay = delay
158
+ @prompt_color = prompt_color
159
+ @command_color = command_color
160
+ @output_color = output_color
161
+ freeze
162
+ end
163
+
164
+ def to_h
165
+ {
166
+ prompt: prompt,
167
+ type: type,
168
+ output: output,
169
+ delay: delay,
170
+ prompt_color: prompt_color,
171
+ command_color: command_color,
172
+ output_color: output_color
173
+ }.compact
174
+ end
175
+
176
+ def to_s
177
+ [prompt, type, output, delay].compact.join("\n")
178
+ end
179
+ end
90
180
  end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ module ParserValidation
5
+ TOP_LEVEL_KEYS = %i[
6
+ version include theme window_theme color_scheme colors window_decoration title window font animation cursor lines frames
7
+ headless limits
8
+ ].freeze
9
+ WINDOW_KEYS = %i[
10
+ width padding opacity visible_lines max_lines max_height wrap overflow margin exact_size trim tab_width
11
+ ansi_state background_gradient scroll_offset
12
+ ].freeze
13
+ FONT_KEYS = %i[family size line_height fallback_family italic_family emoji_family].freeze
14
+ ANIMATION_KEYS = %i[
15
+ typing_speed command_delay cursor_blink loop typing_jitter typing_chunk_size output_delay final_delay max_frames
16
+ dither palette scroll_easing
17
+ ].freeze
18
+ CURSOR_KEYS = %i[style color].freeze
19
+ LIMIT_KEYS = %i[max_lines max_frames max_render_frames max_characters max_pixels].freeze
20
+ LINE_KEYS = %i[prompt command output prompt_color command_color output_color selected].freeze
21
+ FRAME_KEYS = %i[prompt type output delay prompt_color command_color output_color].freeze
22
+
23
+ private
24
+
25
+ def validate_config(raw)
26
+ raise ValidationError, "Empty configuration" if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
27
+ raise ValidationError, "Configuration must be a YAML mapping" unless raw.is_a?(Hash)
28
+
29
+ validate_keys!(raw, TOP_LEVEL_KEYS, "configuration")
30
+ validate_nested_hash!(raw, :window, WINDOW_KEYS)
31
+ validate_nested_hash!(raw, :font, FONT_KEYS)
32
+ validate_nested_hash!(raw, :animation, ANIMATION_KEYS)
33
+ validate_nested_hash!(raw, :cursor, CURSOR_KEYS)
34
+ validate_nested_hash!(raw, :limits, LIMIT_KEYS)
35
+ validate_nested_hash!(raw, :colors, nil)
36
+ validate_nested_hash!(raw, :window_decoration, nil)
37
+ validate_theme!(raw[:theme]) if raw[:theme]
38
+ validate_window_theme!(raw[:window_theme]) if raw[:window_theme]
39
+ validate_color_scheme!(raw[:color_scheme]) if raw.key?(:color_scheme)
40
+
41
+ raise ValidationError, "Configuration must have either 'lines' or 'frames'" if raw[:lines].nil? && raw[:frames].nil?
42
+
43
+ validate_lines!(raw[:lines]) if raw.key?(:lines)
44
+ validate_frames!(raw[:frames]) if raw.key?(:frames)
45
+ end
46
+
47
+ def validate_theme!(theme)
48
+ return if ThemeRegistry.valid_theme?(theme)
49
+
50
+ raise ValidationError, "Invalid theme '#{theme}'\n → Available themes: #{ThemeRegistry.available_themes.join(", ")}"
51
+ end
52
+
53
+ def validate_window_theme!(theme)
54
+ return if ThemeRegistry.valid_window_theme?(theme)
55
+
56
+ raise ValidationError, "Invalid window_theme '#{theme}'"
57
+ end
58
+
59
+ def validate_color_scheme!(scheme)
60
+ return if ThemeRegistry.valid_color_scheme?(scheme)
61
+
62
+ raise ValidationError, "Invalid color_scheme '#{scheme}'"
63
+ end
64
+
65
+ def validate_nested_hash!(raw, key, allowed_keys)
66
+ return unless raw.key?(key)
67
+ raise ValidationError, "#{key} must be a mapping" unless raw[key].is_a?(Hash)
68
+
69
+ validate_keys!(raw[key], allowed_keys, key.to_s) if allowed_keys
70
+ end
71
+
72
+ def validate_keys!(hash, allowed_keys, context)
73
+ unknown_keys = hash.keys - allowed_keys
74
+ return if unknown_keys.empty?
75
+
76
+ raise ValidationError, "Unknown #{context} key(s): #{unknown_keys.join(", ")}"
77
+ end
78
+
79
+ def validate_lines!(lines)
80
+ raise ValidationError, "lines must be an array" unless lines.is_a?(Array)
81
+
82
+ lines.each_with_index do |line, index|
83
+ raise ValidationError, "lines[#{index}] must be a mapping" unless line.is_a?(Hash)
84
+
85
+ validate_keys!(line, LINE_KEYS, "lines[#{index}]")
86
+ if line.values_at(:prompt, :command, :output).all?(&:nil?)
87
+ raise ValidationError, "lines[#{index}] must include at least one of prompt, command, or output"
88
+ end
89
+ validate_line_values!(line, index)
90
+ end
91
+ end
92
+
93
+ def validate_frames!(frames)
94
+ raise ValidationError, "frames must be an array" unless frames.is_a?(Array)
95
+
96
+ frames.each_with_index do |frame, index|
97
+ raise ValidationError, "frames[#{index}] must be a mapping" unless frame.is_a?(Hash)
98
+
99
+ validate_keys!(frame, FRAME_KEYS, "frames[#{index}]")
100
+ validate_frame_shape!(frame, index)
101
+ end
102
+ end
103
+
104
+ def validate_line_values!(line, index)
105
+ %i[prompt command output prompt_color command_color output_color].each do |key|
106
+ validate_string_value!(line[key], "lines[#{index}].#{key}") if line.key?(key)
107
+ end
108
+ validate_boolean_value!(line[:selected], "lines[#{index}].selected") if line.key?(:selected)
109
+ end
110
+
111
+ def validate_frame_shape!(frame, index)
112
+ raise ValidationError, "frames[#{index}].prompt requires type" if frame[:prompt] && frame[:type].nil?
113
+
114
+ if frame.values_at(:type, :output, :delay).all?(&:nil?)
115
+ raise ValidationError, "frames[#{index}] must include type, output, or delay"
116
+ end
117
+
118
+ validate_string_value!(frame[:prompt], "frames[#{index}].prompt") if frame.key?(:prompt)
119
+ validate_string_value!(frame[:type], "frames[#{index}].type") if frame.key?(:type)
120
+ validate_string_value!(frame[:output], "frames[#{index}].output") if frame.key?(:output)
121
+ validate_string_value!(frame[:prompt_color], "frames[#{index}].prompt_color") if frame.key?(:prompt_color)
122
+ validate_string_value!(frame[:command_color], "frames[#{index}].command_color") if frame.key?(:command_color)
123
+ validate_string_value!(frame[:output_color], "frames[#{index}].output_color") if frame.key?(:output_color)
124
+ validate_non_negative_integer!(frame[:delay], "frames[#{index}].delay") if frame.key?(:delay)
125
+ end
126
+
127
+ def validate_string_value!(value, name)
128
+ return if value.is_a?(String)
129
+
130
+ raise ValidationError, "#{name} must be a string"
131
+ end
132
+
133
+ def validate_boolean_value!(value, name)
134
+ return if value == true || value == false
135
+
136
+ raise ValidationError, "#{name} must be true or false"
137
+ end
138
+
139
+ def validate_non_negative_integer!(value, name)
140
+ return if value.is_a?(Integer) && value >= 0
141
+
142
+ raise ValidationError, "#{name} must be a non-negative integer"
143
+ end
144
+ end
145
+ end
@@ -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,114 @@
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 = 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
+ margin: margin,
70
+ canvas_width: (width * scale).to_i + margin * 2,
71
+ canvas_height: (total_height * scale).ceil + margin * 2,
72
+ shadow: shadow
73
+ }
74
+ end
75
+
76
+ def width
77
+ @config.window[:width]
78
+ end
79
+
80
+ def padding
81
+ @config.window[:padding]
82
+ end
83
+
84
+ def title_bar_height
85
+ @config.headless ? 0 : @theme.window_decoration[:title_bar_height]
86
+ end
87
+
88
+ def corner_radius
89
+ @config.headless ? 0 : @theme.window_decoration[:corner_radius]
90
+ end
91
+
92
+ def exact_size?
93
+ @config.window[:exact_size]
94
+ end
95
+
96
+ def canvas_margin(scale, shadow)
97
+ configured = @config.window[:margin]
98
+ return (configured * scale).to_i if configured
99
+ return 0 if @config.headless && !shadow
100
+ return 0 if exact_size?
101
+ return (10 * scale).to_i unless shadow
102
+
103
+ shadow_config = @theme.window_decoration[:shadow]
104
+ (([shadow_config[:blur].to_i, shadow_config[:offset_x].to_i.abs, shadow_config[:offset_y].to_i.abs].max + 10) * scale).to_i
105
+ end
106
+
107
+ def validate_pixel_limit!(geometry)
108
+ pixels = geometry[:canvas_width] * geometry[:canvas_height]
109
+ return if pixels <= @config.limits[:max_pixels]
110
+
111
+ raise ResourceLimitError, "Estimated image is too large (#{pixels} pixels, max #{@config.limits[:max_pixels]})"
112
+ end
113
+ end
114
+ end