55module CLI
66 module UI
77 class Wrap
8+ # SGR parameters are separated by ; or, in the underspecified-but-real
9+ # colon form of extended colors (\x1b[38:2::255:0:0m), by :.
10+ SGR = /\A \x1b \[ [\d ;:]*m\z /
11+
812 #: (String input) -> void
913 def initialize ( input )
1014 @input = input
@@ -14,43 +18,143 @@ def initialize(input)
1418 def wrap ( total_width = Terminal . width )
1519 max_width = total_width - Frame . prefix_width
1620 width = 0 #: Integer
17- final = [ ]
18- # Create an alternation of format codes of parameter lengths 1-20, since + and {1,n} not allowed in lookbehind
19- format_codes = ( 1 ..20 ) . map { |n | /\x1b \[ [\d ;]{#{ n } }m/ } . join ( '|' )
20- codes = ''
21- @input . split ( /(?=\s |\x1b \[ [\d ;]+m|\r )|(?<=\s |#{ format_codes } )/ ) . each do |token |
22- case token
23- when '\x1B[0?m'
24- codes = ''
25- final << token
26- when /\x1b \[ [\d ;]+m/
27- codes += token # Track in use format codes so that they are resent after frame coloring
21+ final = +''
22+ # SGR codes in effect, resent after each line break so that frame
23+ # coloring doesn't clobber them mid-paragraph. An open hyperlink
24+ # likewise gets closed at the break and reopened after it, keeping
25+ # the frame gutter outside the link.
26+ sgr_state = { } #: Hash[String, String]
27+ open_hyperlink = nil #: String?
28+ break_line = -> do
29+ final << ANSI ::HYPERLINK_END if open_hyperlink
30+ final << "\n " << active_sgr ( sgr_state ) << open_hyperlink . to_s
31+ width = 0
32+ end
33+
34+ ANSI . each_token ( @input ) do |kind , token |
35+ if kind == :sequence
36+ case token
37+ when SGR
38+ track_sgr ( token , sgr_state )
39+ when ANSI ::HYPERLINK
40+ match = ANSI ::HYPERLINK . match ( token ) #: as !nil
41+ open_hyperlink = match [ :uri ] . to_s . empty? ? nil : token
42+ end
2843 final << token
29- when "\n "
30- final << "\n #{ codes } "
31- width = 0
32- when /\s /
33- token_width = ANSI . printing_width ( token )
34- if width + token_width <= max_width
35- final << token
36- width += token_width
37- else
38- final << "\n #{ codes } "
39- width = 0
44+ next
45+ end
46+
47+ # Split the text run so each whitespace character is its own
48+ # token: lines break at whitespace, and a space that would sit in
49+ # the last column becomes the break itself.
50+ token . split ( /(?=\s )|(?<=\s )/ ) . each do |chunk |
51+ if chunk == "\n "
52+ break_line . call
53+ next
4054 end
41- else
42- token_width = ANSI . printing_width ( token )
43- if width + token_width <= max_width
44- final << token
45- width += token_width
55+
56+ chunk_width = ANSI . printing_width ( chunk )
57+ if width + chunk_width <= max_width
58+ final << chunk
59+ width += chunk_width
60+ elsif chunk . match? ( /\A \s \z / )
61+ break_line . call
4662 else
47- final << " \n #{ codes } "
48- final << token
49- width = token_width
63+ break_line . call
64+ final << chunk
65+ width = chunk_width
5066 end
5167 end
5268 end
53- final . join
69+ final
70+ end
71+
72+ private
73+
74+ # Reconstructs the active SGR commands as one deduplicated sequence.
75+ #
76+ #: (Hash[String, String] state) -> String
77+ def active_sgr ( state )
78+ state . empty? ? '' : "\e [#{ state . values . join ( ";" ) } m"
79+ end
80+
81+ # Keeps only the most recent command for each SGR parameter, with shared
82+ # slots for the color commands whose payloads can vary. Deleting before
83+ # reinserting preserves the order of each command's last occurrence, so
84+ # an on/off/on sequence replays in the same effective order without
85+ # retaining the full formatting history.
86+ #
87+ # A reset can hide mid-list: parameters reset at a 0 or an empty entry
88+ # (\e[0;33m, \e[;1m), while zeros inside a colon-form parameter are
89+ # subparameters rather than commands.
90+ #
91+ #: (String token, Hash[String, String] state) -> void
92+ def track_sgr ( token , state )
93+ params = token [ 2 ...-1 ] . to_s . split ( ';' , -1 )
94+ params = [ '0' ] if params . empty?
95+ index = 0
96+ while index < params . length
97+ param = params . fetch ( index )
98+ param = '0' if param . empty?
99+ code = param . split ( ':' , 2 ) . first . to_i
100+ command = param
101+ consumed = 0
102+
103+ if code == 38 || code == 48 || code == 58
104+ command , consumed = color_command ( params , index )
105+ end
106+ remember_sgr ( state , code , command ) if command
107+ index += consumed + 1
108+ end
109+ end
110+
111+ # Semicolon-form extended colors consume their following parameters;
112+ # colon-form colors are already one parameter and need no grouping.
113+ #
114+ #: (Array[String] params, Integer index) -> [String?, Integer]
115+ def color_command ( params , index )
116+ param = params . fetch ( index )
117+ return [ param , 0 ] if param . include? ( ':' )
118+
119+ case params [ index + 1 ]
120+ when '5'
121+ return [ nil , params . length - index - 1 ] if params [ index + 2 ] . nil?
122+
123+ [ params [ index , 3 ] . to_a . join ( ';' ) , 2 ]
124+ when '2'
125+ length = params [ index + 2 ] . to_s . empty? ? 6 : 5
126+ return [ nil , params . length - index - 1 ] if params . length < index + length
127+
128+ [ params [ index , length ] . to_a . join ( ';' ) , length - 1 ]
129+ else
130+ [ nil , 0 ]
131+ end
132+ end
133+
134+ #: (Hash[String, String] state, Integer code, String command) -> void
135+ def remember_sgr ( state , code , command )
136+ if code . zero?
137+ state . clear
138+ return
139+ end
140+
141+ key = sgr_key ( code )
142+ state . delete ( key )
143+ state [ key ] = command
144+ end
145+
146+ #: (Integer code) -> String
147+ def sgr_key ( code )
148+ case code
149+ when 30 ..39 , 90 ..97
150+ 'foreground'
151+ when 40 ..49 , 100 ..107
152+ 'background'
153+ when 58 , 59
154+ 'underline_color'
155+ else
156+ code . to_s
157+ end
54158 end
55159 end
56160 end
0 commit comments