Skip to content

Commit

Permalink
Fix ellipsis encoding in the text layout
Browse files Browse the repository at this point in the history
* Use `...` substitution in case of `…` encoding fails and cache result
* * do not encode multiple times the same value
* * handle unsupported encodings
  • Loading branch information
Aleksei Stepanov committed Feb 16, 2024
1 parent 7ac12bf commit 0e2a393
Show file tree
Hide file tree
Showing 2 changed files with 53 additions and 17 deletions.
15 changes: 15 additions & 0 deletions tests/test_text_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,3 +387,18 @@ def test(self):
for pos, a in zip(self.pos_list, answer):
r = text_layout.calc_coords(self.text, t, pos)
self.assertEqual(a, r, f"{t!r} got: {r!r} expected: {a!r}")


class TestEllipsis(unittest.TestCase):
def test_ellipsis_encoding_support(self):
widget = urwid.Text("Test label", wrap=urwid.WrapMode.ELLIPSIS)

with self.subTest("Unicode"), set_temporary_encoding("utf-8"):
widget._invalidate()
canvas = widget.render((5,))
self.assertEqual("Test…", str(canvas))

with self.subTest("ascii"), set_temporary_encoding("ascii"):
widget._invalidate()
canvas = widget.render((5,))
self.assertEqual("Te...", str(canvas))
55 changes: 38 additions & 17 deletions urwid/text_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,48 @@

from __future__ import annotations

import functools
import typing

from urwid.str_util import calc_text_pos, calc_width, is_wide_char, move_next_char, move_prev_char
from urwid.str_util import calc_text_pos, calc_width, get_char_width, is_wide_char, move_next_char, move_prev_char
from urwid.util import calc_trim_text, get_encoding

if typing.TYPE_CHECKING:
from typing_extensions import Literal

from urwid.widget import Align, WrapMode


@functools.lru_cache(maxsize=4)
def get_ellipsis_char(encoding: str) -> bytes:
"""Get ellipsis character for given encoding."""
try:
return "…".encode(encoding)
except UnicodeEncodeError:
return b"..."


@functools.lru_cache(maxsize=4)
def get_ellipsis_width(encoding: str) -> int:
"""Get ellipsis character width for given encoding."""
return sum(get_char_width(char) for char in get_ellipsis_char(get_encoding()).decode(get_encoding()))


class TextLayout:
def supports_align_mode(self, align) -> bool:
def supports_align_mode(self, align: Literal["left", "center", "right"] | Align) -> bool:
"""Return True if align is a supported align mode."""
return True

def supports_wrap_mode(self, wrap) -> bool:
def supports_wrap_mode(self, wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode) -> bool:
"""Return True if wrap is a supported wrap mode."""
return True

def layout(
self,
text: str | bytes,
width: int,
align: Literal["left", "center", "right"],
wrap: Literal["any", "space", "clip", "ellipsis"],
align: Literal["left", "center", "right"] | Align,
wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
"""
Return a layout structure for text.
Expand Down Expand Up @@ -88,20 +106,20 @@ def __init__(self): # , tab_stops=(), tab_stop_every=8):
# self.tab_stops = tab_stops
# self.tab_stop_every = tab_stop_every

def supports_align_mode(self, align: str) -> bool:
def supports_align_mode(self, align: Literal["left", "center", "right"] | Align) -> bool:
"""Return True if align is 'left', 'center' or 'right'."""
return align in {"left", "center", "right"}

def supports_wrap_mode(self, wrap: str) -> bool:
def supports_wrap_mode(self, wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode) -> bool:
"""Return True if wrap is 'any', 'space', 'clip' or 'ellipsis'."""
return wrap in {"any", "space", "clip", "ellipsis"}

def layout(
self,
text: str | bytes,
width: int,
align: Literal["left", "center", "right"],
wrap: Literal["any", "space", "clip", "ellipsis"],
align: Literal["left", "center", "right"] | Align,
wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
"""Return a layout structure for text."""
try:
Expand Down Expand Up @@ -134,10 +152,10 @@ def align_layout(
text: str | bytes,
width: int,
segs: list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]],
wrap: Literal["any", "space", "clip", "ellipsis"],
align: Literal["left", "center", "right"],
wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
align: Literal["left", "center", "right"] | Align,
) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
"""Convert the layout segs to an aligned layout."""
"""Convert the layout segments to an aligned layout."""
out = []
for lines in segs:
sc = line_width(lines)
Expand All @@ -158,12 +176,14 @@ def _calculate_trimmed_segments(
self,
text: str | bytes,
width: int,
wrap: Literal["any", "space", "clip", "ellipsis"],
wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
"""Calculate text segments for cases of text trimmed (wrap is clip or ellipsis)."""
"""Calculate text segments for cases of a text trimmed (wrap is clip or ellipsis)."""
segments = []

nl = "\n" if isinstance(text, str) else b"\n"
ellipsis_char = get_ellipsis_char(get_encoding())
width_ellipsis = get_ellipsis_width(get_encoding())

idx = 0

Expand All @@ -176,7 +196,8 @@ def _calculate_trimmed_segments(
# trim line to max width if needed, add ellipsis if trimmed
if wrap == "ellipsis" and screen_columns > width:
trimmed = True
start_off, end_off, pad_left, pad_right = calc_trim_text(text, idx, nl_pos, 0, width - 1)

start_off, end_off, pad_left, pad_right = calc_trim_text(text, idx, nl_pos, 0, width - width_ellipsis)
# pad_left should be 0, because the start_col parameter was 0 (no trimming on the left)
# similarly spos should not be changed from p
if pad_left != 0:
Expand All @@ -194,7 +215,7 @@ def _calculate_trimmed_segments(
if idx != end_off:
line += [(screen_columns, idx, end_off)]
if trimmed:
line += [(1, end_off, "…".encode(get_encoding()))]
line += [(1, end_off, ellipsis_char)]
line += [(pad_right, end_off)]
segments.append(line)
idx = nl_pos + 1
Expand All @@ -204,7 +225,7 @@ def calculate_text_segments(
self,
text: str | bytes,
width: int,
wrap: Literal["any", "space", "clip", "ellipsis"],
wrap: Literal["any", "space", "clip", "ellipsis"] | WrapMode,
) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:
"""
Calculate the segments of text to display given width screen columns to display them.
Expand Down

0 comments on commit 0e2a393

Please sign in to comment.