1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
|
"""
Tests for clip()'s overtyping (painter) path.
The painter algorithm is used when the text contains cursor movement sequences
(CSI n C/D, backspace, carriage return, HPA) that require column-level tracking
to determine the final visible output. Auto-detection of the overtyping path
happens in clip() via the presence of \\x08, \\r, or horizontal cursor movement
escape sequences, or can be forced with ``overtyping=True``.
These tests codify expected visible results when cursor movement sequences
affect horizontal positions.
"""
# 3rd party
import pytest
# local
from wcwidth import clip
@pytest.mark.parametrize("text,start,end,kwargs,expected", [
# Cursor-right introduces a gap that should be filled with spaces
("hello\x1b[10Cworld", 0, 10, {}, "hello" + " " * 5),
# Clipping just the initial region ignores the later rightward write
("hello\x1b[10Cworld", 0, 5, {}, "hello"),
# Cursor-left overwrites previous characters
("hello\x1b[2DXY", 0, 5, {}, "helXY"),
# Cursor-left overwrites entire visible token
("abc\x1b[3DXY", 0, 5, {}, "XYc"),
# Cursor-left at column 0 (prev_col not > col, no overwrite)
("\x1b[2Dhi", 0, 2, {}, "hi"),
# Cursor-left with no visible tokens emitted
("\x1b[5C\x1b[2Dhi", 5, 7, {}, ""),
# Cursor-left overwrites text, seq tokens preserve column spatial order
("ab\x1b]8;;http://example.com\x07\x1b[2Dcd", 0, 4, {}, "cd\x1b]8;;http://example.com\x07"),
# Cursor-left into wide char twice, second time on empty token triggers i < 0 break
("中\x1b[D\x1b[Da", 0, 4, {}, "a "),
('ab\x1b[5Ccd', 0, 4, {}, 'ab '),
('abcde\x1b[2Df', 0, 6, {}, 'abcfe'),
('hello\x1b[5Dw', 0, 5, {}, 'wello'),
('ab\x1b[10Ccd', 0, 4, {}, 'ab '),
('XY\x1b[Czy', 0, 4, {}, 'XY z'),
('XY\x1b[Czy', 0, 5, {}, 'XY zy'),
('XY\x1b[Czy', 1, 3, {}, 'Y '),
('XY\x1b[Czy', 1, 4, {}, 'Y z'),
('LOL\x1b[5Clol', 0, 12, {}, 'LOL lol'),
('LOL\x1b[5Clol', 1, 11, {}, 'OL lol'),
('LOL\x1b[5Clol', 2, 11, {}, 'L lol'),
('LOL\x1b[5Clol', 3, 11, {}, ' lol'),
('LOL\x1b[5Clol', 4, 11, {}, ' lol'),
('LOL\x1b[5Clol', 5, 11, {}, ' lol'),
('LOL\x1b[5Clol', 6, 11, {}, ' lol'),
('LOL\x1b[5Clol', 7, 11, {}, ' lol'),
('LOL\x1b[5Clol', 8, 11, {}, 'lol'),
('LOL\x1b[5Clol', 9, 11, {}, 'ol'),
# SGR + cursor movement: SGR state update in painter path (line 245)
('\x1b[31mab\x1b[2Dcd', 0, 4, {}, '\x1b[31mcd\x1b[0m'),
# Tab tabsize=0 in painter path (line 272->280 else branch)
('ab\x1b[2D\tcd', 0, 4, {'tabsize': 0}, '\tcd'),
# Zero-width grapheme outside clip window in painter (line 290->301)
('\x1b[2D\u0301hello', 1, 4, {}, 'ell'),
# Wide char partially clipped in painter (lines 298-299)
('ab\x1b[2D中d', 1, 4, {}, ' d'),
# walk_col >= end in painter reconstruction (327->328)
('hello\x1b[2Dxy', 0, 3, {}, 'hel'),
# Hole fillchar in painter reconstruction (345->346)
('\x1b[5Chi', 0, 7, {}, ' hi'),
# Trailing sequences stored at columns after col_limit (352, 354->355, 355->356)
('abc\x1b[2D', 0, 2, {}, 'ab'),
# Bare ESC not part of any sequence, pass through in painter path (239->240)
('a\x1bb\x1b[2Dc', 0, 3, {}, 'c\x1bb'),
# Tab with tabsize>0 in painter; `b` falls at col 4, inside (0,5) (277->284, 278->279, 278->280)
('\x1b[2Da\tb', 0, 5, {'tabsize': 4}, 'a b'),
# propagate_sgr=False in painter path (225->226)
('ab\x1b[2Dcd', 0, 4, {'propagate_sgr': False}, 'cd'),
# Non-SGR sequence before any visible text in painter (225->226 True)
('\x1b]8;;http://example.com\x07ab\x1b[2Dcd', 0, 4, {}, '\x1b]8;;http://example.com\x07cd'),
# Bare ESC at end of text in painter (239->240)
('ab\x1b[2D\x1b', 0, 2, {}, '\x1bab'),
# Wide char overwritten from right side (212 orphan fixup)
('a中\x1b[Db', 0, 4, {}, 'a b'),
# Tab expansion with col+=1 not inside clip window (277->279, 293)
('\x1b[2Ca\tb', 2, 4, {'tabsize': 8}, 'a '),
# CR: carriage return resets column to 0, overwriting earlier cells
('aaa\r\r\rxxx', 0, 4, {}, 'xxx'),
('abc\rXY', 0, 5, {}, 'XYc'),
('hello\rworld', 0, 5, {}, 'world'),
# CR moves back to column 0 then writes within clip window
('abc\rde', 1, 3, {}, 'ec'),
# BS: backspace overwrites previous character
('abc\bde', 0, 5, {}, 'abde'),
('abc\b\bXY', 0, 5, {}, 'aXY'),
('ab\b\b\bXY', 0, 4, {}, 'XY'),
# HPA: horizontal position absolute (CSI n G)
('abc\x1b[GXY', 0, 5, {}, 'XYc'),
('abc\x1b[2GXY', 0, 5, {}, 'aXY'),
('abc\x1b[5GXY', 0, 7, {}, 'abc XY'),
('abc\x1b[5GXY', 0, 5, {}, 'abc X'),
('\x1b[5GXY', 3, 7, {}, ' XY'),
# HPA no-param inside clip window
('abc\x1b[GXY', 1, 4, {}, 'Yc'),
# walk_col >= end with sequences at column == end (line 351)
('\x1b[5C\x1b]8;;http://example.com\x07', 0, 5, {'propagate_sgr': False}, ' \x1b]8;;http://example.com\x07'),
# Trailing sequences past col_limit (line 374)
('\x1b[5C\x1b]8;;http://example.com\x07', 0, 3, {'propagate_sgr': False}, ' \x1b]8;;http://example.com\x07'),
# Lone ESC as first visible thing in painter (captured_style = current_style, line 398)
('\x1b[D\x1b\x1bXy', 0, 3, {}, '\x1b\x1bXy'),
# Hyperlink VISIBLE after captured_style already set
('a\x1b[C\x1b]8;;http://x\x07hi\x1b]8;;\x07', 0, 5, {}, 'a \x1b]8;;http://x\x07hi\x1b]8;;\x07'),
# Tab with tabsize=0 as first visible thing in painter
('\x1b[D\tab', 0, 2, {'tabsize': 0}, '\tab'),
# Zero-width grapheme as first visible thing in painter
('\x1b[D\u0301x', 0, 3, {}, '\u0301x'),
# Generic escape sequence as first visible in painter
('\x1b[D\x1b[Hxy', 0, 3, {}, '\x1b[Hxy'),
])
def test_clip_cursor_sequences_expected_behaviour(text, start, end, kwargs, expected):
"""Verify clip() output matches terminal-visible columns after cursor moves."""
result = clip(text, start, end, **kwargs)
assert repr(result) == repr(expected)
def test_clip_cursor_left_strict_out_of_bounds():
"""Clip() with control_codes='strict' raises on cursor-left beyond string start."""
with pytest.raises(ValueError, match='Cursor left movement'):
clip('a\x1b[5Da', 0, 1, control_codes='strict')
def test_clip_cursor_left_strict_out_of_bounds_painter():
"""Clip() strict-mode raises on cursor-left beyond start in painter path."""
with pytest.raises(ValueError, match='Cursor left movement'):
clip('\x1b[2Dab', 0, 2, control_codes='strict')
def test_clip_cursor_left_out_of_bounds_parse_no_raise():
"""Clip() parse mode silently clamps cursor-left beyond start."""
assert clip('a\x1b[5Da', 0, 1) == 'a'
assert clip('ab\x1b[99Dcd', 0, 4) == 'cd'
def test_clip_strict_cr_allowed():
"""Carriage return is allowed in strict mode (text begins at column 0)."""
assert clip('hello\rworld', 0, 5, control_codes='strict') == 'world'
def test_clip_strict_hpa_allowed():
"""HPA is allowed in strict mode (text begins at column 0)."""
assert clip('abc\x1b[5Gde', 0, 10, control_codes='strict') == 'abc de'
def test_clip_strict_cursor_left_allowed():
"""Cursor-left within bounds is allowed in strict mode."""
assert clip('hello\x1b[2Dxy', 0, 5, control_codes='strict') == 'helxy'
def test_clip_strict_indeterminate_sequence_painter():
"""Clip() strict-mode raises on indeterminate sequence in painter path."""
with pytest.raises(ValueError, match='Indeterminate cursor sequence'):
clip('a\x1b[D\x1b[Hb', 0, 3, control_codes='strict')
|