shellfie 1.0.0 → 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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -2
  3. data/README.md +258 -97
  4. data/lib/shellfie/animation_frame_builder.rb +31 -7
  5. data/lib/shellfie/animation_timeline.rb +2 -1
  6. data/lib/shellfie/ansi_line_buffer.rb +20 -4
  7. data/lib/shellfie/ansi_normalizer.rb +18 -8
  8. data/lib/shellfie/ansi_parser.rb +120 -7
  9. data/lib/shellfie/cassette.rb +76 -0
  10. data/lib/shellfie/cli.rb +65 -2
  11. data/lib/shellfie/cli_authoring.rb +233 -0
  12. data/lib/shellfie/cli_generate.rb +244 -40
  13. data/lib/shellfie/cli_info.rb +106 -6
  14. data/lib/shellfie/cli_run.rb +167 -0
  15. data/lib/shellfie/config.rb +5 -1
  16. data/lib/shellfie/config_defaults.rb +21 -2
  17. data/lib/shellfie/config_validation.rb +93 -4
  18. data/lib/shellfie/dependency_checker.rb +74 -3
  19. data/lib/shellfie/errors.rb +1 -0
  20. data/lib/shellfie/ffmpeg_encoder.rb +46 -0
  21. data/lib/shellfie/font_resolver.rb +11 -1
  22. data/lib/shellfie/gif_generator.rb +157 -11
  23. data/lib/shellfie/gif_palette.rb +8 -4
  24. data/lib/shellfie/html_renderer.rb +54 -0
  25. data/lib/shellfie/line_layout.rb +19 -10
  26. data/lib/shellfie/output_writer.rb +7 -2
  27. data/lib/shellfie/parser.rb +82 -19
  28. data/lib/shellfie/parser_validation.rb +48 -15
  29. data/lib/shellfie/render_geometry.rb +2 -1
  30. data/lib/shellfie/render_segment.rb +17 -5
  31. data/lib/shellfie/renderer.rb +28 -11
  32. data/lib/shellfie/rendering/text_painter.rb +23 -15
  33. data/lib/shellfie/rendering/window_chrome.rb +2 -2
  34. data/lib/shellfie/reproducibility_manifest.rb +41 -0
  35. data/lib/shellfie/session.rb +111 -0
  36. data/lib/shellfie/session_config.rb +562 -0
  37. data/lib/shellfie/session_runner.rb +689 -0
  38. data/lib/shellfie/svg_renderer.rb +222 -0
  39. data/lib/shellfie/terminal_screen.rb +389 -0
  40. data/lib/shellfie/text_metrics.rb +74 -15
  41. data/lib/shellfie/transcript_renderer.rb +92 -0
  42. data/lib/shellfie/version.rb +1 -1
  43. data/lib/shellfie/yaml_safety.rb +147 -0
  44. data/lib/shellfie.rb +8 -1
  45. data/schema/shellfie-v1.schema.json +153 -0
  46. data/schema/shellfie-v2.schema.json +262 -0
  47. metadata +27 -24
  48. data/.rspec +0 -3
  49. data/Rakefile +0 -8
  50. data/docs/.nojekyll +0 -0
  51. data/docs/index.html +0 -205
  52. data/docs/scripts.js +0 -85
  53. data/docs/styles.css +0 -507
  54. data/examples/animation.yml +0 -33
  55. data/examples/colored.yml +0 -20
  56. data/examples/demo.gif +0 -0
  57. data/examples/demo.png +0 -0
  58. data/examples/demo_animation.yml +0 -31
  59. data/examples/headless.png +0 -0
  60. data/examples/headless.yml +0 -16
  61. data/examples/scrolling.yml +0 -48
  62. data/examples/simple.yml +0 -21
  63. data/examples/theme_macos.png +0 -0
  64. data/examples/theme_ubuntu.png +0 -0
  65. data/examples/theme_windows.png +0 -0
  66. data/shellfie.gemspec +0 -32
@@ -1,23 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rbconfig"
4
+
3
5
  module Shellfie
4
6
  module TextMetrics
