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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -2
- data/README.md +248 -228
- data/lib/shellfie/animation_frame_builder.rb +202 -0
- data/lib/shellfie/animation_scroll_easing.rb +77 -0
- data/lib/shellfie/animation_timeline.rb +28 -0
- data/lib/shellfie/ansi_colors.rb +94 -0
- data/lib/shellfie/ansi_line_buffer.rb +103 -0
- data/lib/shellfie/ansi_normalizer.rb +61 -0
- data/lib/shellfie/ansi_parser.rb +162 -83
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +75 -163
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +401 -0
- data/lib/shellfie/cli_info.rb +239 -0
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +112 -25
- data/lib/shellfie/config_defaults.rb +83 -0
- data/lib/shellfie/config_validation.rb +289 -0
- data/lib/shellfie/dependency_checker.rb +147 -0
- data/lib/shellfie/errors.rb +12 -1
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +68 -0
- data/lib/shellfie/format_resolver.rb +15 -0
- data/lib/shellfie/gif_generator.rb +231 -89
- data/lib/shellfie/gif_palette.rb +105 -0
- data/lib/shellfie/headless_theme_registry.rb +42 -0
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/image_magick_command_builder.rb +75 -0
- data/lib/shellfie/line_layout.rb +146 -0
- data/lib/shellfie/output_writer.rb +46 -0
- data/lib/shellfie/parser.rb +183 -30
- data/lib/shellfie/parser_validation.rb +178 -0
- data/lib/shellfie/raster_painter.rb +157 -0
- data/lib/shellfie/render_chrome_cache.rb +40 -0
- data/lib/shellfie/render_geometry.rb +115 -0
- data/lib/shellfie/render_segment.rb +71 -0
- data/lib/shellfie/renderer.rb +96 -149
- data/lib/shellfie/rendering/shape_helpers.rb +42 -0
- data/lib/shellfie/rendering/text_painter.rb +195 -0
- data/lib/shellfie/rendering/window_chrome.rb +196 -0
- data/lib/shellfie/reproducibility_manifest.rb +41 -0
- data/lib/shellfie/session.rb +111 -0
- data/lib/shellfie/session_config.rb +562 -0
- data/lib/shellfie/session_runner.rb +689 -0
- data/lib/shellfie/svg_raster_wrapper.rb +35 -0
- data/lib/shellfie/svg_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +155 -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/transcript_renderer.rb +92 -0
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie/yaml_safety.rb +147 -0
- data/lib/shellfie.rb +44 -3
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +58 -20
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/examples/animation.yml +0 -33
- data/examples/colored.yml +0 -20
- data/examples/demo.gif +0 -0
- data/examples/demo.png +0 -0
- data/examples/demo_animation.yml +0 -31
- data/examples/headless.png +0 -0
- data/examples/headless.yml +0 -16
- data/examples/scrolling.yml +0 -48
- data/examples/simple.yml +0 -21
- data/examples/theme_macos.png +0 -0
- data/examples/theme_ubuntu.png +0 -0
- data/examples/theme_windows.png +0 -0
- data/shellfie.gemspec +0 -32
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ansi_parser"
|
|
4
|
+
require_relative "render_segment"
|
|
5
|
+
require_relative "text_metrics"
|
|
6
|
+
|
|
7
|
+
module Shellfie
|
|
8
|
+
class LineLayout
|
|
9
|
+
attr_reader :visible_count
|
|
10
|
+
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def prepare(lines, content_width:, font_size:, title_bar_height:, padding:, line_height:)
|
|
16
|
+
max_cells = [(content_width / (font_size * 0.6)).floor, 1].max
|
|
17
|
+
mode = @config.window[:wrap] ? "wrap" : @config.window[:overflow]
|
|
18
|
+
display_lines = lines.flat_map { |line| apply_overflow(line, max_cells, mode) }
|
|
19
|
+
line_limit = vertical_line_limit(title_bar_height, padding, line_height)
|
|
20
|
+
@visible_count = line_limit || display_lines.size
|
|
21
|
+
render_limit = render_line_limit(line_limit)
|
|
22
|
+
render_limit ? display_lines.last(render_limit) : display_lines
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def apply_overflow(line, max_cells, mode)
|
|
28
|
+
return [line] if line[:segments].empty?
|
|
29
|
+
|
|
30
|
+
case mode
|
|
31
|
+
when "wrap"
|
|
32
|
+
wrap_line(line, max_cells)
|
|
33
|
+
when "scroll"
|
|
34
|
+
[line.merge(segments: scroll_segments(line[:segments], max_cells))]
|
|
35
|
+
else
|
|
36
|
+
[line.merge(segments: clip_segments(line[:segments], max_cells))]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def vertical_line_limit(title_bar_height, padding, line_height)
|
|
41
|
+
limits = [@config.window[:visible_lines], @config.window[:max_lines]].compact
|
|
42
|
+
[@config.window[:height], @config.window[:max_height]].compact.each do |height|
|
|
43
|
+
available_height = height - title_bar_height - padding * 2
|
|
44
|
+
limits << [(available_height / line_height).floor, 1].max
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
limits.empty? ? nil : limits.min
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_line_limit(line_limit)
|
|
51
|
+
return nil unless line_limit
|
|
52
|
+
|
|
53
|
+
line_limit + (@config.window[:scroll_offset].to_f.positive? ? 1 : 0)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def wrap_line(line, max_cells)
|
|
57
|
+
wrapped = [{ segments: [], selected: line[:selected] }]
|
|
58
|
+
used_cells = 0
|
|
59
|
+
|
|
60
|
+
line[:segments].each do |segment|
|
|
61
|
+
TextMetrics.graphemes(segment.text).each do |char|
|
|
62
|
+
width = TextMetrics.grapheme_width(char, ambiguous_width: ambiguous_width)
|
|
63
|
+
if used_cells.positive? && used_cells + width > max_cells
|
|
64
|
+
wrapped << { segments: [], selected: line[:selected] }
|
|
65
|
+
used_cells = 0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
append_segment_text(wrapped.last[:segments], segment, char)
|
|
69
|
+
used_cells += width
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
wrapped
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def clip_segments(segments, max_cells)
|
|
77
|
+
remaining = max_cells
|
|
78
|
+
segments.each_with_object([]) do |segment, result|
|
|
79
|
+
break result if remaining <= 0
|
|
80
|
+
|
|
81
|
+
text = TextMetrics.take_cells(segment.text, remaining, ambiguous_width: ambiguous_width)
|
|
82
|
+
next if text.empty?
|
|
83
|
+
|
|
84
|
+
result << copy_segment(segment, text)
|
|
85
|
+
remaining -= TextMetrics.cell_width(text, ambiguous_width: ambiguous_width)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def scroll_segments(segments, max_cells)
|
|
90
|
+
total_cells = segments.sum { |segment| TextMetrics.cell_width(segment.text, ambiguous_width: ambiguous_width) }
|
|
91
|
+
return clip_segments(segments, max_cells) if total_cells <= max_cells
|
|
92
|
+
|
|
93
|
+
cells_to_drop = total_cells - max_cells
|
|
94
|
+
scrolled = []
|
|
95
|
+
|
|
96
|
+
segments.each do |segment|
|
|
97
|
+
segment_cells = TextMetrics.cell_width(segment.text, ambiguous_width: ambiguous_width)
|
|
98
|
+
if cells_to_drop >= segment_cells
|
|
99
|
+
cells_to_drop -= segment_cells
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
text = cells_to_drop.positive? ? TextMetrics.drop_cells(segment.text, cells_to_drop, ambiguous_width: ambiguous_width) : segment.text
|
|
104
|
+
cells_to_drop = 0
|
|
105
|
+
scrolled << copy_segment(segment, text) unless text.empty?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
clip_segments(scrolled, max_cells)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def append_segment_text(segments, source_segment, char)
|
|
112
|
+
if segments.last && segment_style_key(segments.last) == segment_style_key(source_segment)
|
|
113
|
+
segments.last.text << char
|
|
114
|
+
else
|
|
115
|
+
segments << copy_segment(source_segment, char)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def copy_segment(segment, text)
|
|
120
|
+
RenderSegment.copy(segment, text)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def segment_style_key(segment)
|
|
124
|
+
[
|
|
125
|
+
segment.foreground,
|
|
126
|
+
segment.background,
|
|
127
|
+
segment.bold,
|
|
128
|
+
segment.italic,
|
|
129
|
+
segment.underline,
|
|
130
|
+
segment.underline_style,
|
|
131
|
+
segment.underline_color,
|
|
132
|
+
segment.dim,
|
|
133
|
+
segment.reverse,
|
|
134
|
+
segment.strikethrough,
|
|
135
|
+
segment.overline,
|
|
136
|
+
segment.blink,
|
|
137
|
+
segment.conceal,
|
|
138
|
+
segment.link
|
|
139
|
+
]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def ambiguous_width
|
|
143
|
+
@config.window[:ambiguous_width]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
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:, io: nil)
|
|
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
|
+
if stdout?(path)
|
|
17
|
+
return File.binread(temp.path) unless io
|
|
18
|
+
|
|
19
|
+
File.open(temp.path, "rb") { |source| IO.copy_stream(source, io) }
|
|
20
|
+
return path
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
FileUtils.mv(temp.path, path)
|
|
24
|
+
path
|
|
25
|
+
ensure
|
|
26
|
+
if temp
|
|
27
|
+
temp.close unless temp.closed?
|
|
28
|
+
File.delete(temp.path) if File.exist?(temp.path)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def stdout?(path)
|
|
35
|
+
path == "-"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def output_directory(path)
|
|
39
|
+
return Dir.tmpdir if stdout?(path)
|
|
40
|
+
|
|
41
|
+
directory = File.dirname(path)
|
|
42
|
+
directory == "." ? Dir.pwd : directory
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/shellfie/parser.rb
CHANGED
|
@@ -3,88 +3,241 @@
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require_relative "config"
|
|
5
5
|
require_relative "errors"
|
|
6
|
+
require_relative "parser_validation"
|
|
7
|
+
require_relative "yaml_safety"
|
|
6
8
|
|
|
7
9
|
module Shellfie
|
|
8
10
|
class Parser
|
|
11
|
+
MAX_INCLUDE_BYTES = 1_048_576
|
|
12
|
+
MAX_INCLUDE_DEPTH = 50
|
|
13
|
+
MAX_INCLUDE_FILES = 100
|
|
14
|
+
MAX_TOTAL_INCLUDE_BYTES = 10 * MAX_INCLUDE_BYTES
|
|
15
|
+
|
|
9
16
|
class << self
|
|
10
|
-
|
|
11
|
-
raise ParseError, "Configuration file not found: #{path}" unless File.exist?(path)
|
|
17
|
+
include ParserValidation
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
parse_string(
|
|
19
|
+
def parse(path)
|
|
20
|
+
return parse_string($stdin.read(MAX_INCLUDE_BYTES + 1), base_dir: Dir.pwd) if path == "-"
|
|
21
|
+
source_path = File.realpath(path)
|
|
22
|
+
content = read_config(source_path)
|
|
23
|
+
state = { files: 1, bytes: content.bytesize, cache: {}, sources: {} }
|
|
24
|
+
parse_string(content, base_dir: File.dirname(source_path), include_stack: [source_path], source_name: source_path,
|
|
25
|
+
include_state: state)
|
|
26
|
+
rescue Errno::ENOENT
|
|
27
|
+
raise ParseError, "Configuration file not found: #{path}"
|
|
15
28
|
end
|
|
16
29
|
|
|
17
|
-
def parse_string(content)
|
|
18
|
-
|
|
30
|
+
def parse_string(content, base_dir: nil, include_stack: [], source_name: nil, include_state: nil)
|
|
31
|
+
raise ParseError, "Configuration is too large (max #{MAX_INCLUDE_BYTES} bytes)" if content.bytesize > MAX_INCLUDE_BYTES
|
|
32
|
+
|
|
33
|
+
raw = YAML.safe_load(content, symbolize_names: true, aliases: true)
|
|
34
|
+
YamlSafety.validate_tree!(raw)
|
|
35
|
+
if base_dir
|
|
36
|
+
include_state ||= { files: 1, bytes: content.bytesize, cache: {}, sources: {} }
|
|
37
|
+
raw = apply_includes(raw, base_dir, stack: include_stack, root: base_dir, state: include_state)
|
|
38
|
+
end
|
|
19
39
|
validate_config(raw)
|
|
20
|
-
|
|
21
|
-
|
|
40
|
+
sources = (include_stack + Array(include_state&.dig(:cache)&.keys)).uniq
|
|
41
|
+
build_config(raw, source_paths: sources)
|
|
42
|
+
rescue Psych::Exception => e
|
|
22
43
|
raise ParseError, "Invalid YAML syntax: #{e.message}"
|
|
44
|
+
rescue ValidationError => e
|
|
45
|
+
raise e unless source_name
|
|
46
|
+
|
|
47
|
+
documents = [[source_name, content]] + include_state.fetch(:sources, {}).to_a.reverse
|
|
48
|
+
raise YamlSafety.annotate_validation_error(e, documents)
|
|
23
49
|
end
|
|
24
50
|
|
|
25
51
|
private
|
|
26
52
|
|
|
27
|
-
def
|
|
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
|
-
def build_config(raw)
|
|
53
|
+
def build_config(raw, source_paths: [])
|
|
41
54
|
options = {
|
|
55
|
+
version: raw[:version],
|
|
42
56
|
theme: raw[:theme],
|
|
57
|
+
window_theme: raw[:window_theme],
|
|
58
|
+
color_scheme: raw[:color_scheme],
|
|
59
|
+
colors: symbolize_hash(raw[:colors]),
|
|
60
|
+
window_decoration: symbolize_hash(raw[:window_decoration]),
|
|
43
61
|
title: raw[:title],
|
|
44
62
|
window: symbolize_hash(raw[:window]),
|
|
45
63
|
font: symbolize_hash(raw[:font]),
|
|
46
64
|
lines: parse_lines(raw[:lines]),
|
|
47
65
|
animation: symbolize_hash(raw[:animation]),
|
|
66
|
+
cursor: symbolize_hash(raw[:cursor]),
|
|
67
|
+
limits: symbolize_hash(raw[:limits]),
|
|
48
68
|
frames: parse_frames(raw[:frames]),
|
|
49
|
-
headless: raw[:headless] || false
|
|
69
|
+
headless: raw[:headless] || false,
|
|
70
|
+
source_paths: source_paths
|
|
50
71
|
}.compact
|
|
51
72
|
|
|
52
73
|
Config.new(options)
|
|
53
74
|
end
|
|
54
75
|
|
|
76
|
+
def apply_includes(raw, base_dir, stack: [], root: base_dir, policy: nil, state:, depth: 0)
|
|
77
|
+
return raw unless raw.is_a?(Hash) && raw[:include]
|
|
78
|
+
raise ParseError, "YAML include depth exceeds #{MAX_INCLUDE_DEPTH}" if depth >= MAX_INCLUDE_DEPTH
|
|
79
|
+
|
|
80
|
+
policy ||= raw[:include_policy] || "allow"
|
|
81
|
+
raise ParseError, "include_policy must be allow or root" unless %w[allow root].include?(policy)
|
|
82
|
+
includes = Array(raw[:include])
|
|
83
|
+
included_config = includes.reduce({}) do |merged, include_path|
|
|
84
|
+
raise ParseError, "Included configuration path must be a string" unless include_path.is_a?(String)
|
|
85
|
+
|
|
86
|
+
include_file = File.expand_path(include_path, base_dir)
|
|
87
|
+
raise ParseError, "Included configuration file not found: #{include_path}" unless File.exist?(include_file)
|
|
88
|
+
|
|
89
|
+
include_file = File.realpath(include_file)
|
|
90
|
+
if policy == "root" && include_file != root && !include_file.start_with?("#{root}#{File::SEPARATOR}")
|
|
91
|
+
raise ParseError, "Included configuration escapes the configuration root: #{include_path}"
|
|
92
|
+
end
|
|
93
|
+
if stack.include?(include_file)
|
|
94
|
+
chain = (stack + [include_file]).map { |path| File.basename(path) }.join(" -> ")
|
|
95
|
+
raise ParseError, "Circular YAML include: #{chain}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
state[:files] += 1
|
|
99
|
+
raise ParseError, "Too many YAML includes (max #{MAX_INCLUDE_FILES})" if state[:files] > MAX_INCLUDE_FILES
|
|
100
|
+
included_raw = state[:cache][include_file]
|
|
101
|
+
unless included_raw
|
|
102
|
+
included_content = read_config(include_file)
|
|
103
|
+
state[:sources][include_file] = included_content
|
|
104
|
+
state[:bytes] += included_content.bytesize
|
|
105
|
+
if state[:bytes] > MAX_TOTAL_INCLUDE_BYTES
|
|
106
|
+
raise ParseError, "Included YAML is too large in total (max #{MAX_TOTAL_INCLUDE_BYTES} bytes)"
|
|
107
|
+
end
|
|
108
|
+
included_raw = YAML.safe_load(included_content, symbolize_names: true, aliases: true)
|
|
109
|
+
YamlSafety.validate_tree!(included_raw)
|
|
110
|
+
state[:cache][include_file] = included_raw
|
|
111
|
+
end
|
|
112
|
+
included_raw = apply_includes(
|
|
113
|
+
included_raw,
|
|
114
|
+
File.dirname(include_file),
|
|
115
|
+
stack: stack + [include_file],
|
|
116
|
+
root: root,
|
|
117
|
+
policy: policy,
|
|
118
|
+
state: state,
|
|
119
|
+
depth: depth + 1
|
|
120
|
+
)
|
|
121
|
+
deep_merge(merged, included_raw || {})
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
deep_merge(included_config, raw.reject { |key, _value| key == :include })
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def read_config(path)
|
|
128
|
+
YamlSafety.read_file(path, max_bytes: MAX_INCLUDE_BYTES)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def deep_merge(base, overrides)
|
|
132
|
+
base.merge(overrides) do |_key, left, right|
|
|
133
|
+
left.is_a?(Hash) && right.is_a?(Hash) ? deep_merge(left, right) : right
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
55
137
|
def symbolize_hash(hash)
|
|
56
138
|
return nil unless hash.is_a?(Hash)
|
|
57
139
|
|
|
58
|
-
|
|
140
|
+
Config.normalize_keys(hash)
|
|
59
141
|
end
|
|
60
142
|
|
|
61
143
|
def parse_lines(lines)
|
|
62
|
-
return []
|
|
144
|
+
return [] if lines.nil?
|
|
63
145
|
|
|
64
146
|
lines.map do |line|
|
|
65
147
|
Line.new(
|
|
66
148
|
prompt: line[:prompt],
|
|
67
149
|
command: line[:command],
|
|
68
|
-
output: line[:output]
|
|
150
|
+
output: line[:output],
|
|
151
|
+
prompt_color: line[:prompt_color],
|
|
152
|
+
command_color: line[:command_color],
|
|
153
|
+
output_color: line[:output_color],
|
|
154
|
+
selected: line[:selected] || false
|
|
69
155
|
)
|
|
70
156
|
end
|
|
71
157
|
end
|
|
72
158
|
|
|
73
159
|
def parse_frames(frames)
|
|
74
|
-
return []
|
|
160
|
+
return [] if frames.nil?
|
|
75
161
|
|
|
76
162
|
frames.map do |frame|
|
|
77
163
|
Frame.new(
|
|
78
164
|
prompt: frame[:prompt],
|
|
79
165
|
type: frame[:type],
|
|
80
166
|
output: frame[:output],
|
|
81
|
-
|
|
167
|
+
screen: frame[:screen],
|
|
168
|
+
delay: frame[:delay] || 0,
|
|
169
|
+
prompt_color: frame[:prompt_color],
|
|
170
|
+
command_color: frame[:command_color],
|
|
171
|
+
output_color: frame[:output_color]
|
|
82
172
|
)
|
|
83
173
|
end
|
|
84
174
|
end
|
|
85
175
|
end
|
|
86
176
|
end
|
|
87
177
|
|
|
88
|
-
Line
|
|
89
|
-
|
|
178
|
+
class Line
|
|
179
|
+
attr_reader :prompt, :command, :output, :prompt_color, :command_color, :output_color, :selected
|
|
180
|
+
|
|
181
|
+
def initialize(prompt: nil, command: nil, output: nil, prompt_color: nil, command_color: nil, output_color: nil,
|
|
182
|
+
selected: false)
|
|
183
|
+
@prompt = prompt
|
|
184
|
+
@command = command
|
|
185
|
+
@output = output
|
|
186
|
+
@prompt_color = prompt_color
|
|
187
|
+
@command_color = command_color
|
|
188
|
+
@output_color = output_color
|
|
189
|
+
@selected = selected
|
|
190
|
+
freeze
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def to_h
|
|
194
|
+
{
|
|
195
|
+
prompt: prompt,
|
|
196
|
+
command: command,
|
|
197
|
+
output: output,
|
|
198
|
+
prompt_color: prompt_color,
|
|
199
|
+
command_color: command_color,
|
|
200
|
+
output_color: output_color,
|
|
201
|
+
selected: selected
|
|
202
|
+
}.compact
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def to_s
|
|
206
|
+
[prompt, command, output].compact.join("\n")
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
class Frame
|
|
211
|
+
attr_reader :prompt, :type, :output, :delay, :prompt_color, :command_color, :output_color, :screen
|
|
212
|
+
|
|
213
|
+
def initialize(prompt: nil, type: nil, output: nil, delay: 0, prompt_color: nil, command_color: nil,
|
|
214
|
+
output_color: nil, screen: nil)
|
|
215
|
+
@prompt = prompt
|
|
216
|
+
@type = type
|
|
217
|
+
@output = output
|
|
218
|
+
@delay = delay
|
|
219
|
+
@prompt_color = prompt_color
|
|
220
|
+
@command_color = command_color
|
|
221
|
+
@output_color = output_color
|
|
222
|
+
@screen = screen
|
|
223
|
+
freeze
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def to_h
|
|
227
|
+
{
|
|
228
|
+
prompt: prompt,
|
|
229
|
+
type: type,
|
|
230
|
+
output: output,
|
|
231
|
+
delay: delay,
|
|
232
|
+
prompt_color: prompt_color,
|
|
233
|
+
command_color: command_color,
|
|
234
|
+
output_color: output_color,
|
|
235
|
+
screen: screen
|
|
236
|
+
}.compact
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def to_s
|
|
240
|
+
[prompt, type, output, screen, delay].compact.join("\n")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
90
243
|
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
5
|
+
module Shellfie
|
|
6
|
+
module ParserValidation
|
|
7
|
+
MAX_FRAME_DELAY_MS = 86_400_000
|
|
8
|
+
TOP_LEVEL_KEYS = %i[
|
|
9
|
+
version include include_policy theme window_theme color_scheme colors window_decoration title window font animation cursor lines frames
|
|
10
|
+
headless limits
|
|
11
|
+
].freeze
|
|
12
|
+
WINDOW_KEYS = %i[
|
|
13
|
+
width height padding opacity visible_lines max_lines max_height wrap overflow margin exact_size trim tab_width
|
|
14
|
+
ambiguous_width osc_policy graphics_policy ansi_state background_gradient scroll_offset
|
|
15
|
+
].freeze
|
|
16
|
+
FONT_KEYS = %i[family size line_height fallback_family italic_family emoji_family].freeze
|
|
17
|
+
ANIMATION_KEYS = %i[
|
|
18
|
+
typing_speed command_delay cursor_blink loop typing_jitter seed typing_chunk_size output_delay final_delay max_frames
|
|
19
|
+
dither palette gif_colors gif_optimize webp_lossless webp_quality webp_method webp_near_lossless
|
|
20
|
+
apng_prediction loop_count scroll_easing
|
|
21
|
+
direction loop_offset
|
|
22
|
+
framerate playback_speed
|
|
23
|
+
].freeze
|
|
24
|
+
CURSOR_KEYS = %i[style color].freeze
|
|
25
|
+
LIMIT_KEYS = %i[max_lines max_frames max_render_frames max_characters max_pixels max_total_pixels max_temp_bytes].freeze
|
|
26
|
+
LINE_KEYS = %i[prompt command output prompt_color command_color output_color selected].freeze
|
|
27
|
+
FRAME_KEYS = %i[prompt type output screen delay prompt_color command_color output_color].freeze
|
|
28
|
+
COLOR_KEYS = %i[
|
|
29
|
+
background foreground title_bar title_text title_bar_border border selection black red green yellow blue magenta cyan
|
|
30
|
+
white bright_black bright_red bright_green bright_yellow bright_blue bright_magenta bright_cyan bright_white
|
|
31
|
+
].freeze
|
|
32
|
+
WINDOW_DECORATION_KEYS = %i[
|
|
33
|
+
title_bar_height button_size button_spacing button_width corner_radius shadow
|
|
34
|
+
].freeze
|
|
35
|
+
SHADOW_KEYS = %i[blur offset_x offset_y color].freeze
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def validate_config(raw)
|
|
40
|
+
raise ValidationError, "Empty configuration" if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
|
|
41
|
+
raise ValidationError, "Configuration must be a YAML mapping" unless raw.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
validate_keys!(raw, TOP_LEVEL_KEYS, "configuration")
|
|
44
|
+
validate_nested_hash!(raw, :window, WINDOW_KEYS)
|
|
45
|
+
validate_nested_hash!(raw, :font, FONT_KEYS)
|
|
46
|
+
validate_nested_hash!(raw, :animation, ANIMATION_KEYS)
|
|
47
|
+
validate_nested_hash!(raw, :cursor, CURSOR_KEYS)
|
|
48
|
+
validate_nested_hash!(raw, :limits, LIMIT_KEYS)
|
|
49
|
+
validate_nested_hash!(raw, :colors, COLOR_KEYS)
|
|
50
|
+
validate_nested_hash!(raw, :window_decoration, WINDOW_DECORATION_KEYS)
|
|
51
|
+
if raw.dig(:window_decoration, :shadow)
|
|
52
|
+
validate_nested_hash!(raw[:window_decoration], :shadow, SHADOW_KEYS, "window_decoration.shadow")
|
|
53
|
+
end
|
|
54
|
+
validate_theme!(raw[:theme]) if raw[:theme]
|
|
55
|
+
validate_window_theme!(raw[:window_theme]) if raw[:window_theme]
|
|
56
|
+
validate_color_scheme!(raw[:color_scheme]) if raw.key?(:color_scheme)
|
|
57
|
+
if raw[:include_policy] && !%w[allow root].include?(raw[:include_policy])
|
|
58
|
+
raise ValidationError, "include_policy must be allow or root"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
raise ValidationError, "Configuration must have either 'lines' or 'frames'" if raw[:lines].nil? && raw[:frames].nil?
|
|
62
|
+
|
|
63
|
+
validate_lines!(raw[:lines]) if raw.key?(:lines)
|
|
64
|
+
validate_frames!(raw[:frames]) if raw.key?(:frames)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_theme!(theme)
|
|
68
|
+
return if ThemeRegistry.valid_theme?(theme)
|
|
69
|
+
|
|
70
|
+
raise ValidationError, "Invalid theme '#{theme}'\n → Available themes: #{ThemeRegistry.available_themes.join(", ")}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_window_theme!(theme)
|
|
74
|
+
return if ThemeRegistry.valid_window_theme?(theme)
|
|
75
|
+
|
|
76
|
+
raise ValidationError, "Invalid window_theme '#{theme}'"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_color_scheme!(scheme)
|
|
80
|
+
return if ThemeRegistry.valid_color_scheme?(scheme)
|
|
81
|
+
|
|
82
|
+
raise ValidationError, "Invalid color_scheme '#{scheme}'"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_nested_hash!(raw, key, allowed_keys, context = key.to_s)
|
|
86
|
+
return unless raw.key?(key)
|
|
87
|
+
raise ValidationError, "#{key} must be a mapping" unless raw[key].is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
validate_keys!(raw[key], allowed_keys, context) if allowed_keys
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_keys!(hash, allowed_keys, context)
|
|
93
|
+
unknown_keys = hash.keys - allowed_keys
|
|
94
|
+
return if unknown_keys.empty?
|
|
95
|
+
|
|
96
|
+
suggestions = unknown_keys.filter_map do |key|
|
|
97
|
+
match = DidYouMean::SpellChecker.new(dictionary: allowed_keys.map(&:to_s)).correct(key.to_s).first
|
|
98
|
+
"#{key} -> #{match}" if match
|
|
99
|
+
end
|
|
100
|
+
hint = suggestions.empty? ? "" : " (did you mean #{suggestions.join(", ")}?)"
|
|
101
|
+
raise ValidationError, "Unknown #{context} key(s): #{unknown_keys.join(", ")}#{hint}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_lines!(lines)
|
|
105
|
+
raise ValidationError, "lines must be an array" unless lines.is_a?(Array)
|
|
106
|
+
|
|
107
|
+
lines.each_with_index do |line, index|
|
|
108
|
+
raise ValidationError, "lines[#{index}] must be a mapping" unless line.is_a?(Hash)
|
|
109
|
+
|
|
110
|
+
validate_keys!(line, LINE_KEYS, "lines[#{index}]")
|
|
111
|
+
if line.values_at(:prompt, :command, :output).all?(&:nil?)
|
|
112
|
+
raise ValidationError, "lines[#{index}] must include at least one of prompt, command, or output"
|
|
113
|
+
end
|
|
114
|
+
validate_line_values!(line, index)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate_frames!(frames)
|
|
119
|
+
raise ValidationError, "frames must be an array" unless frames.is_a?(Array)
|
|
120
|
+
|
|
121
|
+
frames.each_with_index do |frame, index|
|
|
122
|
+
raise ValidationError, "frames[#{index}] must be a mapping" unless frame.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
validate_keys!(frame, FRAME_KEYS, "frames[#{index}]")
|
|
125
|
+
validate_frame_shape!(frame, index)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def validate_line_values!(line, index)
|
|
130
|
+
%i[prompt command output prompt_color command_color output_color].each do |key|
|
|
131
|
+
validate_string_value!(line[key], "lines[#{index}].#{key}") if line.key?(key)
|
|
132
|
+
end
|
|
133
|
+
validate_boolean_value!(line[:selected], "lines[#{index}].selected") if line.key?(:selected)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def validate_frame_shape!(frame, index)
|
|
137
|
+
raise ValidationError, "frames[#{index}].prompt requires type" if frame[:prompt] && frame[:type].nil?
|
|
138
|
+
|
|
139
|
+
if frame.values_at(:type, :output, :screen, :delay).all?(&:nil?)
|
|
140
|
+
raise ValidationError, "frames[#{index}] must include type, output, screen, or delay"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
validate_string_value!(frame[:prompt], "frames[#{index}].prompt") if frame.key?(:prompt)
|
|
144
|
+
validate_string_value!(frame[:type], "frames[#{index}].type") if frame.key?(:type)
|
|
145
|
+
validate_string_value!(frame[:output], "frames[#{index}].output") if frame.key?(:output)
|
|
146
|
+
if frame.key?(:screen) && (!frame[:screen].is_a?(Array) || !frame[:screen].all?(String))
|
|
147
|
+
raise ValidationError, "frames[#{index}].screen must be an array of strings"
|
|
148
|
+
end
|
|
149
|
+
validate_string_value!(frame[:prompt_color], "frames[#{index}].prompt_color") if frame.key?(:prompt_color)
|
|
150
|
+
validate_string_value!(frame[:command_color], "frames[#{index}].command_color") if frame.key?(:command_color)
|
|
151
|
+
validate_string_value!(frame[:output_color], "frames[#{index}].output_color") if frame.key?(:output_color)
|
|
152
|
+
if frame.key?(:delay)
|
|
153
|
+
validate_non_negative_integer!(frame[:delay], "frames[#{index}].delay")
|
|
154
|
+
if frame[:delay] > MAX_FRAME_DELAY_MS
|
|
155
|
+
raise ValidationError, "frames[#{index}].delay must be at most #{MAX_FRAME_DELAY_MS}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def validate_string_value!(value, name)
|
|
161
|
+
return if value.is_a?(String)
|
|
162
|
+
|
|
163
|
+
raise ValidationError, "#{name} must be a string"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def validate_boolean_value!(value, name)
|
|
167
|
+
return if value == true || value == false
|
|
168
|
+
|
|
169
|
+
raise ValidationError, "#{name} must be true or false"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def validate_non_negative_integer!(value, name)
|
|
173
|
+
return if value.is_a?(Integer) && value >= 0
|
|
174
|
+
|
|
175
|
+
raise ValidationError, "#{name} must be a non-negative integer"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|