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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Shellfie
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
+
40
+ module_function
41
+
42
+ def graphemes(text)
43
+ string = text.to_s.dup
44
+ string.force_encoding(Encoding::UTF_8)
45
+ string.scrub.scan(/\X/)
46
+ end
47
+
48
+ def cell_width(text, ambiguous_width: 1)
49
+ graphemes(text).sum { |grapheme| grapheme_width(grapheme, ambiguous_width: ambiguous_width) }
50
+ end
51
+
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)
57
+ result = +""
58
+ used_cells = 0
59
+
60
+ graphemes(text).each do |char|
61
+ width = grapheme_width(char, ambiguous_width: ambiguous_width)
62
+ break if used_cells + width > max_cells
63
+
64
+ result << char
65
+ used_cells += width
66
+ end
67
+
68
+ result
69
+ end
70
+
71
+ def drop_cells(text, cells_to_drop, ambiguous_width: 1)
72
+ used_cells = 0
73
+ graphemes(text).each_with_object(+"") do |char, result|
74
+ width = grapheme_width(char, ambiguous_width: ambiguous_width)
75
+ if used_cells < cells_to_drop
76
+ used_cells += width
77
+ next
78
+ end
79
+
80
+ result << char
81
+ end
82
+ end
83
+
84
+ def split_cells(text, max_cells, ambiguous_width: 1)
85
+ return [text.to_s] if max_cells <= 0
86
+
87
+ chunks = []
88
+ current = +""
89
+ used_cells = 0
90
+
91
+ graphemes(text).each do |char|
92
+ width = grapheme_width(char, ambiguous_width: ambiguous_width)
93
+ if used_cells.positive? && used_cells + width > max_cells
94
+ chunks << current
95
+ current = +""
96
+ used_cells = 0
97
+ end
98
+
99
+ current << char
100
+ used_cells += width
101
+ end
102
+
103
+ chunks << current unless current.empty?
104
+ chunks
105
+ end
106
+
107
+ def char_width(char, ambiguous_width: 1)
108
+ codepoint = char.ord
109
+ return 0 if combining?(codepoint)
110
+ return 0 if codepoint < 32 || codepoint == 0x7f
111
+ return 2 if wide?(codepoint)
112
+ return ambiguous_width if ambiguous?(codepoint)
113
+
114
+ 1
115
+ end
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
+
126
+ def combining?(codepoint)
127
+ (0x0300..0x036f).cover?(codepoint) ||
128
+ (0x1ab0..0x1aff).cover?(codepoint) ||
129
+ (0x1dc0..0x1dff).cover?(codepoint) ||
130
+ (0x20d0..0x20ff).cover?(codepoint) ||
131
+ (0xfe00..0xfe0f).cover?(codepoint) ||
132
+ (0xfe20..0xfe2f).cover?(codepoint) ||
133
+ (0xe0100..0xe01ef).cover?(codepoint)
134
+ end
135
+
136
+ def wide?(codepoint)
137
+ (0x1100..0x115f).cover?(codepoint) ||
138
+ (0x2329..0x232a).cover?(codepoint) ||
139
+ (0x2e80..0xa4cf).cover?(codepoint) ||
140
+ (0xac00..0xd7a3).cover?(codepoint) ||
141
+ (0xf900..0xfaff).cover?(codepoint) ||
142
+ (0xfe10..0xfe19).cover?(codepoint) ||
143
+ (0xfe30..0xfe6f).cover?(codepoint) ||
144
+ (0xff00..0xff60).cover?(codepoint) ||
145
+ (0xffe0..0xffe6).cover?(codepoint) ||
146
+ (0x1f300..0x1faff).cover?(codepoint)
147
+ end
148
+
149
+ def ambiguous?(codepoint)
150
+ return false if codepoint < 0x00a1
151
+
152
+ AMBIGUOUS_RANGES.any? { |range| range.cover?(codepoint) }
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ class ThemeData
5
+ attr_reader :name, :colors, :window_decoration, :button_colors, :buttons_position, :button_style, :font,
6
+ :title_alignment
7
+
8
+ def self.from_theme(theme, name:, colors: {}, window_decoration: {}, font: {}, headless: false)
9
+ data = new(
10
+ name: name,
11
+ colors: theme.colors.merge(colors || {}),
12
+ window_decoration: deep_merge(theme.window_decoration, window_decoration || {}),
13
+ button_colors: theme.button_colors,
14
+ buttons_position: theme.buttons_position,
15
+ button_style: theme.button_style,
16
+ font: theme.font.merge(font || {}),
17
+ title_alignment: theme.title_alignment
18
+ )
19
+ headless ? data.headless : data
20
+ end
21
+
22
+ def self.deep_merge(base, overrides)
23
+ base.merge(overrides) do |_key, left, right|
24
+ left.is_a?(Hash) && right.is_a?(Hash) ? deep_merge(left, right) : right
25
+ end
26
+ end
27
+
28
+ def initialize(name:, colors:, window_decoration:, button_colors:, buttons_position:, button_style:, font:,
29
+ title_alignment:)
30
+ @name = name
31
+ @colors = deep_freeze_copy(colors)
32
+ @window_decoration = deep_freeze_copy(window_decoration)
33
+ @button_colors = deep_freeze_copy(button_colors)
34
+ @buttons_position = buttons_position
35
+ @button_style = button_style
36
+ @font = deep_freeze_copy(font)
37
+ @title_alignment = title_alignment
38
+ freeze
39
+ end
40
+
41
+ def color_for(name)
42
+ return name if name.is_a?(String) && name.start_with?("#")
43
+
44
+ colors[name.to_sym] || colors[:foreground]
45
+ end
46
+
47
+ def headless
48
+ self.class.new(
49
+ name: name,
50
+ colors: colors,
51
+ window_decoration: self.class.deep_merge(
52
+ window_decoration,
53
+ title_bar_height: 0,
54
+ corner_radius: 0,
55
+ button_size: 0,
56
+ button_spacing: 0
57
+ ),
58
+ button_colors: [],
59
+ buttons_position: :left,
60
+ button_style: :none,
61
+ font: font,
62
+ title_alignment: :left
63
+ )
64
+ end
65
+
66
+ private
67
+
68
+ def deep_freeze_copy(value)
69
+ copy = case value
70
+ when Hash
71
+ value.each_with_object({}) { |(key, nested), result| result[key] = deep_freeze_copy(nested) }
72
+ when Array
73
+ value.map { |nested| deep_freeze_copy(nested) }
74
+ else
75
+ value
76
+ end
77
+ copy.freeze
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "themes/base"
4
+ require_relative "themes/configured"
5
+ require_relative "themes/macos"
6
+ require_relative "themes/ubuntu"
7
+ require_relative "themes/windows_terminal"
8
+ require_relative "headless_theme_registry"
9
+ require_relative "theme_data"
10
+
11
+ module Shellfie
12
+ class ThemeRegistry
13
+ WINDOW_THEMES = {
14
+ "macos" => Themes::MacOS,
15
+ "ubuntu" => Themes::Ubuntu,
16
+ "windows" => Themes::WindowsTerminal
17
+ }.freeze
18
+
19
+ COLOR_SCHEMES = {
20
+ "dracula" => {
21
+ background: "#282a36",
22
+ foreground: "#f8f8f2",
23
+ black: "#21222c",
24
+ red: "#ff5555",
25
+ green: "#50fa7b",
26
+ yellow: "#f1fa8c",
27
+ blue: "#6272a4",
28
+ magenta: "#ff79c6",
29
+ cyan: "#8be9fd",
30
+ white: "#f8f8f2",
31
+ bright_black: "#6272a4",
32
+ bright_red: "#ff6e6e",
33
+ bright_green: "#69ff94",
34
+ bright_yellow: "#ffffa5",
35
+ bright_blue: "#d6acff",
36
+ bright_magenta: "#ff92df",
37
+ bright_cyan: "#a4ffff",
38
+ bright_white: "#ffffff"
39
+ },
40
+ "one_dark" => {
41
+ background: "#282c34",
42
+ foreground: "#abb2bf",
43
+ red: "#e06c75",
44
+ green: "#98c379",
45
+ yellow: "#e5c07b",
46
+ blue: "#61afef",
47
+ magenta: "#c678dd",
48
+ cyan: "#56b6c2",
49
+ white: "#abb2bf"
50
+ },
51
+ "solarized_dark" => {
52
+ background: "#002b36",
53
+ foreground: "#839496",
54
+ red: "#dc322f",
55
+ green: "#859900",
56
+ yellow: "#b58900",
57
+ blue: "#268bd2",
58
+ magenta: "#d33682",
59
+ cyan: "#2aa198",
60
+ white: "#eee8d5"
61
+ },
62
+ "catppuccin_mocha" => {
63
+ background: "#1e1e2e",
64
+ foreground: "#cdd6f4",
65
+ red: "#f38ba8",
66
+ green: "#a6e3a1",
67
+ yellow: "#f9e2af",
68
+ blue: "#89b4fa",
69
+ magenta: "#cba6f7",
70
+ cyan: "#94e2d5",
71
+ white: "#bac2de"
72
+ }
73
+ }.freeze
74
+
75
+ class << self
76
+ def build(config)
77
+ base_name = config.theme == "custom" ? (config.window_theme || "macos") : (config.window_theme || config.theme)
78
+ base_theme = fetch_window_theme(base_name).new
79
+ colors = color_scheme(config.color_scheme).merge(config.colors)
80
+
81
+ theme = Themes::Configured.new(
82
+ base_theme,
83
+ name: config.theme,
84
+ colors: colors,
85
+ window_decoration: config.window_decoration,
86
+ font: font_overrides(config)
87
+ )
88
+ theme_data = ThemeData.from_theme(theme, name: config.theme)
89
+ config.headless ? HeadlessThemeRegistry.build(theme_data) : theme_data
90
+ end
91
+
92
+ def valid_theme?(name)
93
+ name == "custom" || WINDOW_THEMES.key?(name)
94
+ end
95
+
96
+ def valid_window_theme?(name)
97
+ WINDOW_THEMES.key?(name)
98
+ end
99
+
100
+ def valid_color_scheme?(name)
101
+ name.nil? || COLOR_SCHEMES.key?(name)
102
+ end
103
+
104
+ def available_themes
105
+ (WINDOW_THEMES.keys + ["custom"]).sort
106
+ end
107
+
108
+ def available_color_schemes
109
+ COLOR_SCHEMES.keys.sort
110
+ end
111
+
112
+ private
113
+
114
+ def fetch_window_theme(name)
115
+ WINDOW_THEMES.fetch(name) do
116
+ raise ValidationError, "Invalid theme '#{name}'\n → Available themes: #{available_themes.join(", ")}"
117
+ end
118
+ end
119
+
120
+ def color_scheme(name)
121
+ return {} unless name
122
+
123
+ COLOR_SCHEMES.fetch(name)
124
+ end
125
+
126
+ def font_overrides(config)
127
+ config.font == Config::DEFAULTS[:font] ? {} : config.font
128
+ end
129
+ end
130
+ end
131
+ end
@@ -23,6 +23,9 @@ module Shellfie
23
23
  foreground: "#ffffff",