7
+ UNICODE_VERSION = RbConfig::CONFIG["UNICODE_VERSION"] || "unknown"
8
+ WIDTH_TABLE_VERSION = "1"
9
+ AMBIGUOUS_RANGES = [
10
+ 0x00a1..0x00a1, 0x00a4..0x00a4, 0x00a7..0x00a8, 0x00aa..0x00aa, 0x00ad..0x00ae,
11
+ 0x00b0..0x00b4, 0x00b6..0x00ba, 0x00bc..0x00bf, 0x00c6..0x00c6, 0x00d0..0x00d0,
12
+ 0x00d7..0x00d8, 0x00de..0x00e1, 0x00e6..0x00e6, 0x00e8..0x00ea, 0x00ec..0x00ed,
13
+ 0x00f0..0x00f0, 0x00f2..0x00f3, 0x00f7..0x00fa, 0x00fc..0x00fc, 0x00fe..0x00fe,
14
+ 0x0101..0x0101, 0x0111..0x0113, 0x011b..0x011b, 0x0126..0x0127, 0x012b..0x012b,
15
+ 0x0131..0x0133, 0x0138..0x0138, 0x013f..0x0142, 0x0144..0x0144, 0x0148..0x014b,
16
+ 0x014d..0x014d, 0x0152..0x0153, 0x0166..0x0167, 0x016b..0x016b, 0x01ce..0x01dc,
17
+ 0x0251..0x0251, 0x0261..0x0261, 0x02c4..0x02c4, 0x02c7..0x02c7, 0x02c9..0x02cb,
18
+ 0x02cd..0x02d0, 0x02d8..0x02db, 0x02dd..0x02df, 0x0391..0x03a1, 0x03a3..0x03a9,
19
+ 0x03b1..0x03c1, 0x03c3..0x03c9, 0x0401..0x0401, 0x0410..0x044f, 0x0451..0x0451,
20
+ 0x2010..0x2010, 0x2013..0x2016, 0x2018..0x2019, 0x201c..0x201d, 0x2020..0x2022,
21
+ 0x2024..0x2026, 0x2030..0x2030, 0x2032..0x2033, 0x2035..0x2035, 0x203b..0x203e,
22
+ 0x2074..0x2074, 0x207f..0x207f, 0x2081..0x2084, 0x20ac..0x20ac, 0x2103..0x2103,
23
+ 0x2105..0x2105, 0x2109..0x2109, 0x2113..0x2113, 0x2116..0x2116, 0x2121..0x2122,
24
+ 0x2126..0x2126, 0x212b..0x212b, 0x2153..0x2154, 0x215b..0x215e, 0x2160..0x216b,
25
+ 0x2170..0x2179, 0x2189..0x2189, 0x2190..0x2199, 0x21b8..0x21b9, 0x21d2..0x21d4,
26
+ 0x21e7..0x21e7, 0x2200..0x2200, 0x2202..0x2203, 0x2207..0x2208, 0x220b..0x220b,
27
+ 0x220f..0x2211, 0x2215..0x2215, 0x221a..0x221a, 0x221d..0x2220, 0x2223..0x2223,
28
+ 0x2225..0x2225, 0x2227..0x222c, 0x222e..0x222e, 0x2234..0x2237, 0x223c..0x223d,
29
+ 0x2248..0x2248, 0x224c..0x224c, 0x2252..0x2252, 0x2260..0x2261, 0x2264..0x2267,
30
+ 0x226a..0x226b, 0x226e..0x226f, 0x2282..0x2283, 0x2286..0x2287, 0x2295..0x2295,
31
+ 0x2299..0x2299, 0x22a5..0x22a5, 0x22bf..0x22bf, 0x2312..0x2312, 0x2460..0x254b,
32
+ 0x2550..0x2573, 0x2580..0x258f, 0x2592..0x2595, 0x25a0..0x25a1, 0x25a3..0x25a9,
33
+ 0x25b2..0x25b3, 0x25b6..0x25b7, 0x25bc..0x25bd, 0x25c0..0x25c1, 0x25c6..0x25c8,
34
+ 0x25cb..0x25cb, 0x25ce..0x25d1, 0x25e2..0x25e5, 0x25ef..0x25ef, 0x2605..0x2606,
35
+ 0x2609..0x2609, 0x260e..0x260f, 0x261c..0x261c, 0x261e..0x261e, 0x2640..0x2640,
36
+ 0x2642..0x2642, 0x2660..0x2661, 0x2663..0x2665, 0x2667..0x266a, 0x266c..0x266d,
37
+ 0x266f..0x266f, 0xe000..0xf8ff, 0xfffd..0xfffd
38
+ ].freeze
39
+
5
40
  module_function
