Files
herdr-session-switch/herdr_hub/text.py
T
gitea-adminandClaude Fable 5.1 ac6bb30f47 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>
2026-10-03 03:09:28 +00:00

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