Skip to content

Commit d92d828

Browse files
Wrap styled text using terminal display widths
Assisted-By: devx/b840c1e6-d979-473d-9216-a53477380bf1
1 parent a94a122 commit d92d828

3 files changed

Lines changed: 272 additions & 31 deletions

File tree

lib/cli/ui/wrap.rb

Lines changed: 135 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
module 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
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
module CLI
6+
module UI
7+
# Locks ANSI's width table into real layout: a wide glyph measured one
8+
# column short would shift every character after it in these strings.
9+
# Expectations are spelled out as exact output rather than measured
10+
# with printing_width, which is the very thing under test. Color and
11+
# cursor movement are disabled so layout arrives as plain text instead
12+
# of repaints.
13+
class WideGlyphLayoutTest < Minitest::Test
14+
def setup
15+
CLI::UI.enable_color = false
16+
CLI::UI.enable_cursor = false
17+
super
18+
end
19+
20+
def teardown
21+
CLI::UI.enable_color = true
22+
CLI::UI.enable_cursor = true
23+
super
24+
end
25+
26+
def test_frame_pads_an_emoji_title_like_a_plain_one
27+
Terminal.stubs(:width).returns(20)
28+
29+
with_emoji = capture_io { Frame.open('🚀 go', timing: false) {} }.first.lines.first.chomp
30+
plain = capture_io { Frame.open('ab go', timing: false) {} }.first.lines.first.chomp
31+
32+
assert_equal('┏━━ 🚀 go ━━━━━━━━━', with_emoji)
33+
# 🚀 spans two columns, like 'ab': the rules must line up.
34+
assert_equal('┏━━ ab go ━━━━━━━━━', plain)
35+
end
36+
37+
def test_table_pads_emoji_cells_by_column
38+
rows = Table.capture_table([['✅ pass', 'ok'], ['status', 'ok']])
39+
40+
assert_equal(['✅ pass ok', 'status ok'], rows)
41+
end
42+
43+
def test_spin_group_truncates_a_vs16_glyph_title_by_column
44+
Terminal.stubs(:width).returns(12)
45+
46+
out, _ = capture_io do
47+
StdoutRouter.ensure_activated
48+
sg = Spinner::SpinGroup.new
49+
sg.add('⚠️ wide glyph title') { true }
50+
sg.wait
51+
end
52+
53+
# ⚠️ (U+26A0 + VS16) takes two columns, so twelve fill at the g.
54+
assert_equal("✓ ⚠️ wide g\e[0m…", out.lines.last.chomp)
55+
end
56+
end
57+
end
58+
end

test/cli/ui/wrap_test.rb

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,85 @@ def test_wrap
1414
Terminal.stubs(:width).returns(20)
1515
assert_equal(ex, w.wrap)
1616
end
17+
18+
def test_wrap_resends_active_codes_after_a_break
19+
wrapped = Wrap.new("\x1b[31maaaa bbbb cccc").wrap(9)
20+
21+
assert_equal("\x1b[31maaaa bbbb\n\x1b[31mcccc", wrapped)
22+
end
23+
24+
def test_wrap_stops_resending_codes_after_a_reset
25+
wrapped = Wrap.new("\x1b[31maaaa\x1b[0m bbbb cccc").wrap(9)
26+
27+
assert_equal("\x1b[31maaaa\x1b[0m bbbb\ncccc", wrapped)
28+
end
29+
30+
def test_wrap_detects_a_reset_hidden_in_a_parameter_list
31+
# \e[0;33m resets, then applies 33: earlier codes die at the reset
32+
# and only the survivors are resent after a break.
33+
wrapped = Wrap.new("\x1b[1m\x1b[0;33maaaa bbbb cccc").wrap(9)
34+
35+
assert_equal("\x1b[1m\x1b[0;33maaaa bbbb\n\x1b[33mcccc", wrapped)
36+
end
37+
38+
def test_wrap_detects_an_empty_parameter_as_a_reset
39+
# An empty SGR parameter (\e[;m) is a 0 to a terminal.
40+
wrapped = Wrap.new("\x1b[31maaaa\x1b[;m bbbb cccc").wrap(9)
41+
42+
assert_equal("\x1b[31maaaa\x1b[;m bbbb\ncccc", wrapped)
43+
end
44+
45+
def test_wrap_tracks_colon_form_sgr_codes
46+
wrapped = Wrap.new("\e[38:2::255:0:0maaaa bbbb cccc").wrap(9)
47+
48+
assert_equal("\e[38:2::255:0:0maaaa bbbb\n\e[38:2::255:0:0mcccc", wrapped)
49+
end
50+
51+
def test_wrap_keeps_sgr_replay_bounded
52+
input = 8.times.map { |i| "\e[#{31 + (i % 7)}m#{(97 + i).chr * 4}" }.join(' ')
53+
wrapped = Wrap.new(input).wrap(5)
54+
55+
assert_equal([2, 2, 2, 2, 2, 2, 2, 1], wrapped.lines.map { |line| line.scan(/\e\[[\d;:]*m/).length })
56+
assert_operator(wrapped.bytesize, :<, input.bytesize * 2)
57+
end
58+
59+
def test_wrap_reconstructs_independent_sgr_attributes
60+
wrapped = Wrap.new("\e[1m\e[31maaaa bbbb").wrap(4)
61+
62+
assert_equal("\e[1m\e[31maaaa\n\e[1;31mbbbb", wrapped)
63+
end
64+
65+
def test_wrap_groups_semicolon_form_extended_colors
66+
wrapped = Wrap.new("\e[1m\e[38;2;1;2;3maaaa bbbb").wrap(4)
67+
68+
assert_equal("\e[1m\e[38;2;1;2;3maaaa\n\e[1;38;2;1;2;3mbbbb", wrapped)
69+
end
70+
71+
def test_wrap_does_not_reinterpret_incomplete_extended_colors
72+
wrapped = Wrap.new("\e[38;2;255maaaa bbbb").wrap(4)
73+
74+
assert_equal("\e[38;2;255maaaa\nbbbb", wrapped)
75+
end
76+
77+
def test_wrap_deduplicates_parameters_in_last_used_order
78+
wrapped = Wrap.new("\e[1m\e[22m\e[1maaaa bbbb").wrap(4)
79+
80+
assert_equal("\e[1m\e[22m\e[1maaaa\n\e[22;1mbbbb", wrapped)
81+
end
82+
83+
def test_wrap_preserves_distinct_unrecognized_parameters
84+
wrapped = Wrap.new("\e[76m\e[77maaaa bbbb").wrap(4)
85+
86+
assert_equal("\e[76m\e[77maaaa\n\e[76;77mbbbb", wrapped)
87+
end
88+
89+
def test_wrap_reopens_a_hyperlink_after_a_break
90+
open_link = "\e]8;;https://example.com\e\\"
91+
close_link = ANSI::HYPERLINK_END
92+
wrapped = Wrap.new("#{open_link}aaaa bbbb cccc#{close_link}").wrap(9)
93+
94+
assert_equal("#{open_link}aaaa bbbb#{close_link}\n#{open_link}cccc#{close_link}", wrapped)
95+
end
1796
end
1897
end
1998
end

0 commit comments

Comments
 (0)