6
41
 
7
- def cell_width(text)
8
- text.to_s.each_char.sum { |char| char_width(char) }
42
+ def graphemes(text)
43
+ string = text.to_s.dup
44
+ string.force_encoding(Encoding::UTF_8)
45
+ string.scrub.scan(/\X/)
9
46
  end
10
47
 
11
- def pixel_width(text, font_size)
12
- (cell_width(text) * font_size * 0.6).ceil
48
+ def cell_width(text, ambiguous_width: 1)
49
+ graphemes(text).sum { |grapheme| grapheme_width(grapheme, ambiguous_width: ambiguous_width) }
13
50
  end
14
51
 
15
- def take_cells(text, max_cells)
52
+ def pixel_width(text, font_size, ambiguous_width: 1)
53
+ (cell_width(text, ambiguous_width: ambiguous_width) * font_size * 0.6).ceil
54
+ end
55
+
56
+ def take_cells(text, max_cells, ambiguous_width: 1)
16
57
  result = +""
17
58
  used_cells = 0
18
59
 
19
- text.to_s.each_char do |char|
20
- width = char_width(char)
60
+ graphemes(text).each do |char|
61
+ width = grapheme_width(char, ambiguous_width: ambiguous_width)
21
62
  break if used_cells + width > max_cells
22
63
 
23
64
  result << char
@@ -27,10 +68,10 @@ module Shellfie
27
68
  result
28
69
  end
29
70
 
30
- def drop_cells(text, cells_to_drop)
71
+ def drop_cells(text, cells_to_drop, ambiguous_width: 1)
31
72
  used_cells = 0
32
- text.to_s.each_char.with_object(+"") do |char, result|
33
- width = char_width(char)
73
+ graphemes(text).each_with_object(+"") do |char, result|
74
+ width = grapheme_width(char, ambiguous_width: ambiguous_width)
34
75
  if used_cells < cells_to_drop
35
76
  used_cells += width
36
77
  next
@@ -40,15 +81,15 @@ module Shellfie
40
81
  end
41
82
  end
42
83
 
43
- def split_cells(text, max_cells)
84
+ def split_cells(text, max_cells, ambiguous_width: 1)
44
85
  return [text.to_s] if max_cells <= 0
45
86
 
46
87
  chunks = []
47
88
  current = +""
48
89
  used_cells = 0
49
90
 
50
- text.to_s.each_char do |char|
51
- width = char_width(char)
91
+ graphemes(text).each do |char|
92
+ width = grapheme_width(char, ambiguous_width: ambiguous_width)
52
93
  if used_cells.positive? && used_cells + width > max_cells
53
94
  chunks << current
54
95
  current = +""
@@ -63,21 +104,33 @@ module Shellfie
63
104
  chunks
64
105
  end
65
106
 
66
- def char_width(char)
107
+ def char_width(char, ambiguous_width: 1)
67
108
  codepoint = char.ord
68
109
  return 0 if combining?(codepoint)
69
110
  return 0 if codepoint < 32 || codepoint == 0x7f
70
111
  return 2 if wide?(codepoint)
112
+ return ambiguous_width if ambiguous?(codepoint)
71
113
 
72
114
  1
73
115
  end
74
116
 
117
+ def grapheme_width(grapheme, ambiguous_width: 1)
118
+ codepoints = grapheme.codepoints
119
+ return 0 if codepoints.empty? || codepoints.all? { |codepoint| combining?(codepoint) }
120
+ return 2 if grapheme.include?("\u200d") || grapheme.include?("\ufe0f") || grapheme.include?("\u20e3") ||
121
+ grapheme.match?(/[\u{1f1e6}-\u{1f1ff}]{2}/)
122
+
123
+ codepoints.map { |codepoint| char_width(codepoint.chr(Encoding::UTF_8), ambiguous_width: ambiguous_width) }.max || 0
124
+ end
125
+
75
126
  def combining?(codepoint)
