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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -2
- data/README.md +248 -228
- data/lib/shellfie/animation_frame_builder.rb +202 -0
- data/lib/shellfie/animation_scroll_easing.rb +77 -0
- data/lib/shellfie/animation_timeline.rb +28 -0
- data/lib/shellfie/ansi_colors.rb +94 -0
- data/lib/shellfie/ansi_line_buffer.rb +103 -0
- data/lib/shellfie/ansi_normalizer.rb +61 -0
- data/lib/shellfie/ansi_parser.rb +162 -83
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +75 -163
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +401 -0
- data/lib/shellfie/cli_info.rb +239 -0
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +112 -25
- data/lib/shellfie/config_defaults.rb +83 -0
- data/lib/shellfie/config_validation.rb +289 -0
- data/lib/shellfie/dependency_checker.rb +147 -0
- data/lib/shellfie/errors.rb +12 -1
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +68 -0
- data/lib/shellfie/format_resolver.rb +15 -0
- data/lib/shellfie/gif_generator.rb +231 -89
- data/lib/shellfie/gif_palette.rb +105 -0
- data/lib/shellfie/headless_theme_registry.rb +42 -0
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/image_magick_command_builder.rb +75 -0
- data/lib/shellfie/line_layout.rb +146 -0
- data/lib/shellfie/output_writer.rb +46 -0
- data/lib/shellfie/parser.rb +183 -30
- data/lib/shellfie/parser_validation.rb +178 -0
- data/lib/shellfie/raster_painter.rb +157 -0
- data/lib/shellfie/render_chrome_cache.rb +40 -0
- data/lib/shellfie/render_geometry.rb +115 -0
- data/lib/shellfie/render_segment.rb +71 -0
- data/lib/shellfie/renderer.rb +96 -149
- data/lib/shellfie/rendering/shape_helpers.rb +42 -0
- data/lib/shellfie/rendering/text_painter.rb +195 -0
- data/lib/shellfie/rendering/window_chrome.rb +196 -0
- data/lib/shellfie/reproducibility_manifest.rb +41 -0
- data/lib/shellfie/session.rb +111 -0
- data/lib/shellfie/session_config.rb +562 -0
- data/lib/shellfie/session_runner.rb +689 -0
- data/lib/shellfie/svg_raster_wrapper.rb +35 -0
- data/lib/shellfie/svg_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +155 -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/transcript_renderer.rb +92 -0
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie/yaml_safety.rb +147 -0
- data/lib/shellfie.rb +44 -3
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +58 -20
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/examples/animation.yml +0 -33
- data/examples/colored.yml +0 -20
- data/examples/demo.gif +0 -0
- data/examples/demo.png +0 -0
- data/examples/demo_animation.yml +0 -31
- data/examples/headless.png +0 -0
- data/examples/headless.yml +0 -16
- data/examples/scrolling.yml +0 -48
- data/examples/simple.yml +0 -21
- data/examples/theme_macos.png +0 -0
- data/examples/theme_ubuntu.png +0 -0
- data/examples/theme_windows.png +0 -0
- 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
|
data/lib/shellfie/themes/base.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
data/lib/shellfie/version.rb
CHANGED