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:
2026-10-03 03:09:28 +00:00
co-authored by Claude Fable 5.1
parent d2228ed5fb
commit ac6bb30f47
7 changed files with 194 additions and 136 deletions
+4 -33
View File
@@ -3,7 +3,8 @@ from __future__ import annotations
import curses
from dataclasses import dataclass
import unicodedata
from .text import pad_display, truncate_display
@dataclass(frozen=True)
@@ -48,36 +49,6 @@ KEY_SECTIONS: tuple[tuple[str, tuple[KeyMapping, ...]], ...] = (
)
def display_width(value: str) -> int:
return sum(
0
if unicodedata.combining(character)
else 2
if unicodedata.east_asian_width(character) in {"W", "F"}
else 1
for character in value
)
def truncate(value: str, width: int) -> str:
if width <= 0:
return ""
result = ""
used = 0
for character in value:
size = display_width(character)
if used + size > width:
break
result += character
used += size
return result
def pad(value: str, width: int) -> str:
clipped = truncate(value, width)
return clipped + " " * max(0, width - display_width(clipped))
class KeyHelpPopup:
def __init__(self) -> None:
self.scroll = 0
@@ -95,7 +66,7 @@ class KeyHelpPopup:
if wide:
lines.append(
(
f" {pad(mapping.keys, key_width)} {mapping.action}",
f" {pad_display(mapping.keys, key_width)} {mapping.action}",
curses.A_NORMAL,
)
)
@@ -172,6 +143,6 @@ class KeyHelpPopup:
if y < 0 or y >= height or x >= width:
return
try:
screen.addstr(y, x, truncate(value, max(0, width - x - 1)), attribute)
screen.addstr(y, x, truncate_display(value, max(0, width - x - 1)), attribute)
except curses.error:
pass