76
127
  (0x0300..0x036f).cover?(codepoint) ||
77
128
  (0x1ab0..0x1aff).cover?(codepoint) ||
78
129
  (0x1dc0..0x1dff).cover?(codepoint) ||
79
130
  (0x20d0..0x20ff).cover?(codepoint) ||
80
- (0xfe20..0xfe2f).cover?(codepoint)
131
+ (0xfe00..0xfe0f).cover?(codepoint) ||
132
+ (0xfe20..0xfe2f).cover?(codepoint) ||
133
+ (0xe0100..0xe01ef).cover?(codepoint)
81
134
  end
82
135
 
83
136
  def wide?(codepoint)
@@ -92,5 +145,11 @@ module Shellfie
92
145
  (0xffe0..0xffe6).cover?(codepoint) ||
93
146
  (0x1f300..0x1faff).cover?(codepoint)
94
147
  end
148
+
149
+ def ambiguous?(codepoint)
150
+ return false if codepoint < 0x00a1
151
+
152
+ AMBIGUOUS_RANGES.any? { |range| range.cover?(codepoint) }
153
+ end
95
154
  end
96
155
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "animation_frame_builder"
5
+ require_relative "ansi_parser"
6
+ require_relative "output_writer"
7
+
8
+ module Shellfie
9
+ class TranscriptRenderer
10
+ def initialize(config)
11
+ @config = config
12
+ end
13
+
14
+ def render(output_path, format:, io: nil)
15
+ OutputWriter.write(output_path, extension: format, io: io) do |temporary_path|
16
+ value = case format
17
+ when "json" then JSON.pretty_generate(document)
18
+ when "ansi" then ansi_text
19
+ when "asciicast", "cast" then asciicast
20
+ else text
21
+ end
22
+ File.write(temporary_path, value)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :config
29
+
30
+ def final_lines
31
+ return config.lines if config.frames.empty?
32
+
33
+ AnimationFrameBuilder.new(config).build.last[:lines]
34
+ end
35
+
36
+ def text
37
+ final_lines.flat_map do |line|
38
+ command = "#{line.prompt}#{line.command}" if line.prompt || line.command
39
+ [command, line.output].compact.map { |value| plain_text(value) }
40
+ end.join("\n") + "\n"
41
+ end
42
+
43
+ def ansi_text
44
+ final_lines.flat_map do |line|
45
+ command = "#{line.prompt}#{line.command}" if line.prompt || line.command
46
+ [command, line.output].compact
47
+ end.join("\n") + "\n"
48
+ end
49
+
50
+ def asciicast
51
+ width = [config.window[:width].to_i / 8, 1].max
52
+ header = { version: 2, width: width, height: [final_lines.size, 1].max, env: { "TERM" => "xterm-256color" } }
53
+ elapsed = 0.0
54
+ source = config.frames.empty? ? [{ text: ansi_text, delay: 0 }] : config.frames.map do |frame|
55
+ { text: frame_to_ansi(frame), delay: frame.delay }
56
+ end
57
+ events = source.map do |event|
58
+ value = [elapsed.round(6), "o", event[:text]]
59
+ elapsed += event[:delay].to_f / 1_000
60
+ value
61
+ end
62
+ ([JSON.generate(header)] + events.map { |event| JSON.generate(event) }).join("\n") + "\n"
63
+ end
64
+
65
+ def frame_to_ansi(frame)
66
+ command = "#{frame.prompt}#{frame.command}" if frame.prompt || frame.command
67
+ [command, frame.output].compact.join("\r\n") + "\r\n"
68
+ end
69
+
70
+ def document
71
+ {
72
+ version: 1,
73
+ title: config.title,
74
+ lines: final_lines.map { |line| plain_value(line.to_h) },
75
+ events: config.frames.map { |frame| plain_value(frame.to_h) }
76
+ }
77
+ end
78
+
79
+ def plain_value(value)
80
+ case value
81
+ when String then plain_text(value)
82
+ when Array then value.map { |item| plain_value(item) }
83
+ when Hash then value.transform_values { |item| plain_value(item) }
84
+ else value
85
+ end
86
+ end
87
+
88
+ def plain_text(value)
89
+ AnsiParser.new(state_mode: :line).parse(value.to_s).map(&:text).join
90
+ end
91
+ end
92
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shellfie
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require "yaml"
5
+
6
+ module Shellfie
7
+ module YamlSafety
8
+ MAX_DEPTH = 100
9
+ MAX_NODES = 100_000
10
+
11
+ module_function
12
+
13
+ def annotate_validation_error(error, documents, provenance: {})
14
+ if (target = validation_path(error.message)) && (origin = provenance[target])
15
+ path, local_path = origin
16
+ content = documents.assoc(path)&.last
17
+ if content && (location = location_for_path(content, local_path))
18
+ return error.class.new("#{path}:#{location[0]}:#{location[1]}: #{error.message}")
19
+ end
20
+ end
21
+
22
+ documents.each do |path, content|
23
+ next unless path && content
24
+
25
+ location = validation_location(content, error.message)
26
+ return error.class.new("#{path}:#{location[0]}:#{location[1]}: #{error.message}") if location
27
+ end
28
+
29
+ path = documents.first&.first
30
+ error.class.new(path ? "#{path}: #{error.message}" : error.message)
31
+ end
32
+
33
+ def read_file(path, max_bytes:, label: "Configuration")
34
+ raise ParseError, "#{label} file not found: #{path}" unless File.file?(path)
35
+
36
+ content = File.open(path, "rb") { |file| file.read(max_bytes + 1) }
37
+ raise ParseError, "#{label} file is too large: #{path} (max #{max_bytes} bytes)" if content.bytesize > max_bytes
38
+
39
+ content
40
+ end
41
+
42
+ def load_file(path, max_bytes:, label: "Configuration", symbolize_names: true)
43
+ value = YAML.safe_load(
44
+ read_file(path, max_bytes: max_bytes, label: label), symbolize_names: symbolize_names, aliases: true
45
+ )
46
+ validate_tree!(value)
47
+ rescue Psych::Exception => e
48
+ raise ParseError, "Invalid #{label.downcase} YAML syntax: #{e.message}"
49
+ end
50
+
51
+ def validate_tree!(value)
52
+ active = {}
53
+ nodes = 0
54
+ stack = [[value, 0, false]]
55
+
56
+ until stack.empty?
57
+ node, depth, leaving = stack.pop
58
+ next unless node.is_a?(Hash) || node.is_a?(Array)
59
+ if leaving
60
+ active.delete(node.object_id)
61
+ next
62
+ end
63
+
64
+ raise ParseError, "YAML aliases must not contain cycles" if active[node.object_id]
65
+ raise ParseError, "YAML nesting is too deep (max #{MAX_DEPTH})" if depth > MAX_DEPTH
66
+ nodes += 1
67
+ raise ParseError, "YAML structure is too large (max #{MAX_NODES} collections)" if nodes > MAX_NODES
68
+
69
+ active[node.object_id] = true
70
+ stack << [node, depth, true]
71
+ children = node.is_a?(Hash) ? node.flat_map { |key, nested| [key, nested] } : node
72
+ children.reverse_each { |child| stack << [child, depth + 1, false] }
73
+ end
74
+
75
+ value
76
+ end
77
+
78
+ def validation_location(content, message)
79
+ locations = {}
80
+ collect_locations(Psych.parse(content).root, [], locations)
81
+ target = validation_path(message)
82
+ return locations[target] if target && locations[target]
83
+
84
+ locations.sort_by { |path, _location| -path.size }.each do |path, location|
85
+ return location if message.include?(format_path(path))
86
+ end
87
+ nil
88
+ rescue Psych::Exception
89
+ nil
90
+ end
91
+
92
+ def location_for_path(content, path)
93
+ locations = {}
94
+ collect_locations(Psych.parse(content).root, [], locations)
95
+ locations[path] || locations[path[0...-1]]
96
+ rescue Psych::Exception
97
+ nil
98
+ end
99
+
100
+ def collect_locations(node, path, locations)
101
+ case node
102
+ when Psych::Nodes::Mapping
103
+ node.children.each_slice(2) do |key, value|
104
+ next unless key.respond_to?(:value)
105
+
106
+ child_path = path + [key.value.to_sym]
107
+ locations[child_path] = [key.start_line + 1, key.start_column + 1]
108
+ collect_locations(value, child_path, locations)
109
+ end
110
+ when Psych::Nodes::Sequence
111
+ node.children.each_with_index { |child, index| collect_locations(child, path + [index], locations) }
112
+ end
113
+ end
114
+
115
+ def validation_path(message)
116
+ if (match = /Unknown (.+?) key\(s\):\s*([^,\s]+)/.match(message))
117
+ prefix = match[1] == "configuration" ? [] : parse_path(match[1])
118
+ return prefix + [match[2].to_sym]
119
+ end
120
+
121
+ explicit = message[/\b(?:steps|outputs|frames|lines)\[\d+\](?:\.[a-z_]+)*|\b(?:terminal|render|window|font|animation|cursor|limits)\.[a-z_]+/]
122
+ return parse_path(explicit) if explicit
123
+
124
+ {
125
+ "Session config version" => [:version], "mode must" => [:mode], "Invalid theme" => [:theme],
126
+ "redaction" => [:redact], "requires" => [:requires], "terminal.env" => %i[terminal env],
127
+ "title must" => [:title], "headless must" => [:headless]
128
+ }.each { |fragment, path| return path if message.include?(fragment) }
129
+ nil
130
+ end
131
+
132
+ def parse_path(value)
133
+ value.to_s.scan(/[A-Za-z_][A-Za-z0-9_]*|\d+/).map { |part| part.match?(/\A\d+\z/) ? part.to_i : part.to_sym }
134
+ end
135
+
136
+ def format_path(path)
137
+ path.each_with_object(+"") do |part, result|
138
+ if part.is_a?(Integer)
139
+ result << "[#{part}]"
140
+ else
141
+ result << "." unless result.empty?
142
+ result << part.to_s
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
data/lib/shellfie.rb CHANGED
@@ -16,6 +16,13 @@ module Shellfie
16
16
  autoload :ImageMagickCommandBuilder, File.expand_path("shellfie/image_magick_command_builder", __dir__)
