shellfie 0.1.0 → 1.0.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +95 -236
  4. data/docs/.nojekyll +0 -0
  5. data/docs/index.html +205 -0
  6. data/docs/scripts.js +85 -0
  7. data/docs/styles.css +507 -0
  8. data/examples/simple.yml +3 -3
  9. data/lib/shellfie/animation_frame_builder.rb +178 -0
  10. data/lib/shellfie/animation_scroll_easing.rb +77 -0
  11. data/lib/shellfie/animation_timeline.rb +27 -0
  12. data/lib/shellfie/ansi_colors.rb +94 -0
  13. data/lib/shellfie/ansi_line_buffer.rb +87 -0
  14. data/lib/shellfie/ansi_normalizer.rb +51 -0
  15. data/lib/shellfie/ansi_parser.rb +50 -84
  16. data/lib/shellfie/cli.rb +22 -173
  17. data/lib/shellfie/cli_generate.rb +197 -0
  18. data/lib/shellfie/cli_info.rb +139 -0
  19. data/lib/shellfie/config.rb +108 -25
  20. data/lib/shellfie/config_defaults.rb +64 -0
  21. data/lib/shellfie/config_validation.rb +200 -0
  22. data/lib/shellfie/dependency_checker.rb +76 -0
  23. data/lib/shellfie/errors.rb +11 -1
  24. data/lib/shellfie/font_resolver.rb +58 -0
  25. data/lib/shellfie/format_resolver.rb +15 -0
  26. data/lib/shellfie/gif_generator.rb +83 -87
  27. data/lib/shellfie/gif_palette.rb +101 -0
  28. data/lib/shellfie/headless_theme_registry.rb +42 -0
  29. data/lib/shellfie/image_magick_command_builder.rb +75 -0
  30. data/lib/shellfie/line_layout.rb +137 -0
  31. data/lib/shellfie/output_writer.rb +41 -0
  32. data/lib/shellfie/parser.rb +113 -23
  33. data/lib/shellfie/parser_validation.rb +145 -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 +114 -0
  37. data/lib/shellfie/render_segment.rb +59 -0
  38. data/lib/shellfie/renderer.rb +79 -149
  39. data/lib/shellfie/rendering/shape_helpers.rb +42 -0
  40. data/lib/shellfie/rendering/text_painter.rb +187 -0
  41. data/lib/shellfie/rendering/window_chrome.rb +196 -0
  42. data/lib/shellfie/svg_raster_wrapper.rb +35 -0
  43. data/lib/shellfie/text_metrics.rb +96 -0
  44. data/lib/shellfie/theme_data.rb +80 -0
  45. data/lib/shellfie/theme_registry.rb +131 -0
  46. data/lib/shellfie/themes/base.rb +10 -1
  47. data/lib/shellfie/themes/configured.rb +61 -0
  48. data/lib/shellfie/themes/macos.rb +3 -1
  49. data/lib/shellfie/themes/ubuntu.rb +2 -1
  50. data/lib/shellfie/themes/windows_terminal.rb +7 -1
  51. data/lib/shellfie/version.rb +1 -1
  52. data/lib/shellfie.rb +37 -3
  53. metadata +37 -2
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "animation_scroll_easing"
4
+ require_relative "animation_timeline"
5
+
6
+ module Shellfie
7
+ class AnimationFrameBuilder
8
+ def initialize(config)
9
+ @config = config
10
+ @random = Random.new(0)
11
+ @scroll_easing = AnimationScrollEasing.new(config)
12
+ end
13
+
14
+ def build
15
+ return [{ lines: @config.lines, delay: @config.animation[:final_delay] }] if @config.frames.empty?
16
+
17
+ frames = []
18
+ current_lines = []
19
+ AnimationTimeline.new(@config).each do |event|
20
+ case event.kind
21
+ when :command
22
+ frames.concat(command_frames(current_lines, event.frame))
23
+ when :output
24
+ frames.concat(output_frames(current_lines, event.frame))
25
+ when :pause
26
+ frames << { lines: build_display_lines(current_lines), delay: event.frame.delay }
27
+ end
28
+ end
29
+
30
+ if @config.animation[:final_delay].positive?
31
+ frames << { lines: build_display_lines(current_lines), delay: @config.animation[:final_delay] }
32
+ end
33
+ frames
34
+ end
35
+
36
+ def cursor_text
37
+ glyph = case @config.cursor[:style]
38
+ when "bar"
39
+ "|"
40
+ when "underline"
41
+ "_"
42
+ else
43
+ "█"
44
+ end
45
+ color = @config.cursor[:color]
46
+ return glyph unless color
47
+
48
+ "#{ansi_color(color)}#{glyph}\e[0m"
49
+ end
50
+
51
+ private
52
+
53
+ def command_frames(current_lines, frame)
54
+ prompt = frame.prompt || ""
55
+ frames = build_typing_frames(current_lines.dup, frame)
56
+ current_lines << command_line(prompt, frame.type, prompt_color: frame.prompt_color, command_color: frame.command_color)
57
+ frames.concat(command_pause_frames(current_lines, frame))
58
+ frames
59
+ end
60
+
61
+ def build_typing_frames(base_lines, frame)
62
+ frames = []
63
+ prompt = frame.prompt || ""
64
+ command = frame.type
65
+ chars = command.chars
66
+ chunk_size = @config.animation[:typing_chunk_size]
67
+
68
+ (chunk_size..chars.length).step(chunk_size).each do |index|
69
+ typed = chars.first(index).join
70
+ frames << typing_frame(base_lines, frame, typed)
71
+ end
72
+
73
+ frames << typing_frame(base_lines, frame, command) if chars.length % chunk_size != 0 || frames.empty?
74
+ final_lines = base_lines.dup
75
+ final_lines << command_line(prompt, command, prompt_color: frame.prompt_color, command_color: frame.command_color)
76
+ frames << { lines: build_display_lines(final_lines), delay: @config.animation[:typing_speed] }
77
+ frames
78
+ end
79
+
80
+ def typing_frame(base_lines, frame, typed)
81
+ lines = base_lines.dup
82
+ lines << command_line(
83
+ frame.prompt || "",
84
+ typed,
85
+ cursor: true,
86
+ prompt_color: frame.prompt_color,
87
+ command_color: frame.command_color
88
+ )
89
+ {
90
+ lines: build_display_lines(lines),
91
+ delay: jittered_delay(@config.animation[:typing_speed], @config.animation[:typing_jitter])
92
+ }
93
+ end
94
+
95
+ def command_pause_frames(current_lines, frame)
96
+ delay = @config.animation[:command_delay]
97
+ return [] unless delay.positive?
98
+ return [{ lines: build_display_lines(current_lines), delay: delay }] unless @config.animation[:cursor_blink]
99
+
100
+ half_delay = [delay / 2, 1].max
101
+ [
102
+ { lines: build_display_lines(current_lines[0...-1] + [cursor_command_line(frame)]), delay: half_delay },
103
+ { lines: build_display_lines(current_lines), delay: delay - half_delay }
104
+ ]
105
+ end
106
+
107
+ def output_frames(current_lines, frame)
108
+ output_lines = frame.output.to_s.split("\n", -1)
109
+ output_delay = @config.animation[:output_delay]
110
+
111
+ if output_delay.positive?
112
+ output_lines.each_with_index.each_with_object([]) do |(line, index), frames|
113
+ previous_count = current_lines.size
114
+ current_lines << { output: line, output_color: frame.output_color }
115
+ delay = @scroll_easing.output_delay(output_delay, index, output_lines.size)
116
+ frames.concat(
117
+ @scroll_easing.transition_frames(
118
+ build_display_lines(current_lines),
119
+ delay: delay,
120
+ previous_count: previous_count
121
+ )
122
+ )
123
+ end
124
+ else
125
+ output_lines.each { |line| current_lines << { output: line, output_color: frame.output_color } }
126
+ [{ lines: build_display_lines(current_lines), delay: frame.delay || 100 }]
127
+ end
128
+ end
129
+
130
+ def command_line(prompt, command, cursor: false, prompt_color: nil, command_color: nil)
131
+ { prompt: prompt, command: command, cursor: cursor, prompt_color: prompt_color, command_color: command_color }
132
+ end
133
+
134
+ def cursor_command_line(frame)
135
+ command_line(
136
+ frame.prompt || "",
137
+ frame.type,
138
+ cursor: true,
139
+ prompt_color: frame.prompt_color,
140
+ command_color: frame.command_color
141
+ )
142
+ end
143
+
144
+ def build_display_lines(lines_data)
145
+ lines_data.map do |line_data|
146
+ if line_data[:prompt]
147
+ command = line_data[:command].to_s
148
+ command += cursor_text if line_data[:cursor]
149
+ Line.new(
150
+ prompt: line_data[:prompt],
151
+ command: command,
152
+ output: nil,
153
+ prompt_color: line_data[:prompt_color],
154
+ command_color: line_data[:command_color]
155
+ )
156
+ else
157
+ Line.new(prompt: nil, command: nil, output: line_data[:output], output_color: line_data[:output_color])
158
+ end
159
+ end
160
+ end
161
+
162
+ def ansi_color(color)
163
+ return "" unless color.to_s.match?(/\A#[0-9a-fA-F]{6}\z/)
164
+
165
+ r = color[1, 2].to_i(16)
166
+ g = color[3, 2].to_i(16)
167
+ b = color[5, 2].to_i(16)
168
+ "\e[38;2;#{r};#{g};#{b}m"
169
+ end
170
+
171
+ def jittered_delay(base_delay, jitter)
172
+ return base_delay unless jitter.positive?
173
+
174
+ factor = 1.0 + @random.rand(-jitter..jitter)
175
+ [(base_delay * factor).round, 1].max
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ class AnimationScrollEasing
5
+ def initialize(config)
6
+ @config = config
7
+ end
8
+
9
+ def output_delay(base_delay, index, total)
10
+ return base_delay if total <= 1 || easing == "linear"
11
+
12
+ progress = index.to_f / (total - 1)
13
+ [(base_delay * delay_factor(progress)).round, 1].max
14
+ end
15
+
16
+ def transition_frames(lines, delay:, previous_count:)
17
+ return [{ lines: lines, delay: delay }] unless scroll_transition?(delay, previous_count)
18
+
19
+ delays = split_delay(delay, scroll_frame_count(delay))
20
+ delays.each_with_index.map do |frame_delay, index|
21
+ progress = (index + 1).to_f / delays.size
22
+ {
23
+ lines: lines,
24
+ delay: frame_delay,
25
+ window: { scroll_offset: scroll_progress(progress) }
26
+ }
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def easing
33
+ @config.animation[:scroll_easing]
34
+ end
35
+
36
+ def scroll_transition?(delay, previous_count)
37
+ visible_lines = @config.window[:visible_lines]
38
+ visible_lines && previous_count >= visible_lines && delay.positive?
39
+ end
40
+
41
+ def scroll_frame_count(delay)
42
+ [[[(delay / 40.0).ceil, 1].max, 4].min, delay].min
43
+ end
44
+
45
+ def split_delay(delay, count)
46
+ base_delay = delay / count
47
+ remainder = delay % count
48
+ Array.new(count) { |index| base_delay + (index < remainder ? 1 : 0) }
49
+ end
50
+
51
+ def delay_factor(progress)
52
+ case easing
53
+ when "ease_in"
54
+ 0.75 + progress * 0.5
55
+ when "ease_out"
56
+ 1.25 - progress * 0.5
57
+ when "ease_in_out"
58
+ 0.75 + (0.5 - (progress - 0.5).abs) * 1.0
59
+ else
60
+ 1.0
61
+ end
62
+ end
63
+
64
+ def scroll_progress(progress)
65
+ case easing
66
+ when "ease_in"
67
+ progress**2
68
+ when "ease_out"
69
+ 1.0 - ((1.0 - progress)**2)
70
+ when "ease_in_out"
71
+ 0.5 - Math.cos(progress * Math::PI) / 2.0
72
+ else
73
+ progress
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ class AnimationTimeline
5
+ Event = Struct.new(:kind, :frame, keyword_init: true)
6
+
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ def each
12
+ return enum_for(:each) unless block_given?
13
+
14
+ @config.frames.each do |frame|
15
+ yield Event.new(kind: :command, frame: frame) if frame.type
16
+ yield Event.new(kind: :output, frame: frame) if frame.output
17
+ yield Event.new(kind: :pause, frame: frame) if pause_frame?(frame)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def pause_frame?(frame)
24
+ frame.delay&.positive? && frame.output.nil? && frame.type.nil?
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ module AnsiColors
5
+ COLORS = {
6
+ 30 => :black,
7
+ 31 => :red,
8
+ 32 => :green,
9
+ 33 => :yellow,
10
+ 34 => :blue,
11
+ 35 => :magenta,
12
+ 36 => :cyan,
13
+ 37 => :white,
14
+ 90 => :bright_black,
15
+ 91 => :bright_red,
16
+ 92 => :bright_green,
17
+ 93 => :bright_yellow,
18
+ 94 => :bright_blue,
19
+ 95 => :bright_magenta,
20
+ 96 => :bright_cyan,
21
+ 97 => :bright_white
22
+ }.freeze
23
+
24
+ BG_COLORS = {
25
+ 40 => :black,
26
+ 41 => :red,
27
+ 42 => :green,
28
+ 43 => :yellow,
29
+ 44 => :blue,
30
+ 45 => :magenta,
31
+ 46 => :cyan,
32
+ 47 => :white,
33
+ 100 => :bright_black,
34
+ 101 => :bright_red,
35
+ 102 => :bright_green,
36
+ 103 => :bright_yellow,
37
+ 104 => :bright_blue,
38
+ 105 => :bright_magenta,
39
+ 106 => :bright_cyan,
40
+ 107 => :bright_white
41
+ }.freeze
42
+
43
+ XTERM_COLOR_STEPS = [0, 95, 135, 175, 215, 255].freeze
44
+
45
+ module_function
46
+
47
+ def parse_extended_color(codes, index)
48
+ return [index, nil] if codes[index + 1].nil?
49
+
50
+ case codes[index + 1]
51
+ when 5
52
+ color_index = codes[index + 2]
53
+ return [index, nil] unless color_index
54
+
55
+ [index + 2, color_256(color_index)]
56
+ when 2
57
+ rgb = codes.values_at(index + 2, index + 3, index + 4)
58
+ return [index, nil] unless rgb.all? { |value| value.is_a?(Integer) && value.between?(0, 255) }
59
+
60
+ [index + 4, format("#%02x%02x%02x", *rgb)]
61
+ else
62
+ [index, nil]
63
+ end
64
+ end
65
+
66
+ def color_256(index)
67
+ return nil unless index.is_a?(Integer) && index.between?(0, 255)
68
+
69
+ if index < 16
70
+ standard_colors[index]
71
+ elsif index < 232
72
+ color_cube(index - 16)
73
+ else
74
+ gray = (index - 232) * 10 + 8
75
+ format("#%02x%02x%02x", gray, gray, gray)
76
+ end
77
+ end
78
+
79
+ def standard_colors
80
+ %i[
81
+ black red green yellow blue magenta cyan white
82
+ bright_black bright_red bright_green bright_yellow
83
+ bright_blue bright_magenta bright_cyan bright_white
84
+ ]
85
+ end
86
+
87
+ def color_cube(index)
88
+ r = XTERM_COLOR_STEPS[index / 36]
89
+ g = XTERM_COLOR_STEPS[(index % 36) / 6]
90
+ b = XTERM_COLOR_STEPS[index % 6]
91
+ format("#%02x%02x%02x", r, g, b)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ class AnsiLineBuffer
5
+ def initialize
6
+ @cells = []
7
+ @column = 0
8
+ @pending_escape = +""
9
+ end
10
+
11
+ def write_escape(sequence)
12
+ @pending_escape << sequence
13
+ end
14
+
15
+ def write_character(char)
16
+ case char
17
+ when "\r"
18
+ @column = 0
19
+ when "\b"
20
+ @column = [@column - 1, 0].max
21
+ @cells[@column] = nil
22
+ when "\a"
23
+ nil
24
+ else
25
+ @cells[@column] = "#{@pending_escape}#{char}"
26
+ @pending_escape = +""
27
+ @column += 1
28
+ end
29
+ end
30
+
31
+ def move(command, params)
32
+ amount = first_param(params, default: 1)
33
+ case command
34
+ when "C"
35
+ @column += amount
36
+ when "D"
37
+ @column = [@column - amount, 0].max
38
+ when "G"
39
+ @column = [amount - 1, 0].max
40
+ end
41
+ end
42
+
43
+ def position(params)
44
+ values = params.to_s.split(";")
45
+ column = values.length >= 2 ? Integer(values[1], exception: false) : 1
46
+ @column = [[column || 1, 1].max - 1, 0].max
47
+ end
48
+
49
+ def clear(command, params)
50
+ mode = first_param(params, default: 0)
51
+ command == "K" ? clear_line(mode) : clear_screen(mode)
52
+ end
53
+
54
+ def to_s
55
+ last = @cells.rindex { |cell| !cell.nil? }
56
+ return @pending_escape unless last
57
+
58
+ @cells[0..last].map { |cell| cell || " " }.join + @pending_escape
59
+ end
60
+
61
+ private
62
+
63
+ def first_param(params, default:)
64
+ value = Integer(params.to_s.split(";").first, exception: false)
65
+ value && value.positive? ? value : default
66
+ end
67
+
68
+ def clear_line(mode)
69
+ case mode
70
+ when 1
71
+ clear_range(0..@column)
72
+ when 2, 3
73
+ @cells.clear
74
+ else
75
+ @cells.slice!(@column..)
76
+ end
77
+ end
78
+
79
+ def clear_screen(mode)
80
+ mode.zero? ? clear_line(0) : clear_line(2)
81
+ end
82
+
83
+ def clear_range(range)
84
+ range.each { |index| @cells[index] = nil if index < @cells.length }
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+ require_relative "ansi_line_buffer"
5
+
6
+ module Shellfie
7
+ module AnsiNormalizer
8
+ ANSI_REGEX = /\e\[([0-9;]*)m/
9
+ OSC_REGEX = /\e\].*?(?:\a|\e\\)/
10
+ CSI_CONTROL_REGEX = /\e\[[0-9;?]*[A-Za-ln-z]/
11
+
12
+ module_function
13
+
14
+ def normalize(text)
15
+ text = text.gsub(OSC_REGEX, "")
16
+ text = apply_line_controls(text)
17
+ text.gsub(CSI_CONTROL_REGEX, "")
18
+ end
19
+
20
+ def apply_line_controls(text)
21
+ buffer = AnsiLineBuffer.new
22
+ scanner = StringScanner.new(text)
23
+
24
+ until scanner.eos?
25
+ if scanner.scan(ANSI_REGEX)
26
+ buffer.write_escape(scanner.matched)
27
+ next
28
+ end
29
+
30
+ if scanner.scan(/\e\[([0-9;?]*)[JK]/)
31
+ buffer.clear(scanner.matched[-1], scanner[1])
32
+ next
33
+ end
34
+
35
+ if scanner.scan(/\e\[([0-9;?]*)[CDG]/)
36
+ buffer.move(scanner.matched[-1], scanner[1])
37
+ next
38
+ end
39
+
40
+ if scanner.scan(/\e\[([0-9;?]*)[Hf]/)
41
+ buffer.position(scanner[1])
42
+ next
43
+ end
44
+
45
+ buffer.write_character(scanner.getch)
46
+ end
47
+
48
+ buffer.to_s
49
+ end
50
+ end
51
+ end