From ac6bb30f47f074456e5a1391ca92c98c19fede73 Mon Sep 17 00:00:00 2001 From: Donald Date: Sat, 3 Oct 2026 03:09:28 +0000 Subject: [PATCH] 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 --- herdr_hub/cli.py | 10 +++- herdr_hub/control.py | 27 ++--------- herdr_hub/key_help.py | 37 ++------------- herdr_hub/selector.py | 87 +++++++--------------------------- herdr_hub/text.py | 104 +++++++++++++++++++++++++++++++++++++++++ tests/test_selector.py | 18 +++---- tests/test_text.py | 47 +++++++++++++++++++ 7 files changed, 194 insertions(+), 136 deletions(-) create mode 100644 herdr_hub/text.py create mode 100644 tests/test_text.py diff --git a/herdr_hub/cli.py b/herdr_hub/cli.py index aa28edb..227764d 100644 --- a/herdr_hub/cli.py +++ b/herdr_hub/cli.py @@ -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: diff --git a/herdr_hub/control.py b/herdr_hub/control.py index b372729..e5dc95d 100644 --- a/herdr_hub/control.py +++ b/herdr_hub/control.py @@ -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: diff --git a/herdr_hub/key_help.py b/herdr_hub/key_help.py index cadf409..ef72677 100644 --- a/herdr_hub/key_help.py +++ b/herdr_hub/key_help.py @@ -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 diff --git a/herdr_hub/selector.py b/herdr_hub/selector.py index 0a10488..eaa15ca 100644 --- a/herdr_hub/selector.py +++ b/herdr_hub/selector.py @@ -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 diff --git a/herdr_hub/text.py b/herdr_hub/text.py new file mode 100644 index 0000000..562e347 --- /dev/null +++ b/herdr_hub/text.py @@ -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 diff --git a/tests/test_selector.py b/tests/test_selector.py index 3f56360..320a439 100644 --- a/tests/test_selector.py +++ b/tests/test_selector.py @@ -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: diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 0000000..1eb6116 --- /dev/null +++ b/tests/test_text.py @@ -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()