shellfie 0.1.1 → 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.
- checksums.yaml +4 -4
- data/README.md +95 -236
- data/docs/.nojekyll +0 -0
- data/docs/index.html +205 -0
- data/docs/scripts.js +85 -0
- data/docs/styles.css +507 -0
- data/examples/simple.yml +3 -3
- data/lib/shellfie/animation_frame_builder.rb +178 -0
- data/lib/shellfie/animation_scroll_easing.rb +77 -0
- data/lib/shellfie/animation_timeline.rb +27 -0
- data/lib/shellfie/ansi_colors.rb +94 -0
- data/lib/shellfie/ansi_line_buffer.rb +87 -0
- data/lib/shellfie/ansi_normalizer.rb +51 -0
- data/lib/shellfie/ansi_parser.rb +50 -84
- data/lib/shellfie/cli.rb +22 -173
- data/lib/shellfie/cli_generate.rb +197 -0
- data/lib/shellfie/cli_info.rb +139 -0
- data/lib/shellfie/config.rb +108 -25
- data/lib/shellfie/config_defaults.rb +64 -0
- data/lib/shellfie/config_validation.rb +200 -0
- data/lib/shellfie/dependency_checker.rb +76 -0
- data/lib/shellfie/errors.rb +11 -1
- data/lib/shellfie/font_resolver.rb +58 -0
- data/lib/shellfie/format_resolver.rb +15 -0
- data/lib/shellfie/gif_generator.rb +83 -87
- data/lib/shellfie/gif_palette.rb +101 -0
- data/lib/shellfie/headless_theme_registry.rb +42 -0
- data/lib/shellfie/image_magick_command_builder.rb +75 -0
- data/lib/shellfie/line_layout.rb +137 -0
- data/lib/shellfie/output_writer.rb +41 -0
- data/lib/shellfie/parser.rb +113 -23
- data/lib/shellfie/parser_validation.rb +145 -0
- data/lib/shellfie/raster_painter.rb +157 -0
- data/lib/shellfie/render_chrome_cache.rb +40 -0
- data/lib/shellfie/render_geometry.rb +114 -0
- data/lib/shellfie/render_segment.rb +59 -0
- data/lib/shellfie/renderer.rb +79 -149
- data/lib/shellfie/rendering/shape_helpers.rb +42 -0
- data/lib/shellfie/rendering/text_painter.rb +187 -0
- data/lib/shellfie/rendering/window_chrome.rb +196 -0
- data/lib/shellfie/svg_raster_wrapper.rb +35 -0
- data/lib/shellfie/text_metrics.rb +96 -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/version.rb +1 -1
- data/lib/shellfie.rb +37 -3
- 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
|
data/lib/shellfie/parser.rb
CHANGED
|
@@ -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
|
-
|
|
80
|
+
Config.normalize_keys(hash)
|
|
59
81
|
end
|
|
60
82
|
|
|
61
83
|
def parse_lines(lines)
|
|
62
|
-
return []
|
|
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 []
|
|
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
|
|
89
|
-
|
|
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
|