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,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../image_magick_command_builder"
4
+
5
+ module Shellfie
6
+ module Rendering
7
+ module ShapeHelpers
8
+ def draw_roundrect(convert, x1, y1, x2, y2, radius)
9
+ if radius.positive?
10
+ ImageMagickCommandBuilder.round_rectangle(convert, x1, y1, x2, y2, radius)
11
+ else
12
+ ImageMagickCommandBuilder.rectangle(convert, x1, y1, x2, y2)
13
+ end
14
+ end
15
+
16
+ def draw_windows_icon(convert, index, center_x, center_y, icon_size)
17
+ case index
18
+ when 0
19
+ ImageMagickCommandBuilder.line(convert, center_x - icon_size / 2, center_y, center_x + icon_size / 2, center_y)
20
+ when 1
21
+ ImageMagickCommandBuilder.rectangle(
22
+ convert,
23
+ center_x - icon_size / 2,
24
+ center_y - icon_size / 2,
25
+ center_x + icon_size / 2,
26
+ center_y + icon_size / 2
27
+ )
28
+ when 2
29
+ ImageMagickCommandBuilder.lines(
30
+ convert,
31
+ [
32
+ { x1: center_x - icon_size / 2, y1: center_y - icon_size / 2,
33
+ x2: center_x + icon_size / 2, y2: center_y + icon_size / 2 },
34
+ { x1: center_x + icon_size / 2, y1: center_y - icon_size / 2,
35
+ x2: center_x - icon_size / 2, y2: center_y + icon_size / 2 }
36
+ ]
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../image_magick_command_builder"
4
+ require_relative "../text_metrics"
5
+
6
+ module Shellfie
7
+ module Rendering
8
+ module TextPainter
9
+ def draw_content(convert, geometry)
10
+ content_y = content_origin_y(geometry)
11
+ draw_selected_backgrounds(convert, geometry, content_y)
12
+
13
+ geometry[:lines].each_with_index do |line, index|
14
+ top = content_y + index * geometry[:scaled_line_height]
15
+ baseline = top + geometry[:scaled_font_size]
16
+ x = geometry[:margin] + geometry[:scaled_padding]
17
+
18
+ draw_line_segments(convert, line[:segments], x, baseline, geometry)
19
+ end
20
+ end
21
+
22
+ def draw_line_segments(convert, segments, x, baseline, geometry)
23
+ positioned_segments = position_segments(segments, x, baseline, geometry)
24
+ draw_segment_backgrounds(convert, positioned_segments)
25
+ draw_positioned_segments(convert, positioned_segments, geometry)
26
+ end
27
+
28
+ def position_segments(segments, x, baseline, geometry)
29
+ current_x = x
30
+ segments.each_with_object([]) do |segment, result|
31
+ text = segment.text.to_s
32
+ next if text.empty?
33
+
34
+ width = TextMetrics.pixel_width(text, geometry[:scaled_font_size], ambiguous_width: geometry[:ambiguous_width])
35
+ top = baseline - geometry[:scaled_font_size]
36
+ foreground, background = segment_colors(segment)
37
+ result << {
38
+ segment: segment,
39
+ text: text,
40
+ x: current_x,
41
+ width: width,
42
+ top: top,
43
+ bottom: top + geometry[:scaled_line_height],
44
+ foreground: foreground,
45
+ background: background
46
+ }
47
+ current_x += width
48
+ end
49
+ end
50
+
51
+ def draw_segment_backgrounds(convert, positioned_segments)
52
+ positioned_segments
53
+ .select { |item| item[:background] }
54
+ .group_by { |item| item[:background] }
55
+ .each do |background, items|
56
+ convert.fill background
57
+ ImageMagickCommandBuilder.rectangles(
58
+ convert,
59
+ items.map { |item| { x1: item[:x], y1: item[:top], x2: item[:x] + item[:width], y2: item[:bottom] } }
60
+ )
61
+ end
62
+ end
63
+
64
+ def draw_positioned_segments(convert, positioned_segments, geometry)
65
+ positioned_segments.each do |item|
66
+ unless item[:segment].conceal
67
+ draw_text(
68
+ convert,
69
+ item[:text],
70
+ item[:x],
71
+ item[:top],
72
+ item[:foreground],
73
+ geometry[:scaled_font_size],
74
+ geometry[:font_config],
75
+ bold: item[:segment].bold,
76
+ italic: item[:segment].italic
77
+ )
78
+ end
79
+ draw_text_decoration(
80
+ convert,
81
+ item[:segment],
82
+ item[:x],
83
+ item[:width],
84
+ item[:top] + geometry[:scaled_font_size],
85
+ geometry
86
+ )
87
+ end
88
+ end
89
+
90
+ def segment_colors(segment)
91
+ foreground = segment.foreground ? theme.color_for(segment.foreground) : theme.colors[:foreground]
92
+ background = segment.background ? theme.color_for(segment.background) : nil
93
+
94
+ if segment.reverse
95
+ foreground, background = background || theme.colors[:background], foreground
96
+ end
97
+
98
+ foreground = color_with_opacity(foreground, 0.6, true) if segment.dim
99
+ [foreground, background]
100
+ end
101
+
102
+ def draw_text(convert, text, x, y, color, font_size, font_config, bold: false, italic: false)
103
+ convert.gravity "NorthWest"
104
+ convert.fill color
105
+ convert.stroke "none"
106
+ font = font_resolver.resolve(font_config, italic: italic)
107
+ convert.font font if font
108
+ convert.pointsize font_size
109
+ convert.weight(bold ? 700 : 400)
110
+ convert.style(italic ? "Italic" : "Normal")
111
+ convert.annotate "+#{x}+#{y}", escape_text(text)
112
+ end
113
+
114
+ def draw_text_decoration(convert, segment, x, width, baseline, geometry)
115
+ return if segment.conceal
116
+ return unless segment.underline || segment.strikethrough || segment.overline
117
+
118
+ line_width = [(geometry[:scaled_font_size] / 12.0).ceil, 1].max
119
+ decoration_color = segment.underline_color ? theme.color_for(segment.underline_color) : segment_colors(segment).first
120
+ convert.stroke decoration_color
121
+ convert.strokewidth line_width
122
+ convert.stroke_dasharray "#{line_width},#{line_width * 2}" if segment.underline_style == :dotted
123
+ convert.stroke_dasharray "#{line_width * 3},#{line_width * 2}" if segment.underline_style == :dashed
124
+
125
+ if segment.underline
126
+ y = baseline + (geometry[:scaled_font_size] * 0.12).ceil
127
+ ImageMagickCommandBuilder.line(convert, x, y, x + width, y)
128
+ ImageMagickCommandBuilder.line(convert, x, y + line_width * 2, x + width, y + line_width * 2) if segment.underline_style == :double
129
+ end
130
+ if segment.strikethrough
131
+ y = baseline - (geometry[:scaled_font_size] * 0.35).ceil
132
+ ImageMagickCommandBuilder.line(convert, x, y, x + width, y)
133
+ end
134
+ if segment.overline
135
+ y = baseline - geometry[:scaled_font_size]
136
+ ImageMagickCommandBuilder.line(convert, x, y, x + width, y)
137
+ end
138
+
139
+ convert.stroke "none"
140
+ convert.stroke_dasharray "none" if %i[dotted dashed].include?(segment.underline_style)
141
+ end
142
+
143
+ def draw_selected_backgrounds(convert, geometry, content_y)
144
+ rectangles = geometry[:lines].each_with_index.each_with_object([]) do |(line, index), result|
145
+ next unless line[:selected]
146
+
147
+ x = geometry[:margin] + geometry[:scaled_padding]
148
+ top = content_y + index * geometry[:scaled_line_height]
149
+ result << {
150
+ x1: x,
151
+ y1: top,
152
+ x2: x + geometry[:scaled_width] - geometry[:scaled_padding] * 2,
153
+ y2: top + geometry[:scaled_line_height]
154
+ }
155
+ end
156
+ return if rectangles.empty?
157
+
158
+ convert.fill theme.colors[:selection]
159
+ ImageMagickCommandBuilder.rectangles(convert, rectangles)
160
+ end
161
+
162
+ def content_origin_y(geometry)
163
+ base_y = geometry[:margin] + geometry[:scaled_title_bar] + geometry[:scaled_padding]
164
+ base_y - (geometry[:scroll_offset].to_f * geometry[:scaled_line_height]).round
165
+ end
166
+
167
+ def fit_text(text, max_width, font_size)
168
+ return "" if max_width <= 0
169
+ return text if TextMetrics.pixel_width(text, font_size, ambiguous_width: config.window[:ambiguous_width]) <= max_width
170
+
171
+ max_cells = [(max_width / (font_size * 0.6)).floor - 3, 0].max
172
+ "#{TextMetrics.take_cells(text, max_cells, ambiguous_width: config.window[:ambiguous_width])}..."
173
+ end
174
+
175
+ def color_with_opacity(color, opacity, allow_rgba)
176
+ return color unless allow_rgba && opacity < 1.0
177
+
178
+ if color.to_s.match?(/\A#[0-9a-fA-F]{6}\z/)
179
+ r = color[1, 2].to_i(16)
180
+ g = color[3, 2].to_i(16)
181
+ b = color[5, 2].to_i(16)
182
+ "rgba(#{r},#{g},#{b},#{opacity})"
183
+ else
184
+ color
185
+ end
186
+ end
187
+
188
+ def escape_text(text)
189
+ text.to_s
190
+ .encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
191
+ .gsub(/[\u0000-\u001f\u007f]/, " ")
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shape_helpers"
4
+ require_relative "../image_magick_command_builder"
5
+ require_relative "../text_metrics"
6
+
7
+ module Shellfie
8
+ module Rendering
9
+ module WindowChrome
10
+ include ShapeHelpers
11
+
12
+ def draw_shadow(convert, geometry)
13
+ shadow = theme.window_decoration[:shadow]
14
+ scale = geometry[:scale]
15
+ offset_x = (shadow[:offset_x] * scale).to_i
16
+ offset_y = (shadow[:offset_y] * scale).to_i
17
+ radius = geometry[:scaled_radius]
18
+ margin = geometry[:margin]
19
+
20
+ convert.fill shadow[:color]
21
+ draw_roundrect(
22
+ convert,
23
+ margin + offset_x,
24
+ margin + offset_y,
25
+ margin + geometry[:scaled_width] - 1 + offset_x,
26
+ margin + geometry[:scaled_height] - 1 + offset_y,
27
+ radius
28
+ )
29
+ convert.blur "0x#{(shadow[:blur] * scale).to_i}"
30
+ end
31
+
32
+ def draw_window(convert, geometry, transparent:)
33
+ margin = geometry[:margin]
34
+ background = color_with_opacity(theme.colors[:background], config.window[:opacity], true)
35
+
36
+ convert.fill background
37
+ draw_roundrect(
38
+ convert,
39
+ margin,
40
+ margin,
41
+ margin + geometry[:scaled_width] - 1,
42
+ margin + geometry[:scaled_height] - 1,
43
+ geometry[:scaled_radius]
44
+ )
45
+
46
+ draw_border(convert, geometry)
47
+ end
48
+
49
+ def draw_title_bar(convert, geometry)
50
+ margin = geometry[:margin]
51
+ title_y2 = margin + geometry[:scaled_title_bar] - 1
52
+
53
+ convert.fill theme.colors[:title_bar]
54
+ draw_roundrect(
55
+ convert,
56
+ margin,
57
+ margin,
58
+ margin + geometry[:scaled_width] - 1,
59
+ title_y2,
60
+ geometry[:scaled_radius]
61
+ )
62
+ convert.fill theme.colors[:title_bar]
63
+ ImageMagickCommandBuilder.rectangle(
64
+ convert,
65
+ margin,
66
+ margin + geometry[:scaled_radius],
67
+ margin + geometry[:scaled_width] - 1,
68
+ title_y2
69
+ )
70
+
71
+ draw_title_separator(convert, geometry, title_y2)
72
+ draw_buttons(convert, geometry)
73
+ draw_title(convert, geometry)
74
+ end
75
+
76
+ def draw_title_separator(convert, geometry, y)
77
+ color = theme.colors[:title_bar_border]
78
+ return unless color
79
+
80
+ convert.stroke color
81
+ convert.strokewidth 1
82
+ ImageMagickCommandBuilder.line(convert, geometry[:margin], y, geometry[:margin] + geometry[:scaled_width] - 1, y)
83
+ convert.stroke "none"
84
+ end
85
+
86
+ def draw_border(convert, geometry)
87
+ color = theme.colors[:border]
88
+ return unless color
89
+
90
+ convert.fill "none"
91
+ convert.stroke color
92
+ convert.strokewidth [geometry[:scale].to_i, 1].max
93
+ draw_roundrect(
94
+ convert,
95
+ geometry[:margin],
96
+ geometry[:margin],
97
+ geometry[:margin] + geometry[:scaled_width] - 1,
98
+ geometry[:margin] + geometry[:scaled_height] - 1,
99
+ geometry[:scaled_radius]
100
+ )
101
+ convert.stroke "none"
102
+ end
103
+
104
+ def draw_buttons(convert, geometry)
105
+ return draw_windows_buttons(convert, geometry) if theme.button_style == :icons
106
+
107
+ button_radius = ((theme.window_decoration[:button_size] / 2.0) * geometry[:scale]).to_i
108
+ centers = circle_button_centers(geometry)
109
+
110
+ centers.each_with_index do |(x, y), index|
111
+ convert.fill theme.button_colors[index]
112
+ convert.stroke "rgba(0,0,0,0.18)"
113
+ convert.strokewidth [geometry[:scale].to_i, 1].max
114
+ ImageMagickCommandBuilder.circle(convert, x, y, button_radius)
115
+ end
116
+ convert.stroke "none"
117
+ end
118
+
119
+ def circle_button_centers(geometry)
120
+ scale = geometry[:scale]
121
+ size = (theme.window_decoration[:button_size] * scale).to_i
122
+ spacing = ((theme.window_decoration[:button_spacing] + theme.window_decoration[:button_size]) * scale).to_i
123
+ y = geometry[:margin] + geometry[:scaled_title_bar] / 2
124
+
125
+ start_x = if theme.buttons_position == :left
126
+ geometry[:margin] + (16 * scale).to_i
127
+ else
128
+ group_width = size * 3 + (theme.window_decoration[:button_spacing] * scale).to_i * 2
129
+ geometry[:margin] + geometry[:scaled_width] - (16 * scale).to_i - group_width + size / 2
130
+ end
131
+
132
+ 3.times.map { |index| [start_x + index * spacing, y] }
133
+ end
134
+
135
+ def draw_windows_buttons(convert, geometry)
136
+ scale = geometry[:scale]
137
+ button_width = ((theme.window_decoration[:button_width] || 46) * scale).to_i
138
+ icon_size = (10 * scale).to_i
139
+ start_x = geometry[:margin] + geometry[:scaled_width] - button_width * 3
140
+ center_y = geometry[:margin] + geometry[:scaled_title_bar] / 2
141
+ color = theme.colors[:title_text]
142
+
143
+ convert.stroke color
144
+ convert.strokewidth [scale.to_i, 1].max
145
+ convert.fill "none"
146
+
147
+ 3.times do |index|
148
+ center_x = start_x + button_width * index + button_width / 2
149
+ draw_windows_icon(convert, index, center_x, center_y, icon_size)
150
+ end
151
+
152
+ convert.stroke "none"
153
+ end
154
+
155
+ def draw_title(convert, geometry)
156
+ scaled_font_size = geometry[:scaled_font_size]
157
+ group_width = button_group_width(geometry)
158
+ reserve_left = theme.buttons_position == :left ? group_width + (32 * geometry[:scale]).to_i : (12 * geometry[:scale]).to_i
159
+ reserve_right = theme.buttons_position == :right ? group_width + (12 * geometry[:scale]).to_i : (12 * geometry[:scale]).to_i
160
+ available_width = geometry[:scaled_width] - reserve_left - reserve_right
161
+ title = fit_text(config.title.to_s, available_width, scaled_font_size)
162
+ title_width = TextMetrics.pixel_width(title, scaled_font_size, ambiguous_width: config.window[:ambiguous_width])
163
+ min_x = geometry[:margin] + reserve_left
164
+ max_x = geometry[:margin] + geometry[:scaled_width] - reserve_right - title_width
165
+ centered_x = geometry[:margin] + (geometry[:scaled_width] - title_width) / 2
166
+ x = title_x(min_x, max_x, centered_x)
167
+ y = geometry[:margin] + geometry[:scaled_title_bar] / 2 + (scaled_font_size * 0.6).round
168
+
169
+ draw_text(convert, title, x, y - scaled_font_size, theme.colors[:title_text], scaled_font_size, geometry[:font_config])
170
+ end
171
+
172
+ def title_x(min_x, max_x, centered_x)
173
+ case theme.title_alignment
174
+ when :left
175
+ min_x
176
+ when :right
177
+ max_x
178
+ else
179
+ [[centered_x, min_x].max, max_x].min
180
+ end
181
+ end
182
+
183
+ def button_group_width(geometry)
184
+ scale = geometry[:scale]
185
+ if theme.button_style == :icons
186
+ ((theme.window_decoration[:button_width] || 46) * 3 * scale).to_i
187
+ else
188
+ size = theme.window_decoration[:button_size]
189
+ spacing = theme.window_decoration[:button_spacing]
190
+ ((size * 3 + spacing * 2) * scale).to_i
191
+ end
192
+ end
193
+
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "rbconfig"
6
+
7
+ module Shellfie
8
+ class ReproducibilityManifest
9
+ def self.build(config, output_path:, format:)
10
+ renderer = Renderer.new(config)
11
+ {
12
+ schema: 1,
13
+ config_sha256: Digest::SHA256.hexdigest(JSON.generate(config.to_h)),
14
+ output: output_path,
15
+ output_sha256: output_digest(output_path),
16
+ format: format,
17
+ ruby: RUBY_DESCRIPTION,
18
+ platform: RbConfig::CONFIG["host_os"],
19
+ unicode: {
20
+ version: TextMetrics::UNICODE_VERSION,
21
+ width_table: TextMetrics::WIDTH_TABLE_VERSION,
22
+ ambiguous_width: config.window[:ambiguous_width]
23
+ },
24
+ imagemagick: DependencyChecker.imagemagick_details[:version],
25
+ ffmpeg: DependencyChecker.ffmpeg_version,
26
+ fonts: renderer.font_info
27
+ }
28
+ end
29
+
30
+ def self.output_digest(path)
31
+ return Digest::SHA256.file(path).hexdigest if File.file?(path)
32
+ return unless File.directory?(path)
33
+
34
+ digest = Digest::SHA256.new
35
+ Dir.glob(File.join(path, "**", "*"), File::FNM_DOTMATCH).select { |entry| File.file?(entry) }.sort.each do |entry|
36
+ digest << entry.delete_prefix("#{path}#{File::SEPARATOR}") << "\0" << Digest::SHA256.file(entry).digest
37
+ end
38
+ digest.hexdigest
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "parser"
5
+ require_relative "terminal_screen"
6
+
7
+ module Shellfie
8
+ class Session
9
+ attr_reader :events, :captures, :exit_status, :screen
10
+
11
+ def initialize(columns:, rows:, title: "Terminal Session", events: [], captures: {}, exit_status: nil)
12
+ unless columns.is_a?(Integer) && columns.between?(1, 500) && rows.is_a?(Integer) && rows.between?(1, 200)
13
+ raise ParseError, "Invalid session dimensions"
14
+ end
15
+
16
+ @title = title
17
+ @events = events
18
+ @captures = captures
19
+ @exit_status = exit_status
20
+ @screen = TerminalScreen.new(columns: columns, rows: rows)
21
+ events.each { |event| @screen.feed(event[:text].to_s) if event.fetch(:visible, true) }
22
+ end
23
+
24
+ def record(text, delay: 0, visible: true, status: nil)
25
+ screen.feed(text) if visible
26
+ event = { text: visible ? text : "", delay: delay, visible: visible, status: status }
27
+ events << event
28
+ @exit_status = status unless status.nil?
29
+ event
30
+ end
31
+
32
+ def capture(name)
33
+ captures[name] = screen.render_lines
34
+ end
35
+
36
+ def render_config(theme:, options: {}, animated: false, lines: nil)
37
+ base = {
38
+ theme: theme,
39
+ title: @title,
40
+ window: options.fetch(:window, {}),
41
+ font: options.fetch(:font, {}),
42
+ animation: options.fetch(:animation, {}),
43
+ headless: options.fetch(:headless, false)
44
+ }
45
+ if animated
46
+ raise ValidationError, "Captured screens cannot be rendered as animations" if lines
47
+
48
+ frames = []
49
+ each_snapshot do |event, snapshot|
50
+ frame = Frame.new(screen: snapshot, delay: [(event[:delay].to_f * 1_000).round, 1].max)
51
+ frames << frame
52
+ end
53
+ Config.new(**base, frames: frames)
54
+ else
55
+ Config.new(**base, lines: (lines || screen.render_lines).map { |line| Line.new(output: line) })
56
+ end
57
+ end
58
+
59
+ def compose_hash
60
+ frames = []
61
+ each_snapshot do |event, snapshot|
62
+ frames << {
63
+ "screen" => snapshot,
64
+ "delay" => [(event[:delay].to_f * 1_000).round, 1].max
65
+ }
66
+ end
67
+ {
68
+ "version" => 1,
69
+ "title" => @title,
70
+ "window" => { "width" => screen.columns * 8, "visible_lines" => screen.rows },
71
+ "frames" => frames
72
+ }
73
+ end
74
+
75
+ def to_h
76
+ {
77
+ version: 1,
78
+ title: @title,
79
+ columns: screen.columns,
80
+ rows: screen.rows,
81
+ events: events,
82
+ captures: captures,
83
+ exit_status: exit_status
84
+ }
85
+ end
86
+
87
+ private
88
+
89
+ def each_snapshot
90
+ replay = TerminalScreen.new(columns: screen.columns, rows: screen.rows)
91
+ count = characters = 0
92
+ events.each do |event|
93
+ next unless event[:visible]
94
+
95
+ text = event[:text].to_s
96
+ next if text.empty? && !event[:delay].to_f.positive?
97
+
98
+ count += 1
99
+ raise ResourceLimitError, "Too many session frames (max #{Config::DEFAULTS[:limits][:max_frames]})" if count > Config::DEFAULTS[:limits][:max_frames]
100
+
101
+ replay.feed(text) unless text.empty?
102
+ snapshot = event[:screen] || replay.render_lines
103
+ characters += snapshot.sum(&:length)
104
+ if characters > Config::DEFAULTS[:limits][:max_characters]
105
+ raise ResourceLimitError, "Session snapshots are too large (max #{Config::DEFAULTS[:limits][:max_characters]} characters)"
106
+ end
107
+ yield event, snapshot
108
+ end
109
+ end
110
+ end
111
+ end