shellfie 0.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.
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mini_magick"
4
+ require_relative "renderer"
5
+
6
+ module Shellfie
7
+ class GifGenerator
8
+ attr_reader :config, :theme
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ @renderer = Renderer.new(config)
13
+ @theme = @renderer.theme
14
+ end
15
+
16
+ def generate(output_path, scale: 1, shadow: true)
17
+ check_dependencies!
18
+
19
+ frames = build_animation_frames
20
+ images = render_frames(frames, scale: scale, shadow: shadow)
21
+ combine_to_gif(images, output_path)
22
+ cleanup_temp_files(images)
23
+ output_path
24
+ end
25
+
26
+ private
27
+
28
+ def check_dependencies!
29
+ result = `which magick 2>/dev/null || which convert 2>/dev/null`.strip
30
+ if result.empty?
31
+ raise DependencyError, <<~MSG
32
+ ImageMagick not found
33
+ → Please install ImageMagick: brew install imagemagick
34
+ → Or visit: https://imagemagick.org/script/download.php
35
+ MSG
36
+ end
37
+ end
38
+
39
+ def build_animation_frames
40
+ frames = []
41
+ current_lines = []
42
+ animation_settings = config.animation
43
+
44
+ config.frames.each do |frame|
45
+ if frame.type
46
+ typing_frames = build_typing_frames(
47
+ current_lines.dup,
48
+ frame.prompt || "",
49
+ frame.type,
50
+ animation_settings[:typing_speed] || 80
51
+ )
52
+ frames.concat(typing_frames)
53
+ current_lines << { prompt: frame.prompt, command: frame.type }
54
+ end
55
+
56
+ if frame.output
57
+ frame.output.split("\n").each do |line|
58
+ current_lines << { output: line }
59
+ end
60
+ frames << { lines: build_display_lines(current_lines), delay: frame.delay || 100 }
61
+ end
62
+
63
+ if frame.delay && frame.delay > 0 && !frame.output
64
+ frames << { lines: build_display_lines(current_lines), delay: frame.delay }
65
+ end
66
+ end
67
+
68
+ frames
69
+ end
70
+
71
+ def build_typing_frames(base_lines, prompt, command, typing_speed)
72
+ frames = []
73
+ chars = command.chars
74
+
75
+ chars.each_with_index do |_char, i|
76
+ typed = command[0..i]
77
+ lines = base_lines.dup
78
+ lines << { prompt: prompt, command: typed, cursor: true }
79
+ frames << { lines: build_display_lines(lines), delay: typing_speed }
80
+ end
81
+
82
+ final_lines = base_lines.dup
83
+ final_lines << { prompt: prompt, command: command }
84
+ frames << { lines: build_display_lines(final_lines), delay: typing_speed }
85
+
86
+ frames
87
+ end
88
+
89
+ def build_display_lines(lines_data)
90
+ lines_data.map do |line_data|
91
+ if line_data[:prompt]
92
+ text = "#{line_data[:prompt]}#{line_data[:command]}"
93
+ text += "█" if line_data[:cursor]
94
+ Line.new(prompt: text, command: nil, output: nil)
95
+ else
96
+ Line.new(prompt: nil, command: nil, output: line_data[:output])
97
+ end
98
+ end
99
+ end
100
+
101
+ def render_frames(frames, scale:, shadow:)
102
+ temp_dir = Dir.mktmpdir("shellfie")
103
+ images = []
104
+
105
+ frames.each_with_index do |frame, idx|
106
+ frame_config = create_frame_config(frame[:lines])
107
+ renderer = Renderer.new(frame_config)
108
+ output_path = File.join(temp_dir, "frame_#{format("%04d", idx)}.png")
109
+ renderer.render(output_path, scale: scale, shadow: shadow)
110
+ images << { path: output_path, delay: frame[:delay] }
111
+ end
112
+
113
+ images
114
+ end
115
+
116
+ def create_frame_config(lines)
117
+ Config.new(
118
+ theme: config.theme,
119
+ title: config.title,
120
+ window: config.window,
121
+ font: config.font,
122
+ lines: lines,
123
+ headless: config.headless
124
+ )
125
+ end
126
+
127
+ def combine_to_gif(images, output_path)
128
+ MiniMagick.convert do |convert|
129
+ convert.dispose "background"
130
+ convert.loop config.animation[:loop] ? 0 : 1
131
+
132
+ images.each do |img|
133
+ delay = (img[:delay] / 10.0).round
134
+ convert.delay delay
135
+ convert << img[:path]
136
+ end
137
+
138
+ convert.dither "FloydSteinberg"
139
+ convert.colors 256
140
+ convert.layers "optimize"
141
+ convert << output_path
142
+ end
143
+ end
144
+
145
+ def cleanup_temp_files(images)
146
+ images.each { |img| File.delete(img[:path]) if File.exist?(img[:path]) }
147
+ temp_dir = File.dirname(images.first[:path]) if images.any?
148
+ Dir.rmdir(temp_dir) if temp_dir && Dir.exist?(temp_dir) && Dir.empty?(temp_dir)
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "config"
5
+ require_relative "errors"
6
+
7
+ module Shellfie
8
+ class Parser
9
+ class << self
10
+ def parse(path)
11
+ raise ParseError, "Configuration file not found: #{path}" unless File.exist?(path)
12
+
13
+ content = File.read(path)
14
+ parse_string(content)
15
+ end
16
+
17
+ def parse_string(content)
18
+ raw = YAML.safe_load(content, symbolize_names: true)
19
+ validate_config(raw)
20
+ build_config(raw)
21
+ rescue Psych::SyntaxError => e
22
+ raise ParseError, "Invalid YAML syntax: #{e.message}"
23
+ end
24
+
25
+ private
26
+
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)
41
+ options = {
42
+ theme: raw[:theme],
43
+ title: raw[:title],
44
+ window: symbolize_hash(raw[:window]),
45
+ font: symbolize_hash(raw[:font]),
46
+ lines: parse_lines(raw[:lines]),
47
+ animation: symbolize_hash(raw[:animation]),
48
+ frames: parse_frames(raw[:frames]),
49
+ headless: raw[:headless] || false
50
+ }.compact
51
+
52
+ Config.new(options)
53
+ end
54
+
55
+ def symbolize_hash(hash)
56
+ return nil unless hash.is_a?(Hash)
57
+
58
+ hash.transform_keys(&:to_sym)
59
+ end
60
+
61
+ def parse_lines(lines)
62
+ return [] unless lines.is_a?(Array)
63
+
64
+ lines.map do |line|
65
+ Line.new(
66
+ prompt: line[:prompt],
67
+ command: line[:command],
68
+ output: line[:output]
69
+ )
70
+ end
71
+ end
72
+
73
+ def parse_frames(frames)
74
+ return [] unless frames.is_a?(Array)
75
+
76
+ frames.map do |frame|
77
+ Frame.new(
78
+ prompt: frame[:prompt],
79
+ type: frame[:type],
80
+ output: frame[:output],
81
+ delay: frame[:delay] || 0
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ Line = Struct.new(:prompt, :command, :output, keyword_init: true)
89
+ Frame = Struct.new(:prompt, :type, :output, :delay, keyword_init: true)
90
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mini_magick"
4
+ require_relative "ansi_parser"
5
+ require_relative "themes/base"
6
+ require_relative "themes/macos"
7
+ require_relative "themes/ubuntu"
8
+ require_relative "themes/windows_terminal"
9
+
10
+ module Shellfie
11
+ class Renderer
12
+ THEMES = {
13
+ "macos" => Themes::MacOS,
14
+ "ubuntu" => Themes::Ubuntu,
15
+ "windows" => Themes::WindowsTerminal
16
+ }.freeze
17
+
18
+ attr_reader :config, :theme
19
+
20
+ def initialize(config)
21
+ @config = config
22
+ @theme = load_theme(config.theme)
23
+ @ansi_parser = AnsiParser.new
24
+ end
25
+
26
+ def render(output_path, scale: 1, shadow: true, transparent: false)
27
+ check_dependencies!
28
+
29
+ lines = build_lines
30
+ create_image(lines, output_path, scale: scale, shadow: shadow, transparent: transparent)
31
+ output_path
32
+ end
33
+
34
+ private
35
+
36
+ def load_theme(name)
37
+ klass = THEMES[name] || THEMES["macos"]
38
+ klass.new
39
+ end
40
+
41
+ def check_dependencies!
42
+ result = `which magick 2>/dev/null || which convert 2>/dev/null`.strip
43
+ if result.empty?
44
+ raise DependencyError, <<~MSG
45
+ ImageMagick not found
46
+ → Please install ImageMagick: brew install imagemagick
47
+ → Or visit: https://imagemagick.org/script/download.php
48
+ MSG
49
+ end
50
+ end
51
+
52
+ def build_lines
53
+ config.lines.flat_map do |line|
54
+ result = []
55
+ if line.prompt
56
+ prompt_segments = @ansi_parser.parse(line.prompt)
57
+ command_segments = line.command ? @ansi_parser.parse(line.command) : []
58
+ result << { segments: prompt_segments + command_segments }
59
+ end
60
+ if line.output
61
+ line.output.split("\n").each do |output_line|
62
+ result << { segments: @ansi_parser.parse(output_line) }
63
+ end
64
+ end
65
+ result
66
+ end
67
+ end
68
+
69
+ def create_image(lines, output_path, scale:, shadow:, transparent:)
70
+ decoration = theme.window_decoration
71
+ font_config = config.font.is_a?(Hash) ? config.font : theme.font
72
+ padding = config.window[:padding] || 20
73
+ width = config.window[:width] || 600
74
+ line_height = (font_config[:size] || 14) * (font_config[:line_height] || 1.4)
75
+ title_bar_height = config.headless ? 0 : decoration[:title_bar_height]
76
+ visible_lines = config.window[:visible_lines]
77
+
78
+ display_line_count = visible_lines || lines.size
79
+ content_height = display_line_count * line_height + padding * 2
80
+ total_height = title_bar_height + content_height
81
+ corner_radius = decoration[:corner_radius]
82
+
83
+ if visible_lines && lines.size > visible_lines
84
+ lines = lines.last(visible_lines)
85
+ end
86
+
87
+ scaled_width = (width * scale).to_i
88
+ scaled_height = (total_height * scale).to_i
89
+ scaled_padding = (padding * scale).to_i
90
+ scaled_title_bar = (title_bar_height * scale).to_i
91
+ scaled_line_height = (line_height * scale).to_i
92
+ scaled_font_size = ((font_config[:size] || 14) * scale).to_i
93
+ scaled_radius = (corner_radius * scale).to_i
94
+
95
+ margin = shadow ? (50 * scale).to_i : (10 * scale).to_i
96
+ canvas_width = scaled_width + margin * 2
97
+ canvas_height = scaled_height + margin * 2
98
+
99
+ MiniMagick.convert do |convert|
100
+ convert.size "#{canvas_width}x#{canvas_height}"
101
+ convert << "xc:transparent"
102
+
103
+ if shadow
104
+ convert.fill "rgba(0,0,0,0.3)"
105
+ shadow_offset = (10 * scale).to_i
106
+ convert.draw "roundrectangle #{margin + shadow_offset},#{margin + shadow_offset} " \
107
+ "#{margin + scaled_width - 1 + shadow_offset},#{margin + scaled_height - 1 + shadow_offset} " \
108
+ "#{scaled_radius},#{scaled_radius}"
109
+ convert.blur "0x#{(15 * scale).to_i}"
110
+ end
111
+
112
+ convert.fill theme.colors[:background]
113
+ convert.draw "roundrectangle #{margin},#{margin} " \
114
+ "#{margin + scaled_width - 1},#{margin + scaled_height - 1} " \
115
+ "#{scaled_radius},#{scaled_radius}"
116
+
117
+ unless config.headless
118
+ convert.fill theme.colors[:title_bar]
119
+ title_y2 = margin + scaled_title_bar - 1
120
+ convert.draw "roundrectangle #{margin},#{margin} " \
121
+ "#{margin + scaled_width - 1},#{title_y2} " \
122
+ "#{scaled_radius},#{scaled_radius}"
123
+ convert.fill theme.colors[:title_bar]
124
+ convert.draw "rectangle #{margin},#{margin + scaled_radius} " \
125
+ "#{margin + scaled_width - 1},#{title_y2}"
126
+
127
+ button_x, button_y = button_positions(margin, scaled_title_bar, scale)
128
+ button_radius = ((theme.window_decoration[:button_size] / 2) * scale).to_i
129
+
130
+ theme.button_colors.each_with_index do |color, i|
131
+ spacing = ((theme.window_decoration[:button_spacing] + theme.window_decoration[:button_size]) * scale).to_i
132
+ bx = button_x + i * spacing
133
+ convert.fill color
134
+ convert.draw "circle #{bx},#{button_y} #{bx + button_radius},#{button_y}"
135
+ end
136
+
137
+ convert.fill theme.colors[:title_text]
138
+ convert.pointsize scaled_font_size
139
+ title_y = margin + scaled_title_bar / 2 + scaled_font_size / 3
140
+ title_x = margin + scaled_width / 2
141
+ convert.gravity "NorthWest"
142
+ convert.draw "text #{title_x - config.title.to_s.length * scaled_font_size / 4},#{title_y - scaled_font_size} '#{escape_text(config.title.to_s)}'"
143
+ end
144
+
145
+ content_y = margin + scaled_title_bar + scaled_padding
146
+ lines.each_with_index do |line, idx|
147
+ y = content_y + idx * scaled_line_height + scaled_font_size
148
+ x = margin + scaled_padding
149
+ draw_line_segments(convert, line[:segments], x, y, scaled_font_size, font_config)
150
+ end
151
+
152
+ convert << output_path
153
+ end
154
+ end
155
+
156
+ def button_positions(margin, title_bar_height, scale)
157
+ button_size = (theme.window_decoration[:button_size] * scale).to_i
158
+ scaled_width = ((config.window[:width] || 600) * scale).to_i
159
+
160
+ if theme.buttons_position == :left
161
+ x = margin + (16 * scale).to_i
162
+ else
163
+ x = margin + scaled_width - (16 * scale).to_i - button_size * 3
164
+ end
165
+ y = margin + title_bar_height / 2
166
+
167
+ [x, y]
168
+ end
169
+
170
+ def draw_line_segments(convert, segments, x, y, font_size, font_config)
171
+ current_x = x
172
+
173
+ segments.each do |segment|
174
+ color = segment.foreground ? theme.color_for(segment.foreground) : theme.colors[:foreground]
175
+ text = escape_text(segment.text)
176
+
177
+ next if text.empty?
178
+
179
+ convert.fill color
180
+ convert.pointsize font_size
181
+
182
+ convert.draw "text #{current_x},#{y} '#{text}'"
183
+
184
+ char_width = font_size * 0.6
185
+ current_x += (segment.text.length * char_width).to_i
186
+ end
187
+ end
188
+
189
+ def escape_text(text)
190
+ text.to_s.gsub("'", "\\\\'").gsub("\\", "\\\\\\\\")
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ module Themes
5
+ class Base
6
+ def name
7
+ "base"
8
+ end
9
+
10
+ def window_decoration
11
+ {
12
+ title_bar_height: 28,
13
+ button_size: 12,
14
+ button_spacing: 8,
15
+ corner_radius: 10,
16
+ shadow: { blur: 20, offset_x: 0, offset_y: 10, color: "rgba(0,0,0,0.3)" }
17
+ }
18
+ end
19
+
20
+ def colors
21
+ {
22
+ background: "#1e1e1e",
23
+ foreground: "#ffffff",
24
+ title_bar: "#3c3c3c",
25
+ title_text: "#ffffff",
26
+ black: "#000000",
27
+ red: "#ff5555",
28
+ green: "#50fa7b",
29
+ yellow: "#f1fa8c",
30
+ blue: "#6272a4",
31
+ magenta: "#ff79c6",
32
+ cyan: "#8be9fd",
33
+ white: "#f8f8f2",
34
+ bright_black: "#6272a4",
35
+ bright_red: "#ff6e6e",
36
+ bright_green: "#69ff94",
37
+ bright_yellow: "#ffffa5",
38
+ bright_blue: "#d6acff",
39
+ bright_magenta: "#ff92df",
40
+ bright_cyan: "#a4ffff",
41
+ bright_white: "#ffffff"
42
+ }
43
+ end
44
+
45
+ def button_colors
46
+ %w[#ff5f56 #ffbd2e #27c93f]
47
+ end
48
+
49
+ def button_style
50
+ :circles
51
+ end
52
+
53
+ def buttons_position
54
+ :left
55
+ end
56
+
57
+ def font
58
+ {
59
+ family: "Monaco",
60
+ size: 14,
61
+ line_height: 1.4
62
+ }
63
+ end
64
+
65
+ def color_for(name)
66
+ return name if name.is_a?(String) && name.start_with?("#")
67
+
68
+ colors[name.to_sym] || colors[:foreground]
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Shellfie
6
+ module Themes
7
+ class MacOS < Base
8
+ def name
9
+ "macos"
10
+ end
11
+
12
+ def window_decoration
13
+ {
14
+ title_bar_height: 28,
15
+ button_size: 12,
16
+ button_spacing: 8,
17
+ corner_radius: 10,
18
+ shadow: { blur: 50, offset_x: 0, offset_y: 25, color: "rgba(0,0,0,0.4)" }
19
+ }
20
+ end
21
+
22
+ def colors
23
+ super.merge(
24
+ background: "#1e1e1e",
25
+ title_bar: "#3c3c3c",
26
+ title_text: "#ffffff"
27
+ )
28
+ end
29
+
30
+ def button_colors
31
+ %w[#ff5f56 #ffbd2e #27c93f]
32
+ end
33
+
34
+ def button_style
35
+ :circles
36
+ end
37
+
38
+ def buttons_position
39
+ :left
40
+ end
41
+
42
+ def font
43
+ {
44
+ family: "SF Mono",
45
+ size: 14,
46
+ line_height: 1.4
47
+ }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Shellfie
6
+ module Themes
7
+ class Ubuntu < Base
8
+ def name
9
+ "ubuntu"
10
+ end
11
+
12
+ def window_decoration
13
+ {
14
+ title_bar_height: 36,
15
+ button_size: 14,
16
+ button_spacing: 6,
17
+ corner_radius: 12,
18
+ shadow: { blur: 30, offset_x: 0, offset_y: 15, color: "rgba(0,0,0,0.35)" }
19
+ }
20
+ end
21
+
22
+ def colors
23
+ super.merge(
24
+ background: "#300a24",
25
+ foreground: "#ffffff",
26
+ title_bar: "#2c2c2c",
27
+ title_text: "#ffffff",
28
+ green: "#4e9a06",
29
+ yellow: "#c4a000",
30
+ blue: "#3465a4",
31
+ magenta: "#75507b",
32
+ cyan: "#06989a"
33
+ )
34
+ end
35
+
36
+ def button_colors
37
+ %w[#f46067 #f5bf55 #5fc454]
38
+ end
39
+
40
+ def button_style
41
+ :circles
42
+ end
43
+
44
+ def buttons_position
45
+ :right
46
+ end
47
+
48
+ def font
49
+ {
50
+ family: "Ubuntu Mono",
51
+ size: 14,
52
+ line_height: 1.4
53
+ }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Shellfie
6
+ module Themes
7
+ class WindowsTerminal < Base
8
+ def name
9
+ "windows"
10
+ end
11
+
12
+ def window_decoration
13
+ {
14
+ title_bar_height: 32,
15
+ button_size: 10,
16
+ button_spacing: 0,
17
+ corner_radius: 0,
18
+ shadow: { blur: 15, offset_x: 0, offset_y: 5, color: "rgba(0,0,0,0.25)" }
19
+ }
20
+ end
21
+
22
+ def colors
23
+ super.merge(
24
+ background: "#0c0c0c",
25
+ foreground: "#cccccc",
26
+ title_bar: "#1f1f1f",
27
+ title_text: "#ffffff",
28
+ black: "#0c0c0c",
29
+ red: "#c50f1f",
30
+ green: "#13a10e",
31
+ yellow: "#c19c00",
32
+ blue: "#0037da",
33
+ magenta: "#881798",
34
+ cyan: "#3a96dd",
35
+ white: "#cccccc",
36
+ bright_black: "#767676",
37
+ bright_red: "#e74856",
38
+ bright_green: "#16c60c",
39
+ bright_yellow: "#f9f1a5",
40
+ bright_blue: "#3b78ff",
41
+ bright_magenta: "#b4009e",
42
+ bright_cyan: "#61d6d6",
43
+ bright_white: "#f2f2f2"
44
+ )
45
+ end
46
+
47
+ def button_style
48
+ :icons
49
+ end
50
+
51
+ def buttons_position
52
+ :right
53
+ end
54
+
55
+ def font
56
+ {
57
+ family: "Cascadia Mono",
58
+ size: 14,
59
+ line_height: 1.3
60
+ }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ VERSION = "0.1.0"
5
+ end
data/lib/shellfie.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shellfie/version"
4
+ require_relative "shellfie/errors"
5
+ require_relative "shellfie/config"
6
+ require_relative "shellfie/parser"
7
+ require_relative "shellfie/ansi_parser"
8
+ require_relative "shellfie/renderer"
9
+ require_relative "shellfie/gif_generator"
10
+ require_relative "shellfie/cli"
11
+
12
+ module Shellfie
13
+ end