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,
|
SessionSelector,
|
||||||
StopSessionOutcome,
|
StopSessionOutcome,
|
||||||
)
|
)
|
||||||
|
from .text import display_width, truncate_display
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_SOCKET_NAME = "herdr-hub"
|
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:
|
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(
|
clean = "".join(
|
||||||
character if ord(character) >= 32 and ord(character) != 127 else "?"
|
character if ord(character) >= 32 and ord(character) != 127 else "?"
|
||||||
for character in label
|
for character in label
|
||||||
)
|
)
|
||||||
if len(clean) <= limit:
|
if display_width(clean) <= limit:
|
||||||
return clean
|
return clean
|
||||||
return clean[: limit - 1] + "…"
|
return truncate_display(clean, limit - 1) + "…"
|
||||||
|
|
||||||
|
|
||||||
def display_target(target: str) -> str:
|
def display_target(target: str) -> str:
|
||||||
|
|||||||
+3
-24
@@ -15,12 +15,12 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, Callable, Sequence
|
from typing import TYPE_CHECKING, Callable, Sequence
|
||||||
import uuid
|
import uuid
|
||||||
import unicodedata
|
|
||||||
|
|
||||||
from .remotes import (
|
from .remotes import (
|
||||||
RemoteEntry, clip_utf8_tail, decode_remote_output, parse_remote_session_result,
|
RemoteEntry, clip_utf8_tail, decode_remote_output, parse_remote_session_result,
|
||||||
remote_control_command, remote_session_command,
|
remote_control_command, remote_session_command,
|
||||||
)
|
)
|
||||||
|
from .text import wrap_display
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .cli import Endpoint
|
from .cli import Endpoint
|
||||||
@@ -431,27 +431,6 @@ class HerdrControl:
|
|||||||
return tab_id
|
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:
|
class ControlMenu:
|
||||||
"""Small action menu; all subprocess work stays off the curses thread."""
|
"""Small action menu; all subprocess work stays off the curses thread."""
|
||||||
ACTIONS = (
|
ACTIONS = (
|
||||||
@@ -570,7 +549,7 @@ class ControlMenu:
|
|||||||
self._add(screen, y + 1, 4, detail, curses.A_DIM)
|
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_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)
|
visible_lines = max(1, height - status_top - 1)
|
||||||
status_offset = min(status_offset, max(0, len(status_lines) - visible_lines))
|
status_offset = min(status_offset, max(0, len(status_lines) - visible_lines))
|
||||||
if future is not None and phase in ("update", "update-restart"):
|
if future is not None and phase in ("update", "update-restart"):
|
||||||
@@ -664,7 +643,7 @@ class ControlMenu:
|
|||||||
height, width = screen.getmaxyx()
|
height, width = screen.getmaxyx()
|
||||||
if y < 0 or y >= height or x >= width:
|
if y < 0 or y >= height or x >= width:
|
||||||
return
|
return
|
||||||
chunks = display_chunks(value, max(1, width - x - 1))
|
chunks = wrap_display(value, max(1, width - x - 1))
|
||||||
try:
|
try:
|
||||||
screen.addstr(y, x, chunks[0] if chunks else "", attribute)
|
screen.addstr(y, x, chunks[0] if chunks else "", attribute)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
|
|||||||
+4
-33
@@ -3,7 +3,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import curses
|
import curses
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import unicodedata
|
|
||||||
|
from .text import pad_display, truncate_display
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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:
|
class KeyHelpPopup:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.scroll = 0
|
self.scroll = 0
|
||||||
@@ -95,7 +66,7 @@ class KeyHelpPopup:
|
|||||||
if wide:
|
if wide:
|
||||||
lines.append(
|
lines.append(
|
||||||
(
|
(
|
||||||
f" {pad(mapping.keys, key_width)} {mapping.action}",
|
f" {pad_display(mapping.keys, key_width)} {mapping.action}",
|
||||||
curses.A_NORMAL,
|
curses.A_NORMAL,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -172,6 +143,6 @@ class KeyHelpPopup:
|
|||||||
if y < 0 or y >= height or x >= width:
|
if y < 0 or y >= height or x >= width:
|
||||||
return
|
return
|
||||||
try:
|
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:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
|
|||||||
+18
-69
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
from concurrent.futures import Future, ThreadPoolExecutor
|
from concurrent.futures import Future, ThreadPoolExecutor
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
import curses
|
import curses
|
||||||
import unicodedata
|
|
||||||
from typing import Callable, Iterable
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
from .aliases import MAX_ALIAS_LENGTH, normalize_alias
|
from .aliases import MAX_ALIAS_LENGTH, normalize_alias
|
||||||
@@ -15,6 +14,13 @@ from .remotes import (
|
|||||||
RemoteQueryResult,
|
RemoteQueryResult,
|
||||||
RemoteRegistry,
|
RemoteRegistry,
|
||||||
)
|
)
|
||||||
|
from .text import (
|
||||||
|
display_width,
|
||||||
|
input_display_line,
|
||||||
|
pad_display,
|
||||||
|
sanitize_display,
|
||||||
|
truncate_display,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
RenameHandler = Callable[[str, str], None]
|
RenameHandler = Callable[[str, str], None]
|
||||||
@@ -27,65 +33,6 @@ class DeleteSessionOutcome:
|
|||||||
tab_closed: bool = False
|
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):
|
class SelectorError(RuntimeError):
|
||||||
"""The interactive selector could not be displayed."""
|
"""The interactive selector could not be displayed."""
|
||||||
|
|
||||||
@@ -1504,7 +1451,7 @@ class SessionSelector:
|
|||||||
self._safe_add(screen, 1, 2, title, curses.A_BOLD)
|
self._safe_add(screen, 1, 2, title, curses.A_BOLD)
|
||||||
if self.view in {"main", "search"}:
|
if self.view in {"main", "search"}:
|
||||||
action_label = "[操作]"
|
action_label = "[操作]"
|
||||||
action_width = terminal_display_width(action_label)
|
action_width = display_width(action_label)
|
||||||
action_x = max(2, width - action_width - 2)
|
action_x = max(2, width - action_width - 2)
|
||||||
self._safe_add(screen, 1, action_x, action_label, curses.A_BOLD)
|
self._safe_add(screen, 1, action_x, action_label, curses.A_BOLD)
|
||||||
self._action_button = (1, action_x, action_x + action_width)
|
self._action_button = (1, action_x, action_x + action_width)
|
||||||
@@ -1515,7 +1462,7 @@ class SessionSelector:
|
|||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
screen.move(
|
screen.move(
|
||||||
2,
|
2,
|
||||||
min(width - 2, 2 + terminal_display_width(search_line)),
|
min(width - 2, 2 + display_width(search_line)),
|
||||||
)
|
)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
@@ -1559,13 +1506,13 @@ class SessionSelector:
|
|||||||
else:
|
else:
|
||||||
detail_room = min(24, max(8, width // 3))
|
detail_room = min(24, max(8, width // 3))
|
||||||
main_room = max(8, width - detail_room - 6 - action_room)
|
main_room = max(8, width - detail_room - 6 - action_room)
|
||||||
main = pad_terminal_text(row.main, main_room)
|
main = pad_display(row.main, main_room)
|
||||||
detail = truncate_terminal_text(row.detail, detail_room)
|
detail = truncate_display(row.detail, detail_room)
|
||||||
line = f" {main}" if detail_room == 0 else f" {main} {detail}"
|
line = f" {main}" if detail_room == 0 else f" {main} {detail}"
|
||||||
self._safe_add(screen, y, 0, line, attribute)
|
self._safe_add(screen, y, 0, line, attribute)
|
||||||
if has_row_action:
|
if has_row_action:
|
||||||
action_label = "[⋯]"
|
action_label = "[⋯]"
|
||||||
action_width = terminal_display_width(action_label)
|
action_width = display_width(action_label)
|
||||||
action_x = max(0, width - action_width - 1)
|
action_x = max(0, width - action_width - 1)
|
||||||
self._safe_add(screen, y, action_x, action_label, attribute)
|
self._safe_add(screen, y, action_x, action_label, attribute)
|
||||||
self._row_action_buttons[y] = (
|
self._row_action_buttons[y] = (
|
||||||
@@ -1599,7 +1546,7 @@ class SessionSelector:
|
|||||||
try:
|
try:
|
||||||
screen.move(
|
screen.move(
|
||||||
2,
|
2,
|
||||||
min(width - 2, 2 + terminal_display_width(search_line)),
|
min(width - 2, 2 + display_width(search_line)),
|
||||||
)
|
)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
@@ -1639,7 +1586,7 @@ class SessionSelector:
|
|||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
screen.move(
|
screen.move(
|
||||||
4,
|
4,
|
||||||
min(width - 2, 2 + terminal_display_width(input_line)),
|
min(width - 2, 2 + display_width(input_line)),
|
||||||
)
|
)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
@@ -1664,7 +1611,7 @@ class SessionSelector:
|
|||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
screen.move(
|
screen.move(
|
||||||
3,
|
3,
|
||||||
min(width - 2, 2 + terminal_display_width(input_line)),
|
min(width - 2, 2 + display_width(input_line)),
|
||||||
)
|
)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
@@ -1682,7 +1629,9 @@ class SessionSelector:
|
|||||||
if y < 0 or y >= height or x >= width:
|
if y < 0 or y >= height or x >= width:
|
||||||
return
|
return
|
||||||
try:
|
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)
|
screen.addstr(y, x, visible, attribute)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
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,
|
SelectorResult,
|
||||||
build_main_rows,
|
build_main_rows,
|
||||||
filter_rows,
|
filter_rows,
|
||||||
|
)
|
||||||
|
from herdr_hub.text import (
|
||||||
|
display_width,
|
||||||
input_display_line,
|
input_display_line,
|
||||||
pad_terminal_text,
|
pad_display,
|
||||||
terminal_display_width,
|
truncate_display,
|
||||||
truncate_terminal_text,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -62,11 +64,11 @@ class FakeScreen:
|
|||||||
|
|
||||||
class SelectorCatalogTests(unittest.TestCase):
|
class SelectorCatalogTests(unittest.TestCase):
|
||||||
def test_terminal_display_width_counts_cjk_and_combining_characters(self) -> None:
|
def test_terminal_display_width_counts_cjk_and_combining_characters(self) -> None:
|
||||||
self.assertEqual(terminal_display_width("Rename: work"), 12)
|
self.assertEqual(display_width("Rename: work"), 12)
|
||||||
self.assertEqual(terminal_display_width("显示名称: 工作"), 14)
|
self.assertEqual(display_width("显示名称: 工作"), 14)
|
||||||
self.assertEqual(terminal_display_width("e\N{COMBINING ACUTE ACCENT}"), 1)
|
self.assertEqual(display_width("e\N{COMBINING ACUTE ACCENT}"), 1)
|
||||||
self.assertEqual(truncate_terminal_text("主机abc", 5), "主机a")
|
self.assertEqual(truncate_display("主机abc", 5), "主机a")
|
||||||
self.assertEqual(terminal_display_width(pad_terminal_text("主机", 6)), 6)
|
self.assertEqual(display_width(pad_display("主机", 6)), 6)
|
||||||
self.assertEqual(input_display_line("名称", "abcdefgh", 9), "名称: …gh")
|
self.assertEqual(input_display_line("名称", "abcdefgh", 9), "名称: …gh")
|
||||||
|
|
||||||
def test_rows_merge_open_running_stopped_and_new_sessions(self) -> None:
|
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