shellfie 0.1.1 → 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.
- checksums.yaml +4 -4
- data/README.md +95 -236
- data/docs/.nojekyll +0 -0
- data/docs/index.html +205 -0
- data/docs/scripts.js +85 -0
- data/docs/styles.css +507 -0
- data/examples/simple.yml +3 -3
- data/lib/shellfie/animation_frame_builder.rb +178 -0
- data/lib/shellfie/animation_scroll_easing.rb +77 -0
- data/lib/shellfie/animation_timeline.rb +27 -0
- data/lib/shellfie/ansi_colors.rb +94 -0
- data/lib/shellfie/ansi_line_buffer.rb +87 -0
- data/lib/shellfie/ansi_normalizer.rb +51 -0
- data/lib/shellfie/ansi_parser.rb +50 -84
- data/lib/shellfie/cli.rb +22 -173
- data/lib/shellfie/cli_generate.rb +197 -0
- data/lib/shellfie/cli_info.rb +139 -0
- data/lib/shellfie/config.rb +108 -25
- data/lib/shellfie/config_defaults.rb +64 -0
- data/lib/shellfie/config_validation.rb +200 -0
- data/lib/shellfie/dependency_checker.rb +76 -0
- data/lib/shellfie/errors.rb +11 -1
- data/lib/shellfie/font_resolver.rb +58 -0
- data/lib/shellfie/format_resolver.rb +15 -0
- data/lib/shellfie/gif_generator.rb +83 -87
- data/lib/shellfie/gif_palette.rb +101 -0
- data/lib/shellfie/headless_theme_registry.rb +42 -0
- data/lib/shellfie/image_magick_command_builder.rb +75 -0
- data/lib/shellfie/line_layout.rb +137 -0
- data/lib/shellfie/output_writer.rb +41 -0
- data/lib/shellfie/parser.rb +113 -23
- data/lib/shellfie/parser_validation.rb +145 -0
- data/lib/shellfie/raster_painter.rb +157 -0
- data/lib/shellfie/render_chrome_cache.rb +40 -0
- data/lib/shellfie/render_geometry.rb +114 -0
- data/lib/shellfie/render_segment.rb +59 -0
- data/lib/shellfie/renderer.rb +79 -149
- data/lib/shellfie/rendering/shape_helpers.rb +42 -0
- data/lib/shellfie/rendering/text_painter.rb +187 -0
- data/lib/shellfie/rendering/window_chrome.rb +196 -0
- data/lib/shellfie/svg_raster_wrapper.rb +35 -0
- data/lib/shellfie/text_metrics.rb +96 -0
- data/lib/shellfie/theme_data.rb +80 -0
- data/lib/shellfie/theme_registry.rb +131 -0
- data/lib/shellfie/themes/base.rb +10 -1
- data/lib/shellfie/themes/configured.rb +61 -0
- data/lib/shellfie/themes/macos.rb +3 -1
- data/lib/shellfie/themes/ubuntu.rb +2 -1
- data/lib/shellfie/themes/windows_terminal.rb +7 -1
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie.rb +37 -3
- metadata +37 -2
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shellfie
|
|
4
|
+
class FontResolver
|
|
5
|
+
FONT_FILES = {
|
|
6
|
+
"Monaco" => "/System/Library/Fonts/Monaco.ttf",
|
|
7
|
+
"SF Mono" => "/System/Library/Fonts/SFNSMono.ttf",
|
|
8
|
+
"SF Mono Italic" => "/System/Library/Fonts/SFNSMonoItalic.ttf",
|
|
9
|
+
"Menlo" => "/System/Library/Fonts/Menlo.ttc",
|
|
10
|
+
"Courier" => "/System/Library/Fonts/Courier.ttc",
|
|
11
|
+
"Courier New" => "/System/Library/Fonts/Supplemental/Courier New.ttf",
|
|
12
|
+
"DejaVu-Sans-Mono" => "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
|
13
|
+
"DejaVu Sans Mono" => "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(command_provider)
|
|
17
|
+
@command_provider = command_provider
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resolve(font_config, italic:)
|
|
21
|
+
candidates = []
|
|
22
|
+
candidates << font_config[:italic_family] if italic
|
|
23
|
+
candidates << font_config[:family]
|
|
24
|
+
candidates << font_config[:fallback_family]
|
|
25
|
+
candidates << font_config[:emoji_family]
|
|
26
|
+
candidates << "Menlo"
|
|
27
|
+
candidates << "DejaVu-Sans-Mono"
|
|
28
|
+
candidates << "Courier"
|
|
29
|
+
|
|
30
|
+
candidates.compact.flat_map { |candidate| font_candidates(candidate.to_s) }
|
|
31
|
+
.find { |candidate| font_available?(candidate) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def font_available?(font)
|
|
37
|
+
return true if File.exist?(font)
|
|
38
|
+
return true if available_fonts.include?(font)
|
|
39
|
+
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def font_candidates(font)
|
|
44
|
+
[font, FONT_FILES[font]].compact
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def available_fonts
|
|
48
|
+
@available_fonts ||= begin
|
|
49
|
+
command = @command_provider.call
|
|
50
|
+
if command.empty?
|
|
51
|
+
[]
|
|
52
|
+
else
|
|
53
|
+
`#{command} -list font 2>/dev/null`.scan(/^\s*Font:\s+(.+)$/).flatten
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shellfie
|
|
4
|
+
class FormatResolver
|
|
5
|
+
class << self
|
|
6
|
+
def resolve(output_path, explicit:, default:)
|
|
7
|
+
return explicit if explicit
|
|
8
|
+
return default if output_path == "-"
|
|
9
|
+
|
|
10
|
+
extension = File.extname(output_path).delete_prefix(".")
|
|
11
|
+
extension.empty? ? default : extension
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "mini_magick"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require_relative "animation_frame_builder"
|
|
7
|
+
require_relative "dependency_checker"
|
|
8
|
+
require_relative "format_resolver"
|
|
9
|
+
require_relative "gif_palette"
|
|
10
|
+
require_relative "image_magick_command_builder"
|
|
11
|
+
require_relative "output_writer"
|
|
12
|
+
require_relative "render_chrome_cache"
|
|
4
13
|
require_relative "renderer"
|
|
5
14
|
|
|
6
15
|
module Shellfie
|
|
@@ -11,141 +20,128 @@ module Shellfie
|
|
|
11
20
|
@config = config
|
|
12
21
|
@renderer = Renderer.new(config)
|
|
13
22
|
@theme = @renderer.theme
|
|
23
|
+
@frame_builder = AnimationFrameBuilder.new(config)
|
|
14
24
|
end
|
|
15
25
|
|
|
16
|
-
def generate(output_path, scale: 1, shadow: true)
|
|
26
|
+
def generate(output_path, scale: 1, shadow: true, transparent: false, format: nil)
|
|
17
27
|
check_dependencies!
|
|
18
28
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
images = []
|
|
30
|
+
chrome_cache = RenderChromeCache.new
|
|
31
|
+
begin
|
|
32
|
+
frames = build_animation_frames
|
|
33
|
+
validate_frame_limit!(frames)
|
|
34
|
+
warn_frame_count(frames)
|
|
35
|
+
images = render_frames(frames, scale: scale, shadow: shadow, transparent: transparent, chrome_cache: chrome_cache)
|
|
36
|
+
extension = FormatResolver.resolve(output_path, explicit: format, default: "gif")
|
|
37
|
+
OutputWriter.write(output_path, extension: extension) do |temporary_path|
|
|
38
|
+
combine_to_animation(images, temporary_path, format: extension)
|
|
39
|
+
end
|
|
40
|
+
ensure
|
|
41
|
+
cleanup_temp_files(images)
|
|
42
|
+
chrome_cache.cleanup
|
|
43
|
+
end
|
|
44
|
+
rescue MiniMagick::Error => e
|
|
45
|
+
raise RenderError.new("ImageMagick animation render failed: #{e.message}", category: :render)
|
|
24
46
|
end
|
|
25
47
|
|
|
26
48
|
private
|
|
27
49
|
|
|
28
50
|
def check_dependencies!
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
51
|
+
DependencyChecker.configure_mini_magick!
|
|
52
|
+
DependencyChecker.ensure_imagemagick!
|
|
37
53
|
end
|
|
38
54
|
|
|
39
55
|
def build_animation_frames
|
|
40
|
-
|
|
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
|
|
56
|
+
@frame_builder.build
|
|
69
57
|
end
|
|
70
58
|
|
|
71
|
-
def
|
|
72
|
-
|
|
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
|
|
59
|
+
def cursor_text
|
|
60
|
+
@frame_builder.cursor_text
|
|
87
61
|
end
|
|
88
62
|
|
|
89
|
-
def
|
|
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:)
|
|
63
|
+
def render_frames(frames, scale:, shadow:, transparent:, chrome_cache:)
|
|
102
64
|
temp_dir = Dir.mktmpdir("shellfie")
|
|
103
65
|
images = []
|
|
104
66
|
|
|
105
67
|
frames.each_with_index do |frame, idx|
|
|
106
|
-
frame_config = create_frame_config(frame[:lines])
|
|
107
|
-
renderer = Renderer.new(frame_config)
|
|
68
|
+
frame_config = create_frame_config(frame[:lines], window_overrides: frame[:window] || {})
|
|
69
|
+
renderer = Renderer.new(frame_config, chrome_cache: chrome_cache)
|
|
108
70
|
output_path = File.join(temp_dir, "frame_#{format("%04d", idx)}.png")
|
|
109
|
-
renderer.render(output_path, scale: scale, shadow: shadow)
|
|
71
|
+
renderer.render(output_path, scale: scale, shadow: shadow, transparent: transparent)
|
|
110
72
|
images << { path: output_path, delay: frame[:delay] }
|
|
111
73
|
end
|
|
112
74
|
|
|
113
75
|
images
|
|
114
76
|
end
|
|
115
77
|
|
|
116
|
-
def create_frame_config(lines)
|
|
78
|
+
def create_frame_config(lines, window_overrides: {})
|
|
117
79
|
Config.new(
|
|
118
80
|
theme: config.theme,
|
|
81
|
+
window_theme: config.window_theme,
|
|
82
|
+
color_scheme: config.color_scheme,
|
|
83
|
+
colors: config.colors,
|
|
84
|
+
window_decoration: config.window_decoration,
|
|
119
85
|
title: config.title,
|
|
120
|
-
window: config.window,
|
|
86
|
+
window: config.window.merge(window_overrides),
|
|
121
87
|
font: config.font,
|
|
122
88
|
lines: lines,
|
|
89
|
+
animation: config.animation,
|
|
90
|
+
cursor: config.cursor,
|
|
91
|
+
limits: config.limits,
|
|
123
92
|
headless: config.headless
|
|
124
93
|
)
|
|
125
94
|
end
|
|
126
95
|
|
|
127
|
-
def
|
|
128
|
-
|
|
129
|
-
|
|
96
|
+
def combine_to_animation(images, output_path, format:)
|
|
97
|
+
palette = GifPalette.new(config: config, theme: theme) if format == "gif"
|
|
98
|
+
ImageMagickCommandBuilder.convert do |convert|
|
|
99
|
+
convert.dispose "none" if format == "gif"
|
|
130
100
|
convert.loop config.animation[:loop] ? 0 : 1
|
|
131
101
|
|
|
132
102
|
images.each do |img|
|
|
133
|
-
delay
|
|
134
|
-
convert.delay delay
|
|
103
|
+
convert.delay gif_delay(img[:delay])
|
|
135
104
|
convert << img[:path]
|
|
136
105
|
end
|
|
137
106
|
|
|
138
|
-
convert
|
|
139
|
-
convert
|
|
107
|
+
configure_animation_format(convert, format, images: images, palette: palette)
|
|
108
|
+
ImageMagickCommandBuilder.output(convert, output_path, format: format)
|
|
109
|
+
end
|
|
110
|
+
ensure
|
|
111
|
+
palette&.cleanup
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def configure_animation_format(convert, format, images:, palette:)
|
|
115
|
+
case format
|
|
116
|
+
when "gif"
|
|
117
|
+
palette.apply(convert, images: images)
|
|
140
118
|
convert.layers "optimize"
|
|
141
|
-
|
|
119
|
+
when "webp"
|
|
120
|
+
convert.define "webp:lossless=true"
|
|
142
121
|
end
|
|
143
122
|
end
|
|
144
123
|
|
|
145
124
|
def cleanup_temp_files(images)
|
|
146
|
-
images.each { |img| File.delete(img[:path]) if File.exist?(img[:path]) }
|
|
147
125
|
temp_dir = File.dirname(images.first[:path]) if images.any?
|
|
148
|
-
|
|
126
|
+
FileUtils.rm_rf(temp_dir) if temp_dir && Dir.exist?(temp_dir)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def gif_delay(milliseconds)
|
|
130
|
+
[(milliseconds / 10.0).round, 1].max
|
|
149
131
|
end
|
|
132
|
+
|
|
133
|
+
def warn_frame_count(frames)
|
|
134
|
+
max_frames = config.animation[:max_frames]
|
|
135
|
+
return unless max_frames && frames.size > max_frames
|
|
136
|
+
|
|
137
|
+
$stderr.puts "Warning: animation will generate #{frames.size} frames (max_frames is #{max_frames})"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validate_frame_limit!(frames)
|
|
141
|
+
return if frames.size <= config.limits[:max_render_frames]
|
|
142
|
+
|
|
143
|
+
raise ResourceLimitError, "Animation would generate #{frames.size} frames (max #{config.limits[:max_render_frames]})"
|
|
144
|
+
end
|
|
145
|
+
|
|
150
146
|
end
|
|
151
147
|
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require_relative "image_magick_command_builder"
|
|
5
|
+
|
|
6
|
+
module Shellfie
|
|
7
|
+
class GifPalette
|
|
8
|
+
def initialize(config:, theme:, command_builder: ImageMagickCommandBuilder)
|
|
9
|
+
@config = config
|
|
10
|
+
@theme = theme
|
|
11
|
+
@command_builder = command_builder
|
|
12
|
+
@temporary_files = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def apply(convert, images: [])
|
|
16
|
+
convert.dither(dither_mode)
|
|
17
|
+
|
|
18
|
+
case @config.animation[:palette]
|
|
19
|
+
when "global"
|
|
20
|
+
apply_global_palette(convert, images)
|
|
21
|
+
when "theme"
|
|
22
|
+
apply_theme_palette(convert)
|
|
23
|
+
else
|
|
24
|
+
convert.colors 256
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cleanup
|
|
29
|
+
@temporary_files.each { |file| file.close! if file.respond_to?(:close!) }
|
|
30
|
+
@temporary_files.clear
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def apply_global_palette(convert, images)
|
|
36
|
+
palette_path = build_global_palette(images)
|
|
37
|
+
convert.remap palette_path if palette_path
|
|
38
|
+
convert.colors 256
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def apply_theme_palette(convert)
|
|
42
|
+
palette_path = build_theme_palette
|
|
43
|
+
convert.remap palette_path if palette_path
|
|
44
|
+
convert.colors(theme_color_count)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_global_palette(images)
|
|
48
|
+
return nil if images.empty?
|
|
49
|
+
|
|
50
|
+
path = palette_path
|
|
51
|
+
@command_builder.convert do |convert|
|
|
52
|
+
images.each { |image| convert << image[:path] }
|
|
53
|
+
convert.append
|
|
54
|
+
convert.colors 256
|
|
55
|
+
convert.unique_colors
|
|
56
|
+
@command_builder.output(convert, path, format: "png")
|
|
57
|
+
end
|
|
58
|
+
path
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_theme_palette
|
|
62
|
+
colors = theme_colors.first(256)
|
|
63
|
+
return nil if colors.empty?
|
|
64
|
+
|
|
65
|
+
path = palette_path
|
|
66
|
+
@command_builder.convert do |convert|
|
|
67
|
+
@command_builder.canvas(convert, width: colors.size, height: 1, background: "xc:transparent")
|
|
68
|
+
colors.each_with_index do |color, index|
|
|
69
|
+
convert.fill color
|
|
70
|
+
@command_builder.point(convert, index, 0)
|
|
71
|
+
end
|
|
72
|
+
@command_builder.output(convert, path, format: "png")
|
|
73
|
+
end
|
|
74
|
+
path
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def palette_path
|
|
78
|
+
file = Tempfile.new(["shellfie-palette", ".png"])
|
|
79
|
+
@temporary_files << file
|
|
80
|
+
file.close
|
|
81
|
+
file.path
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def dither_mode
|
|
85
|
+
@config.animation[:dither] ? "FloydSteinberg" : "None"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def theme_color_count
|
|
89
|
+
[[theme_colors.size, 16].max, 256].min
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def theme_colors
|
|
93
|
+
@theme_colors ||= [
|
|
94
|
+
@theme.colors.values,
|
|
95
|
+
@theme.button_colors,
|
|
96
|
+
@theme.window_decoration.dig(:shadow, :color),
|
|
97
|
+
@theme.window_decoration[:border]
|
|
98
|
+
].flatten.compact.uniq
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "theme_data"
|
|
4
|
+
|
|
5
|
+
module Shellfie
|
|
6
|
+
class HeadlessThemeRegistry
|
|
7
|
+
VARIANTS = {
|
|
8
|
+
"plain" => {
|
|
9
|
+
window_decoration: {
|
|
10
|
+
title_bar_height: 0,
|
|
11
|
+
corner_radius: 0,
|
|
12
|
+
button_size: 0,
|
|
13
|
+
button_spacing: 0
|
|
14
|
+
},
|
|
15
|
+
button_colors: [],
|
|
16
|
+
button_style: :none,
|
|
17
|
+
buttons_position: :left,
|
|
18
|
+
title_alignment: :left
|
|
19
|
+
}
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def build(theme, variant: "plain")
|
|
24
|
+
settings = VARIANTS.fetch(variant)
|
|
25
|
+
ThemeData.new(
|
|
26
|
+
name: theme.name,
|
|
27
|
+
colors: theme.colors,
|
|
28
|
+
window_decoration: ThemeData.deep_merge(theme.window_decoration, settings[:window_decoration]),
|
|
29
|
+
button_colors: settings[:button_colors],
|
|
30
|
+
buttons_position: settings[:buttons_position],
|
|
31
|
+
button_style: settings[:button_style],
|
|
32
|
+
font: theme.font,
|
|
33
|
+
title_alignment: settings[:title_alignment]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def available_variants
|
|
38
|
+
VARIANTS.keys
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mini_magick"
|
|
4
|
+
|
|
5
|
+
module Shellfie
|
|
6
|
+
class ImageMagickCommandBuilder
|
|
7
|
+
class << self
|
|
8
|
+
def convert(&block)
|
|
9
|
+
MiniMagick.convert(&block)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def canvas(convert, width:, height:, background:)
|
|
13
|
+
convert.size "#{width}x#{height}"
|
|
14
|
+
convert << background
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def output(convert, path, format: nil)
|
|
18
|
+
convert << output_path(path, format: format || File.extname(path).delete_prefix("."))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def output_path(path, format:)
|
|
22
|
+
format == "apng" ? "apng:#{path}" : path
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def draw(convert, command)
|
|
26
|
+
convert.draw command
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def rectangle(convert, x1, y1, x2, y2)
|
|
30
|
+
draw(convert, "rectangle #{x1},#{y1} #{x2},#{y2}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def rectangles(convert, rectangles)
|
|
34
|
+
return if rectangles.empty?
|
|
35
|
+
|
|
36
|
+
draw(convert, rectangles.map { |rect| "rectangle #{rect[:x1]},#{rect[:y1]} #{rect[:x2]},#{rect[:y2]}" }.join(" "))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def round_rectangle(convert, x1, y1, x2, y2, radius)
|
|
40
|
+
draw(convert, "roundrectangle #{x1},#{y1} #{x2},#{y2} #{radius},#{radius}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def line(convert, x1, y1, x2, y2)
|
|
44
|
+
draw(convert, "line #{x1},#{y1} #{x2},#{y2}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def lines(convert, lines)
|
|
48
|
+
return if lines.empty?
|
|
49
|
+
|
|
50
|
+
draw(convert, lines.map { |line| "line #{line[:x1]},#{line[:y1]} #{line[:x2]},#{line[:y2]}" }.join(" "))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def circle(convert, center_x, center_y, radius)
|
|
54
|
+
draw(convert, "circle #{center_x},#{center_y} #{center_x + radius},#{center_y}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def point(convert, x, y)
|
|
58
|
+
draw(convert, "point #{x},#{y}")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def region(convert, x:, y:, width:, height:)
|
|
62
|
+
convert.region "#{width}x#{height}+#{x}+#{y}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def clear_region(convert)
|
|
66
|
+
convert << "+region"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def composite_over(convert)
|
|
70
|
+
convert.compose "over"
|
|
71
|
+
convert.composite
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ansi_parser"
|
|
4
|
+
require_relative "render_segment"
|
|
5
|
+
require_relative "text_metrics"
|
|
6
|
+
|
|
7
|
+
module Shellfie
|
|
8
|
+
class LineLayout
|
|
9
|
+
attr_reader :visible_count
|
|
10
|
+
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def prepare(lines, content_width:, font_size:, title_bar_height:, padding:, line_height:)
|
|
16
|
+
max_cells = [(content_width / (font_size * 0.6)).floor, 1].max
|
|
17
|
+
mode = @config.window[:wrap] ? "wrap" : @config.window[:overflow]
|
|
18
|
+
display_lines = lines.flat_map { |line| apply_overflow(line, max_cells, mode) }
|
|
19
|
+
line_limit = vertical_line_limit(title_bar_height, padding, line_height)
|
|
20
|
+
@visible_count = line_limit || display_lines.size
|
|
21
|
+
render_limit = render_line_limit(line_limit)
|
|
22
|
+
render_limit ? display_lines.last(render_limit) : display_lines
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def apply_overflow(line, max_cells, mode)
|
|
28
|
+
return [line] if line[:segments].empty?
|
|
29
|
+
|
|
30
|
+
case mode
|
|
31
|
+
when "wrap"
|
|
32
|
+
wrap_line(line, max_cells)
|
|
33
|
+
when "scroll"
|
|
34
|
+
[line.merge(segments: scroll_segments(line[:segments], max_cells))]
|
|
35
|
+
else
|
|
36
|
+
[line.merge(segments: clip_segments(line[:segments], max_cells))]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def vertical_line_limit(title_bar_height, padding, line_height)
|
|
41
|
+
limits = [@config.window[:visible_lines], @config.window[:max_lines]].compact
|
|
42
|
+
if @config.window[:max_height]
|
|
43
|
+
available_height = @config.window[:max_height] - title_bar_height - padding * 2
|
|
44
|
+
limits << [(available_height / line_height).floor, 1].max
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
limits.empty? ? nil : limits.min
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_line_limit(line_limit)
|
|
51
|
+
return nil unless line_limit
|
|
52
|
+
|
|
53
|
+
line_limit + (@config.window[:scroll_offset].to_f.positive? ? 1 : 0)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def wrap_line(line, max_cells)
|
|
57
|
+
wrapped = [{ segments: [], selected: line[:selected] }]
|
|
58
|
+
used_cells = 0
|
|
59
|
+
|
|
60
|
+
line[:segments].each do |segment|
|
|
61
|
+
segment.text.to_s.each_char do |char|
|
|
62
|
+
width = TextMetrics.char_width(char)
|
|
63
|
+
if used_cells.positive? && used_cells + width > max_cells
|
|
64
|
+
wrapped << { segments: [], selected: line[:selected] }
|
|
65
|
+
used_cells = 0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
append_segment_text(wrapped.last[:segments], segment, char)
|
|
69
|
+
used_cells += width
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
wrapped
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def clip_segments(segments, max_cells)
|
|
77
|
+
remaining = max_cells
|
|
78
|
+
segments.each_with_object([]) do |segment, result|
|
|
79
|
+
break result if remaining <= 0
|
|
80
|
+
|
|
81
|
+
text = TextMetrics.take_cells(segment.text, remaining)
|
|
82
|
+
next if text.empty?
|
|
83
|
+
|
|
84
|
+
result << copy_segment(segment, text)
|
|
85
|
+
remaining -= TextMetrics.cell_width(text)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def scroll_segments(segments, max_cells)
|
|
90
|
+
total_cells = segments.sum { |segment| TextMetrics.cell_width(segment.text) }
|
|
91
|
+
return clip_segments(segments, max_cells) if total_cells <= max_cells
|
|
92
|
+
|
|
93
|
+
cells_to_drop = total_cells - max_cells
|
|
94
|
+
scrolled = []
|
|
95
|
+
|
|
96
|
+
segments.each do |segment|
|
|
97
|
+
segment_cells = TextMetrics.cell_width(segment.text)
|
|
98
|
+
if cells_to_drop >= segment_cells
|
|
99
|
+
cells_to_drop -= segment_cells
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
text = cells_to_drop.positive? ? TextMetrics.drop_cells(segment.text, cells_to_drop) : segment.text
|
|
104
|
+
cells_to_drop = 0
|
|
105
|
+
scrolled << copy_segment(segment, text) unless text.empty?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
clip_segments(scrolled, max_cells)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def append_segment_text(segments, source_segment, char)
|
|
112
|
+
if segments.last && segment_style_key(segments.last) == segment_style_key(source_segment)
|
|
113
|
+
segments.last.text << char
|
|
114
|
+
else
|
|
115
|
+
segments << copy_segment(source_segment, char)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def copy_segment(segment, text)
|
|
120
|
+
RenderSegment.copy(segment, text)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def segment_style_key(segment)
|
|
124
|
+
[
|
|
125
|
+
segment.foreground,
|
|
126
|
+
segment.background,
|
|
127
|
+
segment.bold,
|
|
128
|
+
segment.italic,
|
|
129
|
+
segment.underline,
|
|
130
|
+
segment.dim,
|
|
131
|
+
segment.reverse,
|
|
132
|
+
segment.strikethrough,
|
|
133
|
+
segment.overline
|
|
134
|
+
]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|