@@ -46,8 +46,6 @@ module Replay
4646 # ones it can't match -- garbled, aborted, cut off, or carrying
4747 # embedded controls -- take the csi state's character loop, and both
4848 # paths funnel into the same apply, so neither can drift on dispatch.
49- # The pass earns its keep: a 1.6MB capture holding 91k CSI sequences
50- # replays a third again as slow without it (159ms to 206ms).
5149 CSI_BODY = /([\x30 -\x3f ]*)([\x20 -\x2f ]*)([\x40 -\x7e ])/
5250 # An OSC payload runs to its end: BEL or ST terminate it, CAN and SUB
5351 # abort it, and an aborting ESC starts a sequence of its own.
@@ -67,27 +65,43 @@ module Replay
6765 # owns both cells, but only the first contributes to the output.
6866 CONTINUATION = ''
6967
70- # A grid of lines with no viewport, each line an array of cells: one
71- # grapheme cluster per column, a wide cluster owning its cell and a
72- # CONTINUATION marker in the next, as terminal emulators store wide
73- # glyphs. Height is unbounded because a capture has no scrollback to
74- # lose, width because the writer has already truncated to the
75- # terminal.
68+ # A grid of lines with no viewport. Lines stay compact strings while
69+ # output only appends to them; moving back to edit one promotes it to
70+ # an array with one cell per terminal column. This keeps ordinary logs
71+ # proportional to their input while preserving terminal semantics for
72+ # repainted rows. Height is unbounded because a capture has no
73+ # scrollback to lose, width because the writer has already truncated
74+ # to the terminal.
7675 class Screen
7776 #: -> void
7877 def initialize
79- @lines = [ [ ] ] #: Array[Array[String]]
78+ @lines = [ +'' ] #: Array[String | Array[String]]
79+ # Compact strings need their terminal width because String#length
80+ # is not a column count for wide graphemes. Promoted rows use the
81+ # cell array's length and have a nil entry here.
82+ @widths = [ 0 ] #: Array[Integer?]
83+ # Only the current compact row needs its trailing grapheme cached.
84+ # Returning to a nonempty row without a cache promotes it.
85+ @trailing_cluster = nil #: String?
8086 @row = 0 #: Integer
8187 @col = 0 #: Integer
8288 @padding = 0 #: Integer
8389 @saved = [ 0 , 0 ] #: Array[Integer]
84- @conjured = { } . compare_by_identity #: Hash[Array[String], bool]
85- @alternate = nil #: [Array[Array[String]], Integer, Integer, Array[Integer], Integer]?
90+ @conjured = { } . compare_by_identity #: Hash[String | Array[String], bool]
91+ @alternate = nil #: [Array[String | Array[String]], Array[Integer? ], Integer, Integer, Array[Integer], Integer]?
8692 end
8793
8894 #: (String text) -> void
8995 def write ( text )
9096 line = materialize ( @row )
97+ if line . is_a? ( String ) && @col == @widths . fetch ( @row )
98+ if @trailing_cluster || line . empty?
99+ append ( line , text )
100+ return
101+ end
102+ end
103+
104+ line = promote ( @row )
91105 if text . ascii_only?
92106 # Every character in an ASCII printable run is one column
93107 # wide, so the whole run lands in one splice.
@@ -118,7 +132,7 @@ def write(text)
118132 # terminal; the gap only becomes real if something writes into it.
119133 #: (Integer rows) -> void
120134 def move_rows ( rows )
121- @row = ( @row + rows ) . clamp ( 0 , @lines . length - 1 + room )
135+ change_row ( ( @row + rows ) . clamp ( 0 , @lines . length - 1 + room ) )
122136 end
123137
124138 #: (Integer cols) -> void
@@ -131,7 +145,7 @@ def move_columns(cols)
131145 #: (Integer col) -> void
132146 def column ( col )
133147 limit = MAX_COLUMN
134- limit = [ @lines . fetch ( @row ) . length , limit ] . max if @row < @lines . length
148+ limit = [ line_width ( @row ) , limit ] . max if @row < @lines . length
135149 @col = col . clamp ( 0 , limit )
136150 end
137151
@@ -140,8 +154,11 @@ def column(col)
140154 #: -> void
141155 def line_feed
142156 materialize ( @row )
143- @row += 1
144- @lines << [ ] while @lines . length <= @row
157+ change_row ( @row + 1 )
158+ while @lines . length <= @row
159+ @lines << +''
160+ @widths << 0
161+ end
145162 end
146163
147164 # A capture holds the writer's bare \n, but the tty driver's ONLCR
@@ -172,7 +189,7 @@ def save_cursor
172189
173190 #: -> void
174191 def restore_cursor
175- @row = @ saved. fetch ( 0 )
192+ change_row ( @ saved. fetch ( 0 ) )
176193 @col = @saved . fetch ( 1 )
177194 end
178195
@@ -182,6 +199,27 @@ def erase_line(mode)
182199
183200 # Modes a terminal doesn't define are ignored, not coerced to zero.
184201 line = @lines . fetch ( @row )
202+ if line . is_a? ( String )
203+ width = @widths . fetch ( @row ) . to_i
204+ case mode
205+ when 0
206+ return if @col >= width
207+
208+ if @col . zero?
209+ line . clear
210+ @widths [ @row ] = 0
211+ invalidate_trailing_cluster
212+ return
213+ end
214+ when 2
215+ line . clear
216+ @widths [ @row ] = 0
217+ invalidate_trailing_cluster
218+ return
219+ end
220+ end
221+
222+ line = promote ( @row )
185223 case mode
186224 when 0
187225 split_wide ( line , @col )
@@ -200,15 +238,22 @@ def insert_lines(count)
200238 inserted = [ count , room ] . min
201239 @padding += inserted
202240 @lines [ @row , 0 ] = Array . new ( inserted ) { conjure }
241+ @widths [ @row , 0 ] = Array . new ( inserted , 0 )
242+ invalidate_trailing_cluster
203243 end
204244
205245 # Deleting a conjured row hands its charge back: an insert and its
206246 # paired delete net to nothing.
207247 #: (Integer count) -> void
208248 def delete_lines ( count )
209249 removed = @lines . slice! ( @row , count )
250+ @widths . slice! ( @row , count )
210251 removed &.each { |line | @padding -= 1 if @conjured . delete ( line ) }
211- @lines << [ ] if @lines . empty?
252+ if @lines . empty?
253+ @lines << +''
254+ @widths << 0
255+ end
256+ invalidate_trailing_cluster
212257 end
213258
214259 # The alternate screen holds a full-screen UI -- a prompt, a pager
@@ -219,11 +264,13 @@ def delete_lines(count)
219264 def enter_alternate
220265 return if @alternate
221266
222- @alternate = [ @lines , @row , @col , @saved , @padding ]
223- @lines = [ [ ] ]
267+ @alternate = [ @lines , @widths , @row , @col , @saved , @padding ]
268+ @lines = [ +'' ]
269+ @widths = [ 0 ]
224270 @row = 0
225271 @col = 0
226272 @saved = [ 0 , 0 ]
273+ invalidate_trailing_cluster
227274 end
228275
229276 # Leaving discards the scratch grid and restores the saved one,
@@ -236,8 +283,9 @@ def exit_alternate
236283 return unless stash
237284
238285 @lines . each { |line | @conjured . delete ( line ) }
239- @lines , @row , @col , @saved , @padding = stash
286+ @lines , @widths , @ row, @col , @saved , @padding = stash
240287 @alternate = nil
288+ invalidate_trailing_cluster
241289 end
242290
243291 # Trailing whitespace is trimmed from every line: a terminal renders
@@ -249,13 +297,57 @@ def exit_alternate
249297 def to_s
250298 lines = @lines
251299 if ( stash = @alternate )
252- lines = stash . fetch ( 0 ) #: as Array[Array[String]]
300+ lines = stash . fetch ( 0 ) #: as Array[String | Array[String]]
253301 end
254- lines . map { |line | line . join . rstrip } . join ( "\n " )
302+ lines . map { |line | line . is_a? ( String ) ? line . rstrip : line . join . rstrip } . join ( "\n " )
255303 end
256304
257305 private
258306
307+ # Appending to a compact row avoids retaining one String object per
308+ # terminal cell. Re-segment only the cached boundary grapheme because
309+ # presentation sequences can split a combining sequence.
310+ #: (String line, String text) -> void
311+ def append ( line , text )
312+ if text . ascii_only?
313+ mark_content ( line )
314+ line << text
315+ @col += text . length
316+ @widths [ @row ] = @col
317+ @trailing_cluster = text [ -1 ]
318+ return
319+ end
320+
321+ clusters = text . grapheme_clusters
322+ previous = @trailing_cluster
323+ if previous
324+ combined = "#{ previous } #{ text } " . grapheme_clusters
325+ if combined . first != previous
326+ old_width = TerminalWidth . grapheme_width ( previous )
327+ replacement = combined . shift . to_s
328+ combined . reject! { |cluster | cluster . match? ( LEADING_MARK ) }
329+ line . delete_suffix! ( previous )
330+ line << replacement << combined . join
331+ @col += TerminalWidth . grapheme_width ( replacement ) - old_width
332+ @col += combined . sum { |cluster | TerminalWidth . grapheme_width ( cluster ) }
333+ @widths [ @row ] = @col
334+ @trailing_cluster = combined . last || replacement
335+ return
336+ end
337+ end
338+
339+ clusters . reject! { |cluster | cluster . match? ( LEADING_MARK ) }
340+ return if clusters . empty?
341+
342+ mark_content ( line )
343+ clusters . each do |cluster |
344+ line << cluster
345+ @col += TerminalWidth . grapheme_width ( cluster )
346+ end
347+ @widths [ @row ] = @col
348+ @trailing_cluster = clusters . last
349+ end
350+
259351 # A presentation sequence can split one grapheme into separate
260352 # printable runs: "e\e[31m\u0301" is still one displayed cell.
261353 # Re-segment the new run with the glyph immediately before the
@@ -317,7 +409,7 @@ def put(line, cells)
317409 # A conjured row that receives display content is content after all:
318410 # hand its charge back. A leading combining mark at column zero is
319411 # ignored before reaching here, so it cannot spend the padding cap.
320- #: (Array[String] line) -> void
412+ #: (String | Array[String] line) -> void
321413 def mark_content ( line )
322414 @padding -= 1 if @conjured . delete ( line )
323415 end
@@ -342,26 +434,66 @@ def room
342434 [ MAX_PADDING - @padding , 0 ] . max
343435 end
344436
345- #: -> Array[ String]
437+ #: -> String
346438 def conjure
347- line = [ ] #: Array[String]
439+ line = + ''
348440 @conjured [ line ] = true
349441 line
350442 end
351443
352- # Fills in the rows a cursor move skipped, so every entry stays an
353- # Array. The budget can overshoot by one move's worth: move_rows
354- # clamps against the room left when it runs, which insert_lines may
355- # since have spent.
356- #: (Integer row) -> Array[String]
444+ # Fills in rows a cursor move skipped. The budget can overshoot by
445+ # one move's worth: move_rows clamps against the room left when it
446+ # runs, which insert_lines may since have spent.
447+ #: (Integer row) -> (String | Array[String])
357448 def materialize ( row )
358449 gap = row + 1 - @lines . length
359450 if gap . positive?
360451 @padding += gap
361- @lines << conjure while @lines . length <= row
452+ while @lines . length <= row
453+ @lines << conjure
454+ @widths << 0
455+ end
362456 end
363457 @lines . fetch ( row )
364458 end
459+
460+ # Converts a compact row to terminal cells the first time an
461+ # operation needs to edit it in place.
462+ #: (Integer row) -> Array[String]
463+ def promote ( row )
464+ line = @lines . fetch ( row )
465+ return line unless line . is_a? ( String )
466+
467+ cells = [ ] #: Array[String]
468+ line . grapheme_clusters . each do |cluster |
469+ cells << cluster
470+ cells << CONTINUATION if TerminalWidth . grapheme_width ( cluster ) == 2
471+ end
472+ @conjured [ cells ] = true if @conjured . delete ( line )
473+ @lines [ row ] = cells
474+ @widths [ row ] = nil
475+ invalidate_trailing_cluster if row == @row
476+ cells
477+ end
478+
479+ #: (Integer row) -> Integer
480+ def line_width ( row )
481+ line = @lines . fetch ( row )
482+ line . is_a? ( String ) ? @widths . fetch ( row ) . to_i : line . length
483+ end
484+
485+ #: (Integer row) -> void
486+ def change_row ( row )
487+ return if row == @row
488+
489+ @row = row
490+ invalidate_trailing_cluster
491+ end
492+
493+ #: -> void
494+ def invalidate_trailing_cluster
495+ @trailing_cluster = nil
496+ end
365497 end
366498
367499 class << self
@@ -474,9 +606,26 @@ def escape(screen, scanner)
474606 when "\e " then next
475607 when "\x18 " , "\x1a " then return :ground
476608 when /[\x20 -\x2f ]/
477- # nF sequences: intermediate bytes, then one final byte.
478- scanner . skip ( /[\x20 -\x2f ]*[\x30 -\x7e ]?/ )
479- return :ground
609+ return escape_intermediate ( screen , scanner )
610+ when SIMPLE_CONTROL then simple_control ( screen , char )
611+ else return :ground
612+ end
613+ end
614+ :ground
615+ end
616+
617+ # ESC intermediate state: collect through the final byte while
618+ # executing embedded C0 controls and ignoring DEL. Bulk-skipping this
619+ # tail would end the sequence at an embedded control, exposing its
620+ # final byte as printable text.
621+ #: (Screen screen, StringScanner scanner) -> Symbol
622+ def escape_intermediate ( screen , scanner )
623+ until scanner . eos?
624+ case ( char = scanner . getch . to_s )
625+ when /[\x30 -\x7e ]/ then return :ground
626+ when /[\x20 -\x2f ]/ then next
627+ when "\e " then return :escape
628+ when "\x18 " , "\x1a " then return :ground
480629 when SIMPLE_CONTROL then simple_control ( screen , char )
481630 else return :ground
482631 end
@@ -548,7 +697,10 @@ def apply(screen, params, intermediates, final)
548697 # tracks, except the alternate screen: a full-screen UI draws
549698 # there and a terminal discards it on exit, so it must not reach
550699 # the replayed scrollback either.
551- if params == '?1049'
700+ if params . match? ( /\A \? [\d ;]*\z / )
701+ modes = params . delete_prefix ( '?' ) . split ( ';' )
702+ return unless modes . include? ( '1049' )
703+
552704 case final
553705 when 'h' then screen . enter_alternate
554706 when 'l' then screen . exit_alternate
0 commit comments