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:
+8
-2
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user