Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ requirements.txt or equivalent. Their signatures will never change.

.. autofunction:: wcwidth.strip_sequences

.. autofunction:: wcwidth.propagate_sgr

.. autofunction:: wcwidth.list_versions

.. _SEMVER: https://semver.org
29 changes: 23 additions & 6 deletions docs/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,10 @@ clusters, and wide characters to a given display width.
>>> wrap('コンニチハ', 4)
['コン', 'ニチ', 'ハ']

>>> # Text with ANSI color sequences
>>> wrap('\x1b[31mhello world\x1b[0m', 5)
['\x1b[31mhello', 'world\x1b[0m']
>>> # Text with ANSI color sequences - SGR codes are propagated by default
>>> # Each line ends with reset, next line starts with restored style
>>> wrap('\x1b[1;31mhello world\x1b[0m', 5)
['\x1b[1;31mhello\x1b[0m', '\x1b[1;31mworld\x1b[0m']

clip()
------
Expand All @@ -254,8 +255,13 @@ Use `clip()`_ to extract a substring by column positions, preserving terminal se
>>> clip('中文字', 1, 5, fillchar='.')
'.文.'

>>> # *ALL* Terminal sequences are preserved
>>> clip('\x1b[31m中文\x1b[0m', 0, 3)
>>> # SGR codes are propagated by default - result begins with active style
>>> # and ends with reset if styles are active
>>> clip('\x1b[1;31mHello world\x1b[0m', 6, 11)
'\x1b[1;31mworld\x1b[0m'

>>> # Disable SGR propagation to preserve original sequences as-is
>>> clip('\x1b[31m中文\x1b[0m', 0, 3, propagate_sgr=False)
'\x1b[31m中 \x1b[0m'

strip_sequences()
Expand Down Expand Up @@ -330,6 +336,10 @@ Or execute individual tasks, see ``tox -lv`` for all available targets::

tox -e pylint,py36,py314

To run tests with detailed coverage reporting showing missing lines::

tox -epy314 -- --cov-report=term-missing

Updating Unicode Version
------------------------

Expand Down Expand Up @@ -444,7 +454,13 @@ languages.
History
=======

0.4.1 *next release*
0.4.1 *2026-01-26*
* **New** Function `propagate_sgr()`_ for applying SGR state propagation to a list of lines.
* **Bugfix** `wrap()`_ now propagates SGR styling across lines (each line ends with reset, next
line restores active style). Pass ``propagate_sgr=False`` for previous behavior.
* **Bugfix** `clip()`_ now propagates SGR state (result begins with active style, ends with
reset). Pass ``propagate_sgr=False`` for previous behavior.
* **Bugfix** `clip()`_ combining characters and zero-width marks at clipping boundaries.
* **Bugfix** OSC Hyperlinks when broken mid-text by ``wrap()``. `PR #193`_.

0.4.0 *2026-01-25*
Expand Down Expand Up @@ -680,6 +696,7 @@ https://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c::
.. _`wrap()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.wrap
.. _`clip()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.clip
.. _`strip_sequences()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.strip_sequences
.. _`propagate_sgr()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.propagate_sgr
.. _`iter_sequences()`: https://wcwidth.readthedocs.io/en/latest/api.html#wcwidth.iter_sequences
.. _`Unicode Standard Annex #29`: https://www.unicode.org/reports/tr29/
.. _`Terminal.detect_ambiguous_width()`: https://blessed.readthedocs.io/en/latest/api/terminal.html#blessed.terminal.Terminal.detect_ambiguous_width
Expand Down
38 changes: 36 additions & 2 deletions tests/test_benchmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,17 +198,27 @@ def test_wrap_with_ansi(benchmark):
benchmark(wcwidth.wrap, text, 20)


def test_wrap_with_ansi_no_propagate(benchmark):
"""Benchmark wrap() with ANSI but SGR propagation disabled."""
text = '\x1b[31mThe quick brown fox\x1b[0m jumps over the lazy dog'
benchmark(wcwidth.wrap, text, 20, propagate_sgr=False)


def test_wrap_complex_sgr(benchmark):
"""Benchmark wrap() with complex SGR (256-color, multiple attributes)."""
text = '\x1b[1;3;38;5;208mBold italic orange text that wraps\x1b[0m'
benchmark(wcwidth.wrap, text, 10)


def test_wrap_hyperlink_no_id(benchmark):
"""Benchmark wrap() with OSC 8 hyperlinks without id (requires id generation)."""
# Multiple hyperlinks without ids, each spanning several words
link = '\x1b]8;;https://example.com/path\x1b\\click here for details\x1b]8;;\x1b\\'
text = f'See {link} and also {link} for more. Read {link} now. ' * 10
benchmark(wcwidth.wrap, text, 40)


def test_wrap_hyperlink_with_id(benchmark):
"""Benchmark wrap() with OSC 8 hyperlinks with existing ids."""
# Multiple hyperlinks with ids
link1 = '\x1b]8;id=a;https://example.com\x1b\\click here for details\x1b]8;;\x1b\\'
link2 = '\x1b]8;id=b;https://other.org\x1b\\visit this page now\x1b]8;;\x1b\\'
text = f'See {link1} and also {link2} for more. Read {link1} now. ' * 10
Expand Down Expand Up @@ -239,6 +249,30 @@ def test_clip_with_ansi(benchmark):
benchmark(wcwidth.clip, text, 0, 3)


