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