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>
105 lines
3.2 KiB
Python
105 lines
3.2 KiB
Python
"""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
|