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
+18 -69
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass, replace
import curses
import unicodedata
from typing import Callable, Iterable
from .aliases import MAX_ALIAS_LENGTH, normalize_alias
@@ -15,6 +14,13 @@ from .remotes import (
RemoteQueryResult,
RemoteRegistry,
)
from .text import (
display_width,
input_display_line,
pad_display,
sanitize_display,
truncate_display,
)
RenameHandler = Callable[[str, str], None]
@@ -27,65 +33,6 @@ class DeleteSessionOutcome:
tab_closed: bool = False
def terminal_display_width(text: str) -> int:
"""Return the number of terminal cells occupied by Unicode text."""
width = 0
for character in text:
if unicodedata.combining(character):
continue
if unicodedata.category(character).startswith("C"):
continue
width += 2 if unicodedata.east_asian_width(character) in {"W", "F"} else 1
return width
def truncate_terminal_text(text: str, max_width: int) -> str:
if max_width <= 0:
return ""
result: list[str] = []
width = 0
for character in text:
character_width = terminal_display_width(character)
if width + character_width > max_width:
break
result.append(character)
width += character_width
return "".join(result)
def terminal_text_tail(text: str, max_width: int) -> str:
if max_width <= 0:
return ""
start = len(text)
width = 0
for index in range(len(text) - 1, -1, -1):
character_width = terminal_display_width(text[index])
if width + character_width > max_width:
break
start = index
width += character_width
return text[start:]
def pad_terminal_text(text: str, width: int) -> str:
truncated = truncate_terminal_text(text, width)
return truncated + " " * max(0, width - terminal_display_width(truncated))
def input_display_line(prompt: str, value: str, width: int) -> str:
prefix = f"{prompt}: "
if terminal_display_width(prefix) >= width:
return truncate_terminal_text(prefix, width)
value_width = width - terminal_display_width(prefix)
if terminal_display_width(value) <= value_width:
visible_value = value
elif value_width <= 1:
visible_value = "…"[:value_width]
else:
visible_value = "…" + terminal_text_tail(value, value_width - 1)
return prefix + visible_value
class SelectorError(RuntimeError):
"""The interactive selector could not be displayed."""
@@ -1504,7 +1451,7 @@ class SessionSelector:
self._safe_add(screen, 1, 2, title, curses.A_BOLD)
if self.view in {"main", "search"}:
action_label = "[操作]"
action_width = terminal_display_width(action_label)
action_width = display_width(action_label)
action_x = max(2, width - action_width - 2)
self._safe_add(screen, 1, action_x, action_label, curses.A_BOLD)
self._action_button = (1, action_x, action_x + action_width)
@@ -1515,7 +1462,7 @@ class SessionSelector:
curses.curs_set(1)
screen.move(
2,
min(width - 2, 2 + terminal_display_width(search_line)),
min(width - 2, 2 + display_width(search_line)),
)
except curses.error:
pass
@@ -1559,13 +1506,13 @@ class SessionSelector:
else:
detail_room = min(24, max(8, width // 3))
main_room = max(8, width - detail_room - 6 - action_room)
main = pad_terminal_text(row.main, main_room)
detail = truncate_terminal_text(row.detail, detail_room)
main = pad_display(row.main, main_room)
detail = truncate_display(row.detail, detail_room)
line = f" {main}" if detail_room == 0 else f" {main} {detail}"
self._safe_add(screen, y, 0, line, attribute)
if has_row_action:
action_label = "[⋯]"
action_width = terminal_display_width(action_label)
action_width = display_width(action_label)
action_x = max(0, width - action_width - 1)
self._safe_add(screen, y, action_x, action_label, attribute)
self._row_action_buttons[y] = (
@@ -1599,7 +1546,7 @@ class SessionSelector:
try:
screen.move(
2,
min(width - 2, 2 + terminal_display_width(search_line)),
min(width - 2, 2 + display_width(search_line)),
)
except curses.error:
pass
@@ -1639,7 +1586,7 @@ class SessionSelector:
curses.curs_set(1)
screen.move(
4,
min(width - 2, 2 + terminal_display_width(input_line)),
min(width - 2, 2 + display_width(input_line)),
)
except curses.error:
pass
@@ -1664,7 +1611,7 @@ class SessionSelector:
curses.curs_set(1)
screen.move(
3,
min(width - 2, 2 + terminal_display_width(input_line)),
min(width - 2, 2 + display_width(input_line)),
)
except curses.error:
pass
@@ -1682,7 +1629,9 @@ class SessionSelector:
if y < 0 or y >= height or x >= width:
return
try:
visible = truncate_terminal_text(text, max(0, width - x - 1))
# Remote error text can carry escape sequences; curses would
# otherwise draw them as ^[ and misalign the row.
visible = truncate_display(sanitize_display(text), max(0, width - x - 1))
screen.addstr(y, x, visible, attribute)
except curses.error:
pass