def test_clip_with_ansi_no_propagate(benchmark):
"""Benchmark clip() with ANSI but SGR propagation disabled."""
text = '\x1b[31m中文字\x1b[0m'
benchmark(wcwidth.clip, text, 0, 3, propagate_sgr=False)


def test_clip_complex_sgr(benchmark):
"""Benchmark clip() with complex SGR clipping from middle."""
text = '\x1b[1;38;5;208mHello world text\x1b[0m'
benchmark(wcwidth.clip, text, 6, 11)


def test_propagate_sgr_multiline(benchmark):
"""Benchmark propagate_sgr() with multiple lines."""
lines = ['\x1b[1;31mline one', 'line two', 'line three\x1b[0m']
benchmark(wcwidth.propagate_sgr, lines)


def test_propagate_sgr_no_sequences(benchmark):
"""Benchmark propagate_sgr() fast path (no sequences)."""
lines = ['line one', 'line two', 'line three']
benchmark(wcwidth.propagate_sgr, lines)


def test_strip_sequences_simple(benchmark):
"""Benchmark strip_sequences() with simple ANSI codes."""
text = '\x1b[31mred\x1b[0m'
Expand Down
40 changes: 37 additions & 3 deletions tests/test_clip.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,24 @@ def test_clip_sequences_before_start():


def test_clip_sequences_after_end():
assert clip('hello\x1b[31m world\x1b[0m', 0, 5) == 'hello\x1b[31m\x1b[0m'
# With propagate_sgr=True (default), no style active at start, so no prefix
assert clip('hello\x1b[31m world\x1b[0m', 0, 5) == 'hello'
# With propagate_sgr=False, all sequences preserved
assert clip('hello\x1b[31m world\x1b[0m', 0, 5, propagate_sgr=False) == 'hello\x1b[31m\x1b[0m'


def test_clip_sequences_multiple():
assert clip('\x1b[1m\x1b[31mbold red\x1b[0m', 0, 4) == '\x1b[1m\x1b[31mbold\x1b[0m'
# With propagate_sgr=True (default), sequences collapsed to minimal
assert clip('\x1b[1m\x1b[31mbold red\x1b[0m', 0, 4) == '\x1b[1;31mbold\x1b[0m'
# With propagate_sgr=False, all sequences preserved separately
assert clip('\x1b[1m\x1b[31mbold red\x1b[0m', 0, 4, propagate_sgr=False) == '\x1b[1m\x1b[31mbold\x1b[0m'


def test_clip_sequences_only():
assert clip('\x1b[31m\x1b[0m', 0, 10) == '\x1b[31m\x1b[0m'
# With propagate_sgr=True (default), no visible text means empty result
assert clip('\x1b[31m\x1b[0m', 0, 10) == ''
# With propagate_sgr=False, sequences preserved
assert clip('\x1b[31m\x1b[0m', 0, 10, propagate_sgr=False) == '\x1b[31m\x1b[0m'


def test_clip_sequences_osc_hyperlink():
Expand All @@ -131,6 +140,10 @@ def test_clip_sequences_cjk_with_sequences():
assert clip('\x1b[31m中文\x1b[0m', 0, 3) == '\x1b[31m中 \x1b[0m'


def test_clip_sequences_partial_wide_at_start():
assert clip('\x1b[31m中文\x1b[0m', 1, 4) == '\x1b[31m 文\x1b[0m'


def test_clip_sequences_between_chars():
assert clip('a\x1b[31mb\x1b[0mc', 1, 2) == '\x1b[31mb\x1b[0m'

Expand Down Expand Up @@ -167,6 +180,27 @@ def test_clip_combining_multiple():
assert clip('e\u0301\u0327', 0, 1) == 'e\u0301\u0327'


def test_clip_zero_width_position_bounds():
# Standalone combining mark before visible region should NOT be included
assert clip('\u0301hello', 1, 4) == 'ell'
# Standalone combining mark after visible region should NOT be included
assert clip('hello\u0301', 0, 3) == 'hel'
# Combining mark within visible region should be included (attached to base)
assert clip('he\u0301llo', 0, 4) == 'he\u0301ll'


def test_clip_prepend_grapheme():
# PREPEND characters (Arabic Number Sign) cluster with following char, width 2
# Full cluster fits
assert clip('\u0600abc', 0, 2) == '\u0600a'
# Cluster split at start boundary - replaced with fillchar
assert clip('\u0600abc', 0, 1) == ' '
# Cluster split at end boundary - partial overlap gets fillchar
assert clip('\u0600abc', 1, 3) == ' b'
# Clipping after the prepend cluster
assert clip('\u0600abc', 2, 4) == 'bc'


def test_clip_ambiguous_width_1():
assert clip('\u00b1test', 0, 3, ambiguous_width=1) == '\u00b1te'

Expand Down
Loading
Loading