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
+8 -2
View File
@@ -44,6 +44,7 @@ from .selector import (
SessionSelector,
StopSessionOutcome,
)
from .text import display_width, truncate_display
DEFAULT_SOCKET_NAME = "herdr-hub"
@@ -199,13 +200,18 @@ def validate_remote_target(target: str | None) -> None:
def truncate_label(label: str, limit: int = 48) -> str:
"""Clip a tmux window label to ``limit`` terminal cells.
tmux renders names by cell, so clipping by code point let CJK names run
twice as wide as ASCII ones before the ellipsis appeared.
"""
clean = "".join(
character if ord(character) >= 32 and ord(character) != 127 else "?"
for character in label
)
if len(clean) <= limit:
if display_width(clean) <= limit:
return clean
return clean[: limit - 1] + "…"
return truncate_display(clean, limit - 1) + "…"
def display_target(target: str) -> str:
+3 -24
View File
@@ -15,12 +15,12 @@ import threading
import time
from typing import TYPE_CHECKING, Callable, Sequence
import uuid
import unicodedata
from .remotes import (
RemoteEntry, clip_utf8_tail, decode_remote_output, parse_remote_session_result,
remote_control_command, remote_session_command,
)
from .text import wrap_display
if TYPE_CHECKING:
from .cli import Endpoint
@@ -431,27 +431,6 @@ class HerdrControl:
return tab_id
def display_chunks(text: str, width: int) -> list[str]:
chunks: list[str] = []
line = ""
cells = 0
for character in text:
if character == "\n":
chunks.append(line)
line, cells = "", 0
continue
if ord(character) < 32 or ord(character) == 127:
character = " "
size = 0 if unicodedata.combining(character) else (2 if unicodedata.east_asian_width(character) in ("W", "F") else 1)
if cells + size > width and line:
chunks.append(line)
line, cells = "", 0
line += character
cells += size
chunks.append(line)
return chunks
class ControlMenu:
"""Small action menu; all subprocess work stays off the curses thread."""
ACTIONS = (
@@ -570,7 +549,7 @@ class ControlMenu:
self._add(screen, y + 1, 4, detail, curses.A_DIM)
status_top = action_top + len(self.ACTIONS) * action_stride + (0 if compact else 1)
status_lines = display_chunks(status, max(1, width - 5)) if status else []
status_lines = wrap_display(status, max(1, width - 5)) if status else []
visible_lines = max(1, height - status_top - 1)
status_offset = min(status_offset, max(0, len(status_lines) - visible_lines))
if future is not None and phase in ("update", "update-restart"):
@@ -664,7 +643,7 @@ class ControlMenu:
height, width = screen.getmaxyx()
if y < 0 or y >= height or x >= width:
return
chunks = display_chunks(value, max(1, width - x - 1))
chunks = wrap_display(value, max(1, width - x - 1))
try:
screen.addstr(y, x, chunks[0] if chunks else "", attribute)
except curses.error:
+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
+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
+104
View File
@@ -0,0 +1,104 @@
"""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
+10 -8
View File
@@ -22,10 +22,12 @@ from herdr_hub.selector import (
SelectorResult,
build_main_rows,
filter_rows,
)
from herdr_hub.text import (
display_width,
input_display_line,
pad_terminal_text,
terminal_display_width,
truncate_terminal_text,
pad_display,
truncate_display,
)
@@ -62,11 +64,11 @@ class FakeScreen:
class SelectorCatalogTests(unittest.TestCase):
def test_terminal_display_width_counts_cjk_and_combining_characters(self) -> None:
self.assertEqual(terminal_display_width("Rename: work"), 12)
self.assertEqual(terminal_display_width("显示名称: 工作"), 14)
self.assertEqual(terminal_display_width("e\N{COMBINING ACUTE ACCENT}"), 1)
self.assertEqual(truncate_terminal_text("主机abc", 5), "主机a")
self.assertEqual(terminal_display_width(pad_terminal_text("主机", 6)), 6)
self.assertEqual(display_width("Rename: work"), 12)
self.assertEqual(display_width("显示名称: 工作"), 14)
self.assertEqual(display_width("e\N{COMBINING ACUTE ACCENT}"), 1)
self.assertEqual(truncate_display("主机abc", 5), "主机a")
self.assertEqual(display_width(pad_display("主机", 6)), 6)
self.assertEqual(input_display_line("名称", "abcdefgh", 9), "名称: …gh")
def test_rows_merge_open_running_stopped_and_new_sessions(self) -> None:
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import unittest
from herdr_hub.cli import truncate_label
from herdr_hub.text import (
display_tail,
display_width,
input_display_line,
pad_display,
sanitize_display,
truncate_display,
wrap_display,
)
class DisplayWidthTests(unittest.TestCase):
def test_widths_count_cells_not_code_points(self) -> None:
self.assertEqual(display_width("Rename: work"), 12)
self.assertEqual(display_width("显示名称: 工作"), 14)
self.assertEqual(display_width("e\N{COMBINING ACUTE ACCENT}"), 1)
self.assertEqual(display_width("a\N{ZERO WIDTH JOINER}b"), 2)
self.assertEqual(truncate_display("主机abc", 5), "主机a")
self.assertEqual(truncate_display("abc", 0), "")
self.assertEqual(display_tail("abc主机", 5), "c主机")
self.assertEqual(display_tail("abc主机", 3), "机")
self.assertEqual(pad_display("主机", 6), "主机 ")
self.assertEqual(display_width(pad_display("主机abc", 4)), 4)
self.assertEqual(input_display_line("名称", "abcdefgh", 9), "名称: …gh")
def test_wrapping_and_sanitising_keep_control_characters_off_screen(self) -> None:
self.assertEqual(wrap_display("ab\ncd", 10), ["ab", "cd"])
self.assertEqual(wrap_display("主机主机", 4), ["主机", "主机"])
self.assertEqual(wrap_display("a\x1bb\x7fc", 10), ["a b c"])
self.assertEqual(wrap_display("", 10), [""])
self.assertEqual(sanitize_display("x\x1b[31my"), "x [31my")
def test_labels_are_clipped_by_cells(self) -> None:
self.assertEqual(truncate_label("abc", 10), "abc")
self.assertEqual(truncate_label("tab\there", 10), "tab?here")
# Ten cells hold four CJK characters plus the ellipsis.
self.assertEqual(truncate_label("客厅开发机器人工作站", 10), "客厅开发…")
self.assertEqual(truncate_label("a" * 12, 10), "a" * 9 + "…")
if __name__ == "__main__":
unittest.main()