shellfie 0.1.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -2
  3. data/README.md +248 -228
  4. data/lib/shellfie/animation_frame_builder.rb +202 -0
  5. data/lib/shellfie/animation_scroll_easing.rb +77 -0
  6. data/lib/shellfie/animation_timeline.rb +28 -0
  7. data/lib/shellfie/ansi_colors.rb +94 -0
  8. data/lib/shellfie/ansi_line_buffer.rb +103 -0
  9. data/lib/shellfie/ansi_normalizer.rb +61 -0
  10. data/lib/shellfie/ansi_parser.rb +162 -83
  11. data/lib/shellfie/cassette.rb +76 -0
  12. data/lib/shellfie/cli.rb +75 -163
  13. data/lib/shellfie/cli_authoring.rb +233 -0
  14. data/lib/shellfie/cli_generate.rb +401 -0
  15. data/lib/shellfie/cli_info.rb +239 -0
  16. data/lib/shellfie/cli_run.rb +167 -0
  17. data/lib/shellfie/config.rb +112 -25
  18. data/lib/shellfie/config_defaults.rb +83 -0
  19. data/lib/shellfie/config_validation.rb +289 -0
  20. data/lib/shellfie/dependency_checker.rb +147 -0
  21. data/lib/shellfie/errors.rb +12 -1
  22. data/lib/shellfie/ffmpeg_encoder.rb +46 -0
  23. data/lib/shellfie/font_resolver.rb +68 -0
  24. data/lib/shellfie/format_resolver.rb +15 -0
  25. data/lib/shellfie/gif_generator.rb +231 -89
  26. data/lib/shellfie/gif_palette.rb +105 -0
  27. data/lib/shellfie/headless_theme_registry.rb +42 -0
  28. data/lib/shellfie/html_renderer.rb +54 -0
  29. data/lib/shellfie/image_magick_command_builder.rb +75 -0
  30. data/lib/shellfie/line_layout.rb +146 -0
  31. data/lib/shellfie/output_writer.rb +46 -0
  32. data/lib/shellfie/parser.rb +183 -30
  33. data/lib/shellfie/parser_validation.rb +178 -0
  34. data/lib/shellfie/raster_painter.rb +157 -0
  35. data/lib/shellfie/render_chrome_cache.rb +40 -0
  36. data/lib/shellfie/render_geometry.rb +115 -0
  37. data/lib/shellfie/render_segment.rb +71 -0
  38. data/lib/shellfie/renderer.rb +96 -149
  39. data/lib/shellfie/rendering/shape_helpers.rb +42 -0
  40. data/lib/shellfie/rendering/text_painter.rb +195 -0
  41. data/lib/shellfie/rendering/window_chrome.rb +196 -0
  42. data/lib/shellfie/reproducibility_manifest.rb +41 -0
  43. data/lib/shellfie/session.rb +111 -0
  44. data/lib/shellfie/session_config.rb +562 -0
  45. data/lib/shellfie/session_runner.rb +689 -0
  46. data/lib/shellfie/svg_raster_wrapper.rb +35 -0
  47. data/lib/shellfie/svg_renderer.rb +222 -0
  48. data/lib/shellfie/terminal_screen.rb +389 -0
  49. data/lib/shellfie/text_metrics.rb +155 -0
  50. data/lib/shellfie/theme_data.rb +80 -0
  51. data/lib/shellfie/theme_registry.rb +131 -0
  52. data/lib/shellfie/themes/base.rb +10 -1
  53. data/lib/shellfie/themes/configured.rb +61 -0
  54. data/lib/shellfie/themes/macos.rb +3 -1
  55. data/lib/shellfie/themes/ubuntu.rb +2 -1
  56. data/lib/shellfie/themes/windows_terminal.rb +7 -1
  57. data/lib/shellfie/transcript_renderer.rb +92 -0
  58. data/lib/shellfie/version.rb +1 -1
  59. data/lib/shellfie/yaml_safety.rb +147 -0
  60. data/lib/shellfie.rb +44 -3
  61. data/schema/shellfie-v1.schema.json +153 -0
  62. data/schema/shellfie-v2.schema.json +262 -0
  63. metadata +58 -20
  64. data/.rspec +0 -3
  65. data/Rakefile +0 -8
  66. data/examples/animation.yml +0 -33
  67. data/examples/colored.yml +0 -20
  68. data/examples/demo.gif +0 -0
  69. data/examples/demo.png +0 -0
  70. data/examples/demo_animation.yml +0 -31
  71. data/examples/headless.png +0 -0
  72. data/examples/headless.yml +0 -16
  73. data/examples/scrolling.yml +0 -48
  74. data/examples/simple.yml +0 -21
  75. data/examples/theme_macos.png +0 -0
  76. data/examples/theme_ubuntu.png +0 -0
  77. data/examples/theme_windows.png +0 -0
  78. data/shellfie.gemspec +0 -32
@@ -0,0 +1,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
@@ -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
- def parse(path)
11
- raise ParseError, "Configuration file not found: #{path}" unless File.exist?(path)
17
+ include ParserValidation
12
18
 
13
- content = File.read(path)
14
- parse_string(content)
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
- raw = YAML.safe_load(content, symbolize_names: true)
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
- build_config(raw)
21
- rescue Psych::SyntaxError => e
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 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
- 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
- hash.transform_keys(&:to_sym)
140
+ Config.normalize_keys(hash)
59
141
  end
60
142
 
61
143
  def parse_lines(lines)
62
- return [] unless lines.is_a?(Array)
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 [] unless frames.is_a?(Array)
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
- delay: frame[:delay] || 0
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 = Struct.new(:prompt, :command, :output, keyword_init: true)
89
- Frame = Struct.new(:prompt, :type, :output, :delay, keyword_init: true)
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