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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- 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,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shellfie
|
|
4
|
+
module CLIInfo
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def run_init
|
|
8
|
+
puts <<~YAML
|
|
9
|
+
# Shellfie configuration file
|
|
10
|
+
theme: macos
|
|
11
|
+
title: "Terminal — zsh"
|
|
12
|
+
|
|
13
|
+
window:
|
|
14
|
+
width: 600
|
|
15
|
+
padding: 20
|
|
16
|
+
|
|
17
|
+
lines:
|
|
18
|
+
- prompt: "$ "
|
|
19
|
+
command: "gem install shellfie"
|
|
20
|
+
|
|
21
|
+
- output: |
|
|
22
|
+
Fetching shellfie-#{VERSION}.gem
|
|
23
|
+
Successfully installed shellfie-#{VERSION}
|
|
24
|
+
1 gem installed
|
|
25
|
+
|
|
26
|
+
- prompt: "$ "
|
|
27
|
+
command: "shellfie --version"
|
|
28
|
+
|
|
29
|
+
- output: "shellfie #{VERSION}"
|
|
30
|
+
YAML
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run_themes
|
|
34
|
+
puts "Available themes:"
|
|
35
|
+
puts
|
|
36
|
+
ThemeRegistry.available_themes.each { |theme| puts " #{theme}" }
|
|
37
|
+
puts
|
|
38
|
+
puts "Available color schemes:"
|
|
39
|
+
ThemeRegistry.available_color_schemes.each { |scheme| puts " #{scheme}" }
|
|
40
|
+
puts
|
|
41
|
+
puts "Use: shellfie generate config.yml -o output.png -t THEME_NAME"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run_validate
|
|
45
|
+
input_file = @args.shift
|
|
46
|
+
raise ConfigError, "Input file is required" unless input_file
|
|
47
|
+
|
|
48
|
+
config = Parser.parse(input_file)
|
|
49
|
+
puts "✓ Configuration is valid"
|
|
50
|
+
puts " Theme: #{config.theme}"
|
|
51
|
+
puts " Title: #{config.title}"
|
|
52
|
+
puts " Mode: #{config.animated? ? "animated" : "static"}"
|
|
53
|
+
puts " Lines: #{config.lines.size}" if config.static?
|
|
54
|
+
puts " Source frames: #{config.frames.size}" if config.animated?
|
|
55
|
+
puts " Estimated render frames: #{AnimationFrameBuilder.new(config).build.size}" if config.animated?
|
|
56
|
+
geometry = Renderer.new(config).estimate
|
|
57
|
+
puts " Estimated size: #{geometry[:canvas_width]}x#{geometry[:canvas_height]}"
|
|
58
|
+
puts " Logical size: #{geometry[:logical_width]}x#{geometry[:logical_height]} @#{geometry[:scale]}x"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_inspect
|
|
62
|
+
input_file = @args.shift
|
|
63
|
+
raise ConfigError, "Input file is required" unless input_file
|
|
64
|
+
|
|
65
|
+
info = Shellfie.inspect_config(input_file)
|
|
66
|
+
puts "Config:"
|
|
67
|
+
puts " Version: #{info[:config][:version]}"
|
|
68
|
+
puts " Theme: #{info[:theme]}"
|
|
69
|
+
puts " Title: #{info[:config][:title]}"
|
|
70
|
+
puts " Mode: #{info[:config][:frames].empty? ? "static" : "animated"}"
|
|
71
|
+
puts " Lines: #{info[:config][:lines].size}"
|
|
72
|
+
puts " Frames: #{info[:config][:frames].size}"
|
|
73
|
+
puts " Estimated size: #{info[:geometry][:canvas_width]}x#{info[:geometry][:canvas_height]}"
|
|
74
|
+
puts " Logical size: #{info[:geometry][:logical_width]}x#{info[:geometry][:logical_height]} @#{info[:geometry][:scale]}x"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def run_doctor
|
|
78
|
+
failed = false
|
|
79
|
+
DependencyChecker.doctor.each do |check|
|
|
80
|
+
status = check[:ok] ? "ok" : "fail"
|
|
81
|
+
failed ||= !check[:ok]
|
|
82
|
+
puts "#{status.ljust(4)} #{check[:name]}: #{check[:detail]}"
|
|
83
|
+
end
|
|
84
|
+
exit 4 if failed
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def run_version
|
|
88
|
+
puts "shellfie #{VERSION}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def show_help
|
|
92
|
+
puts <<~HELP
|
|
93
|
+
Shellfie - Terminal screenshot-style image generator
|
|
94
|
+
|
|
95
|
+
Usage: shellfie <command> [options]
|
|
96
|
+
shf <command> [options]
|
|
97
|
+
|
|
98
|
+
Commands:
|
|
99
|
+
generate Generate image from configuration file
|
|
100
|
+
init Output sample configuration
|
|
101
|
+
themes List available themes
|
|
102
|
+
validate Validate configuration file
|
|
103
|
+
inspect Print resolved config and estimated image size
|
|
104
|
+
doctor Check dependencies and local environment
|
|
105
|
+
version Show version
|
|
106
|
+
help Show this help
|
|
107
|
+
|
|
108
|
+
Generate Options:
|
|
109
|
+
-o, --output PATH Output file path (required)
|
|
110
|
+
-t, --theme NAME Override theme (macos, ubuntu, windows)
|
|
111
|
+
-a, --animate Generate animated GIF
|
|
112
|
+
-s, --scale FACTOR Output scale (1, 2, 3)
|
|
113
|
+
-w, --width PIXELS Override width
|
|
114
|
+
--no-shadow Disable shadow effect
|
|
115
|
+
--no-header Disable window header (headless mode)
|
|
116
|
+
--transparent Transparent background
|
|
117
|
+
--fps FPS Override animation typing FPS
|
|
118
|
+
--overflow MODE Line overflow mode: clip, wrap, scroll
|
|
119
|
+
--wrap, --no-wrap Enable or disable long-line wrapping
|
|
120
|
+
--exact-size Match canvas to configured window size
|
|
121
|
+
--format FORMAT Output format: png, gif, svg, webp, apng
|
|
122
|
+
--force Overwrite existing output files
|
|
123
|
+
--quiet Suppress non-error output
|
|
124
|
+
--verbose Print progress details
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
shellfie generate config.yml -o terminal.png
|
|
128
|
+
shellfie generate config.yml -o demo.gif --animate
|
|
129
|
+
shellfie generate config.yml -o retina.png --scale 2
|
|
130
|
+
shellfie init > my-config.yml
|
|
131
|
+
shellfie themes
|
|
132
|
+
|
|
133
|
+
# Short form
|
|
134
|
+
shf generate config.yml -o terminal.png
|
|
135
|
+
shf init > config.yml
|
|
136
|
+
HELP
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/shellfie/config.rb
CHANGED
|
@@ -1,40 +1,83 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "config_validation"
|
|
4
|
+
require_relative "config_defaults"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "theme_registry"
|
|
7
|
+
|
|
3
8
|
module Shellfie
|
|
4
9
|
class Config
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
10
|
+
include ConfigValidation
|
|
11
|
+
|
|
12
|
+
VALID_THEMES = ThemeRegistry.available_themes.freeze
|
|
13
|
+
VALID_OVERFLOW_MODES = %w[clip wrap scroll].freeze
|
|
14
|
+
VALID_CURSOR_STYLES = %w[block bar underline].freeze
|
|
15
|
+
VALID_PALETTES = %w[global adaptive theme].freeze
|
|
16
|
+
VALID_SCROLL_EASINGS = %w[linear ease_in ease_out ease_in_out].freeze
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def deep_dup(value)
|
|
20
|
+
case value
|
|
21
|
+
when Hash
|
|
22
|
+
value.each_with_object({}) { |(key, nested_value), result| result[key] = deep_dup(nested_value) }
|
|
23
|
+
when Array
|
|
24
|
+
value.map { |nested_value| deep_dup(nested_value) }
|
|
25
|
+
else
|
|
26
|
+
value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def deep_freeze(value)
|
|
31
|
+
case value
|
|
32
|
+
when Hash
|
|
33
|
+
value.each_value { |nested_value| deep_freeze(nested_value) }
|
|
34
|
+
when Array
|
|
35
|
+
value.each { |nested_value| deep_freeze(nested_value) }
|
|
36
|
+
end
|
|
37
|
+
value.freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def normalize_keys(value)
|
|
41
|
+
case value
|
|
42
|
+
when Hash
|
|
43
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
44
|
+
normalized_key = key.is_a?(String) ? key.to_sym : key
|
|
45
|
+
result[normalized_key] = normalize_keys(nested_value)
|
|
46
|
+
end
|
|
47
|
+
when Array
|
|
48
|
+
value.map { |nested_value| normalize_keys(nested_value) }
|
|
49
|
+
else
|
|
50
|
+
value
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
DEFAULTS = deep_freeze(deep_dup(ConfigDefaults::VALUES))
|
|
25
56
|
|
|
26
|
-
attr_reader :theme, :
|
|
57
|
+
attr_reader :version, :theme, :window_theme, :color_scheme, :colors, :window_decoration, :title, :window, :font,
|
|
58
|
+
:lines, :animation, :frames, :headless, :cursor, :limits
|
|
27
59
|
|
|
28
60
|
def initialize(options = {})
|
|
61
|
+
options = self.class.normalize_keys(options)
|
|
29
62
|
merged = merge_defaults(options)
|
|
63
|
+
@version = merged[:version]
|
|
30
64
|
@theme = merged[:theme]
|
|
65
|
+
@window_theme = merged[:window_theme]
|
|
66
|
+
@color_scheme = merged[:color_scheme]
|
|
67
|
+
@colors = merged[:colors]
|
|
68
|
+
@window_decoration = merged[:window_decoration]
|
|
31
69
|
@title = merged[:title] || "Terminal"
|
|
32
70
|
@window = merged[:window]
|
|
33
71
|
@font = merged[:font]
|
|
34
72
|
@lines = merged[:lines] || []
|
|
35
73
|
@animation = merged[:animation]
|
|
36
74
|
@frames = merged[:frames] || []
|
|
37
|
-
@
|
|
75
|
+
@cursor = merged[:cursor]
|
|
76
|
+
@limits = merged[:limits]
|
|
77
|
+
@headless = merged[:headless] || false
|
|
78
|
+
|
|
79
|
+
validate!
|
|
80
|
+
freeze_state!
|
|
38
81
|
end
|
|
39
82
|
|
|
40
83
|
def static?
|
|
@@ -45,21 +88,61 @@ module Shellfie
|
|
|
45
88
|
!static?
|
|
46
89
|
end
|
|
47
90
|
|
|
91
|
+
def to_h
|
|
92
|
+
{
|
|
93
|
+
version: version,
|
|
94
|
+
theme: theme,
|
|
95
|
+
window_theme: window_theme,
|
|
96
|
+
color_scheme: color_scheme,
|
|
97
|
+
colors: colors,
|
|
98
|
+
window_decoration: window_decoration,
|
|
99
|
+
title: title,
|
|
100
|
+
window: window,
|
|
101
|
+
font: font,
|
|
102
|
+
animation: animation,
|
|
103
|
+
cursor: cursor,
|
|
104
|
+
limits: limits,
|
|
105
|
+
headless: headless,
|
|
106
|
+
lines: lines.map(&:to_h),
|
|
107
|
+
frames: frames.map(&:to_h)
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
|
|
48
111
|
private
|
|
49
112
|
|
|
50
113
|
def merge_defaults(options)
|
|
51
|
-
result =
|
|
114
|
+
result = self.class.deep_dup(DEFAULTS)
|
|
52
115
|
DEFAULTS.each do |key, value|
|
|
116
|
+
next unless options.key?(key)
|
|
117
|
+
|
|
53
118
|
result[key] = if value.is_a?(Hash) && options[key].is_a?(Hash)
|
|
54
|
-
|
|
119
|
+
result[key].merge(options[key])
|
|
55
120
|
else
|
|
56
|
-
|
|
121
|
+
self.class.deep_dup(options[key])
|
|
57
122
|
end
|
|
58
123
|
end
|
|
59
124
|
result[:title] = options[:title]
|
|
60
125
|
result[:lines] = options[:lines]
|
|
61
126
|
result[:frames] = options[:frames]
|
|
127
|
+
result[:headless] = options[:headless] if options.key?(:headless)
|
|
62
128
|
result
|
|
63
129
|
end
|
|
130
|
+
|
|
131
|
+
def freeze_state!
|
|
132
|
+
@colors = self.class.deep_freeze(@colors)
|
|
133
|
+
@window_decoration = self.class.deep_freeze(@window_decoration)
|
|
134
|
+
@window = self.class.deep_freeze(@window)
|
|
135
|
+
@font = self.class.deep_freeze(@font)
|
|
136
|
+
@animation = self.class.deep_freeze(@animation)
|
|
137
|
+
@cursor = self.class.deep_freeze(@cursor)
|
|
138
|
+
@limits = self.class.deep_freeze(@limits)
|
|
139
|
+
@lines = self.class.deep_freeze(@lines)
|
|
140
|
+
@frames = self.class.deep_freeze(@frames)
|
|
141
|
+
@title.freeze
|
|
142
|
+
@theme.freeze
|
|
143
|
+
@window_theme.freeze if @window_theme
|
|
144
|
+
@color_scheme.freeze if @color_scheme
|
|
145
|
+
freeze
|
|
146
|
+
end
|
|
64
147
|
end
|
|
65
148
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shellfie
|
|
4
|
+
module ConfigDefaults
|
|
5
|
+
VALUES = {
|
|
6
|
+
version: 1,
|
|
7
|
+
theme: "macos",
|
|
8
|
+
window_theme: nil,
|
|
9
|
+
color_scheme: nil,
|
|
10
|
+
colors: {},
|
|
11
|
+
window_decoration: {},
|
|
12
|
+
window: {
|
|
13
|
+
width: 600,
|
|
14
|
+
padding: 20,
|
|
15
|
+
opacity: 1.0,
|
|
16
|
+
visible_lines: nil,
|
|
17
|
+
max_lines: nil,
|
|
18
|
+
max_height: nil,
|
|
19
|
+
wrap: false,
|
|
20
|
+
overflow: "clip",
|
|
21
|
+
margin: nil,
|
|
22
|
+
exact_size: false,
|
|
23
|
+
trim: false,
|
|
24
|
+
tab_width: 8,
|
|
25
|
+
ansi_state: "persistent",
|
|
26
|
+
background_gradient: nil,
|
|
27
|
+
scroll_offset: 0.0
|
|
28
|
+
},
|
|
29
|
+
font: {
|
|
30
|
+
family: "Monaco",
|
|
31
|
+
size: 14,
|
|
32
|
+
line_height: 1.4,
|
|
33
|
+
fallback_family: nil,
|
|
34
|
+
italic_family: nil,
|
|
35
|
+
emoji_family: nil
|
|
36
|
+
},
|
|
37
|
+
animation: {
|
|
38
|
+
typing_speed: 80,
|
|
39
|
+
command_delay: 500,
|
|
40
|
+
cursor_blink: true,
|
|
41
|
+
loop: false,
|
|
42
|
+
typing_jitter: 0.0,
|
|
43
|
+
typing_chunk_size: 1,
|
|
44
|
+
output_delay: 0,
|
|
45
|
+
final_delay: 1_000,
|
|
46
|
+
max_frames: nil,
|
|
47
|
+
dither: true,
|
|
48
|
+
palette: "global",
|
|
49
|
+
scroll_easing: "linear"
|
|
50
|
+
},
|
|
51
|
+
cursor: {
|
|
52
|
+
style: "block",
|
|
53
|
+
color: nil
|
|
54
|
+
},
|
|
55
|
+
limits: {
|
|
56
|
+
max_lines: 10_000,
|
|
57
|
+
max_frames: 500,
|
|
58
|
+
max_render_frames: 2_000,
|
|
59
|
+
max_characters: 200_000,
|
|
60
|
+
max_pixels: 50_000_000
|
|
61
|
+
}
|
|
62
|
+
}.freeze
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shellfie
|
|
4
|
+
module ConfigValidation
|
|
5
|
+
def validate!
|
|
6
|
+
validate_version!
|
|
7
|
+
validate_theme!
|
|
8
|
+
validate_window!
|
|
9
|
+
validate_font!
|
|
10
|
+
validate_animation!
|
|
11
|
+
validate_cursor!
|
|
12
|
+
validate_lines!
|
|
13
|
+
validate_limits!
|
|
14
|
+
validate_headless!
|
|
15
|
+
validate_resource_limits!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def validate_version!
|
|
21
|
+
return if @version == 1
|
|
22
|
+
|
|
23
|
+
raise ValidationError, "Unsupported config version '#{@version}'"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate_theme!
|
|
27
|
+
return if ThemeRegistry.valid_theme?(@theme)
|
|
28
|
+
|
|
29
|
+
raise ValidationError, "Invalid theme '#{@theme}'\n → Available themes: #{ThemeRegistry.available_themes.join(", ")}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def validate_window_theme!
|
|
33
|
+
return if @window_theme.nil? || ThemeRegistry.valid_window_theme?(@window_theme)
|
|
34
|
+
|
|
35
|
+
raise ValidationError, "Invalid window_theme '#{@window_theme}'"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate_color_scheme!
|
|
39
|
+
return if ThemeRegistry.valid_color_scheme?(@color_scheme)
|
|
40
|
+
|
|
41
|
+
raise ValidationError, "Invalid color_scheme '#{@color_scheme}'"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate_window!
|
|
45
|
+
validate_window_theme!
|
|
46
|
+
validate_color_scheme!
|
|
47
|
+
validate_positive_integer!(@window[:width], "window.width")
|
|
48
|
+
validate_non_negative_integer!(@window[:padding], "window.padding")
|
|
49
|
+
%i[opacity scroll_offset].each { |key| validate_number_range!(@window[key], "window.#{key}", 0.0, 1.0) }
|
|
50
|
+
validate_optional_positive_integer!(@window[:visible_lines], "window.visible_lines")
|
|
51
|
+
validate_optional_positive_integer!(@window[:max_lines], "window.max_lines")
|
|
52
|
+
validate_optional_positive_integer!(@window[:max_height], "window.max_height")
|
|
53
|
+
validate_optional_non_negative_integer!(@window[:margin], "window.margin")
|
|
54
|
+
validate_positive_integer!(@window[:tab_width], "window.tab_width")
|
|
55
|
+
validate_boolean!(@window[:wrap], "window.wrap")
|
|
56
|
+
validate_boolean!(@window[:exact_size], "window.exact_size")
|
|
57
|
+
validate_boolean!(@window[:trim], "window.trim")
|
|
58
|
+
validate_overflow!
|
|
59
|
+
validate_ansi_state!
|
|
60
|
+
validate_background_gradient!
|
|
61
|
+
validate_minimum_width!
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_font!
|
|
65
|
+
validate_optional_string!(@font[:family], "font.family")
|
|
66
|
+
validate_optional_string!(@font[:fallback_family], "font.fallback_family")
|
|
67
|
+
validate_optional_string!(@font[:italic_family], "font.italic_family")
|
|
68
|
+
validate_optional_string!(@font[:emoji_family], "font.emoji_family")
|
|
69
|
+
validate_positive_number!(@font[:size], "font.size")
|
|
70
|
+
validate_positive_number!(@font[:line_height], "font.line_height")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_animation!
|
|
74
|
+
validate_non_negative_integer!(@animation[:typing_speed], "animation.typing_speed")
|
|
75
|
+
validate_non_negative_integer!(@animation[:command_delay], "animation.command_delay")
|
|
76
|
+
validate_number_range!(@animation[:typing_jitter], "animation.typing_jitter", 0.0, 1.0)
|
|
77
|
+
validate_positive_integer!(@animation[:typing_chunk_size], "animation.typing_chunk_size")
|
|
78
|
+
validate_non_negative_integer!(@animation[:output_delay], "animation.output_delay")
|
|
79
|
+
validate_non_negative_integer!(@animation[:final_delay], "animation.final_delay")
|
|
80
|
+
validate_optional_positive_integer!(@animation[:max_frames], "animation.max_frames")
|
|
81
|
+
validate_boolean!(@animation[:cursor_blink], "animation.cursor_blink")
|
|
82
|
+
validate_boolean!(@animation[:loop], "animation.loop")
|
|
83
|
+
validate_boolean!(@animation[:dither], "animation.dither")
|
|
84
|
+
validate_inclusion!(@animation[:palette], "animation.palette", self.class::VALID_PALETTES)
|
|
85
|
+
validate_inclusion!(@animation[:scroll_easing], "animation.scroll_easing", self.class::VALID_SCROLL_EASINGS)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_cursor!
|
|
89
|
+
return if self.class::VALID_CURSOR_STYLES.include?(@cursor[:style])
|
|
90
|
+
|
|
91
|
+
raise ValidationError, "cursor.style must be one of: #{self.class::VALID_CURSOR_STYLES.join(", ")}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_lines!
|
|
95
|
+
raise ValidationError, "lines must be an Array" unless @lines.is_a?(Array)
|
|
96
|
+
raise ValidationError, "frames must be an Array" unless @frames.is_a?(Array)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_limits!
|
|
100
|
+
@limits.each_key do |key|
|
|
101
|
+
validate_positive_integer!(@limits[key], "limits.#{key}")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_headless!
|
|
106
|
+
validate_boolean!(@headless, "headless")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_resource_limits!
|
|
110
|
+
raise ResourceLimitError, "Too many lines (max #{@limits[:max_lines]})" if @lines.size > @limits[:max_lines]
|
|
111
|
+
raise ResourceLimitError, "Too many frames (max #{@limits[:max_frames]})" if @frames.size > @limits[:max_frames]
|
|
112
|
+
|
|
113
|
+
total_characters = (@lines.sum { |line| line.to_s.length } + @frames.sum { |frame| frame.to_s.length })
|
|
114
|
+
return if total_characters <= @limits[:max_characters]
|
|
115
|
+
|
|
116
|
+
raise ResourceLimitError, "Configuration text is too large (max #{@limits[:max_characters]} characters)"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_overflow!
|
|
120
|
+
return if self.class::VALID_OVERFLOW_MODES.include?(@window[:overflow])
|
|
121
|
+
|
|
122
|
+
raise ValidationError, "window.overflow must be one of: #{self.class::VALID_OVERFLOW_MODES.join(", ")}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_ansi_state!
|
|
126
|
+
return if %w[persistent line].include?(@window[:ansi_state])
|
|
127
|
+
|
|
128
|
+
raise ValidationError, "window.ansi_state must be persistent or line"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_background_gradient!
|
|
132
|
+
gradient = @window[:background_gradient]
|
|
133
|
+
return if gradient.nil?
|
|
134
|
+
return if gradient.is_a?(Array) && gradient.size == 2 && gradient.all? { |color| color.is_a?(String) }
|
|
135
|
+
|
|
136
|
+
raise ValidationError, "window.background_gradient must be an array of two colors"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def validate_minimum_width!
|
|
140
|
+
min_width = [120, (@window[:padding] * 2) + 40].max
|
|
141
|
+
return if @window[:width] >= min_width
|
|
142
|
+
|
|
143
|
+
raise ValidationError, "window.width must be at least #{min_width}px for the configured padding"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_optional_positive_integer!(value, name)
|
|
147
|
+
return if value.nil?
|
|
148
|
+
|
|
149
|
+
validate_positive_integer!(value, name)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_optional_non_negative_integer!(value, name)
|
|
153
|
+
return if value.nil?
|
|
154
|
+
|
|
155
|
+
validate_non_negative_integer!(value, name)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def validate_optional_string!(value, name)
|
|
159
|
+
return if value.nil? || value.is_a?(String)
|
|
160
|
+
|
|
161
|
+
raise ValidationError, "#{name} must be a string"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def validate_positive_integer!(value, name)
|
|
165
|
+
return if value.is_a?(Integer) && value.positive?
|
|
166
|
+
|
|
167
|
+
raise ValidationError, "#{name} must be a positive integer"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def validate_non_negative_integer!(value, name)
|
|
171
|
+
return if value.is_a?(Integer) && value >= 0
|
|
172
|
+
|
|
173
|
+
raise ValidationError, "#{name} must be a non-negative integer"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def validate_positive_number!(value, name)
|
|
177
|
+
return if value.is_a?(Numeric) && value.positive?
|
|
178
|
+
|
|
179
|
+
raise ValidationError, "#{name} must be a positive number"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def validate_number_range!(value, name, min, max)
|
|
183
|
+
return if value.is_a?(Numeric) && value >= min && value <= max
|
|
184
|
+
|
|
185
|
+
raise ValidationError, "#{name} must be between #{min} and #{max}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def validate_inclusion!(value, name, allowed)
|
|
189
|
+
return if allowed.include?(value)
|
|
190
|
+
|
|
191
|
+
raise ValidationError, "#{name} must be one of: #{allowed.join(", ")}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def validate_boolean!(value, name)
|
|
195
|
+
return if value == true || value == false
|
|
196
|
+
|
|
197
|
+
raise ValidationError, "#{name} must be true or false"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Shellfie
|
|
6
|
+
class DependencyChecker
|
|
7
|
+
IMAGE_MAGICK_COMMANDS = %w[magick convert].freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def imagemagick_path
|
|
11
|
+
@imagemagick_path ||= find_executable(IMAGE_MAGICK_COMMANDS)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def imagemagick_available?
|
|
15
|
+
!imagemagick_path.to_s.empty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def ensure_imagemagick!
|
|
19
|
+
return if imagemagick_available?
|
|
20
|
+
|
|
21
|
+
raise DependencyError, <<~MSG
|
|
22
|
+
ImageMagick not found
|
|
23
|
+
→ Please install ImageMagick: brew install imagemagick
|
|
24
|
+
→ Or visit: https://imagemagick.org/script/download.php
|
|
25
|
+
MSG
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def configure_mini_magick!(timeout: 30)
|
|
29
|
+
return unless defined?(MiniMagick) && MiniMagick.respond_to?(:timeout=)
|
|
30
|
+
|
|
31
|
+
MiniMagick.timeout = timeout
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def doctor(output_dir: Dir.pwd)
|
|
35
|
+
[
|
|
36
|
+
check("Ruby", RUBY_VERSION, Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.0.0")),
|
|
37
|
+
check("ImageMagick", imagemagick_path || "not found", imagemagick_available?),
|
|
38
|
+
check("Writable output", output_dir, File.writable?(output_dir)),
|
|
39
|
+
check("Encoding", Encoding.default_external.name, true)
|
|
40
|
+
]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def check(name, detail, ok)
|
|
46
|
+
{ name: name, detail: detail, ok: ok }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def find_executable(names)
|
|
50
|
+
paths = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
|
|
51
|
+
extensions = executable_extensions
|
|
52
|
+
|
|
53
|
+
names.each do |name|
|
|
54
|
+
paths.each do |path|
|
|
55
|
+
extensions.each do |extension|
|
|
56
|
+
candidate = File.join(path, "#{name}#{extension}")
|
|
57
|
+
return candidate if File.file?(candidate) && File.executable?(candidate)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def executable_extensions
|
|
66
|
+
return [""] unless windows?
|
|
67
|
+
|
|
68
|
+
ENV.fetch("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def windows?
|
|
72
|
+
RbConfig::CONFIG["host_os"].match?(/mswin|mingw|cygwin/i)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/shellfie/errors.rb
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Shellfie
|
|
4
|
-
class Error < StandardError
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :category, :context
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, category: nil, context: {})
|
|
8
|
+
@category = category
|
|
9
|
+
@context = context.freeze
|
|
10
|
+
super(message)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
5
13
|
|
|
6
14
|
class ConfigError < Error; end
|
|
7
15
|
class ParseError < ConfigError; end
|
|
8
16
|
class ValidationError < ConfigError; end
|
|
17
|
+
class ResourceLimitError < ConfigError; end
|
|
9
18
|
|
|
10
19
|
class RenderError < Error; end
|
|
11
20
|
class FontError < RenderError; end
|
|
12
21
|
class ImageError < RenderError; end
|
|
13
22
|
|
|
14
23
|
class DependencyError < Error; end
|
|
24
|
+
class FileSystemError < Error; end
|
|
15
25
|
end
|