24
24
  title_bar: "#3c3c3c",
25
25
  title_text: "#ffffff",
26
+ title_bar_border: "rgba(255,255,255,0.08)",
27
+ border: "rgba(255,255,255,0.10)",
28
+ selection: "rgba(80,140,255,0.28)",
26
29
  black: "#000000",
27
30
  red: "#ff5555",
28
31
  green: "#50fa7b",
@@ -54,11 +57,17 @@ module Shellfie
54
57
  :left
55
58
  end
56
59
 
60
+ def title_alignment
61
+ :center
62
+ end
63
+
57
64
  def font
58
65
  {
59
66
  family: "Monaco",
60
67
  size: 14,
61
- line_height: 1.4
68
+ line_height: 1.4,
69
+ fallback_family: "Menlo",
70
+ emoji_family: "Apple Color Emoji"
62
71
  }
63
72
  end
64
73
 
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ module Themes
5
+ class Configured
6
+ def initialize(base_theme, name:, colors: {}, window_decoration: {}, font: {})
7
+ @base_theme = base_theme
8
+ @name = name
9
+ @colors = colors || {}
10
+ @window_decoration = window_decoration || {}
11
+ @font = font || {}
12
+ end
13
+
14
+ def name
15
+ @name
16
+ end
17
+
18
+ def window_decoration
19
+ deep_merge(@base_theme.window_decoration, @window_decoration)
20
+ end
21
+
22
+ def colors
23
+ @base_theme.colors.merge(@colors)
24
+ end
25
+
26
+ def font
27
+ @base_theme.font.merge(@font)
28
+ end
29
+
30
+ def button_colors
31
+ @base_theme.button_colors
32
+ end
33
+
34
+ def button_style
35
+ @base_theme.button_style
36
+ end
37
+
38
+ def buttons_position
39
+ @base_theme.buttons_position
40
+ end
41
+
42
+ def title_alignment
43
+ @base_theme.title_alignment
44
+ end
45
+
46
+ def color_for(name)
47
+ return name if name.is_a?(String) && name.start_with?("#")
48
+
49
+ colors[name.to_sym] || colors[:foreground]
50
+ end
51
+
52
+ private
53
+
54
+ def deep_merge(base, overrides)
55
+ base.merge(overrides) do |_key, left, right|
56
+ left.is_a?(Hash) && right.is_a?(Hash) ? deep_merge(left, right) : right
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -43,7 +43,9 @@ module Shellfie
43
43
  {
44
44
  family: "SF Mono",
45
45
  size: 14,
46
- line_height: 1.4
46
+ line_height: 1.4,
47
+ fallback_family: "Menlo",
48
+ emoji_family: "Apple Color Emoji"
47
49
  }
48
50
  end
49
51
  end
@@ -49,7 +49,8 @@ module Shellfie
49
49
  {
50
50
  family: "Ubuntu Mono",
51
51
  size: 14,
52
- line_height: 1.4
52
+ line_height: 1.4,
53
+ fallback_family: "DejaVu Sans Mono"
53
54
  }
54
55
  end
55
56
  end
@@ -13,6 +13,7 @@ module Shellfie
13
13
  {
14
14
  title_bar_height: 32,
15
15
  button_size: 10,
16
+ button_width: 46,
16
17
  button_spacing: 0,
17
18
  corner_radius: 0,
18
19
  shadow: { blur: 15, offset_x: 0, offset_y: 5, color: "rgba(0,0,0,0.25)" }
@@ -52,11 +53,16 @@ module Shellfie
52
53
  :right
53
54
  end
54
55
 
56
+ def title_alignment
57
+ :left
58
+ end
59
+
55
60
  def font
56
61
  {
57
62
  family: "Cascadia Mono",
58
63
  size: 14,
59
- line_height: 1.3
64
+ line_height: 1.3,
65
+ fallback_family: "Consolas"
60
66
  }
61
67
  end
62
68
  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 = "0.1.1"
4
+ VERSION = "1.1.0"
5
5
  end