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,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require "yaml"
6
+ require "cgi/escape"
7
+
8
+ module Shellfie
9
+ module CLIInfo
10
+ private
11
+
12
+ def run_init
13
+ puts <<~YAML
14
+ # Shellfie configuration file
15
+ version: 1
16
+ theme: macos
17
+ title: "Terminal — zsh"
18
+
19
+ window:
20
+ width: 600
21
+ padding: 20
22
+
23
+ lines:
24
+ - prompt: "$ "
25
+ command: "gem install shellfie"
26
+
27
+ - output: |
28
+ Fetching shellfie-#{VERSION}.gem
29
+ Successfully installed shellfie-#{VERSION}
30
+ 1 gem installed
31
+
32
+ - prompt: "$ "
33
+ command: "shellfie --version"
34
+
35
+ - output: "shellfie #{VERSION}"
36
+ YAML
37
+ end
38
+
39
+ def run_themes
40
+ puts "Available themes:"
41
+ puts
42
+ ThemeRegistry.available_themes.each { |theme| puts " #{theme}" }
43
+ puts
44
+ puts "Available color schemes:"
45
+ ThemeRegistry.available_color_schemes.each { |scheme| puts " #{scheme}" }
46
+ puts
47
+ puts "Use: shellfie generate config.yml -o output.png -t THEME_NAME"
48
+ end
49
+
50
+ def run_validate
51
+ format = "text"
52
+ OptionParser.new { |opts| opts.on("--format FORMAT", "text, json, sarif, or junit") { |value| format = value } }.parse!(@args)
53
+ raise ValidationError, "validation format must be text, json, sarif, or junit" unless %w[text json sarif junit].include?(format)
54
+ @options[:validation_format] = format
55
+ input_file = @args.shift
56
+ raise ConfigError, "Input file is required" unless input_file
57
+ @options[:validation_path] = input_file
58
+
59
+ if configuration_version(input_file) == 2
60
+ session = SessionConfig.parse(input_file)
61
+ return emit_validation_report(valid: true, path: input_file, details: { version: 2, steps: session.steps.size, outputs: session.outputs.size }) if format != "text"
62
+
63
+ puts "✓ Session configuration is valid"
64
+ puts " Steps: #{session.steps.size}"
65
+ puts " Outputs: #{session.outputs.size}"
66
+ return
67
+ end
68
+
69
+ config = Parser.parse(input_file)
70
+ if format != "text"
71
+ return emit_validation_report(
72
+ valid: true, path: input_file,
73
+ details: { version: 1, theme: config.theme, mode: config.animated? ? "animated" : "static" }
74
+ )
75
+ end
76
+
77
+ puts "✓ Configuration is valid"
78
+ puts " Theme: #{config.theme}"
79
+ puts " Title: #{config.title}"
80
+ puts " Mode: #{config.animated? ? "animated" : "static"}"
81
+ puts " Lines: #{config.lines.size}" if config.static?
82
+ puts " Source frames: #{config.frames.size}" if config.animated?
83
+ puts " Estimated render frames: #{AnimationFrameBuilder.new(config).build.size}" if config.animated?
84
+ geometry = Renderer.new(config).estimate
85
+ puts " Estimated size: #{geometry[:canvas_width]}x#{geometry[:canvas_height]}"
86
+ puts " Logical size: #{geometry[:logical_width]}x#{geometry[:logical_height]} @#{geometry[:scale]}x"
87
+ end
88
+
89
+ def run_inspect
90
+ json = false
91
+ OptionParser.new { |opts| opts.on("--json", "Print machine-readable JSON") { json = true } }.parse!(@args)
92
+ input_file = @args.shift
93
+ raise ConfigError, "Input file is required" unless input_file
94
+
95
+ if configuration_version(input_file) == 2
96
+ session = SessionConfig.parse(input_file)
97
+ info = {
98
+ config: session.to_h,
99
+ mode: session.mode,
100
+ terminal: session.terminal,
101
+ steps: session.steps.size,
102
+ outputs: session.outputs
103
+ }
104
+ return puts(JSON.pretty_generate(info)) if json
105
+
106
+ puts "Session:"
107
+ puts " Version: 2"
108
+ puts " Mode: #{session.mode}"
109
+ puts " Terminal: #{session.terminal[:columns]}x#{session.terminal[:rows]} (#{session.terminal[:shell]})"
110
+ puts " Steps: #{session.steps.size}"
111
+ puts " Outputs: #{session.outputs.size}"
112
+ return
113
+ end
114
+
115
+ info = Shellfie.inspect_config(input_file)
116
+ info[:unicode] = {
117
+ version: TextMetrics::UNICODE_VERSION,
118
+ width_table: TextMetrics::WIDTH_TABLE_VERSION,
119
+ ambiguous_width: info.dig(:config, :window, :ambiguous_width) || 1
120
+ }
121
+ return puts(JSON.pretty_generate(info)) if json
122
+ puts "Config:"
123
+ puts " Version: #{info[:config][:version]}"
124
+ puts " Theme: #{info[:theme]}"
125
+ puts " Title: #{info[:config][:title]}"
126
+ puts " Mode: #{info[:config][:frames].empty? ? "static" : "animated"}"
127
+ puts " Lines: #{info[:config][:lines].size}"
128
+ puts " Frames: #{info[:config][:frames].size}"
129
+ puts " Estimated size: #{info[:geometry][:canvas_width]}x#{info[:geometry][:canvas_height]}"
130
+ puts " Logical size: #{info[:geometry][:logical_width]}x#{info[:geometry][:logical_height]} @#{info[:geometry][:scale]}x"
131
+ puts " Unicode: #{info[:unicode][:version]} (width table #{info[:unicode][:width_table]}, ambiguous=#{info[:unicode][:ambiguous_width]})"
132
+ info.fetch(:fonts, {}).each do |style, font|
133
+ fingerprint = font[:sha256] ? " (sha256: #{font[:sha256]})" : ""
134
+ puts " Font #{style}: #{font[:name] || "not found"}#{fingerprint}"
135
+ end
136
+ end
137
+
138
+ def run_doctor
139
+ failed = false
140
+ DependencyChecker.doctor.each do |check|
141
+ status = check[:ok] ? "ok" : "fail"
142
+ failed ||= !check[:ok]
143
+ puts "#{status.ljust(4)} #{check[:name]}: #{check[:detail]}"
144
+ end
145
+ exit 4 if failed
146
+ end
147
+
148
+ def run_version
149
+ puts "shellfie #{VERSION}"
150
+ end
151
+
152
+ def emit_validation_report(valid:, path: nil, details: nil, error: nil)
153
+ format = @options[:validation_format]
154
+ path ||= @options[:validation_path]
155
+ message = error&.message
156
+ case format
157
+ when "json"
158
+ puts JSON.pretty_generate(version: 1, valid: valid, path: path, details: details, errors: message ? [{ message: message }] : [])
159
+ when "sarif"
160
+ result = if message
161
+ [{ level: "error", message: { text: message }, locations: path ? [{ physicalLocation: { artifactLocation: { uri: path } } }] : [] }]
162
+ else
163
+ []
164
+ end
165
+ puts JSON.pretty_generate(
166
+ version: "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
167
+ runs: [{ tool: { driver: { name: "shellfie", version: VERSION } }, results: result }]
168
+ )
169
+ when "junit"
170
+ failure = %(<failure message="#{CGI.escapeHTML(message)}">#{CGI.escapeHTML(message)}</failure>) if message
171
+ puts %(<testsuite name="shellfie validate" tests="1" failures="#{valid ? 0 : 1}"><testcase name="#{CGI.escapeHTML(path || "configuration")}">#{failure}</testcase></testsuite>)
172
+ else
173
+ raise ValidationError, "validation format must be text, json, sarif, or junit"
174
+ end
175
+ end
176
+
177
+ def show_help
178
+ puts <<~HELP
179
+ Shellfie - Deterministic terminal visual compiler
180
+
181
+ Usage: shellfie <command> [options]
182
+ shf <command> [options]
183
+
184
+ Commands:
185
+ generate Render outputs from a configuration file
186
+ run Execute and render a version 2 terminal session
187
+ record Run a session and save a cassette or editable YAML
188
+ replay Render an existing cassette without executing commands
189
+ new Create a config from a template
190
+ format Normalize YAML formatting
191
+ compile Print the resolved config or session IR
192
+ schema Print the version 1 or 2 JSON Schema
193
+ completion Print bash, zsh, fish, or PowerShell completion
194
+ watch Regenerate when a config or included file changes
195
+ init Output sample configuration
196
+ themes List available themes
197
+ validate Validate configuration file
198
+ inspect Print resolved config and estimated image size
199
+ doctor Check dependencies and local environment
200
+ version Show version
201
+ help Show this help
202
+
203
+ Generate Options:
204
+ -o, --output PATH Output path/template (defaults beside input)
205
+ -t, --theme NAME Override theme (macos, ubuntu, windows)
206
+ -a, --animate Render animated output
207
+ -s, --scale FACTOR Output scale (1, 2, 3)
208
+ -w, --width PIXELS Override width
209
+ --no-shadow Disable shadow effect
210
+ --no-header Disable window header (headless mode)
211
+ --transparent Transparent background
212
+ --typing-rate CPS Typing rate in characters per second
213
+ --framerate FPS Output timing precision
214
+ --seed N Deterministic animation jitter seed
215
+ --playback-speed N Playback speed multiplier
216
+ --fps FPS Deprecated alias for --framerate
217
+ --overflow MODE Line overflow mode: clip, wrap, scroll
218
+ --wrap, --no-wrap Enable or disable long-line wrapping
219
+ --exact-size Match canvas to configured window size
220
+ --format FORMAT Also: mp4, webm, png-sequence, html, txt, json
221
+ --force Overwrite existing output files
222
+ --quiet Suppress non-error output
223
+ --verbose Print progress details
224
+ --manifest PATH Write environment and output fingerprints
225
+
226
+ Examples:
227
+ shellfie generate config.yml -o terminal.png
228
+ shellfie generate config.yml -o demo.gif --animate
229
+ shellfie generate config.yml -o retina.png --scale 2
230
+ shellfie init > my-config.yml
231
+ shellfie themes
232
+
233
+ # Short form
234
+ shf generate config.yml -o terminal.png
235
+ shf init > config.yml
236
+ HELP
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optparse"
5
+ require "yaml"
6
+ require_relative "cassette"
7
+ require_relative "output_writer"
8
+ require_relative "session_config"
9
+
10
+ module Shellfie
11
+ module CLIRun
12
+ private
13
+
14
+ def run_session(record: false)
15
+ parser = build_run_parser(record: record)
16
+ parser.parse!(@args)
17
+ input = @args.shift
18
+ raise ConfigError, "Session configuration is required" unless input
19
+
20
+ config = SessionConfig.parse(input)
21
+ raise ConfigError, "mode: replay is not executable; use shellfie replay CASSETTE.json" if config.mode == "replay"
22
+ cassette_path = @options[:cassette]
23
+ yaml_path = @options[:yaml]
24
+ raise ConfigError, "record requires --cassette PATH or --yaml PATH" if record && !cassette_path && !yaml_path
25
+ resolved_outputs = resolve_session_outputs(config.outputs, base_dir: config.base_dir, allow_empty: record && (cassette_path || yaml_path))
26
+ preflight_session_artifacts!(resolved_outputs, cassette_path, yaml_path, input_path: config.path)
27
+ preflight_render_dependencies!(resolved_outputs.map { |_path, format, _output| format })
28
+ raise DependencyError, "Live sessions are not supported on native Windows" if Gem.win_platform?
29
+
30
+ require_relative "session_runner"
31
+ session = SessionRunner.new(config).run
32
+ write_cassette(cassette_path, session) if cassette_path
33
+ write_recording(yaml_path, session) if yaml_path
34
+ render_session_outputs(session, config.outputs, base_dir: config.base_dir, theme: config.theme, render: config.render,
35
+ resolved: resolved_outputs)
36
+ end
37
+
38
+ def replay_session
39
+ build_replay_parser.parse!(@args)
40
+ input = @args.shift
41
+ raise ConfigError, "Cassette file is required" unless input
42
+
43
+ session = Cassette.read(input)
44
+ render_session_outputs(session, [], base_dir: Dir.pwd, theme: @options[:theme] || "macos", render: {})
45
+ end
46
+
47
+ def build_run_parser(record:)
48
+ OptionParser.new do |opts|
49
+ opts.banner = "Usage: shellfie #{record ? "record" : "run"} SESSION.yml [options]"
50
+ session_output_options(opts)
51
+ opts.on("--cassette PATH", "Write an offline replay cassette") { |path| @options[:cassette] = path }
52
+ opts.on("--yaml PATH", "Write an editable compose recording") { |path| @options[:yaml] = path } if record
53
+ end
54
+ end
55
+
56
+ def build_replay_parser
57
+ OptionParser.new do |opts|
58
+ opts.banner = "Usage: shellfie replay SESSION.json [options]"
59
+ session_output_options(opts)
60
+ opts.on("-t", "--theme NAME", "Render theme") { |theme| @options[:theme] = theme }
61
+ end
62
+ end
63
+
64
+ def session_output_options(opts)
65
+ opts.on("-o", "--output PATH", "Output path (overrides config outputs)") { |path| @options[:output] = path }
66
+ opts.on("--format FORMAT", "Output format") { |format| @options[:format] = parse_format(format) }
67
+ opts.on("-a", "--animate", "Render the captured timeline") { @options[:animate] = true }
68
+ opts.on("--force", "Overwrite existing outputs") { @options[:force] = true }
69
+ opts.on("--quiet", "Suppress generated paths") { @options[:quiet] = true }
70
+ end
71
+
72
+ def render_session_outputs(session, configured_outputs, base_dir:, theme:, render:, resolved: nil)
73
+ resolved ||= resolve_session_outputs(configured_outputs, base_dir: base_dir)
74
+ resolved.each do |path, format, output|
75
+ ensure_output_writable!(path)
76
+ animate = output.fetch(:animate, @options[:animate] || CLIGenerate::ANIMATED_FORMATS.include?(format))
77
+ capture = output[:capture]
78
+ captured_lines = capture && (session.captures[capture] || session.captures[capture.to_sym])
79
+ raise ConfigError, "Unknown capture: #{capture}" if capture && !captured_lines
80
+
81
+ config = session.render_config(theme: theme, options: render, animated: animate, lines: captured_lines)
82
+ original_options = @options
83
+ @options = @options.merge(output.slice(:scale, :shadow, :transparent))
84
+ write_rendered_output(config, path, animate: animate, format: format)
85
+ ensure
86
+ @options = original_options
87
+ end
88
+ end
89
+
90
+ def resolve_session_outputs(configured_outputs, base_dir:, allow_empty: false)
91
+ outputs = if @options[:output]
92
+ [{ path: @options[:output], format: @options[:format], animate: @options[:animate] }]
93
+ else
94
+ configured_outputs
95
+ end
96
+ raise ConfigError, "Output is required with -o or outputs in the session config" if outputs.empty? && !allow_empty
97
+
98
+ resolved = outputs.map do |output|
99
+ path = output[:path] == "-" ? "-" : File.expand_path(output[:path], base_dir)
100
+ format = (output[:format] || @options[:format] || File.extname(path).delete_prefix(".")).to_s.downcase
101
+ unless CLIGenerate::SUPPORTED_FORMATS.include?(format)
102
+ raise ValidationError, "format must be one of: #{CLIGenerate::SUPPORTED_FORMATS.join(", ")}"
103
+ end
104
+ animate = output[:animate].nil? ? (@options[:animate] || CLIGenerate::ANIMATED_FORMATS.include?(format)) : output[:animate]
105
+ validate_output_mode!(format, animate)
106
+ raise ConfigError, "Captured screens cannot be rendered as animations" if output[:capture] && animate
107
+
108
+ [path, format, output.merge(animate: animate)]
109
+ end
110
+ duplicate = resolved.group_by(&:first).find { |_path, items| items.size > 1 }&.first
111
+ raise ConfigError, "Multiple outputs resolve to the same path: #{duplicate}" if duplicate
112
+
113
+ resolved
114
+ end
115
+
116
+ def preflight_session_artifacts!(resolved_outputs, cassette_path, yaml_path, input_path: nil)
117
+ metadata = [cassette_path, yaml_path].compact
118
+ raise ConfigError, "Cassette and YAML outputs cannot be stdout" if metadata.include?("-")
119
+
120
+ paths = resolved_outputs.filter_map { |path, _format, _output| path unless path == "-" } +
121
+ metadata.map { |path| File.expand_path(path) }
122
+ collision = paths.group_by { |path| canonical_output_path(path) }.find { |_path, items| items.size > 1 }&.first
123
+ raise ConfigError, "Session artifacts resolve to the same path: #{collision}" if collision
124
+ canonical_paths = paths.map { |path| canonical_output_path(path) }
125
+ sequence_dirs = resolved_outputs.filter_map do |path, format, _output|
126
+ canonical_output_path(path) if format == "png-sequence"
127
+ end
128
+ nested = sequence_dirs.find do |directory|
129
+ canonical_paths.any? { |path| path != directory && (path_within?(path, directory) || path_within?(directory, path)) }
130
+ end
131
+ raise ConfigError, "Session artifact conflicts with a PNG sequence directory: #{nested}" if nested
132
+ if input_path && paths.any? { |path| canonical_output_path(path) == canonical_output_path(input_path) }
133
+ raise ConfigError, "Session artifact conflicts with the session configuration: #{input_path}"
134
+ end
135
+ resolved_outputs.each do |path, format, _output|
136
+ if format == "png-sequence" && Dir.exist?(path) && !replaceable_png_sequence_directory?(path)
137
+ raise FileSystemError, "Refusing to replace a non-Shellfie directory: #{path}"
138
+ end
139
+ end
140
+
141
+ paths.each { |path| ensure_output_writable!(path) }
142
+ if resolved_outputs.any? { |_path, format, output| format == "mp4" && (@options[:transparent] || output[:transparent]) }
143
+ raise ConfigError, "MP4 output does not support transparency"
144
+ end
145
+ end
146
+
147
+ def write_cassette(path, session)
148
+ FileUtils.mkdir_p(File.dirname(path)) unless File.dirname(path) == "."
149
+ if File.exist?(path) && !@options[:force]
150
+ raise FileSystemError, "Cassette already exists: #{path} (use --force to overwrite)"
151
+ end
152
+
153
+ Cassette.write(path, session)
154
+ $stderr.puts "Recorded: #{path}" unless @options[:quiet]
155
+ end
156
+
157
+ def write_recording(path, session)
158
+ FileUtils.mkdir_p(File.dirname(path)) unless File.dirname(path) == "."
159
+ if File.exist?(path) && !@options[:force]
160
+ raise FileSystemError, "Recording already exists: #{path} (use --force to overwrite)"
161
+ end
162
+
163
+ OutputWriter.write(path, extension: "yml") { |temporary_path| File.write(temporary_path, YAML.dump(session.compose_hash)) }
164
+ $stderr.puts "Recorded: #{path}" unless @options[:quiet]
165
+ end
166
+ end
167
+ end
@@ -1,40 +1,86 @@
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
- DEFAULTS = {
6
- theme: "macos",
7
- window: {
8
- width: 600,
9
- padding: 20,
10
- opacity: 1.0,
11
- visible_lines: nil
12
- },
13
- font: {
14
- family: "Monaco",
15
- size: 14,
16
- line_height: 1.4
17
- },
18
- animation: {
19
- typing_speed: 80,
20
- command_delay: 500,
21
- cursor_blink: true,
22
- loop: false
23
- }
24
- }.freeze
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
+ VALID_APNG_PREDICTIONS = %w[none sub up avg paeth mixed].freeze
18
+ VALID_ANIMATION_DIRECTIONS = %w[forward reverse ping_pong].freeze
19
+
20
+ class << self
21
+ def deep_dup(value)
22
+ case value
23
+ when Hash
24
+ value.each_with_object({}) { |(key, nested_value), result| result[key] = deep_dup(nested_value) }
25
+ when Array
26
+ value.map { |nested_value| deep_dup(nested_value) }
27
+ else
28
+ value
29
+ end
30
+ end
31
+
32
+ def deep_freeze(value)
33
+ case value
34
+ when Hash
35
+ value.each_value { |nested_value| deep_freeze(nested_value) }
36
+ when Array
37
+ value.each { |nested_value| deep_freeze(nested_value) }
38
+ end
39
+ value.freeze
40
+ end
41
+
42
+ def normalize_keys(value)
43
+ case value
44
+ when Hash
45
+ value.each_with_object({}) do |(key, nested_value), result|
46
+ normalized_key = key.is_a?(String) ? key.to_sym : key
47
+ result[normalized_key] = normalize_keys(nested_value)
48
+ end
49
+ when Array
50
+ value.map { |nested_value| normalize_keys(nested_value) }
51
+ else
52
+ value
53
+ end
54
+ end
55
+ end
56
+
57
+ DEFAULTS = deep_freeze(deep_dup(ConfigDefaults::VALUES))
25
58
 
26
- attr_reader :theme, :title, :window, :font, :lines, :animation, :frames, :headless
59
+ attr_reader :version, :theme, :window_theme, :color_scheme, :colors, :window_decoration, :title, :window, :font,
60
+ :lines, :animation, :frames, :headless, :cursor, :limits, :source_paths
27
61
 
28
62
  def initialize(options = {})
63
+ options = self.class.normalize_keys(options)
29
64
  merged = merge_defaults(options)
65
+ @version = merged[:version]
30
66
  @theme = merged[:theme]
67
+ @window_theme = merged[:window_theme]
68
+ @color_scheme = merged[:color_scheme]
69
+ @colors = merged[:colors]
70
+ @window_decoration = merged[:window_decoration]
31
71
  @title = merged[:title] || "Terminal"
32
72
  @window = merged[:window]
33
73
  @font = merged[:font]
34
74
  @lines = merged[:lines] || []
35
75
  @animation = merged[:animation]
36
76
  @frames = merged[:frames] || []
37
- @headless = options[:headless] || false
77
+ @cursor = merged[:cursor]
78
+ @limits = merged[:limits]
79
+ @headless = merged[:headless] || false
80
+ @source_paths = Array(options[:source_paths])
81
+
82
+ validate!
83
+ freeze_state!
38
84
  end
39
85
 
40
86
  def static?
@@ -45,21 +91,62 @@ module Shellfie
45
91
  !static?
46
92
  end
47
93
 
94
+ def to_h
95
+ {
96
+ version: version,
97
+ theme: theme,
98
+ window_theme: window_theme,
99
+ color_scheme: color_scheme,
100
+ colors: colors,
101
+ window_decoration: window_decoration,
102
+ title: title,
103
+ window: window,
104
+ font: font,
105
+ animation: animation,
106
+ cursor: cursor,
107
+ limits: limits,
108
+ headless: headless,
109
+ lines: lines.map(&:to_h),
110
+ frames: frames.map(&:to_h)
111
+ }
112
+ end
113
+
48
114
  private
49
115
 
50
116
  def merge_defaults(options)
51
- result = {}
117
+ result = self.class.deep_dup(DEFAULTS)
52
118
  DEFAULTS.each do |key, value|
119
+ next unless options.key?(key)
120
+
53
121
  result[key] = if value.is_a?(Hash) && options[key].is_a?(Hash)
54
- value.merge(options[key])
122
+ result[key].merge(options[key])
55
123
  else
56
- options.key?(key) ? options[key] : value
124
+ self.class.deep_dup(options[key])
57
125
  end
58
126
  end
59
127
  result[:title] = options[:title]
60
128
  result[:lines] = options[:lines]
61
129
  result[:frames] = options[:frames]
130
+ result[:headless] = options[:headless] if options.key?(:headless)
62
131
  result
63
132
  end
133
+
134
+ def freeze_state!
135
+ @colors = self.class.deep_freeze(@colors)
136
+ @window_decoration = self.class.deep_freeze(@window_decoration)
137
+ @window = self.class.deep_freeze(@window)
138
+ @font = self.class.deep_freeze(@font)
139
+ @animation = self.class.deep_freeze(@animation)
140
+ @cursor = self.class.deep_freeze(@cursor)
141
+ @limits = self.class.deep_freeze(@limits)
142
+ @lines = self.class.deep_freeze(@lines)
143
+ @frames = self.class.deep_freeze(@frames)
144
+ @title.freeze
145
+ @source_paths = self.class.deep_freeze(@source_paths)
146
+ @theme.freeze
147
+ @window_theme.freeze if @window_theme
148
+ @color_scheme.freeze if @color_scheme
149
+ freeze
150
+ end
64
151
  end
65
152
  end
@@ -0,0 +1,83 @@
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
+ height: nil,
15
+ padding: 20,
16
+ opacity: 1.0,
17
+ visible_lines: nil,
18
+ max_lines: nil,
19
+ max_height: nil,
20
+ wrap: false,
21
+ overflow: "clip",
22
+ margin: nil,
23
+ exact_size: false,
24
+ trim: false,
25
+ tab_width: 8,
26
+ ambiguous_width: 1,
27
+ osc_policy: "ignore",
28
+ graphics_policy: "ignore",
29
+ ansi_state: "persistent",
30
+ background_gradient: nil,
31
+ scroll_offset: 0.0
32
+ },
33
+ font: {
34
+ family: "Monaco",
35
+ size: 14,
36
+ line_height: 1.4,
37
+ fallback_family: nil,
38
+ italic_family: nil,
39
+ emoji_family: nil
40
+ },
41
+ animation: {
42
+ typing_speed: 80,
43
+ command_delay: 500,
44
+ cursor_blink: true,
45
+ loop: false,
46
+ typing_jitter: 0.0,
47
+ seed: 0,
48
+ typing_chunk_size: 1,
49
+ output_delay: 0,
50
+ final_delay: 1_000,
51
+ max_frames: nil,
52
+ dither: true,
53
+ palette: "global",
54
+ gif_colors: 256,
55
+ gif_optimize: true,
56
+ webp_lossless: true,
57
+ webp_quality: 100,
58
+ webp_method: 4,
59
+ webp_near_lossless: 100,
60
+ apng_prediction: "paeth",
61
+ loop_count: nil,
62
+ direction: "forward",
63
+ loop_offset: 0,
64
+ scroll_easing: "linear",
65
+ framerate: 30,
66
+ playback_speed: 1.0
67
+ },
68
+ cursor: {
69
+ style: "block",
70
+ color: nil
71
+ },
72
+ limits: {
73
+ max_lines: 10_000,
74
+ max_frames: 500,
75
+ max_render_frames: 2_000,
76
+ max_characters: 200_000,
77
+ max_pixels: 50_000_000,
78
+ max_total_pixels: 2_000_000_000,
79
+ max_temp_bytes: 8_000_000_000
80
+ }
81
+ }.freeze
82
+ end
83
+ end