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:
+18
-69
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user