Unify terminal display-width helpers
key_help, selector and control each carried their own East Asian width and truncation logic with slightly different rules, and cli clipped tmux labels by code point, so a CJK display name ran twice as wide as an ASCII one before its ellipsis appeared. Move the cell-width helpers into herdr_hub.text, make truncate_label cell-aware, and sanitise control characters before curses draws a row so an escape sequence in a remote error cannot be rendered as ^[ and misalign the list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""Terminal cell-width helpers shared by the popups and tmux labels.
|
||||
|
||||
curses and tmux lay text out in cells: East Asian wide characters take two,
|
||||
combining marks and zero-width format characters none. Each popup used to
|
||||
carry its own copy of this logic with slightly different rules, and tmux
|
||||
labels were clipped by code point, so CJK names truncated inconsistently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
|
||||
|
||||
ZERO_WIDTH_CATEGORIES = frozenset({"Mn", "Me", "Cf", "Cc"})
|
||||
|
||||
|
||||
def char_width(character: str) -> int:
|
||||
if (
|
||||
unicodedata.combining(character)
|
||||
or unicodedata.category(character) in ZERO_WIDTH_CATEGORIES
|
||||
):
|
||||
return 0
|
||||
return 2 if unicodedata.east_asian_width(character) in {"W", "F"} else 1
|
||||
|
||||
|
||||
def display_width(text: str) -> int:
|
||||
"""Number of terminal cells ``text`` occupies."""
|
||||
return sum(char_width(character) for character in text)
|
||||
|
||||
|
||||
def truncate_display(text: str, width: int) -> str:
|
||||
"""Longest prefix of ``text`` that fits in ``width`` cells."""
|
||||
if width <= 0:
|
||||
return ""
|
||||
used = 0
|
||||
for index, character in enumerate(text):
|
||||
size = char_width(character)
|
||||
if used + size > width:
|
||||
return text[:index]
|
||||
used += size
|
||||
return text
|
||||
|
||||
|
||||
def display_tail(text: str, width: int) -> str:
|
||||
"""Longest suffix of ``text`` that fits in ``width`` cells."""
|
||||
if width <= 0:
|
||||
return ""
|
||||
used = 0
|
||||
for index in range(len(text) - 1, -1, -1):
|
||||
size = char_width(text[index])
|
||||
if used + size > width:
|
||||
return text[index + 1 :]
|
||||
used += size
|
||||
return text
|
||||
|
||||
|
||||
def pad_display(text: str, width: int) -> str:
|
||||
"""Clip or space-pad ``text`` to exactly ``width`` cells."""
|
||||
clipped = truncate_display(text, width)
|
||||
return clipped + " " * max(0, width - display_width(clipped))
|
||||
|
||||
|
||||
def sanitize_display(text: str) -> str:
|
||||
"""Replace control characters, which curses would render as ``^X``."""
|
||||
return "".join(
|
||||
" " if unicodedata.category(character) == "Cc" else character
|
||||
for character in text
|
||||
)
|
||||
|
||||
|
||||
def wrap_display(text: str, width: int) -> list[str]:
|
||||
"""Hard-wrap at cell boundaries; newlines break, control characters blank."""
|
||||
lines: list[str] = []
|
||||
line = ""
|
||||
cells = 0
|
||||
for character in text:
|
||||
if character == "\n":
|
||||
lines.append(line)
|
||||
line, cells = "", 0
|
||||
continue
|
||||
if unicodedata.category(character) == "Cc":
|
||||
character = " "
|
||||
size = char_width(character)
|
||||
if cells + size > width and line:
|
||||
lines.append(line)
|
||||
line, cells = "", 0
|
||||
line += character
|
||||
cells += size
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def input_display_line(prompt: str, value: str, width: int) -> str:
|
||||
"""``prompt: value`` clipped to ``width`` cells, keeping the end of the value."""
|
||||
prefix = f"{prompt}: "
|
||||
if display_width(prefix) >= width:
|
||||
return truncate_display(prefix, width)
|
||||
value_width = width - display_width(prefix)
|
||||
if display_width(value) <= value_width:
|
||||
visible_value = value
|
||||
elif value_width <= 1:
|
||||
visible_value = "…"[:value_width]
|
||||
else:
|
||||
visible_value = "…" + display_tail(value, value_width - 1)
|
||||
return prefix + visible_value
|
||||
Reference in New Issue
Block a user