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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +95 -236
  3. data/docs/.nojekyll +0 -0
  4. data/docs/index.html +205 -0
  5. data/docs/scripts.js +85 -0
  6. data/docs/styles.css +507 -0
  7. data/examples/simple.yml +3 -3
  8. data/lib/shellfie/animation_frame_builder.rb +178 -0
  9. data/lib/shellfie/animation_scroll_easing.rb +77 -0
  10. data/lib/shellfie/animation_timeline.rb +27 -0
  11. data/lib/shellfie/ansi_colors.rb +94 -0
  12. data/lib/shellfie/ansi_line_buffer.rb +87 -0
  13. data/lib/shellfie/ansi_normalizer.rb +51 -0
  14. data/lib/shellfie/ansi_parser.rb +50 -84
  15. data/lib/shellfie/cli.rb +22 -173
  16. data/lib/shellfie/cli_generate.rb +197 -0
  17. data/lib/shellfie/cli_info.rb +139 -0
  18. data/lib/shellfie/config.rb +108 -25
  19. data/lib/shellfie/config_defaults.rb +64 -0
  20. data/lib/shellfie/config_validation.rb +200 -0
  21. data/lib/shellfie/dependency_checker.rb +76 -0
  22. data/lib/shellfie/errors.rb +11 -1
  23. data/lib/shellfie/font_resolver.rb +58 -0
  24. data/lib/shellfie/format_resolver.rb +15 -0
  25. data/lib/shellfie/gif_generator.rb +83 -87
  26. data/lib/shellfie/gif_palette.rb +101 -0
  27. data/lib/shellfie/headless_theme_registry.rb +42 -0
  28. data/lib/shellfie/image_magick_command_builder.rb +75 -0
  29. data/lib/shellfie/line_layout.rb +137 -0
  30. data/lib/shellfie/output_writer.rb +41 -0
  31. data/lib/shellfie/parser.rb +113 -23
  32. data/lib/shellfie/parser_validation.rb +145 -0
  33. data/lib/shellfie/raster_painter.rb +157 -0
  34. data/lib/shellfie/render_chrome_cache.rb +40 -0
  35. data/lib/shellfie/render_geometry.rb +114 -0
  36. data/lib/shellfie/render_segment.rb +59 -0
  37. data/lib/shellfie/renderer.rb +79 -149
  38. data/lib/shellfie/rendering/shape_helpers.rb +42 -0
  39. data/lib/shellfie/rendering/text_painter.rb +187 -0
  40. data/lib/shellfie/rendering/window_chrome.rb +196 -0
  41. data/lib/shellfie/svg_raster_wrapper.rb +35 -0
  42. data/lib/shellfie/text_metrics.rb +96 -0
  43. data/lib/shellfie/theme_data.rb +80 -0
  44. data/lib/shellfie/theme_registry.rb +131 -0
  45. data/lib/shellfie/themes/base.rb +10 -1
  46. data/lib/shellfie/themes/configured.rb +61 -0
  47. data/lib/shellfie/themes/macos.rb +3 -1
  48. data/lib/shellfie/themes/ubuntu.rb +2 -1
  49. data/lib/shellfie/themes/windows_terminal.rb +7 -1
  50. data/lib/shellfie/version.rb +1 -1
  51. data/lib/shellfie.rb +37 -3
  52. 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
- 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
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
- 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
51
+ DependencyChecker.configure_mini_magick!
52
+ DependencyChecker.ensure_imagemagick!
37
53
  end
38
54
 
39
55
  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
56
+ @frame_builder.build
69
57
  end
70
58
 
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
59
+ def cursor_text
60
+ @frame_builder.cursor_text
87
61
  end
88
62
 
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:)
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 combine_to_gif(images, output_path)
128
- MiniMagick.convert do |convert|
129
- convert.dispose "none"
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 = (img[:delay] / 10.0).round
134
- convert.delay delay
103
+ convert.delay gif_delay(img[:delay])
135
104
  convert << img[:path]
136
105
  end
137
106
 
138
- convert.dither "FloydSteinberg"
139
- convert.colors 256
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
- convert << output_path
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
- Dir.rmdir(temp_dir) if temp_dir && Dir.exist?(temp_dir) && Dir.empty?(temp_dir)
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