17
17
  autoload :RenderChromeCache, File.expand_path("shellfie/render_chrome_cache", __dir__)
18
18
  autoload :Renderer, File.expand_path("shellfie/renderer", __dir__)
19
+ autoload :TerminalScreen, File.expand_path("shellfie/terminal_screen", __dir__)
20
+ autoload :TranscriptRenderer, File.expand_path("shellfie/transcript_renderer", __dir__)
21
+ autoload :Cassette, File.expand_path("shellfie/cassette", __dir__)
22
+ autoload :Session, File.expand_path("shellfie/session", __dir__)
23
+ autoload :SessionConfig, File.expand_path("shellfie/session_config", __dir__)
24
+ autoload :SessionRunner, File.expand_path("shellfie/session_runner", __dir__)
25
+ autoload :ReproducibilityManifest, File.expand_path("shellfie/reproducibility_manifest", __dir__)
19
26
 
20
27
  class << self
21
28
  def parse(source)
@@ -41,7 +48,7 @@ module Shellfie
41
48
  def inspect_config(source, scale: 1, shadow: true)
42
49
  config = parse(source)
43
50
  geometry = Renderer.new(config).estimate(scale: scale, shadow: shadow)
44
- { config: config.to_h, theme: config.theme, geometry: geometry }
51
+ { config: config.to_h, theme: config.theme, geometry: geometry, fonts: Renderer.new(config).font_info }
45
52
  end
46
53
  end
47
54
  end
@@ -0,0 +1,153 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/ydah/shellfie/main/schema/shellfie-v1.schema.json",
4
+ "title": "Shellfie compose configuration",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "version": { "const": 1 },
9
+ "include": { "oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] },
10
+ "include_policy": { "enum": ["allow", "root"] },
11
+ "theme": { "enum": ["macos", "ubuntu", "windows", "custom"] },
12
+ "window_theme": { "enum": ["macos", "ubuntu", "windows"] },
13
+ "color_scheme": { "enum": ["dracula", "one_dark", "solarized_dark", "catppuccin_mocha", null] },
14
+ "colors": {
15
+ "type": "object",
16
+ "propertyNames": { "enum": ["background", "foreground", "title_bar", "title_text", "title_bar_border", "border", "selection", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "bright_black", "bright_red", "bright_green", "bright_yellow", "bright_blue", "bright_magenta", "bright_cyan", "bright_white"] },
17
+ "additionalProperties": { "type": "string" }
18
+ },
19
+ "window_decoration": {
20
+ "type": "object",
21
+ "additionalProperties": false,
22
+ "properties": {
23
+ "title_bar_height": { "type": "number", "minimum": 0 },
24
+ "button_size": { "type": "number", "minimum": 0 },
25
+ "button_spacing": { "type": "number", "minimum": 0 },
26
+ "button_width": { "type": "number", "minimum": 0 },
27
+ "corner_radius": { "type": "number", "minimum": 0 },
28
+ "shadow": {
29
+ "type": "object", "additionalProperties": false,
30
+ "properties": {
31
+ "blur": { "type": "number", "minimum": 0 }, "offset_x": { "type": "number" },
32
+ "offset_y": { "type": "number" }, "color": { "type": "string" }
33
+ }
34
+ }
35
+ }
36
+ },
37
+ "title": { "type": "string" },
38
+ "headless": { "type": "boolean" },
39
+ "window": {
40
+ "type": "object",
41
+ "additionalProperties": false,
42
+ "properties": {
43
+ "width": { "type": "integer", "minimum": 120 },
44
+ "height": { "type": ["integer", "null"], "minimum": 1 },
45
+ "padding": { "type": "integer", "minimum": 0, "maximum": 40 },
46
+ "opacity": { "type": "number", "minimum": 0, "maximum": 1 },
47
+ "visible_lines": { "type": ["integer", "null"], "minimum": 1 },
48
+ "max_lines": { "type": ["integer", "null"], "minimum": 1 },
49
+ "max_height": { "type": ["integer", "null"], "minimum": 1 },
50
+ "wrap": { "type": "boolean" },
51
+ "overflow": { "enum": ["clip", "wrap", "scroll"] },
52
+ "margin": { "type": ["integer", "null"], "minimum": 0 },
53
+ "exact_size": { "type": "boolean" },
54
+ "trim": { "type": "boolean" },
55
+ "tab_width": { "type": "integer", "minimum": 1 },
56
+ "ambiguous_width": { "enum": [1, 2] },
57
+ "osc_policy": { "enum": ["ignore", "preserve", "apply"] },
58
+ "graphics_policy": { "enum": ["ignore", "error"] },
59
+ "ansi_state": { "enum": ["persistent", "line"] },
60
+ "background_gradient": { "type": ["array", "null"], "minItems": 2, "maxItems": 2, "items": { "type": "string" } },
61
+ "scroll_offset": { "type": "number", "minimum": 0, "maximum": 1 }
62
+ }
63
+ },
64
+ "font": {
65
+ "type": "object",
66
+ "additionalProperties": false,
67
+ "properties": {
68
+ "family": { "type": ["string", "null"] },
69
+ "size": { "type": "number", "exclusiveMinimum": 0 },
70
+ "line_height": { "type": "number", "exclusiveMinimum": 0 },
71
+ "fallback_family": { "type": ["string", "null"] },
72
+ "italic_family": { "type": ["string", "null"] },
73
+ "emoji_family": { "type": ["string", "null"] }
74
+ }
75
+ },
76
+ "animation": {
77
+ "type": "object",
78
+ "additionalProperties": false,
79
+ "properties": {
80
+ "typing_speed": { "type": "integer", "minimum": 0, "maximum": 86400000 },
81
+ "command_delay": { "type": "integer", "minimum": 0, "maximum": 86400000 },
82
+ "cursor_blink": { "type": "boolean" },
83
+ "loop": { "type": "boolean" },
84
+ "typing_jitter": { "type": "number", "minimum": 0, "maximum": 1 },
85
+ "seed": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
86
+ "typing_chunk_size": { "type": "integer", "minimum": 1 },
87
+ "output_delay": { "type": "integer", "minimum": 0, "maximum": 86400000 },
88
+ "final_delay": { "type": "integer", "minimum": 0, "maximum": 86400000 },
89
+ "max_frames": { "type": ["integer", "null"], "minimum": 1 },
90
+ "dither": { "type": "boolean" },
91
+ "palette": { "enum": ["global", "adaptive", "theme"] },
92
+ "gif_colors": { "type": "integer", "minimum": 2, "maximum": 256 },
93
+ "gif_optimize": { "type": "boolean" },
94
+ "webp_lossless": { "type": "boolean" },
95
+ "webp_quality": { "type": "integer", "minimum": 0, "maximum": 100 },
96
+ "webp_method": { "type": "integer", "minimum": 0, "maximum": 6 },
97
+ "webp_near_lossless": { "type": "integer", "minimum": 0, "maximum": 100 },
98
+ "apng_prediction": { "enum": ["none", "sub", "up", "avg", "paeth", "mixed"] },
99
+ "loop_count": { "type": ["integer", "null"], "minimum": 0, "maximum": 65535 },
100
+ "direction": { "enum": ["forward", "reverse", "ping_pong"] },
101
+ "loop_offset": { "type": "integer", "minimum": 0 },
102
+ "scroll_easing": { "enum": ["linear", "ease_in", "ease_out", "ease_in_out"] },
103
+ "framerate": { "type": "integer", "minimum": 1, "maximum": 120 },
104
+ "playback_speed": { "type": "number", "exclusiveMinimum": 0, "maximum": 100 }
105
+ }
106
+ },
107
+ "cursor": {
108
+ "type": "object", "additionalProperties": false,
109
+ "properties": { "style": { "enum": ["block", "bar", "underline"] }, "color": { "type": ["string", "null"] } }
110
+ },
111
+ "limits": {
112
+ "type": "object", "additionalProperties": false,
113
+ "properties": {
114
+ "max_lines": { "type": "integer", "minimum": 1, "maximum": 10000 },
115
+ "max_frames": { "type": "integer", "minimum": 1, "maximum": 500 },
116
+ "max_render_frames": { "type": "integer", "minimum": 1, "maximum": 2000 },
117
+ "max_characters": { "type": "integer", "minimum": 1, "maximum": 200000 },
118
+ "max_pixels": { "type": "integer", "minimum": 1, "maximum": 50000000 },
119
+ "max_total_pixels": { "type": "integer", "minimum": 1, "maximum": 2000000000 },
120
+ "max_temp_bytes": { "type": "integer", "minimum": 1, "maximum": 8000000000 }
121
+ }
122
+ },
123
+ "lines": { "type": "array", "maxItems": 10000, "items": { "$ref": "#/$defs/line" } },
124
+ "frames": { "type": "array", "maxItems": 500, "items": { "$ref": "#/$defs/frame" } }
125
+ },
126
+ "anyOf": [{ "required": ["lines"] }, { "required": ["frames"] }],
127
+ "$defs": {
128
+ "line": {
129
+ "type": "object",
130
+ "additionalProperties": false,
131
+ "properties": {
132
+ "prompt": { "type": "string" }, "command": { "type": "string" }, "output": { "type": "string" },
133
+ "prompt_color": { "type": "string" }, "command_color": { "type": "string" }, "output_color": { "type": "string" },
134
+ "selected": { "type": "boolean" }
135
+ },
136
+ "anyOf": [{ "required": ["prompt"] }, { "required": ["command"] }, { "required": ["output"] }]
137
+ },
138
+ "frame": {
139
+ "type": "object",
140
+ "additionalProperties": false,
141
+ "properties": {
142
+ "prompt": { "type": "string" }, "type": { "type": "string" }, "output": { "type": "string" },
143
+ "screen": { "type": "array", "items": { "type": "string" } },
144
+ "delay": { "type": "integer", "minimum": 0, "maximum": 86400000 }, "prompt_color": { "type": "string" },
145
+ "command_color": { "type": "string" }, "output_color": { "type": "string" }
146
+ },
147
+ "allOf": [
148
+ { "anyOf": [{ "required": ["type"] }, { "required": ["output"] }, { "required": ["screen"] }, { "required": ["delay"] }] },
149
+ { "if": { "required": ["prompt"] }, "then": { "required": ["type"] } }
150
+ ]
151
+ }
152
+ }
153
+ }