From 2c7b69c50b4cf9c65ba26bc31220db8f9176a8eb Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Wed, 23 Sep 2026 11:47:57 -0400 Subject: [PATCH 01/12] add does_text_sizing() to bin/display-modes.py example script --- bin/display-modes.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bin/display-modes.py b/bin/display-modes.py index 9b9fe6c3..ecadaff2 100755 --- a/bin/display-modes.py +++ b/bin/display-modes.py @@ -134,6 +134,11 @@ def display_sugar_methods(term): print(f" {_yn(term, term.does_kitty_notifications())} " f"Kitty desktop notifications (OSC 99)" + term.clear_eol) + print(' Testing Kitty Text Sizing...' + term.clear_eol, end='\r', flush=True) + print(f" {_yn(term, term.does_text_sizing())} " + f"Kitty text sizing (OSC 66)" + term.clear_eol) + + def display_decrqss(term): """Query and display terminal state via DECRQSS.""" From 73c91fb929f86aca1b0529ecfa4abba23de9bcf1 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Wed, 23 Sep 2026 13:59:45 -0400 Subject: [PATCH 02/12] wcwidth>=0.9.0 integration --- blessed/line_editor.py | 67 ++++++++++++++-------- blessed/sequences.py | 49 +++++++++++++--- blessed/terminal.py | 115 +++++++++++++++++++++++++++++++++----- blessed/win_terminal.py | 10 +++- docs/history.rst | 9 +++ docs/line_editor.rst | 4 ++ docs/measuring.rst | 38 ++++++++++++- pyproject.toml | 2 +- tests/accessories.py | 15 ++++- tests/test_line_editor.py | 11 ++++ tests/test_sequences.py | 44 +++++++++++++++ tests/test_xtgettcap.py | 15 +++-- 12 files changed, 321 insertions(+), 58 deletions(-) diff --git a/blessed/line_editor.py b/blessed/line_editor.py index a51f887e..6a970900 100644 --- a/blessed/line_editor.py +++ b/blessed/line_editor.py @@ -146,6 +146,7 @@ def my_handler(editor: LineEditor) -> LineEditResult: ... """ + # pylint: disable-next=too-many-locals def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments self, history: Optional[LineHistory] = None, @@ -161,6 +162,8 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument bg_sgr: str = "", ellipsis_sgr: str = "", keymap: Optional[Dict[str, Optional[Callable[..., LineEditResult]]]] = None, + ambiguous_width: int = 1, + term_program: Union[bool, str] = False, ) -> None: """Initialize editor with optional history, display, and keymap settings.""" self._buf: List[str] = [] @@ -182,6 +185,8 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument self.suggestion_sgr: str = suggestion_sgr self.bg_sgr: str = bg_sgr self.ellipsis_sgr: str = ellipsis_sgr + self.ambiguous_width: int = ambiguous_width + self.term_program: Union[bool, str] = term_program self.keymap: Dict[str, Optional[Callable[..., LineEditResult]]] = dict(DEFAULT_KEYMAP) if keymap: self.keymap.update(keymap) @@ -191,6 +196,11 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument self._prev_overflow: Tuple[bool, bool] = (False, False) self._prev_scroll_offset: int = 0 + def _width(self, text: str) -> int: + """Return the display width of *text* under this editor's corrections.""" + return wcswidth(text, ambiguous_width=self.ambiguous_width, + term_program=self.term_program) + @property def history(self) -> LineHistory: """Return the attached :class:`LineHistory` instance.""" @@ -219,18 +229,18 @@ def display(self) -> DisplayState: """Return the current :class:`DisplayState` for rendering.""" if self.password_mode: text = self.password_char * len(self._buf) - cursor_col = self._cursor * wcswidth(self.password_char) + cursor_col = self._cursor * self._width(self.password_char) suggestion = "" else: text = self.line cursor_col = self._cursor_display_col() suggestion = self._get_suggestion() if self._needs_hscroll(): - content_w = wcswidth(text) + wcswidth(suggestion) + content_w = self._width(text) + self._width(suggestion) offset = self._compute_scroll(cursor_col, content_w) return self._apply_sgr(_apply_hscroll( text, suggestion, cursor_col, self.max_width, - self.ellipsis, scroll_offset=offset)) + self.ellipsis, scroll_offset=offset, measure=self._width)) return self._apply_sgr( DisplayState(text=text, cursor=cursor_col, suggestion=suggestion)) @@ -254,7 +264,7 @@ def render(self, term: Terminal, row: int, width: int, """ self._col_offset = col cur = self.display - ellipsis_w = wcswidth(self.ellipsis) + ellipsis_w = self._width(self.ellipsis) parts: List[str] = [term.move_yx(row, col), cur.bg_sgr] rendered = 0 @@ -264,11 +274,11 @@ def render(self, term: Terminal, row: int, width: int, if cur.text: parts.extend((cur.bg_sgr, cur.text_sgr, cur.text)) - rendered += wcswidth(cur.text) + rendered += self._width(cur.text) if cur.suggestion: parts.extend((cur.bg_sgr, cur.suggestion_sgr, cur.suggestion)) - rendered += wcswidth(cur.suggestion) + rendered += self._width(cur.suggestion) if cur.overflow_right: parts.extend((cur.bg_sgr, cur.ellipsis_sgr, self.ellipsis)) @@ -304,7 +314,7 @@ def render_insert( col = self._prev_cursor parts: List[str] = [term.move_yx(row, off + col), cur.bg_sgr, cur.text_sgr, grapheme] - new_content_w = wcswidth(cur.text) + wcswidth(cur.suggestion) + new_content_w = self._width(cur.text) + self._width(cur.suggestion) if cur.suggestion: parts.extend((cur.bg_sgr, cur.suggestion_sgr, cur.suggestion)) trail = self._prev_content_w - new_content_w @@ -331,7 +341,7 @@ def render_backspace(self, term: Terminal, row: int) -> Optional[str]: return None off = self._col_offset col = cur.cursor - new_content_w = wcswidth(cur.text) + wcswidth(cur.suggestion) + new_content_w = self._width(cur.text) + self._width(cur.suggestion) erase = self._prev_content_w - new_content_w parts: List[str] = [term.move_yx(row, off + col)] if cur.suggestion: @@ -393,7 +403,7 @@ def _compute_scroll(self, cursor_col: int, content_width: int) -> int: self._scroll_offset = 0 return 0 jump = max(1, int(usable * self.scroll_jump)) - ellipsis_w = wcswidth(self.ellipsis) + ellipsis_w = self._width(self.ellipsis) left_margin = ellipsis_w if self._scroll_offset > 0 else 0 right_edge = self._scroll_offset + usable - left_margin if cursor_col >= right_edge: @@ -416,7 +426,7 @@ def _maybe_reset_limit_bell(self) -> None: self._limit_bell_fired = False def _cursor_display_col(self) -> int: - return sum(wcswidth(g) for g in self._buf[:self._cursor]) + return sum(self._width(g) for g in self._buf[:self._cursor]) def _save_undo(self) -> None: self._undo_stack.append((list(self._buf), self._cursor)) @@ -617,7 +627,8 @@ def _get_suggestion(self) -> str: def _clip_graphemes( - combined: str, scroll_offset: int, usable: int + combined: str, scroll_offset: int, usable: int, *, + measure: Callable[[str], int] = wcswidth, ) -> Tuple[List[str], int]: """ Collect visible graphemes from *combined* within a scroll window. @@ -625,13 +636,14 @@ def _clip_graphemes( :param combined: Full text (buffer + suggestion). :param scroll_offset: Column offset of the left edge. :param usable: Available display columns. + :param measure: Callable returning the display width of a grapheme. :returns: ``(visible_parts, visible_width)`` tuple. """ vis_parts: List[str] = [] vis_width = 0 col = 0 for grapheme in iter_graphemes(combined): - g_w = wcswidth(grapheme) + g_w = measure(grapheme) if col + g_w <= scroll_offset: col += g_w continue @@ -644,7 +656,8 @@ def _clip_graphemes( def _split_text_suggestion( - vis_parts: List[str], scroll_offset: int, text_w: int + vis_parts: List[str], scroll_offset: int, text_w: int, *, + measure: Callable[[str], int] = wcswidth, ) -> Tuple[str, str]: """ Split visible graphemes into text and suggestion portions. @@ -652,13 +665,14 @@ def _split_text_suggestion( :param vis_parts: Graphemes within the visible window. :param scroll_offset: Column offset of the left edge. :param text_w: Display width of the buffer text (before suggestion). + :param measure: Callable returning the display width of a grapheme. :returns: ``(visible_text, visible_suggestion)`` tuple. """ vis_text_parts: List[str] = [] vis_suggest_parts: List[str] = [] pos = 0 for grapheme in vis_parts: - g_w = wcswidth(grapheme) + g_w = measure(grapheme) if scroll_offset + pos < text_w: vis_text_parts.append(grapheme) else: @@ -682,7 +696,8 @@ def _default_scroll_offset(cursor_col: int, max_width: int) -> int: def _trim_right_overflow( - vis_parts: List[str], vis_width: int, ellipsis_w: int, usable: int + vis_parts: List[str], vis_width: int, ellipsis_w: int, usable: int, *, + measure: Callable[[str], int] = wcswidth, ) -> int: """ Remove trailing graphemes to make room for a right-side ellipsis. @@ -691,25 +706,28 @@ def _trim_right_overflow( :param vis_width: Current total display width of *vis_parts*. :param ellipsis_w: Display width of the ellipsis character. :param usable: Available display columns. + :param measure: Callable returning the display width of a grapheme. :returns: Updated visible width after trimming. """ while vis_parts and vis_width + ellipsis_w > usable: - vis_width -= wcswidth(vis_parts.pop()) + vis_width -= measure(vis_parts.pop()) return vis_width -def _apply_hscroll( # pylint: disable=too-many-positional-arguments +def _apply_hscroll( # pylint: disable=too-many-positional-arguments,too-many-locals text: str, suggestion: str, cursor_col: int, max_width: int, ellipsis: str = "\u2026", scroll_offset: Optional[int] = None, + *, + measure: Callable[[str], int] = wcswidth, ) -> DisplayState: """Build a :class:`DisplayState` with horizontal scrolling applied.""" - text_w = wcswidth(text) + text_w = measure(text) - if text_w + wcswidth(suggestion) < max_width and cursor_col < max_width: + if text_w + measure(suggestion) < max_width and cursor_col < max_width: return DisplayState( text=text, cursor=cursor_col, suggestion=suggestion) @@ -717,18 +735,19 @@ def _apply_hscroll( # pylint: disable=too-many-positional-arguments scroll_offset = _default_scroll_offset(cursor_col, max_width) overflow_left = scroll_offset > 0 - ellipsis_w = wcswidth(ellipsis) + ellipsis_w = measure(ellipsis) usable = max_width - (ellipsis_w if overflow_left else 0) vis_parts, vis_width = _clip_graphemes( - text + suggestion, scroll_offset, usable) + text + suggestion, scroll_offset, usable, measure=measure) - overflow_right = (scroll_offset + usable) < text_w + wcswidth(suggestion) + overflow_right = (scroll_offset + usable) < text_w + measure(suggestion) if overflow_right: - _trim_right_overflow(vis_parts, vis_width, ellipsis_w, usable) + _trim_right_overflow(vis_parts, vis_width, ellipsis_w, usable, + measure=measure) vis_text, vis_suggestion = _split_text_suggestion( - vis_parts, scroll_offset, text_w) + vis_parts, scroll_offset, text_w, measure=measure) return DisplayState( text=vis_text, diff --git a/blessed/sequences.py b/blessed/sequences.py index 480e6fe7..7e6b5c14 100644 --- a/blessed/sequences.py +++ b/blessed/sequences.py @@ -3,7 +3,7 @@ # std imports import re -from typing import TYPE_CHECKING, Tuple, Pattern, Iterator, Optional, SupportsIndex +from typing import TYPE_CHECKING, Tuple, Union, Literal, Pattern, Iterator, Optional, SupportsIndex # 3rd party from wcwidth import SequenceTextWrapper # noqa: F401 # re-exported for API compatibility @@ -177,7 +177,9 @@ def ljust(self, width: SupportsIndex, fillchar: str = ' ') -> str: :returns: String of ``text``, left-aligned by ``width``. :rtype: str """ - return wcwidth_ljust(self, width.__index__(), fillchar, control_codes='ignore') + return wcwidth_ljust(self, width.__index__(), fillchar, control_codes='ignore', + ambiguous_width=self._term.ambiguous_width, + term_program=self._term.term_program) def rjust(self, width: SupportsIndex, fillchar: str = ' ') -> str: """ @@ -188,7 +190,9 @@ def rjust(self, width: SupportsIndex, fillchar: str = ' ') -> str: :returns: String of ``text``, right-aligned by ``width``. :rtype: str """ - return wcwidth_rjust(self, width.__index__(), fillchar, control_codes='ignore') + return wcwidth_rjust(self, width.__index__(), fillchar, control_codes='ignore', + ambiguous_width=self._term.ambiguous_width, + term_program=self._term.term_program) def center(self, width: SupportsIndex, fillchar: str = ' ') -> str: """ @@ -199,7 +203,37 @@ def center(self, width: SupportsIndex, fillchar: str = ' ') -> str: :returns: String of ``text``, centered by ``width``. :rtype: str """ - return wcwidth_center(self, width.__index__(), fillchar, control_codes='ignore') + return wcwidth_center(self, width.__index__(), fillchar, control_codes='ignore', + ambiguous_width=self._term.ambiguous_width, + term_program=self._term.term_program) + + def clip(self, start: SupportsIndex = 0, end: SupportsIndex = -1, *, + fillchar: str = ' ', tabsize: int = 8, + ambiguous_width: Optional[SupportsIndex] = None, + propagate_sgr: bool = True, + control_codes: Literal['parse', 'strict', 'ignore'] = 'parse', + overtyping: Optional[bool] = None, + term_program: Union[bool, str, None] = None) -> str: + """ + Return a window of this string spanning display columns ``start`` to ``end``. + + Like :meth:`~.Terminal.clip`, which documents the arguments. Horizontal cursor + movement is expanded by :meth:`padd` first, and the terminal's detected + ``ambiguous_width`` and ``term_program`` are used unless overridden. + + :rtype: str + :returns: This string clipped to display columns (start, end) + """ + term = self._term + ambiguous_width = (term.ambiguous_width if ambiguous_width is None + else ambiguous_width.__index__()) + if term_program is None: + term_program = term.term_program + return wcwidth_clip( + self.padd(), start.__index__(), end.__index__(), + fillchar=fillchar, tabsize=tabsize, ambiguous_width=ambiguous_width, + propagate_sgr=propagate_sgr, control_codes=control_codes, + overtyping=overtyping, term_program=term_program) def truncate(self, width: SupportsIndex) -> str: """ @@ -220,9 +254,7 @@ def truncate(self, width: SupportsIndex) -> str: :rtype: str :returns: String truncated to exactly ``width`` printable characters. """ - # Use padd() to expand terminal-specific cursor movements to spaces, - # then use wcwidth's clip() to truncate while preserving all sequences. - return wcwidth_clip(self.padd(), 0, width.__index__()) + return self.clip(0, width.__index__()) def length(self) -> int: r""" @@ -248,7 +280,8 @@ def length(self) -> int: as ``term.clear`` will not give accurate returns, it is not considered lengthy (a length of 0). """ - return wcwidth_width(self) + return wcwidth_width(self, ambiguous_width=self._term.ambiguous_width, + term_program=self._term.term_program) def strip(self, chars: Optional[str] = None) -> str: """ diff --git a/blessed/terminal.py b/blessed/terminal.py index 45b944ed..2c5abd55 100644 --- a/blessed/terminal.py +++ b/blessed/terminal.py @@ -35,7 +35,6 @@ from wcwidth import wrap as wcwidth_wrap from wcwidth import ljust as wcwidth_ljust from wcwidth import rjust as wcwidth_rjust -from wcwidth import width as wcwidth_width from wcwidth import center as wcwidth_center # local @@ -230,7 +229,9 @@ def __init__(self, stream: Optional[IO[str]] = None, force_styling: Union[bool, None] = False, kind_fallback: str = 'xterm-256color', - _xtgettcap_data: Optional[TermcapResponse] = None + _xtgettcap_data: Optional[TermcapResponse] = None, + _software_version_data: Optional[SoftwareVersion] = None, + _ambiguous_width_data: Optional[int] = None ) -> None: """ Initialize the terminal. @@ -280,6 +281,10 @@ def __init__(self, ] self._normal = None + # Injected init-time detection values, see __init__detect_measurement(). + self._software_version_data = _software_version_data + self._ambiguous_width_data = _ambiguous_width_data + # we assume our input stream to be line-buffered until either the # cbreak of raw context manager methods are entered with an attached tty. self._line_buffered = True @@ -322,6 +327,62 @@ def __init__(self, self.__init__capabilities() self.__init__query_caches() + # Step 6: Detect measurement-affecting terminal properties + self.__init__detect_measurement() + + def __init__detect_measurement(self) -> None: + """Detect terminal properties affecting width measurement, see #418.""" + self.ambiguous_width = self._detect_ambiguous_width() + self.term_program = self._detect_term_program() + + def _software_version_from_env(self) -> Optional[SoftwareVersion]: + """Return terminal software from ``TERM_PROGRAM`` environment values.""" + term_program = os.environ.get('TERM_PROGRAM', '') + term_version = os.environ.get('TERM_PROGRAM_VERSION', '') + raw = ' '.join(filter(None, (term_program, term_version))) + if raw: + return SoftwareVersion(raw=raw, name=term_program, version=term_version) + return None + + def _detect_term_program(self) -> Union[bool, str]: + """ + Return the terminal software name for wcwidth correction tables. + + XTVERSION is queried only when the terminal can answer on the keyboard fd, + otherwise the ``TERM_PROGRAM`` environment values are used. Returns ``False`` + when unknown. + """ + version: Optional[SoftwareVersion] + if self._software_version_data is not None: + version = self._software_version_data + elif self.is_a_tty and self.does_styling and self._keyboard_fd is not None: + version = self.get_software_version() + else: + version = self._software_version_from_env() + return version.name if version is not None and version.name else False + + def _detect_ambiguous_width(self) -> int: + """ + Return the East Asian ambiguous character width, 1 or 2. + + The environment variable ``AMBIGUOUS_WIDE`` overrides detection, which is + otherwise measured only when the terminal can answer on the keyboard fd. + """ + if self._ambiguous_width_data is not None: + return self._ambiguous_width_data + override = os.environ.get('AMBIGUOUS_WIDE') + if override is not None: + try: + value = int(override) + except ValueError: + value = 0 + if value in {1, 2}: + return value + self.errors.append(f'AMBIGUOUS_WIDE={override!r}: expected 1 or 2') + if self.is_a_tty and self.does_styling and self._keyboard_fd is not None: + return self.detect_ambiguous_width() + return 1 + def __init__keyboard_state(self) -> None: """Initialize minimal keyboard state needed for XTGETTCAP probe.""" # Build database of int code <=> KEY_NAME (static, no jinxed deps). @@ -1341,13 +1402,10 @@ def get_software_version(self, timeout: Optional[float] = TERMINAL_QUERY_TIMEOUT # variables, set by many modern terminal emulators (iTerm2, Apple # Terminal, VS Code, WezTerm, Hyper, mintty, etc.), however, they # are not forwarded over protocols like ssh, less unreliable. - term_program = os.environ.get('TERM_PROGRAM', '') - term_version = os.environ.get('TERM_PROGRAM_VERSION', '') - raw = ' '.join(filter(None, (term_program, term_version))) - if raw: - self._software_version_cache = SoftwareVersion( - raw=raw, name=term_program, version=term_version) - return self._software_version_cache + version = self._software_version_from_env() + if version is not None: + self._software_version_cache = version + return version return None @@ -3773,7 +3831,9 @@ def ljust(self, text: str, width: Optional[SupportsIndex] = None, fillchar: str """ if width is None: width = self.width - return wcwidth_ljust(text, width.__index__(), fillchar) + return wcwidth_ljust(text, width.__index__(), fillchar, + ambiguous_width=self.ambiguous_width, + term_program=self.term_program) def rjust(self, text: str, width: Optional[SupportsIndex] = None, fillchar: str = ' ') -> str: """ @@ -3788,7 +3848,9 @@ def rjust(self, text: str, width: Optional[SupportsIndex] = None, fillchar: str """ if width is None: width = self.width - return wcwidth_rjust(text, width.__index__(), fillchar) + return wcwidth_rjust(text, width.__index__(), fillchar, + ambiguous_width=self.ambiguous_width, + term_program=self.term_program) def center(self, text: str, width: Optional[SupportsIndex] = None, fillchar: str = ' ') -> str: """ @@ -3803,7 +3865,9 @@ def center(self, text: str, width: Optional[SupportsIndex] = None, fillchar: str """ if width is None: width = self.width - return wcwidth_center(text, width.__index__(), fillchar) + return wcwidth_center(text, width.__index__(), fillchar, + ambiguous_width=self.ambiguous_width, + term_program=self.term_program) def text_sized( self, @@ -3895,6 +3959,29 @@ def _validate(value: Union[int, str], name: str, return text return TextSizing(params, text, '\x07').make_sequence() + def clip(self, text: str, start: SupportsIndex = 0, end: SupportsIndex = -1, + **kwargs: object) -> str: + r""" + Return a window of ``text`` spanning display columns ``start`` to ``end``. + + Terminal sequences are retained, wide characters split at a boundary are + replaced by ``fillchar``, and horizontal cursor movement is expanded by + :meth:`Sequence.padd`. The detected :attr:`ambiguous_width` and + :attr:`term_program` are used unless overridden. + + :arg str text: Text to clip, may contain terminal sequences + :arg int start: Absolute starting column, inclusive (default 0) + :arg int end: Absolute ending column, exclusive; -1 (default) means + "to the end of the line" + :arg \**kwargs: See :func:`wcwidth.clip` + :rtype: str + :returns: ``text`` clipped to display columns (start, end) + + >>> term.clip('hello world', 6) + 'world' + """ + return Sequence(text, self).clip(start, end, **kwargs) + def truncate(self, text: str, width: Optional[SupportsIndex] = None) -> str: r""" Truncate ``text`` to ``width`` printable characters, retaining terminal sequences. @@ -3938,7 +4025,7 @@ def length(self, text: str) -> int: (y, x)(0, 0), are evaluated as a printable length of *0*. """ - return wcwidth_width(text) + return Sequence(text, self).length() def strip(self, text: str, chars: Optional[str] = None) -> str: r""" @@ -4042,6 +4129,8 @@ def wrap(self, text: str, width: Optional[int] = None, **kwargs: object) -> List raise ValueError( f"invalid width {width!r}({type(width)!r}) (must be integer > 0)" ) + kwargs.setdefault('ambiguous_width', self.ambiguous_width) + kwargs.setdefault('term_program', self.term_program) lines: List[str] = [] for line in text.splitlines(): lines.extend( diff --git a/blessed/win_terminal.py b/blessed/win_terminal.py index 8ea7f10c..20d7ac3e 100644 --- a/blessed/win_terminal.py +++ b/blessed/win_terminal.py @@ -14,7 +14,7 @@ from jinxed import win32 # local -from .keyboard import TERMINAL_QUERY_TIMEOUT_SECONDS +from .keyboard import TERMINAL_QUERY_TIMEOUT_SECONDS, SoftwareVersion from .terminal import WINSZ from .terminal import Terminal as _Terminal from .dec_modes import DecPrivateMode as _DecPrivateMode @@ -114,7 +114,9 @@ def __init__(self, stream: Optional[IO[str]] = None, force_styling: Union[bool, None] = False, kind_fallback: str = 'vtwin10', - _xtgettcap_data: Optional[TermcapResponse] = None + _xtgettcap_data: Optional[TermcapResponse] = None, + _software_version_data: Optional[SoftwareVersion] = None, + _ambiguous_width_data: Optional[int] = None ) -> None: """Initialize Windows terminal instance.""" # Initialize instance attributes needed by kbhit() during @@ -124,7 +126,9 @@ def __init__(self, self._native_resize: bool = False super().__init__(kind=kind, stream=stream, force_styling=force_styling, kind_fallback=kind_fallback, - _xtgettcap_data=_xtgettcap_data) + _xtgettcap_data=_xtgettcap_data, + _software_version_data=_software_version_data, + _ambiguous_width_data=_ambiguous_width_data) self._prev_button_state: int = 0 def getch(self, decode_latin1: bool = False) -> str: diff --git a/docs/history.rst b/docs/history.rst index ce6c8c70..a7734dbc 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -3,6 +3,15 @@ Version History =============== +1.51 + * introduced: :meth:`~Terminal.clip` to extract a window of display columns from a string, + retaining terminal sequences. + * improved: :meth:`~Terminal.length`, :meth:`~Terminal.ljust`, :meth:`~Terminal.rjust`, + :meth:`~Terminal.center`, :meth:`~Terminal.truncate`, and :meth:`~Terminal.wrap` now apply + wcwidth_ correction tables by detecting the terminal software name and East Asian ambiguous + character width at class-initialization. The environment variable ``AMBIGUOUS_WIDE`` of ``1`` + or ``2`` skips interactive detection, :ghissue:`418`. + 1.50 * change: default timeout of automatic terminal queries from 1 to 5 seconds with environment variable ``BLESSED_QUERY_TIMEOUT_SECONDS`` override, :ghpull:`414`. diff --git a/docs/line_editor.rst b/docs/line_editor.rst index 810c8ba6..6cfd86c1 100644 --- a/docs/line_editor.rst +++ b/docs/line_editor.rst @@ -71,6 +71,10 @@ Override with ``text_sgr``, ``suggestion_sgr``, ``bg_sgr``, or ``ellipsis_sgr``: editor = LineEditor(bg_sgr=term.on_brown, max_width=term.width) +When ``max_width`` is set, display width is measured with :mod:`wcwidth`; pass +:attr:`~.Terminal.ambiguous_width` and :attr:`~.Terminal.term_program` so that ambiguous and +terminal-corrected characters are measured as the terminal renders them. + When ``max_width`` is set and text overflows, ``overflow_left`` and ``overflow_right`` indicate which edges are truncated. Use ``ellipsis_sgr`` to style the overflow indicator. diff --git a/docs/measuring.rst b/docs/measuring.rst index db8674cc..0cf1f1b3 100644 --- a/docs/measuring.rst +++ b/docs/measuring.rst @@ -91,8 +91,18 @@ Alignment By combining the measure of the printable width of strings containing sequences with the terminal width, the :meth:`~.Terminal.center`, :meth:`~.Terminal.ljust`, :meth:`~.Terminal.rjust`, -:meth:`~Terminal.truncate`, and :meth:`~Terminal.wrap` methods "just work" for strings that -contain sequences. +:meth:`~Terminal.truncate`, :meth:`~Terminal.wrap`, and :meth:`~Terminal.clip` methods "just work" +for strings that contain sequences. + +Where :meth:`~Terminal.truncate` removes everything right of a single column, +:meth:`~Terminal.clip` extracts a window of display columns, from ``start`` to ``end``: + +.. code-block:: python + + from blessed import Terminal + term = Terminal() + + print(term.clip('the quick brown fox', 4, 9)) # 'quick' .. code-block:: python @@ -118,6 +128,30 @@ In the following example, :meth:`~Terminal.wrap` word-wraps a short poem contain for line in poem: print('\n'.join(term.wrap(line, width=25, subsequent_indent=' ' * 4))) +Terminal Corrections +-------------------- + +The rendered width of some characters depends on the terminal software, as discovered by the +`ucs-detect `_ project. At class-initialization, +blessed identifies the terminal software with the `XTVERSION `_ +query (``CSI > q``) and measures whether East Asian ambiguous characters are rendered wide or +narrow, then applies the matching wcwidth_ `correction tables +`_ to the methods above. + +The result is available as :attr:`~.Terminal.term_program` and :attr:`~.Terminal.ambiguous_width`: + +.. code-block:: python + + from blessed import Terminal + term = Terminal() + + print(term.term_program, term.ambiguous_width) + +When no terminal answers, such as when output is redirected, the terminal software falls back to +the ``TERM_PROGRAM`` environment variable and ambiguous width is assumed narrow. Set the +environment variable ``AMBIGUOUS_WIDE`` to ``1`` or ``2`` to skip interactive detection, such as +for automated tests. + Text Sizing ----------- diff --git a/pyproject.toml b/pyproject.toml index a6f63f80..e42fc7b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ classifiers = [ 'Typing :: Typed', ] dependencies = [ - 'wcwidth>=0.8.3', + 'wcwidth>=0.9.0', 'jinxed>=2.1,<3', ] dynamic = ['version'] diff --git a/tests/accessories.py b/tests/accessories.py index 17333d09..fb630050 100644 --- a/tests/accessories.py +++ b/tests/accessories.py @@ -13,6 +13,7 @@ # local from blessed import Terminal from blessed.dec_modes import DecModeResponse +from blessed.keyboard import SoftwareVersion from blessed._capabilities import TermcapResponse from .conftest import IS_WINDOWS @@ -51,10 +52,17 @@ def assert_timeout_elapsed(elapsed, timeout): # Sentinel to distinguish "use default fake XTGETTCAP" from "force real probe" NO_XTGETTCAP_DATA = object() +# Injected software version meaning "no terminal software detected". +_NO_SOFTWARE_VERSION = SoftwareVersion(raw='', name='', version='') + def TestTerminal(is_a_tty=None, _xtgettcap_data=DEFAULT_TERMCAP_RESPONSE, - _xtgettcap_timeout=None, **kwargs) -> Terminal: - """Create a Terminal instance with optional is_a_tty override and default _xtgettcap_data.""" + _xtgettcap_timeout=None, _detect=False, **kwargs) -> Terminal: + """Create a Terminal instance with optional is_a_tty override and default _xtgettcap_data. + + Unless ``_detect`` is True, init-time terminal detection is injected with + neutral values: the forked-PTY harness has no terminal to answer queries. + """ if 'kind' not in kwargs: kwargs['kind'] = TEST_KIND if _xtgettcap_data is not NO_XTGETTCAP_DATA: @@ -62,6 +70,9 @@ def TestTerminal(is_a_tty=None, _xtgettcap_data=DEFAULT_TERMCAP_RESPONSE, if _xtgettcap_timeout is not None: import blessed.terminal blessed.terminal.TERMINAL_QUERY_TIMEOUT_SECONDS = _xtgettcap_timeout + if not _detect: + kwargs.setdefault('_software_version_data', _NO_SOFTWARE_VERSION) + kwargs.setdefault('_ambiguous_width_data', 1) term = Terminal(**kwargs) if is_a_tty is not None: term._is_a_tty = is_a_tty diff --git a/tests/test_line_editor.py b/tests/test_line_editor.py index ab362a0f..e10733bc 100644 --- a/tests/test_line_editor.py +++ b/tests/test_line_editor.py @@ -1104,3 +1104,14 @@ def test_render_with_suggestion(self, op, method, sgr_check) -> None: assert result is not None if sgr_check: assert ed.suggestion_sgr in result + + +def test_measurement_corrections(): + """Display width honors configured ambiguous width and terminal corrections.""" + ed = LineEditor(ambiguous_width=2) + ed.feed_key("\u00a7") + assert ed.display.cursor == 2 + + ed = LineEditor(term_program="vte") + ed.feed_key("\u2630") + assert ed.display.cursor == 1 diff --git a/tests/test_sequences.py b/tests/test_sequences.py index 37d635d3..dd333672 100644 --- a/tests/test_sequences.py +++ b/tests/test_sequences.py @@ -796,6 +796,50 @@ def child(kind): child(any_term) +def test_clip_window(any_term): + """terminal.clip() selects a window of display columns.""" + def child(kind): + term = TestTerminal(kind=kind) + assert term.clip('hello world', 6, 11) == 'world' + assert term.clip('hello world', 6) == 'world' + assert term.clip('hello world', 11, 11) == '' + assert term.clip('hello world', 5, 100) == ' world' + # a wide character split at a boundary is filled + assert term.clip('AB\uff23', 0, 3) == 'AB ' + assert term.clip('AB\uff23', 0, 3, fillchar='.') == 'AB.' + assert term.clip('AB\uff23', 2, 4) == '\uff23' + + child(any_term) + + +def test_clip_matches_truncate(any_term): + """terminal.clip(text, 0, width) agrees with terminal.truncate(text, width).""" + def child(kind): + term = TestTerminal(kind=kind, force_styling=True) + given = f'{term.bold_red("Testing")} yellow peppers' + for width in (0, 1, 3, term.length(given)): + assert term.clip(given, 0, width) == term.truncate(given, width) + + if term.move_right(5): + moved = f'one{term.move_right(5)}two' + assert term.clip(moved, 0, 9) == term.truncate(moved, 9) + + child(any_term) + + +def test_clip_sequence_method(any_term): + """Sequence.clip() matches Terminal.clip().""" + def child(kind): + # local + from blessed.sequences import Sequence + + term = TestTerminal(kind=kind) + assert Sequence('hello world', term).clip(6) == term.clip('hello world', 6) + assert Sequence('hello world', term).clip(0, 5) == 'hello' + + child(any_term) + + @pytest.mark.skipif(sys.version_info[:2] < (3, 8), reason="Only supported on Python >= 3.8") def test_supports_index(any_term): """Ensure sequence formatting methods support objects with __index__()""" diff --git a/tests/test_xtgettcap.py b/tests/test_xtgettcap.py index 020cbfa6..411d56af 100644 --- a/tests/test_xtgettcap.py +++ b/tests/test_xtgettcap.py @@ -127,7 +127,8 @@ def test_xtgettcap_probe_oserror(): with mock.patch('os.isatty', return_value=True), \ mock.patch.object(Terminal, '_xtgettcap_batch', side_effect=OSError('broken pipe')): - t = Terminal(stream=sys.__stdout__, force_styling=True) + t = TestTerminal(stream=sys.__stdout__, force_styling=True, + _xtgettcap_data=NO_XTGETTCAP_DATA) assert any('OSError' in err for err in t.errors) @@ -1258,7 +1259,8 @@ def test_xtgettcap_skip_ansicon_env(): with mock.patch.dict(os.environ, {'ANSICON': '1'}), \ mock.patch('os.isatty', return_value=True), \ mock.patch.object(Terminal, '_xtgettcap_batch') as mock_batch: - t = Terminal(stream=sys.__stdout__, force_styling=True) + t = TestTerminal(stream=sys.__stdout__, force_styling=True, + _xtgettcap_data=NO_XTGETTCAP_DATA) mock_batch.assert_not_called() assert any('ansicon' in err for err in t.errors) assert t._xtgettcap_cache.supported is False @@ -1269,7 +1271,8 @@ def test_xtgettcap_skip_conemuansi_env(): with mock.patch.dict(os.environ, {'ConEmuANSI': 'ON'}), \ mock.patch('os.isatty', return_value=True), \ mock.patch.object(Terminal, '_xtgettcap_batch') as mock_batch: - t = Terminal(stream=sys.__stdout__, force_styling=True) + t = TestTerminal(stream=sys.__stdout__, force_styling=True, + _xtgettcap_data=NO_XTGETTCAP_DATA) mock_batch.assert_not_called() assert any('ansicon' in err for err in t.errors) assert t._xtgettcap_cache.supported is False @@ -1280,7 +1283,8 @@ def test_xtgettcap_skip_Terminal_app(): with mock.patch.dict(os.environ, {'TERM_PROGRAM': 'Apple_Terminal'}), \ mock.patch('os.isatty', return_value=True), \ mock.patch.object(Terminal, '_xtgettcap_batch') as mock_batch: - t = Terminal(stream=sys.__stdout__, force_styling=True) + t = TestTerminal(stream=sys.__stdout__, force_styling=True, + _xtgettcap_data=NO_XTGETTCAP_DATA) mock_batch.assert_not_called() assert any('Terminal.app' in err for err in t.errors) assert t._xtgettcap_cache.supported is False @@ -1313,7 +1317,8 @@ def test_xtgettcap_skip_early_conhost(env, build, skipped): mock.patch('os.isatty', return_value=True), \ mock.patch.object(Terminal, '_xtgettcap_batch', return_value=TermcapResponse(supported=False)) as mock_batch: - t = Terminal(stream=sys.__stdout__, force_styling=True) + t = TestTerminal(stream=sys.__stdout__, force_styling=True, + _xtgettcap_data=NO_XTGETTCAP_DATA) assert any('conhost' in err for err in t.errors) is skipped assert mock_batch.called is not skipped From aa998eb2197aa17d421ac803c4accda9fd4f2cd3 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Wed, 23 Sep 2026 14:52:48 -0400 Subject: [PATCH 03/12] bump python-requires to 3.9 to match wcwidth's --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e42fc7b3..37254ea7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ classifiers = [ 'Typing :: Typed', ] dependencies = [ - 'wcwidth>=0.9.0', + 'wcwidth>=0.9.1', 'jinxed>=2.1,<3', ] dynamic = ['version'] @@ -55,7 +55,7 @@ license-files = ['LICENSE'] keywords = ['terminal', 'sequences', 'tty', 'curses', 'ncurses', 'formatting', 'style', 'color', 'console', 'keyboard', 'ansi', 'xterm'] readme = 'docs/intro.rst' -requires-python = '>=3.8' +requires-python = '>=3.9' [project.optional-dependencies] docs = [ From c419f4a03aed665ae689e3a4f8628160afb202f7 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Wed, 23 Sep 2026 17:08:01 -0400 Subject: [PATCH 04/12] drop python 3.8 support (matching wcwidth) --- .github/workflows/tests.yml | 2 +- CONTRIBUTING.rst | 4 ++-- docs/history.rst | 1 + docs/intro.rst | 2 +- pyproject.toml | 1 - tests/conftest.py | 1 + tests/test_core.py | 25 +++++++++++++++++++++++++ tests/test_length_sequence.py | 21 +++++++++++++++++++++ tests/test_sequences.py | 1 - tox.ini | 2 +- 10 files changed, 53 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dd8bc29c..1fda35b7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.9', '3.10', '3.11', '3.12'] test_quick: [1] include: diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index e62fff75..59d1d2bc 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -30,9 +30,9 @@ Install and run tox Py.test is used as the test runner, supporting positional arguments, you may for example use `looponfailing `_ -with python 3.8, stopping at the first failing test case:: +with python 3.9, stopping at the first failing test case:: - tox -epy38 -- -x + tox -epy39 -- -x The test runner (``tox``) ensures all code and documentation complies with standard python style guides, pep8 and pep257, as well as various static diff --git a/docs/history.rst b/docs/history.rst index a7734dbc..2739b9f6 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -11,6 +11,7 @@ Version History wcwidth_ correction tables by detecting the terminal software name and East Asian ambiguous character width at class-initialization. The environment variable ``AMBIGUOUS_WIDE`` of ``1`` or ``2`` skips interactive detection, :ghissue:`418`. + * dropped: Python 3.8 support, wcwidth 0.9.0 requires Python 3.9 or newer. 1.50 * change: default timeout of automatic terminal queries from 1 to 5 seconds with environment diff --git a/docs/intro.rst b/docs/intro.rst index 9791d76e..1ed1a8ca 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -73,7 +73,7 @@ Exemplary 3rd-party examples which use *blessed*, Requirements ------------ -*Blessed* works with Windows, Mac, Linux, and BSD's, on Python 3.7+. +*Blessed* works with Windows, Mac, Linux, and BSD's, on Python 3.9+. Brief Overview -------------- diff --git a/pyproject.toml b/pyproject.toml index 37254ea7..157b5a09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ classifiers = [ 'Operating System :: POSIX', 'Operating System :: Microsoft :: Windows', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', diff --git a/tests/conftest.py b/tests/conftest.py index a7fe759e..1db88b9e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ def pytest_configure(config: pytest.Config) -> None: os.environ.pop('TERM_PROGRAM', None) os.environ.pop('TERM_PROGRAM_VERSION', None) os.environ.pop('ESCDELAY', None) + os.environ.pop('AMBIGUOUS_WIDE', None) os.environ.pop('BLESSED_QUERY_TIMEOUT_SECONDS', None) diff --git a/tests/test_core.py b/tests/test_core.py index d55fd43a..9aad1ea6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1066,6 +1066,31 @@ def test_detect_ambiguous_width_second_timeout(): assert result == 77 +@pytest.mark.parametrize('value,expected', [('1', 1), ('2', 2)]) +def test_ambiguous_width_env_override(monkeypatch, value, expected): + """AMBIGUOUS_WIDE=1|2 overrides init-time detection.""" + monkeypatch.setenv('AMBIGUOUS_WIDE', value) + term = TestTerminal(_detect=True, stream=StringIO(), force_styling=True) + assert term.ambiguous_width == expected + + +def test_ambiguous_width_env_invalid(monkeypatch): + """An invalid AMBIGUOUS_WIDE is reported and ignored.""" + monkeypatch.setenv('AMBIGUOUS_WIDE', 'wide') + term = TestTerminal(_detect=True, stream=StringIO(), force_styling=True) + assert term.ambiguous_width == 1 + assert any('AMBIGUOUS_WIDE' in err for err in term.errors) + + +def test_no_query_without_tty(): + """Init-time detection writes nothing and keeps defaults without a terminal.""" + stream = StringIO() + term = TestTerminal(_detect=True, stream=stream, force_styling=True) + assert term.ambiguous_width == 1 + assert term.term_program is False + assert stream.getvalue() == '' + + @pytest.mark.skipif(not IS_WINDOWS, reason='requires jinxed.win32 (msvcrt)') def test_windows_init_streams_encoding(): """__init__streams uses get_console_input_encoding when IS_WINDOWS is True.""" diff --git a/tests/test_length_sequence.py b/tests/test_length_sequence.py index aadbeb34..6edd884f 100644 --- a/tests/test_length_sequence.py +++ b/tests/test_length_sequence.py @@ -36,6 +36,14 @@ def child(): child() +def test_length_term_program_correction(monkeypatch): + """Length honors wcwidth corrections for the detected term_program.""" + monkeypatch.setenv('TERM_PROGRAM', 'vte') + term = TestTerminal(_detect=True, stream=StringIO(), force_styling=True) + assert term.term_program == 'vte' + assert term.length('\u2630') == 1 # VTE renders trigrams narrow + + def test_length_with_zwj(): """Test that ZWJ sequences are measured correctly like wcswidth().""" def child(): @@ -418,6 +426,19 @@ def child(kind, lines=25, cols=80): child(kind=any_term) +def test_Sequence_alignment_ambiguous_width(monkeypatch): + """Alignment padding honors the detected ambiguous width.""" + # local + from blessed.sequences import Sequence + + monkeypatch.setenv('AMBIGUOUS_WIDE', '2') + term = TestTerminal(_detect=True, stream=StringIO(), force_styling=True) + assert term.ambiguous_width == 2 + assert term.ljust('\u00a7', 3) == '\u00a7 ' + assert term.center('\u00a7', 4) == ' \u00a7 ' + assert Sequence('\u00a7', term).ljust(3) == '\u00a7 ' + + def test_hyperlink_nostyling(): """Test length our of hyperlink URL's.""" def child(): diff --git a/tests/test_sequences.py b/tests/test_sequences.py index dd333672..5ced2765 100644 --- a/tests/test_sequences.py +++ b/tests/test_sequences.py @@ -840,7 +840,6 @@ def child(kind): child(any_term) -@pytest.mark.skipif(sys.version_info[:2] < (3, 8), reason="Only supported on Python >= 3.8") def test_supports_index(any_term): """Ensure sequence formatting methods support objects with __index__()""" diff --git a/tox.ini b/tox.ini index c6bb3c24..fdf46fc8 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ envlist = pydocstyle mypy sphinx - py{38,39,310,311,312,313,314} + py{39,310,311,312,313,314} ####### Python Test Environments ####### From 02571a8ba4d7c71869b160b2da31f2a7cd997203 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Wed, 23 Sep 2026 17:39:28 -0400 Subject: [PATCH 05/12] docwork, examples, etc. --- bin/display-modes.py | 1 - bin/keyboard_kitty_simple.py | 8 ++------ blessed/__init__.py | 2 +- blessed/terminal.py | 10 ++++------ docs/history.rst | 14 +++++++------- 5 files changed, 14 insertions(+), 21 deletions(-) diff --git a/bin/display-modes.py b/bin/display-modes.py index ecadaff2..413183ec 100755 --- a/bin/display-modes.py +++ b/bin/display-modes.py @@ -139,7 +139,6 @@ def display_sugar_methods(term): f"Kitty text sizing (OSC 66)" + term.clear_eol) - def display_decrqss(term): """Query and display terminal state via DECRQSS.""" print(term.bold("DECRQSS State Queries (DCS $ q):")) diff --git a/bin/keyboard_kitty_simple.py b/bin/keyboard_kitty_simple.py index 8f10ba9c..00f02042 100644 --- a/bin/keyboard_kitty_simple.py +++ b/bin/keyboard_kitty_simple.py @@ -15,9 +15,5 @@ else "released") if key.pressed and key.value == 'q': break - print( - f"name={ - key.key_name} value={ - key.key_value} kind={kind}, sequence={ - key!r}", - end="\r\n") + print(f"name={key.key_name} value={key.key_value} kind={kind}, " + f"sequence={key!r}", end="\r\n") diff --git a/blessed/__init__.py b/blessed/__init__.py index a6f40f4b..9d8562fc 100644 --- a/blessed/__init__.py +++ b/blessed/__init__.py @@ -15,4 +15,4 @@ from blessed.line_editor import LineEditor, LineHistory __all__ = ('Terminal', 'LineEditor', 'LineHistory') -__version__ = "1.50.0" +__version__ = "1.51.0" diff --git a/blessed/terminal.py b/blessed/terminal.py index 2c5abd55..2d5d25d8 100644 --- a/blessed/terminal.py +++ b/blessed/terminal.py @@ -281,7 +281,7 @@ def __init__(self, ] self._normal = None - # Injected init-time detection values, see __init__detect_measurement(). + # Injected init-time detection values self._software_version_data = _software_version_data self._ambiguous_width_data = _ambiguous_width_data @@ -327,12 +327,10 @@ def __init__(self, self.__init__capabilities() self.__init__query_caches() - # Step 6: Detect measurement-affecting terminal properties - self.__init__detect_measurement() - - def __init__detect_measurement(self) -> None: - """Detect terminal properties affecting width measurement, see #418.""" + # Step 6: Detect measurement-affecting terminal properties, see #418. + #: Width of East Asian ambiguous characters, 1 or 2. self.ambiguous_width = self._detect_ambiguous_width() + #: Terminal software name for wcwidth correction tables, ``False`` when unknown. self.term_program = self._detect_term_program() def _software_version_from_env(self) -> Optional[SoftwareVersion]: diff --git a/docs/history.rst b/docs/history.rst index 2739b9f6..28af643b 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -4,14 +4,13 @@ Version History =============== 1.51 - * introduced: :meth:`~Terminal.clip` to extract a window of display columns from a string, - retaining terminal sequences. + * introduced: :meth:`~Terminal.clip` to extract a "window" of displayed columns from a string, a + more precise version of :meth:`~Terminal.truncate`, retaining terminal sequences, :ghpull:`420`. * improved: :meth:`~Terminal.length`, :meth:`~Terminal.ljust`, :meth:`~Terminal.rjust`, - :meth:`~Terminal.center`, :meth:`~Terminal.truncate`, and :meth:`~Terminal.wrap` now apply - wcwidth_ correction tables by detecting the terminal software name and East Asian ambiguous - character width at class-initialization. The environment variable ``AMBIGUOUS_WIDE`` of ``1`` - or ``2`` skips interactive detection, :ghissue:`418`. - * dropped: Python 3.8 support, wcwidth 0.9.0 requires Python 3.9 or newer. + :meth:`~Terminal.center`, :meth:`~Terminal.truncate`, and :meth:`~Terminal.wrap` with + terminal-specific Corrections_ by detecting the terminal software name and East Asian ambiguous + modes at class-initialization, :ghissue:`420`. + * deprecated: Python 3.8 and earlier no longer supported. :ghpull:`420`. 1.50 * change: default timeout of automatic terminal queries from 1 to 5 seconds with environment @@ -596,3 +595,4 @@ Version History .. _NO_COLOR: https://no-color.org/ .. _jinxed: https://jinxed.readthedocs.io/en/stable/ .. _XTGETTCAP: https://codeberg.org/dnkl/foot#xtgettcap +.. _`Corrections`: https://wcwidth.readthedocs.io/en/latest/intro.html#corrections From 37c664072f4d76c2513af931aa4b4ce52f60b994 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Wed, 23 Sep 2026 17:49:26 -0400 Subject: [PATCH 06/12] small performance fix --- blessed/terminal.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/blessed/terminal.py b/blessed/terminal.py index 2d5d25d8..00136c73 100644 --- a/blessed/terminal.py +++ b/blessed/terminal.py @@ -35,6 +35,7 @@ from wcwidth import wrap as wcwidth_wrap from wcwidth import ljust as wcwidth_ljust from wcwidth import rjust as wcwidth_rjust +from wcwidth import width as wcwidth_width from wcwidth import center as wcwidth_center # local @@ -4023,7 +4024,8 @@ def length(self, text: str) -> int: (y, x)(0, 0), are evaluated as a printable length of *0*. """ - return Sequence(text, self).length() + return wcwidth_width(text, ambiguous_width=self.ambiguous_width, + term_program=self.term_program) def strip(self, text: str, chars: Optional[str] = None) -> str: r""" From ccde000bca6e1e94a01ed79f978c1749bd9a986b Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Wed, 23 Sep 2026 20:14:56 -0400 Subject: [PATCH 07/12] reduction of constructor args and code cov --- bin/display-modes.py | 15 +++++++++ blessed/sequences.py | 18 ++++------- blessed/terminal.py | 56 ++++++++++++---------------------- blessed/win_terminal.py | 10 ++---- docs/history.rst | 4 +++ docs/measuring.rst | 17 ++++++----- docs/terminal.rst | 15 +++++---- tests/accessories.py | 34 +++++++++++++-------- tests/test_core.py | 11 ++++--- tests/test_length_sequence.py | 4 +-- tests/test_sequences.py | 13 ++++++++ tests/test_software_version.py | 8 ++--- tests/test_xtgettcap.py | 15 +++++++-- 13 files changed, 124 insertions(+), 96 deletions(-) diff --git a/bin/display-modes.py b/bin/display-modes.py index 413183ec..e5df5176 100755 --- a/bin/display-modes.py +++ b/bin/display-modes.py @@ -9,6 +9,7 @@ python bin/display-modes.py --force # bypass cached results """ # std imports +import os import sys # local @@ -113,6 +114,16 @@ def display_sugar_methods(term): print(f" Colors: {xtgettcap.num_colors}") print(f" Capabilities: {len(xtgettcap)}") + print(' Testing XTVERSION...' + term.clear_eol, end='\r', flush=True) + version = term.get_software_version() + print(f" {_yn(term, version is not None)} XTVERSION (CSI > q)" + term.clear_eol) + if version is not None: + # a TERM_PROGRAM value, when defined, is returned without inquiry + source = 'TERM_PROGRAM' if os.environ.get('TERM_PROGRAM') else 'inquiry' + print(f" Terminal name: {term.bright_cyan(version.name or '--')}") + print(f" Version: {version.version or '--'}") + print(f" Source: {source}") + print(' Testing Kitty graphics...' + term.clear_eol, end='\r', flush=True) print(f" {_yn(term, term.does_kitty_graphics())} " f"Kitty graphics protocol (APC)" + term.clear_eol) @@ -233,6 +244,10 @@ def main(): _24bit = term.bright_green('24-bit') _no_colors = term.bright_red(str(term.number_of_colors)) print(f" .number_of_colors: {_24bit if term.number_of_colors == 1 << 24 else _no_colors}") + _ambig = term.bright_cyan('Wide (2)') if term.ambiguous_width == 2 else 'Narrow (1)' + print(f" .ambiguous_width: {_ambig}") + _program = term.bright_cyan(term.term_program or term.bright_black('unknown')) + print(f" .term_program: {_program}") print() # Display Device Attributes diff --git a/blessed/sequences.py b/blessed/sequences.py index 7e6b5c14..86b92410 100644 --- a/blessed/sequences.py +++ b/blessed/sequences.py @@ -3,7 +3,7 @@ # std imports import re -from typing import TYPE_CHECKING, Tuple, Union, Literal, Pattern, Iterator, Optional, SupportsIndex +from typing import TYPE_CHECKING, Tuple, Literal, Pattern, Iterator, Optional, SupportsIndex # 3rd party from wcwidth import SequenceTextWrapper # noqa: F401 # re-exported for API compatibility @@ -209,31 +209,25 @@ def center(self, width: SupportsIndex, fillchar: str = ' ') -> str: def clip(self, start: SupportsIndex = 0, end: SupportsIndex = -1, *, fillchar: str = ' ', tabsize: int = 8, - ambiguous_width: Optional[SupportsIndex] = None, propagate_sgr: bool = True, control_codes: Literal['parse', 'strict', 'ignore'] = 'parse', - overtyping: Optional[bool] = None, - term_program: Union[bool, str, None] = None) -> str: + overtyping: Optional[bool] = None) -> str: """ Return a window of this string spanning display columns ``start`` to ``end``. Like :meth:`~.Terminal.clip`, which documents the arguments. Horizontal cursor movement is expanded by :meth:`padd` first, and the terminal's detected - ``ambiguous_width`` and ``term_program`` are used unless overridden. + ``ambiguous_width`` and ``term_program`` are applied. :rtype: str :returns: This string clipped to display columns (start, end) """ - term = self._term - ambiguous_width = (term.ambiguous_width if ambiguous_width is None - else ambiguous_width.__index__()) - if term_program is None: - term_program = term.term_program return wcwidth_clip( self.padd(), start.__index__(), end.__index__(), - fillchar=fillchar, tabsize=tabsize, ambiguous_width=ambiguous_width, + fillchar=fillchar, tabsize=tabsize, + ambiguous_width=self._term.ambiguous_width, propagate_sgr=propagate_sgr, control_codes=control_codes, - overtyping=overtyping, term_program=term_program) + overtyping=overtyping, term_program=self._term.term_program) def truncate(self, width: SupportsIndex) -> str: """ diff --git a/blessed/terminal.py b/blessed/terminal.py index 00136c73..8db8ca66 100644 --- a/blessed/terminal.py +++ b/blessed/terminal.py @@ -230,9 +230,7 @@ def __init__(self, stream: Optional[IO[str]] = None, force_styling: Union[bool, None] = False, kind_fallback: str = 'xterm-256color', - _xtgettcap_data: Optional[TermcapResponse] = None, - _software_version_data: Optional[SoftwareVersion] = None, - _ambiguous_width_data: Optional[int] = None + _xtgettcap_data: Optional[TermcapResponse] = None ) -> None: """ Initialize the terminal. @@ -282,10 +280,6 @@ def __init__(self, ] self._normal = None - # Injected init-time detection values - self._software_version_data = _software_version_data - self._ambiguous_width_data = _ambiguous_width_data - # we assume our input stream to be line-buffered until either the # cbreak of raw context manager methods are entered with an attached tty. self._line_buffered = True @@ -347,17 +341,13 @@ def _detect_term_program(self) -> Union[bool, str]: """ Return the terminal software name for wcwidth correction tables. - XTVERSION is queried only when the terminal can answer on the keyboard fd, - otherwise the ``TERM_PROGRAM`` environment values are used. Returns ``False`` - when unknown. + A ``TERM_PROGRAM`` value takes precedence, XTVERSION is queried otherwise, when + the terminal can answer on the keyboard fd. Returns ``False`` when unknown. """ - version: Optional[SoftwareVersion] - if self._software_version_data is not None: - version = self._software_version_data - elif self.is_a_tty and self.does_styling and self._keyboard_fd is not None: + version = self._software_version_from_env() + if version is None and self.is_a_tty and self.does_styling \ + and self._keyboard_fd is not None: version = self.get_software_version() - else: - version = self._software_version_from_env() return version.name if version is not None and version.name else False def _detect_ambiguous_width(self) -> int: @@ -367,10 +357,8 @@ def _detect_ambiguous_width(self) -> int: The environment variable ``AMBIGUOUS_WIDE`` overrides detection, which is otherwise measured only when the terminal can answer on the keyboard fd. """ - if self._ambiguous_width_data is not None: - return self._ambiguous_width_data override = os.environ.get('AMBIGUOUS_WIDE') - if override is not None: + if override: try: value = int(override) except ValueError: @@ -1350,15 +1338,12 @@ def get_device_attributes(self, timeout: Optional[float] = TERMINAL_QUERY_TIMEOU def get_software_version(self, timeout: Optional[float] = TERMINAL_QUERY_TIMEOUT_SECONDS, force: bool = False) -> Optional[SoftwareVersion]: """ - Query the terminal's software name and version using XTVERSION. - - Sends an XTVERSION query to the terminal and returns a - :class:`SoftwareVersion` instance with the terminal's name and version. + Return the terminal's software name and version. - If an XTVERSION query fails to respond within the ``timeout`` - specified, falls back to the ``TERM_PROGRAM`` and - ``TERM_PROGRAM_VERSION`` environment variables. Returns ``None`` - only if both methods fail. + A non-empty ``TERM_PROGRAM`` value takes precedence and no inquiry is made, + otherwise an XTVERSION query is sent to the terminal and the response is + returned as a :class:`SoftwareVersion` instance. Returns ``None`` only if both + methods fail. **Only Successful responses are cached indefinitely** unless ``force=True`` is specified. Unlike other query methods, there is no "sticky failure", failed queries are not cached and @@ -1387,6 +1372,12 @@ def get_software_version(self, timeout: Optional[float] = TERMINAL_QUERY_TIMEOUT if self._software_version_cache is not None and not force: return self._software_version_cache + # defined by many modern terminal emulators and take precedence + version = self._software_version_from_env() + if version is not None: + self._software_version_cache = version + return version + # Build and send query sequence and expected response pattern query = '\x1b[>q' @@ -1397,15 +1388,6 @@ def get_software_version(self, timeout: Optional[float] = TERMINAL_QUERY_TIMEOUT self._software_version_cache = SoftwareVersion.from_match(match) return self._software_version_cache - # Fallback: use TERM_PROGRAM and TERM_PROGRAM_VERSION environment - # variables, set by many modern terminal emulators (iTerm2, Apple - # Terminal, VS Code, WezTerm, Hyper, mintty, etc.), however, they - # are not forwarded over protocols like ssh, less unreliable. - version = self._software_version_from_env() - if version is not None: - self._software_version_cache = version - return version - return None def does_sixel( @@ -3966,7 +3948,7 @@ def clip(self, text: str, start: SupportsIndex = 0, end: SupportsIndex = -1, Terminal sequences are retained, wide characters split at a boundary are replaced by ``fillchar``, and horizontal cursor movement is expanded by :meth:`Sequence.padd`. The detected :attr:`ambiguous_width` and - :attr:`term_program` are used unless overridden. + :attr:`term_program` are applied. :arg str text: Text to clip, may contain terminal sequences :arg int start: Absolute starting column, inclusive (default 0) diff --git a/blessed/win_terminal.py b/blessed/win_terminal.py index 20d7ac3e..8ea7f10c 100644 --- a/blessed/win_terminal.py +++ b/blessed/win_terminal.py @@ -14,7 +14,7 @@ from jinxed import win32 # local -from .keyboard import TERMINAL_QUERY_TIMEOUT_SECONDS, SoftwareVersion +from .keyboard import TERMINAL_QUERY_TIMEOUT_SECONDS from .terminal import WINSZ from .terminal import Terminal as _Terminal from .dec_modes import DecPrivateMode as _DecPrivateMode @@ -114,9 +114,7 @@ def __init__(self, stream: Optional[IO[str]] = None, force_styling: Union[bool, None] = False, kind_fallback: str = 'vtwin10', - _xtgettcap_data: Optional[TermcapResponse] = None, - _software_version_data: Optional[SoftwareVersion] = None, - _ambiguous_width_data: Optional[int] = None + _xtgettcap_data: Optional[TermcapResponse] = None ) -> None: """Initialize Windows terminal instance.""" # Initialize instance attributes needed by kbhit() during @@ -126,9 +124,7 @@ def __init__(self, self._native_resize: bool = False super().__init__(kind=kind, stream=stream, force_styling=force_styling, kind_fallback=kind_fallback, - _xtgettcap_data=_xtgettcap_data, - _software_version_data=_software_version_data, - _ambiguous_width_data=_ambiguous_width_data) + _xtgettcap_data=_xtgettcap_data) self._prev_button_state: int = 0 def getch(self, decode_latin1: bool = False) -> str: diff --git a/docs/history.rst b/docs/history.rst index 28af643b..7a7fe422 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -10,6 +10,10 @@ Version History :meth:`~Terminal.center`, :meth:`~Terminal.truncate`, and :meth:`~Terminal.wrap` with terminal-specific Corrections_ by detecting the terminal software name and East Asian ambiguous modes at class-initialization, :ghissue:`420`. + * change: :meth:`~Terminal.get_software_version` returns the ``TERM_PROGRAM`` and + ``TERM_PROGRAM_VERSION`` environment values when defined, without inquiry, and these values also + override the Corrections_ detection and the automatic queries otherwise made by + :meth:`~Terminal.detect_ambiguous_width`, :ghpull:`420`. * deprecated: Python 3.8 and earlier no longer supported. :ghpull:`420`. 1.50 diff --git a/docs/measuring.rst b/docs/measuring.rst index 0cf1f1b3..ffea3b35 100644 --- a/docs/measuring.rst +++ b/docs/measuring.rst @@ -133,10 +133,11 @@ Terminal Corrections The rendered width of some characters depends on the terminal software, as discovered by the `ucs-detect `_ project. At class-initialization, -blessed identifies the terminal software with the `XTVERSION `_ -query (``CSI > q``) and measures whether East Asian ambiguous characters are rendered wide or -narrow, then applies the matching wcwidth_ `correction tables -`_ to the methods above. +blessed identifies the terminal software by the ``TERM_PROGRAM`` environment variable, set by most +modern terminal emulators, and otherwise by the `XTVERSION +`_ query (``CSI > q``). It also measures whether East Asian +ambiguous characters are rendered wide or narrow, then applies the matching wcwidth_ `correction +tables `_ to the methods above. The result is available as :attr:`~.Terminal.term_program` and :attr:`~.Terminal.ambiguous_width`: @@ -147,10 +148,10 @@ The result is available as :attr:`~.Terminal.term_program` and :attr:`~.Terminal print(term.term_program, term.ambiguous_width) -When no terminal answers, such as when output is redirected, the terminal software falls back to -the ``TERM_PROGRAM`` environment variable and ambiguous width is assumed narrow. Set the -environment variable ``AMBIGUOUS_WIDE`` to ``1`` or ``2`` to skip interactive detection, such as -for automated tests. +When no terminal answers, such as when output is redirected, the terminal software is ``False`` and +ambiguous width is assumed narrow. Set the environment variable ``AMBIGUOUS_WIDE`` to ``1`` or +``2`` to skip measurement, such as for automated tests. Blank ``TERM_PROGRAM`` or +``AMBIGUOUS_WIDE`` to unset them and make the corresponding query. Text Sizing ----------- diff --git a/docs/terminal.rst b/docs/terminal.rst index 4f30f311..fd1f45b3 100644 --- a/docs/terminal.rst +++ b/docs/terminal.rst @@ -393,14 +393,13 @@ applications to identify the terminal software name and version. You can query the terminal's software version using the :meth:`~.Terminal.get_software_version` method, which returns a :class:`~.SoftwareVersion` object with ``name`` and ``version`` attributes, or, -``None`` if the terminal fails to respond. - -If the XTVERSION query does not receive a response within the timeout, -the method falls back to the ``TERM_PROGRAM`` and ``TERM_PROGRAM_VERSION`` -environment variables, which are set by many modern terminal emulators -(iTerm2, Apple Terminal.app, VS Code, WezTerm, Hyper, mintty, and others). -These variables are not forwarded over protocols like ssh, so they are less -reliable than XTVERSION. +``None`` if neither method succeeds. + +When the ``TERM_PROGRAM`` and ``TERM_PROGRAM_VERSION`` environment variables are defined, which +many modern terminal emulators set (iTerm2, Apple Terminal.app, VS Code, WezTerm, Hyper, mintty, +and others), they take precedence and are returned without inquiry. Blank ``TERM_PROGRAM`` to +have the XTVERSION query made instead. These variables are not forwarded over protocols like +ssh, so they are less reliable than XTVERSION. Example program to display terminal version information: diff --git a/tests/accessories.py b/tests/accessories.py index fb630050..f1890816 100644 --- a/tests/accessories.py +++ b/tests/accessories.py @@ -13,7 +13,6 @@ # local from blessed import Terminal from blessed.dec_modes import DecModeResponse -from blessed.keyboard import SoftwareVersion from blessed._capabilities import TermcapResponse from .conftest import IS_WINDOWS @@ -52,17 +51,30 @@ def assert_timeout_elapsed(elapsed, timeout): # Sentinel to distinguish "use default fake XTGETTCAP" from "force real probe" NO_XTGETTCAP_DATA = object() -# Injected software version meaning "no terminal software detected". -_NO_SOFTWARE_VERSION = SoftwareVersion(raw='', name='', version='') +# Values overriding init-time terminal detection. A terminal is attached in most +# tests, and queries made at initialization go unanswered by most of them. +DETECTION_OVERRIDES = {'TERM_PROGRAM': TEST_KIND, 'AMBIGUOUS_WIDE': '1'} -def TestTerminal(is_a_tty=None, _xtgettcap_data=DEFAULT_TERMCAP_RESPONSE, - _xtgettcap_timeout=None, _detect=False, **kwargs) -> Terminal: - """Create a Terminal instance with optional is_a_tty override and default _xtgettcap_data. +@contextlib.contextmanager +def detection_overrides(): + """Define values overriding init-time terminal detection, when unset. - Unless ``_detect`` is True, init-time terminal detection is injected with - neutral values: the forked-PTY harness has no terminal to answer queries. + Values are blanked by tests that answer the query they would override. """ + added = {key: value for key, value in DETECTION_OVERRIDES.items() + if key not in os.environ} + os.environ.update(added) + try: + yield + finally: + for key in added: + del os.environ[key] + + +def TestTerminal(is_a_tty=None, _xtgettcap_data=DEFAULT_TERMCAP_RESPONSE, + _xtgettcap_timeout=None, **kwargs) -> Terminal: + """Create a Terminal instance with optional is_a_tty override and default _xtgettcap_data.""" if 'kind' not in kwargs: kwargs['kind'] = TEST_KIND if _xtgettcap_data is not NO_XTGETTCAP_DATA: @@ -70,10 +82,8 @@ def TestTerminal(is_a_tty=None, _xtgettcap_data=DEFAULT_TERMCAP_RESPONSE, if _xtgettcap_timeout is not None: import blessed.terminal blessed.terminal.TERMINAL_QUERY_TIMEOUT_SECONDS = _xtgettcap_timeout - if not _detect: - kwargs.setdefault('_software_version_data', _NO_SOFTWARE_VERSION) - kwargs.setdefault('_ambiguous_width_data', 1) - term = Terminal(**kwargs) + with detection_overrides(): + term = Terminal(**kwargs) if is_a_tty is not None: term._is_a_tty = is_a_tty return term diff --git a/tests/test_core.py b/tests/test_core.py index 9aad1ea6..bc244f04 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1070,22 +1070,25 @@ def test_detect_ambiguous_width_second_timeout(): def test_ambiguous_width_env_override(monkeypatch, value, expected): """AMBIGUOUS_WIDE=1|2 overrides init-time detection.""" monkeypatch.setenv('AMBIGUOUS_WIDE', value) - term = TestTerminal(_detect=True, stream=StringIO(), force_styling=True) + term = TestTerminal(stream=StringIO(), force_styling=True) assert term.ambiguous_width == expected def test_ambiguous_width_env_invalid(monkeypatch): """An invalid AMBIGUOUS_WIDE is reported and ignored.""" monkeypatch.setenv('AMBIGUOUS_WIDE', 'wide') - term = TestTerminal(_detect=True, stream=StringIO(), force_styling=True) + term = TestTerminal(stream=StringIO(), force_styling=True) assert term.ambiguous_width == 1 assert any('AMBIGUOUS_WIDE' in err for err in term.errors) -def test_no_query_without_tty(): +def test_no_query_without_tty(monkeypatch): """Init-time detection writes nothing and keeps defaults without a terminal.""" + # blank values are unset: nothing overrides detection + monkeypatch.setenv('TERM_PROGRAM', '') + monkeypatch.setenv('AMBIGUOUS_WIDE', '') stream = StringIO() - term = TestTerminal(_detect=True, stream=stream, force_styling=True) + term = TestTerminal(stream=stream, force_styling=True) assert term.ambiguous_width == 1 assert term.term_program is False assert stream.getvalue() == '' diff --git a/tests/test_length_sequence.py b/tests/test_length_sequence.py index 6edd884f..49214f68 100644 --- a/tests/test_length_sequence.py +++ b/tests/test_length_sequence.py @@ -39,7 +39,7 @@ def child(): def test_length_term_program_correction(monkeypatch): """Length honors wcwidth corrections for the detected term_program.""" monkeypatch.setenv('TERM_PROGRAM', 'vte') - term = TestTerminal(_detect=True, stream=StringIO(), force_styling=True) + term = TestTerminal(stream=StringIO(), force_styling=True) assert term.term_program == 'vte' assert term.length('\u2630') == 1 # VTE renders trigrams narrow @@ -432,7 +432,7 @@ def test_Sequence_alignment_ambiguous_width(monkeypatch): from blessed.sequences import Sequence monkeypatch.setenv('AMBIGUOUS_WIDE', '2') - term = TestTerminal(_detect=True, stream=StringIO(), force_styling=True) + term = TestTerminal(stream=StringIO(), force_styling=True) assert term.ambiguous_width == 2 assert term.ljust('\u00a7', 3) == '\u00a7 ' assert term.center('\u00a7', 4) == ' \u00a7 ' diff --git a/tests/test_sequences.py b/tests/test_sequences.py index 5ced2765..8a2db6d5 100644 --- a/tests/test_sequences.py +++ b/tests/test_sequences.py @@ -840,6 +840,19 @@ def child(kind): child(any_term) +def test_sequence_length(any_term): + """Sequence.length() measures the printable width of sequences.""" + def child(kind): + # local + from blessed.sequences import Sequence + + term = TestTerminal(kind=kind, force_styling=True) + given = term.clear + term.red('コンニチハ') + assert Sequence(given, term).length() == 10 + + child(any_term) + + def test_supports_index(any_term): """Ensure sequence formatting methods support objects with __index__()""" diff --git a/tests/test_software_version.py b/tests/test_software_version.py index cac2cc55..0e7c8d41 100644 --- a/tests/test_software_version.py +++ b/tests/test_software_version.py @@ -217,7 +217,7 @@ def child(): def test_get_software_version_env_fallback(): - """Test get_software_version() falls back to TERM_PROGRAM env vars.""" + """Test get_software_version() uses TERM_PROGRAM env vars, without inquiry.""" def child(term): import os os.environ['TERM_PROGRAM'] = 'iTerm.app' @@ -235,7 +235,7 @@ def child(term): output = pty_test(child, parent_func=None, test_name='test_get_software_version_env_fallback') - assert output == '\x1b[>q\x1b[6nENV_FALLBACK' + assert output == 'ENV_FALLBACK' def test_get_software_version_env_fallback_no_version(): @@ -256,7 +256,7 @@ def child(term): output = pty_test(child, parent_func=None, test_name='test_get_software_version_env_fallback_no_version') - assert output == '\x1b[>q\x1b[6nENV_NO_VER' + assert output == 'ENV_NO_VER' def test_get_software_version_env_fallback_version_only(): @@ -277,7 +277,7 @@ def child(term): output = pty_test(child, parent_func=None, test_name='test_get_software_version_env_fallback_version_only') - assert output == '\x1b[>q\x1b[6nENV_VER_ONLY' + assert output == 'ENV_VER_ONLY' def test_get_software_version_no_env_no_response(): diff --git a/tests/test_xtgettcap.py b/tests/test_xtgettcap.py index 411d56af..9eb88618 100644 --- a/tests/test_xtgettcap.py +++ b/tests/test_xtgettcap.py @@ -1065,8 +1065,12 @@ def child(term): @pytestmark_pty -def test_terminal_init_xtgettcap_success(): - """Terminal() init with real XTGETTCAP probe and batch succeeds.""" +def test_terminal_init_xtgettcap_success(monkeypatch): + """Terminal() init queries XTGETTCAP, XTVERSION, and ambiguous width successfully.""" + # blank values are unset: the queries made at initialization are answered below + monkeypatch.setenv('TERM_PROGRAM', '') + monkeypatch.setenv('AMBIGUOUS_WIDE', '') + def parent(master_fd): # Wait for child to emit queries (indicates it's past # read_until_semaphore). No need to drain stdout -- child's @@ -1081,6 +1085,11 @@ def parent(master_fd): os.write(master_fd, b'\x1bP1+r524742=38\x1b\\') os.write(master_fd, b'\x1bP1+r544e=787465726d\x1b\\') os.write(master_fd, b'\x1b[10;20R') + # the two cursor reports that measure U+00A7, 20 and 22 columns -- width 2 + os.write(master_fd, b'\x1b[10;20R') + os.write(master_fd, b'\x1b[10;22R') + # XTVERSION reply, followed by its own CPR boundary + os.write(master_fd, b'\x1bP>|XTerm(390)\x1b\\\x1b[10;20R') def child(term): assert term._xtgettcap_cache is not None @@ -1088,6 +1097,8 @@ def child(term): assert term._xtgettcap_cache['TN'] == 'xterm' assert term._xtgettcap_cache['colors'] == '256' assert term._xtgettcap_cache['RGB'] == '8' + assert term.ambiguous_width == 2 + assert term.term_program == 'XTerm' with term.cbreak(): leaked = term.inkey(timeout=0) assert not leaked From 975cb96bdaca3fdaeb439f74c14098a5485641e9 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Thu, 1 Oct 2026 11:48:51 -0400 Subject: [PATCH 08/12] don't ambiguous_width on class init and more examples --- blessed/keyboard.py | 9 ++ blessed/terminal.py | 194 ++++++++++++++++++++------------- docs/history.rst | 12 +- docs/measuring.rst | 21 ++-- tests/accessories.py | 2 +- tests/test_core.py | 51 ++++++++- tests/test_software_version.py | 130 ++++++++++++++++++---- tests/test_xtgettcap.py | 8 +- 8 files changed, 315 insertions(+), 112 deletions(-) diff --git a/blessed/keyboard.py b/blessed/keyboard.py index 1f0be11c..f918702d 100644 --- a/blessed/keyboard.py +++ b/blessed/keyboard.py @@ -1465,6 +1465,7 @@ def resolve_sequence(text: str, _match_legacy_csi_tilde_form, _match_legacy_ss3_fkey_form, _match_xtgettcap_response, + _match_xtversion_response, _match_cpr_response] if capture_cpr: # prioritize capturing CPR_RESPONSE over legacy CSI Modifiers @@ -1617,6 +1618,14 @@ def _match_xtgettcap_response(text: str) -> Optional[Keystroke]: return None +def _match_xtversion_response(text: str) -> Optional[Keystroke]: + """Match XTVERSION reply: ESC P > | [version] ESC backslash.""" + match = SoftwareVersion.RE_RESPONSE.match(text) + if match: + return Keystroke(ucs=match.group(0), name='XTVERSION_RESPONSE') + return None + + def _match_cpr_response(text: str) -> Optional[Keystroke]: """Match CPR (Cursor Position Report): ESC [ row ; column R.""" # Matches terminal CPR responses where row >= 2. Row 1 is not diff --git a/blessed/terminal.py b/blessed/terminal.py index 8db8ca66..ccb3fec7 100644 --- a/blessed/terminal.py +++ b/blessed/terminal.py @@ -37,6 +37,7 @@ from wcwidth import rjust as wcwidth_rjust from wcwidth import width as wcwidth_width from wcwidth import center as wcwidth_center +from wcwidth import list_term_programs # local from .color import COLOR_DISTANCE_ALGORITHMS, xterm256gray_from_rgb, xterm256color_from_rgb @@ -143,6 +144,7 @@ _RE_KITTY_POINTER = re.compile(r'\x1b\]22;([^\x07\x1b]+)(?:\x07|\x1b\\)') _FONT_QUERY_CHUNK_SIZE = 256 + # Small transparent PNG used by does_iterm2_graphics() (base64-encoded). _ITERM2_PROBE_IMAGE = ( '\x1b]1337;File=inline=1;size=68;width=1;height=1:' @@ -322,52 +324,68 @@ def __init__(self, self.__init__capabilities() self.__init__query_caches() - # Step 6: Detect measurement-affecting terminal properties, see #418. - #: Width of East Asian ambiguous characters, 1 or 2. - self.ambiguous_width = self._detect_ambiguous_width() - #: Terminal software name for wcwidth correction tables, ``False`` when unknown. - self.term_program = self._detect_term_program() + #: Width of East Asian ambiguous characters, always 1, may be 2 by env value, + #: ``AMBIGUOUS_WIDE`` or until detected by call to :meth:`detect_ambiguous_width`. + self.ambiguous_width = self.__init_ambiguous_width() + self._ambiguous_width_probed: bool = False + + #: Normalized terminal software name for python wcwidth correction tables. + #: ``True`` when unknown, so wcwidth reads ``TERM_PROGRAM`` and ``TERM`` itself. + self.term_program = self.__init_term_program() or True def _software_version_from_env(self) -> Optional[SoftwareVersion]: - """Return terminal software from ``TERM_PROGRAM`` environment values.""" + """ + Return terminal software from ``TERM_PROGRAM`` environment values. + + An empty ``TERM_PROGRAM`` is treated as unset: ``TERM_PROGRAM_VERSION`` alone + does not stand in for a terminal name, so a query is still made. + """ term_program = os.environ.get('TERM_PROGRAM', '') + if not term_program: + return None term_version = os.environ.get('TERM_PROGRAM_VERSION', '') raw = ' '.join(filter(None, (term_program, term_version))) - if raw: - return SoftwareVersion(raw=raw, name=term_program, version=term_version) - return None + return SoftwareVersion(raw=raw, name=term_program, version=term_version) - def _detect_term_program(self) -> Union[bool, str]: + def __init_term_program(self) -> str: """ - Return the terminal software name for wcwidth correction tables. + Return a normalized terminal software name for use with wcwidth correction tables. - A ``TERM_PROGRAM`` value takes precedence, XTVERSION is queried otherwise, when - the terminal can answer on the keyboard fd. Returns ``False`` when unknown. + A non-empty ``TERM_PROGRAM`` takes precedence, and a ``TERM`` that names its terminal + is left to wcwidth's own environment lookup; both skip the inquiry. XTVERSION is + queried otherwise, when the terminal can answer on the keyboard fd. Returns an empty + string when unknown. """ version = self._software_version_from_env() - if version is None and self.is_a_tty and self.does_styling \ - and self._keyboard_fd is not None: - version = self.get_software_version() - return version.name if version is not None and version.name else False - - def _detect_ambiguous_width(self) -> int: - """ - Return the East Asian ambiguous character width, 1 or 2. + if version is not None: + return version.name + term = os.environ.get('TERM', '').strip().lower() - The environment variable ``AMBIGUOUS_WIDE`` overrides detection, which is - otherwise measured only when the terminal can answer on the keyboard fd. - """ - override = os.environ.get('AMBIGUOUS_WIDE') - if override: - try: - value = int(override) - except ValueError: - value = 0 - if value in {1, 2}: - return value - self.errors.append(f'AMBIGUOUS_WIDE={override!r}: expected 1 or 2') + # TERM=xterm identification is non-authoritative for terminal identification. subsequent + # XTVERSION query can identify 'xterm' or its imposters, 'mlterm' and 'putty'. + if term != 'xterm' and term in list_term_programs(): + return '' if self.is_a_tty and self.does_styling and self._keyboard_fd is not None: - return self.detect_ambiguous_width() + version = self.get_software_version() + if version is not None and version.name: + return version.name + return '' + + def __init_ambiguous_width(self) -> int: + """Return ``AMBIGUOUS_WIDE`` when it is 1 or 2, otherwise 1 (default).""" + # Although it is possible to detect "ambiguous width as wide" on class initialization, + # because it is uncommon and destructive (erases the next two columns), only environment + # value AMBIGUOUS_WIDE is used. + override = os.environ.get('AMBIGUOUS_WIDE') + if not override: + return 1 + try: + value = int(override) + except ValueError: + value = 0 + if value in {1, 2}: + return value + self.errors.append(f'AMBIGUOUS_WIDE={override!r}: expected 1 or 2') return 1 def __init__keyboard_state(self) -> None: @@ -708,7 +726,7 @@ def __init__keycodes(self) -> None: self._keymap_prefixes.update([ '\x1b[M', # Legacy mouse (needs 3 more bytes) '\x1b[<', # SGR mouse (variable length) - '\x1bP', # DCS, an XTGETTCAP reply arriving after its query timed out + '\x1bP', # DCS: an XTGETTCAP or XTVERSION reply arriving after its query timed out '\x1b[200', # Bracketed paste start and its starting prefixes, '\x1b[20', '\x1b[2']) @@ -1421,35 +1439,54 @@ def does_sixel( def detect_ambiguous_width( self, timeout: float = TERMINAL_QUERY_TIMEOUT_SECONDS, - fallback: int = 1) -> int: + fallback: int = 1, + force: bool = False) -> int: r""" - Detect whether terminal renders ambiguous width characters as width 1 or 2. - - East Asian ambiguous width characters can be rendered as either single (1) or double width - (2), depending on terminal settings. This method measures the actual rendered width by - printing a test character and querying the cursor position. The character is drawn and - erased at the current cursor position. The test character used is U+00A7 (SECTION SIGN), an - early Unicode character with East Asian Width property "Ambiguous". - - When :attr:`is_a_tty` is False, no sequences are transmitted or response awaited, and - ``fallback`` is returned without inquiry. + Detect whether the terminal renders ambiguous width characters as width 1 or 2. :arg float timeout: Timeout in seconds for any single cursor position response. :arg int fallback: Value to return on timeout, invalid measurement, or not a tty. + :arg bool force: Measure again, bypassing the cache and the ``AMBIGUOUS_WIDE`` override. :rtype: int :returns: 1 for "Ambiguous width as narrow", 2 is "Ambiguous width as wide" + East Asian ambiguous width characters render as either single (1) or double width (2) + depending on terminal settings. This measures the rendered width of U+00A7 (SECTION SIGN), + an early Unicode character with East Asian Width property "Ambiguous", by drawing and + erasing it at the current cursor position. Ambiguous width is used in "legacy" contexts, + like with BIG5, gbk, or shift-jis encoding, and most especially in mathematics or artwork, + like on terminal bulletin board systems, https://bbs.modem.xyz/encodings.html#big5, and + sometimes simply as a user preference, for neat and tidy vertical alignment. `configuration + `_ is + usually required to enable "ambiguous width as wide". + + The result is cached, assigned to :attr:`ambiguous_width`, and returned, and used + automatically by subsequent alignment methods, :meth:`length`, :meth:`wrap`, :meth:`ljust`, + :meth:`rjust`, and :meth:`center`. + Example usage:: >>> term = Terminal() - >>> width = term.detect_ambiguous_width() - >>> if width == 2: - ... # Terminal uses double-width for ambiguous characters - ... pass + >>> term.length('\u00a7') + 1 + >>> term.detect_ambiguous_width() + 2 + >>> term.length('\u00a7') + 2 + + When :attr:`is_a_tty` is False, no sequences are transmitted or response awaited, and + ``fallback`` is returned without inquiry. """ + if not force and self._ambiguous_width_probed: + return self.ambiguous_width if not self.is_a_tty: return fallback + self.ambiguous_width = self._probe_ambiguous_width(timeout, fallback) + self._ambiguous_width_probed = True + return self.ambiguous_width + def _probe_ambiguous_width(self, timeout: float, fallback: int) -> int: + """Measure the ambiguous width of U+00A7, see :meth:`detect_ambiguous_width`.""" stime = time.time() # Save cursor position @@ -1463,24 +1500,23 @@ def detect_ambiguous_width( self.stream.flush() return fallback - # Print ambiguous width character (U+00A7 SECTION SIGN) + # Print ambiguous width character (SECTION SIGN) self.stream.write('\u00a7') self.stream.flush() # Get new column position _, new_col = self.get_location(timeout=_time_left(stime, timeout)) - # Clean up: restore cursor and overwrite the test character + # Calculate rendered width + width = new_col - initial_col + + # Clean up: restore cursor and overwrite the test character with exactly the + # columns it occupied, two when the measurement is unknown self.stream.write(self.restore) - self.stream.write(' ') # Two spaces to cover potential width-2 character + self.stream.write(' ' * (1 if width == 1 else 2)) self.stream.write(self.restore) self.stream.flush() - if new_col == -1: - return fallback - - # Calculate rendered width - width = new_col - initial_col return width if width in {1, 2} else fallback def _is_apple_terminal(self, timeout: Optional[float]) -> bool: @@ -4401,6 +4437,26 @@ def _is_incomplete_keystroke(self, text: str) -> bool: any(text.startswith(p) for p in self._keymap_prefixes) or any(p.startswith(text) for p in self._keymap_prefixes)) + def _apply_errant_response(self, ks: Keystroke) -> bool: + """ + Apply a query reply that arrived after its query had already timed out. + + :arg Keystroke ks: resolved keystroke, possibly such a reply. + :rtype: bool + :returns: True when *ks* was a delayed reply; it is never returned as a keystroke, + and is recorded in :attr:`errors`. A delayed XTGETTCAP response is also + available to later :meth:`get_xtgettcap` calls; a delayed XTVERSION response is + discarded, as :attr:`term_program` is fixed at class initialization. + """ + if ks.name not in {'XTGETTCAP_RESPONSE', 'XTVERSION_RESPONSE'}: + return False + self.errors.append(f'errant/delayed {ks.name} {str(ks)!r}') + if ks.name == 'XTGETTCAP_RESPONSE': + if self.does_styling and (late_caps := ks.xtgettcap): + self._update_xtgettcap_cache( + TermcapResponse(supported=True, capabilities=late_caps)) + return True + def inkey(self, timeout: Optional[float] = None, esc_delay: float = DEFAULT_ESCDELAY, capture_cpr: bool = False) -> Keystroke: @@ -4501,16 +4557,12 @@ def inkey(self, timeout: Optional[float] = None, # buffer any remaining text received self.ungetch(ucs[len(ks):]) - # An XTGETTCAP reply may arrive long after its query has timed out: fold it into the - # response cache and continue awaiting user input. It is never returned as a keystroke. + # Replies to queries that timed out may arrive much later: fold them into the relevant + # cache and continue awaiting user input. They are never returned as a keystroke. # Conservatively, although it may be possible to re-initialize self._jinxed_term and - # self._number_of_colors by any given response, it is not done to ensure consistency of - # API behavior after class initialization. - if ks.name == 'XTGETTCAP_RESPONSE': - self.errors.append(f'errant/delayed XTGETTCAP_RESPONSE {str(ks)!r}') - if self.does_styling and (late_caps := ks.xtgettcap): - self._update_xtgettcap_cache( - TermcapResponse(supported=True, capabilities=late_caps)) + # self._number_of_colors by any given XTGETTCAP response, it is not done to ensure + # consistency of API behavior after class initialization. + if self._apply_errant_response(ks): return self.inkey(timeout=_time_left(stime, timeout), esc_delay=esc_delay, capture_cpr=capture_cpr) @@ -4635,12 +4687,8 @@ async def async_inkey( # buffer any remaining text self.ungetch(ucs[len(ks):]) - # squelch late-arriving XTGETTCAP replies. - if ks.name == 'XTGETTCAP_RESPONSE': - self.errors.append(f'errant/delayed XTGETTCAP_RESPONSE {str(ks)!r}') - if self.does_styling and (late_caps := ks.xtgettcap): - self._update_xtgettcap_cache( - TermcapResponse(supported=True, capabilities=late_caps)) + # squelch late-arriving query replies. + if self._apply_errant_response(ks): return await self.async_inkey(timeout=_time_left(stime, timeout), esc_delay=esc_delay, capture_cpr=capture_cpr) diff --git a/docs/history.rst b/docs/history.rst index 7a7fe422..8a3d979e 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -8,12 +8,16 @@ Version History more precise version of :meth:`~Terminal.truncate`, retaining terminal sequences, :ghpull:`420`. * improved: :meth:`~Terminal.length`, :meth:`~Terminal.ljust`, :meth:`~Terminal.rjust`, :meth:`~Terminal.center`, :meth:`~Terminal.truncate`, and :meth:`~Terminal.wrap` with - terminal-specific Corrections_ by detecting the terminal software name and East Asian ambiguous - modes at class-initialization, :ghissue:`420`. + terminal-specific Corrections_ by detecting the terminal software name at + class-initialization, :ghissue:`420`. + * change: East Asian ambiguous width is not measured at class-initialization. Call + :meth:`~Terminal.detect_ambiguous_width` to measure it, which assigns and caches + :attr:`~Terminal.ambiguous_width`, or set ``AMBIGUOUS_WIDE`` to ``1`` or ``2`` to supply the + value without measurement, :ghpull:`420`. * change: :meth:`~Terminal.get_software_version` returns the ``TERM_PROGRAM`` and ``TERM_PROGRAM_VERSION`` environment values when defined, without inquiry, and these values also - override the Corrections_ detection and the automatic queries otherwise made by - :meth:`~Terminal.detect_ambiguous_width`, :ghpull:`420`. + override the Corrections_ detection and the XTVERSION query otherwise made for + :attr:`~Terminal.term_program`, :ghpull:`420`. * deprecated: Python 3.8 and earlier no longer supported. :ghpull:`420`. 1.50 diff --git a/docs/measuring.rst b/docs/measuring.rst index ffea3b35..998ce250 100644 --- a/docs/measuring.rst +++ b/docs/measuring.rst @@ -135,23 +135,28 @@ The rendered width of some characters depends on the terminal software, as disco `ucs-detect `_ project. At class-initialization, blessed identifies the terminal software by the ``TERM_PROGRAM`` environment variable, set by most modern terminal emulators, and otherwise by the `XTVERSION -`_ query (``CSI > q``). It also measures whether East Asian -ambiguous characters are rendered wide or narrow, then applies the matching wcwidth_ `correction -tables `_ to the methods above. +`_ query (``CSI > q``), then applies the matching wcwidth_ +`correction tables `_ to the +methods above. -The result is available as :attr:`~.Terminal.term_program` and :attr:`~.Terminal.ambiguous_width`: +Whether East Asian ambiguous characters are rendered wide or narrow is measured on demand, by +:meth:`~.Terminal.detect_ambiguous_width`, which draws and erases a test character and assigns the +result to :attr:`~.Terminal.ambiguous_width`. Until it is called, the value is 1, or +``AMBIGUOUS_WIDE`` when that environment variable is ``1`` or ``2``, so call it after constructing +the terminal and before any measurement: .. code-block:: python from blessed import Terminal term = Terminal() + term.detect_ambiguous_width() print(term.term_program, term.ambiguous_width) -When no terminal answers, such as when output is redirected, the terminal software is ``False`` and -ambiguous width is assumed narrow. Set the environment variable ``AMBIGUOUS_WIDE`` to ``1`` or -``2`` to skip measurement, such as for automated tests. Blank ``TERM_PROGRAM`` or -``AMBIGUOUS_WIDE`` to unset them and make the corresponding query. +The call measures even when ``AMBIGUOUS_WIDE`` is set, which only supplies the value until then, +such as for automated tests. When no terminal answers, such as when output is redirected, the +terminal software is ``True`` and ambiguous width remains narrow. Blank ``TERM_PROGRAM`` to unset +it and make the XTVERSION query. Text Sizing ----------- diff --git a/tests/accessories.py b/tests/accessories.py index f1890816..c79ad83d 100644 --- a/tests/accessories.py +++ b/tests/accessories.py @@ -53,7 +53,7 @@ def assert_timeout_elapsed(elapsed, timeout): # Values overriding init-time terminal detection. A terminal is attached in most # tests, and queries made at initialization go unanswered by most of them. -DETECTION_OVERRIDES = {'TERM_PROGRAM': TEST_KIND, 'AMBIGUOUS_WIDE': '1'} +DETECTION_OVERRIDES = {'TERM_PROGRAM': TEST_KIND} @contextlib.contextmanager diff --git a/tests/test_core.py b/tests/test_core.py index bc244f04..8f94a9da 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1045,6 +1045,22 @@ def test_detect_ambiguous_width_unit(cpr1, cpr2, expected): assert result == expected +@pytest.mark.parametrize('cpr2,erase', [ + ('\x1b[1;11R', 1), # narrow + ('\x1b[1;12R', 2), # wide + ('\x1b[1;10R', 2), # unmeasured, cover the probe +]) +def test_detect_ambiguous_width_erases_measured_columns(cpr2, erase): + """The probe is erased by backspacing over the columns it occupied.""" + stream = StringIO() + term = TestTerminal(stream=stream, force_styling=True) + term._is_a_tty = True + term.ungetch('\x1b[1;10R') + term.ungetch(cpr2) + term.detect_ambiguous_width(timeout=0.1, fallback=1) + assert stream.getvalue().endswith(term.restore + ' ' * erase + term.restore) + + def test_detect_ambiguous_width_first_timeout(): """detect_ambiguous_width returns fallback when first get_location times out.""" stream = StringIO() @@ -1066,6 +1082,36 @@ def test_detect_ambiguous_width_second_timeout(): assert result == 77 +def test_detect_ambiguous_width_applies_and_caches(): + """The measured width is assigned, then cached until force=True measures again.""" + stream = StringIO() + term = TestTerminal(stream=stream, force_styling=True) + term._is_a_tty = True + term.ungetch('\x1b[1;10R') + term.ungetch('\x1b[1;12R') + assert term.detect_ambiguous_width(timeout=0.1) == 2 + assert term.ambiguous_width == 2 + written = stream.getvalue() + assert term.detect_ambiguous_width(timeout=0.1) == 2 + assert stream.getvalue() == written + term.ungetch('\x1b[1;10R') + term.ungetch('\x1b[1;11R') + assert term.detect_ambiguous_width(timeout=0.1, force=True) == 1 + assert term.ambiguous_width == 1 + + +def test_detect_ambiguous_width_measures_despite_env_override(monkeypatch): + """AMBIGUOUS_WIDE seeds the value, an explicit call measures anyway.""" + monkeypatch.setenv('AMBIGUOUS_WIDE', '2') + term = TestTerminal(stream=StringIO(), force_styling=True) + term._is_a_tty = True + assert term.ambiguous_width == 2 + term.ungetch('\x1b[1;10R') + term.ungetch('\x1b[1;11R') + assert term.detect_ambiguous_width(timeout=0.1) == 1 + assert term.ambiguous_width == 1 + + @pytest.mark.parametrize('value,expected', [('1', 1), ('2', 2)]) def test_ambiguous_width_env_override(monkeypatch, value, expected): """AMBIGUOUS_WIDE=1|2 overrides init-time detection.""" @@ -1083,14 +1129,15 @@ def test_ambiguous_width_env_invalid(monkeypatch): def test_no_query_without_tty(monkeypatch): - """Init-time detection writes nothing and keeps defaults without a terminal.""" + """Init-time detection writes nothing and leaves measurement to wcwidth without a terminal.""" # blank values are unset: nothing overrides detection monkeypatch.setenv('TERM_PROGRAM', '') monkeypatch.setenv('AMBIGUOUS_WIDE', '') stream = StringIO() term = TestTerminal(stream=stream, force_styling=True) assert term.ambiguous_width == 1 - assert term.term_program is False + # Unknown, so wcwidth reads TERM_PROGRAM and TERM itself. + assert term.term_program is True assert stream.getvalue() == '' diff --git a/tests/test_software_version.py b/tests/test_software_version.py index 0e7c8d41..77730928 100644 --- a/tests/test_software_version.py +++ b/tests/test_software_version.py @@ -17,7 +17,9 @@ - X.Org 7.7.0(370) """ # std +import io import time +from unittest import mock # 3rd party import pytest @@ -28,7 +30,11 @@ TestTerminal, pty_test, ) -from blessed.keyboard import SoftwareVersion +from blessed.keyboard import (SoftwareVersion, + resolve_sequence, + get_keyboard_codes, + get_keyboard_sequences) +from blessed.terminal import Terminal pytestmark = pytest.mark.skipif( not TEST_KEYBOARD or IS_WINDOWS, @@ -170,9 +176,6 @@ def child(term): def test_get_software_version_retry_after_timeout(): """Test get_software_version() can retry after timeout.""" def child(term): - import os - os.environ.pop('TERM_PROGRAM', None) - os.environ.pop('TERM_PROGRAM_VERSION', None) # First query fails (timeout) sv1 = term.get_software_version(timeout=0.01) @@ -204,10 +207,6 @@ def test_get_software_version_raw_stored(): def test_get_software_version_not_a_tty(): """Test get_software_version() returns None when not a TTY and no env vars.""" def child(): - import io - import os - os.environ.pop('TERM_PROGRAM', None) - os.environ.pop('TERM_PROGRAM_VERSION', None) term = TestTerminal(stream=io.StringIO(), force_styling=True) term._is_a_tty = False @@ -243,7 +242,6 @@ def test_get_software_version_env_fallback_no_version(): def child(term): import os os.environ['TERM_PROGRAM'] = 'Apple_Terminal' - os.environ.pop('TERM_PROGRAM_VERSION', None) try: sv = term.get_software_version(timeout=0.01) assert sv is not None @@ -260,32 +258,25 @@ def child(term): def test_get_software_version_env_fallback_version_only(): - """Test env fallback works when only TERM_PROGRAM_VERSION is set.""" + """An empty TERM_PROGRAM is treated as unset, so a query is still made.""" def child(term): import os - os.environ.pop('TERM_PROGRAM', None) os.environ['TERM_PROGRAM_VERSION'] = '1.2.3' try: sv = term.get_software_version(timeout=0.01) - assert sv is not None - assert sv.name == '' - assert sv.version == '1.2.3' - assert sv.raw == '1.2.3' + assert sv is None finally: del os.environ['TERM_PROGRAM_VERSION'] return b'ENV_VER_ONLY' output = pty_test(child, parent_func=None, test_name='test_get_software_version_env_fallback_version_only') - assert output == 'ENV_VER_ONLY' + assert output == '\x1b[>q\x1b[6nENV_VER_ONLY' def test_get_software_version_no_env_no_response(): """Test get_software_version() returns None with no XTVERSION and no env vars.""" def child(term): - import os - os.environ.pop('TERM_PROGRAM', None) - os.environ.pop('TERM_PROGRAM_VERSION', None) sv = term.get_software_version(timeout=0.01) assert sv is None return b'NO_ENV' @@ -301,3 +292,104 @@ def test_software_version_init(): assert sv.raw == '\x1bP>|kitty(0.24.2)\x1b\\' assert sv.name == 'kitty' assert sv.version == '0.24.2' + + +def test_xtversion_response_resolves_as_a_named_response(): + """A complete XTVERSION reply is a named response, not keys.""" + term = TestTerminal(stream=io.StringIO(), force_styling=True) + ks = resolve_sequence('\x1bP>|kitty(0.24.2)\x1b\\', + get_keyboard_sequences(term), get_keyboard_codes(), + term._keymap_prefixes) + assert ks.name == 'XTVERSION_RESPONSE' + assert str(ks) == '\x1bP>|kitty(0.24.2)\x1b\\' + + +def test_incomplete_xtversion_is_not_alt_shift_p(): + """A split reply holds for esc_delay rather than resolving as Alt+Shift+P.""" + term = TestTerminal(stream=io.StringIO(), force_styling=True) + keymap, codes = get_keyboard_sequences(term), get_keyboard_codes() + assert resolve_sequence('\x1bP', keymap, codes, term._keymap_prefixes, + final=False).name == 'KEY_ESCAPE' + assert resolve_sequence('\x1bP', keymap, codes, term._keymap_prefixes, + final=True).name == 'KEY_ALT_SHIFT_P' + + +def test_late_xtversion_response_is_not_a_keystroke(): + """A reply arriving after its query timed out is recorded, not applied.""" + def child(term): + term._software_version_cache = None # pylint: disable=protected-access + term.term_program = True # unknown, as after a timed-out query + term.ungetch('\x1bP>|kitty(0.24.2)\x1b\\') + term.ungetch('Z') + + ks = term.inkey(timeout=0.1) + + assert str(ks) == 'Z', repr(ks) + assert term.term_program is True + assert term._software_version_cache is None + assert any('XTVERSION_RESPONSE' in err for err in term.errors), term.errors + return b'LATE_XTV' + + output = pty_test(child, parent_func=None, + test_name='test_late_xtversion_response_is_not_a_keystroke') + assert output == 'LATE_XTV' + + +def test_empty_term_program_still_queries(monkeypatch): + """An empty TERM_PROGRAM is unset, so XTVERSION is queried for a name.""" + monkeypatch.setenv('TERM_PROGRAM', '') + monkeypatch.delenv('TERM', raising=False) + term = TestTerminal(force_styling=True) + term._is_a_tty = True # pylint: disable=protected-access + term._keyboard_fd = 0 # pylint: disable=protected-access + response = SoftwareVersion('\x1bP>|kitty(0.24.2)\x1b\\', 'kitty', '0.24.2') + + with mock.patch.object(Terminal, 'get_software_version', return_value=response) as query: + assert term._Terminal__init_term_program() == 'kitty' + + assert query.called + + +@pytest.mark.parametrize('term', ['xterm-kitty', 'foot']) +def test_resolvable_term_skips_the_query(monkeypatch, term): + """A TERM that names its terminal is left to wcwidth's own environment lookup.""" + monkeypatch.setenv('TERM_PROGRAM', '') + monkeypatch.setenv('TERM', term) + terminal = TestTerminal(force_styling=True) + terminal._is_a_tty = True # pylint: disable=protected-access + terminal._keyboard_fd = 0 # pylint: disable=protected-access + + with mock.patch.object(Terminal, 'get_software_version') as query: + assert terminal._Terminal__init_term_program() == '' + + assert not query.called + + +def test_claimed_term_still_queries(monkeypatch): + """TERM=xterm resolves in wcwidth, but terminals claim it without being it.""" + monkeypatch.setenv('TERM_PROGRAM', '') + monkeypatch.setenv('TERM', 'xterm') + terminal = TestTerminal(force_styling=True) + terminal._is_a_tty = True # pylint: disable=protected-access + terminal._keyboard_fd = 0 # pylint: disable=protected-access + response = SoftwareVersion('\x1bP>|mlterm(3.9.4)\x1b\\', 'mlterm', '3.9.4') + + with mock.patch.object(Terminal, 'get_software_version', return_value=response) as query: + assert terminal._Terminal__init_term_program() == 'mlterm' + + assert query.called + + +def test_generic_term_still_queries(monkeypatch): + """A TERM that wcwidth does not resolve is named by XTVERSION instead.""" + monkeypatch.setenv('TERM_PROGRAM', '') + monkeypatch.setenv('TERM', 'xterm-256color') + terminal = TestTerminal(force_styling=True) + terminal._is_a_tty = True # pylint: disable=protected-access + terminal._keyboard_fd = 0 # pylint: disable=protected-access + response = SoftwareVersion('\x1bP>|foot(1.20.1)\x1b\\', 'foot', '1.20.1') + + with mock.patch.object(Terminal, 'get_software_version', return_value=response) as query: + assert terminal._Terminal__init_term_program() == 'foot' + + assert query.called diff --git a/tests/test_xtgettcap.py b/tests/test_xtgettcap.py index 9eb88618..6183b008 100644 --- a/tests/test_xtgettcap.py +++ b/tests/test_xtgettcap.py @@ -1066,7 +1066,7 @@ def child(term): @pytestmark_pty def test_terminal_init_xtgettcap_success(monkeypatch): - """Terminal() init queries XTGETTCAP, XTVERSION, and ambiguous width successfully.""" + """Terminal() init queries XTGETTCAP and XTVERSION successfully.""" # blank values are unset: the queries made at initialization are answered below monkeypatch.setenv('TERM_PROGRAM', '') monkeypatch.setenv('AMBIGUOUS_WIDE', '') @@ -1085,9 +1085,6 @@ def parent(master_fd): os.write(master_fd, b'\x1bP1+r524742=38\x1b\\') os.write(master_fd, b'\x1bP1+r544e=787465726d\x1b\\') os.write(master_fd, b'\x1b[10;20R') - # the two cursor reports that measure U+00A7, 20 and 22 columns -- width 2 - os.write(master_fd, b'\x1b[10;20R') - os.write(master_fd, b'\x1b[10;22R') # XTVERSION reply, followed by its own CPR boundary os.write(master_fd, b'\x1bP>|XTerm(390)\x1b\\\x1b[10;20R') @@ -1097,8 +1094,9 @@ def child(term): assert term._xtgettcap_cache['TN'] == 'xterm' assert term._xtgettcap_cache['colors'] == '256' assert term._xtgettcap_cache['RGB'] == '8' - assert term.ambiguous_width == 2 assert term.term_program == 'XTerm' + # ambiguous width is measured on demand, not at initialization + assert term.ambiguous_width == 1 with term.cbreak(): leaked = term.inkey(timeout=0) assert not leaked From 48078ed2b57960557fc27c6d1f32f9d203c1c3f7 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Sun, 4 Oct 2026 20:28:13 -0400 Subject: [PATCH 09/12] small refactor of LineEditor rename and classify some internal things, support ambiguous width and term_program --- blessed/line_editor.py | 240 ++++++++++++++++++-------------------- tests/test_line_editor.py | 18 ++- 2 files changed, 122 insertions(+), 136 deletions(-) diff --git a/blessed/line_editor.py b/blessed/line_editor.py index 6a970900..9876d279 100644 --- a/blessed/line_editor.py +++ b/blessed/line_editor.py @@ -196,7 +196,7 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument self._prev_overflow: Tuple[bool, bool] = (False, False) self._prev_scroll_offset: int = 0 - def _width(self, text: str) -> int: + def _wcswidth(self, text: str) -> int: """Return the display width of *text* under this editor's corrections.""" return wcswidth(text, ambiguous_width=self.ambiguous_width, term_program=self.term_program) @@ -229,18 +229,18 @@ def display(self) -> DisplayState: """Return the current :class:`DisplayState` for rendering.""" if self.password_mode: text = self.password_char * len(self._buf) - cursor_col = self._cursor * self._width(self.password_char) + cursor_col = self._cursor * self._wcswidth(self.password_char) suggestion = "" else: text = self.line cursor_col = self._cursor_display_col() suggestion = self._get_suggestion() if self._needs_hscroll(): - content_w = self._width(text) + self._width(suggestion) + content_w = self._wcswidth(text) + self._wcswidth(suggestion) offset = self._compute_scroll(cursor_col, content_w) - return self._apply_sgr(_apply_hscroll( + return self._apply_sgr(self._apply_hscroll( text, suggestion, cursor_col, self.max_width, - self.ellipsis, scroll_offset=offset, measure=self._width)) + self.ellipsis, scroll_offset=offset)) return self._apply_sgr( DisplayState(text=text, cursor=cursor_col, suggestion=suggestion)) @@ -264,7 +264,7 @@ def render(self, term: Terminal, row: int, width: int, """ self._col_offset = col cur = self.display - ellipsis_w = self._width(self.ellipsis) + ellipsis_w = self._wcswidth(self.ellipsis) parts: List[str] = [term.move_yx(row, col), cur.bg_sgr] rendered = 0 @@ -274,11 +274,11 @@ def render(self, term: Terminal, row: int, width: int, if cur.text: parts.extend((cur.bg_sgr, cur.text_sgr, cur.text)) - rendered += self._width(cur.text) + rendered += self._wcswidth(cur.text) if cur.suggestion: parts.extend((cur.bg_sgr, cur.suggestion_sgr, cur.suggestion)) - rendered += self._width(cur.suggestion) + rendered += self._wcswidth(cur.suggestion) if cur.overflow_right: parts.extend((cur.bg_sgr, cur.ellipsis_sgr, self.ellipsis)) @@ -314,7 +314,7 @@ def render_insert( col = self._prev_cursor parts: List[str] = [term.move_yx(row, off + col), cur.bg_sgr, cur.text_sgr, grapheme] - new_content_w = self._width(cur.text) + self._width(cur.suggestion) + new_content_w = self._wcswidth(cur.text) + self._wcswidth(cur.suggestion) if cur.suggestion: parts.extend((cur.bg_sgr, cur.suggestion_sgr, cur.suggestion)) trail = self._prev_content_w - new_content_w @@ -341,7 +341,7 @@ def render_backspace(self, term: Terminal, row: int) -> Optional[str]: return None off = self._col_offset col = cur.cursor - new_content_w = self._width(cur.text) + self._width(cur.suggestion) + new_content_w = self._wcswidth(cur.text) + self._wcswidth(cur.suggestion) erase = self._prev_content_w - new_content_w parts: List[str] = [term.move_yx(row, off + col)] if cur.suggestion: @@ -403,7 +403,7 @@ def _compute_scroll(self, cursor_col: int, content_width: int) -> int: self._scroll_offset = 0 return 0 jump = max(1, int(usable * self.scroll_jump)) - ellipsis_w = self._width(self.ellipsis) + ellipsis_w = self._wcswidth(self.ellipsis) left_margin = ellipsis_w if self._scroll_offset > 0 else 0 right_edge = self._scroll_offset + usable - left_margin if cursor_col >= right_edge: @@ -426,7 +426,7 @@ def _maybe_reset_limit_bell(self) -> None: self._limit_bell_fired = False def _cursor_display_col(self) -> int: - return sum(self._width(g) for g in self._buf[:self._cursor]) + return sum(self._wcswidth(g) for g in self._buf[:self._cursor]) def _save_undo(self) -> None: self._undo_stack.append((list(self._buf), self._cursor)) @@ -625,60 +625,111 @@ def _get_suggestion(self) -> str: return match[len(line):] return "" + def _clip_graphemes( + self, combined: str, scroll_offset: int, usable: int, + ) -> Tuple[List[str], int]: + """ + Collect visible graphemes from *combined* within a scroll window. -def _clip_graphemes( - combined: str, scroll_offset: int, usable: int, *, - measure: Callable[[str], int] = wcswidth, -) -> Tuple[List[str], int]: - """ - Collect visible graphemes from *combined* within a scroll window. - - :param combined: Full text (buffer + suggestion). - :param scroll_offset: Column offset of the left edge. - :param usable: Available display columns. - :param measure: Callable returning the display width of a grapheme. - :returns: ``(visible_parts, visible_width)`` tuple. - """ - vis_parts: List[str] = [] - vis_width = 0 - col = 0 - for grapheme in iter_graphemes(combined): - g_w = measure(grapheme) - if col + g_w <= scroll_offset: + :param combined: Full text (buffer + suggestion). + :param scroll_offset: Column offset of the left edge. + :param usable: Available display columns. + :returns: ``(visible_parts, visible_width)`` tuple. + """ + vis_parts: List[str] = [] + vis_width = 0 + col = 0 + for grapheme in iter_graphemes(combined): + g_w = self._wcswidth(grapheme) + if col + g_w <= scroll_offset: + col += g_w + continue + if vis_width + g_w > usable: + break + vis_parts.append(grapheme) + vis_width += g_w col += g_w - continue - if vis_width + g_w > usable: - break - vis_parts.append(grapheme) - vis_width += g_w - col += g_w - return vis_parts, vis_width - - -def _split_text_suggestion( - vis_parts: List[str], scroll_offset: int, text_w: int, *, - measure: Callable[[str], int] = wcswidth, -) -> Tuple[str, str]: - """ - Split visible graphemes into text and suggestion portions. + return vis_parts, vis_width - :param vis_parts: Graphemes within the visible window. - :param scroll_offset: Column offset of the left edge. - :param text_w: Display width of the buffer text (before suggestion). - :param measure: Callable returning the display width of a grapheme. - :returns: ``(visible_text, visible_suggestion)`` tuple. - """ - vis_text_parts: List[str] = [] - vis_suggest_parts: List[str] = [] - pos = 0 - for grapheme in vis_parts: - g_w = measure(grapheme) - if scroll_offset + pos < text_w: - vis_text_parts.append(grapheme) - else: - vis_suggest_parts.append(grapheme) - pos += g_w - return "".join(vis_text_parts), "".join(vis_suggest_parts) + def _split_text_suggestion( + self, vis_parts: List[str], scroll_offset: int, text_w: int, + ) -> Tuple[str, str]: + """ + Split visible graphemes into text and suggestion portions. + + :param vis_parts: Graphemes within the visible window. + :param scroll_offset: Column offset of the left edge. + :param text_w: Display width of the buffer text (before suggestion). + :returns: ``(visible_text, visible_suggestion)`` tuple. + """ + vis_text_parts: List[str] = [] + vis_suggest_parts: List[str] = [] + pos = 0 + for grapheme in vis_parts: + g_w = self._wcswidth(grapheme) + if scroll_offset + pos < text_w: + vis_text_parts.append(grapheme) + else: + vis_suggest_parts.append(grapheme) + pos += g_w + return "".join(vis_text_parts), "".join(vis_suggest_parts) + + def _trim_right_overflow( + self, vis_parts: List[str], vis_width: int, ellipsis_w: int, usable: int, + ) -> int: + """ + Remove trailing graphemes to make room for a right-side ellipsis. + + :param vis_parts: Visible grapheme list (mutated in place). + :param vis_width: Current total display width of *vis_parts*. + :param ellipsis_w: Display width of the ellipsis character. + :param usable: Available display columns. + :returns: Updated visible width after trimming. + """ + while vis_parts and vis_width + ellipsis_w > usable: + vis_width -= self._wcswidth(vis_parts.pop()) + return vis_width + + def _apply_hscroll( # pylint: disable=too-many-positional-arguments,too-many-locals + self, + text: str, + suggestion: str, + cursor_col: int, + max_width: int, + ellipsis: str = "\u2026", + scroll_offset: Optional[int] = None, + ) -> DisplayState: + """Build a :class:`DisplayState` with horizontal scrolling applied.""" + text_w = self._wcswidth(text) + + if text_w + self._wcswidth(suggestion) < max_width and cursor_col < max_width: + return DisplayState( + text=text, cursor=cursor_col, suggestion=suggestion) + + if scroll_offset is None: + scroll_offset = _default_scroll_offset(cursor_col, max_width) + + overflow_left = scroll_offset > 0 + ellipsis_w = self._wcswidth(ellipsis) + usable = max_width - (ellipsis_w if overflow_left else 0) + + vis_parts, vis_width = self._clip_graphemes( + text + suggestion, scroll_offset, usable) + + overflow_right = (scroll_offset + usable) < text_w + self._wcswidth(suggestion) + if overflow_right: + self._trim_right_overflow(vis_parts, vis_width, ellipsis_w, usable) + + vis_text, vis_suggestion = self._split_text_suggestion( + vis_parts, scroll_offset, text_w) + + return DisplayState( + text=vis_text, + cursor=cursor_col - scroll_offset + (ellipsis_w if overflow_left else 0), + suggestion=vis_suggestion, + overflow_left=overflow_left, + overflow_right=overflow_right, + ) def _default_scroll_offset(cursor_col: int, max_width: int) -> int: @@ -695,69 +746,6 @@ def _default_scroll_offset(cursor_col: int, max_width: int) -> int: return 0 -def _trim_right_overflow( - vis_parts: List[str], vis_width: int, ellipsis_w: int, usable: int, *, - measure: Callable[[str], int] = wcswidth, -) -> int: - """ - Remove trailing graphemes to make room for a right-side ellipsis. - - :param vis_parts: Visible grapheme list (mutated in place). - :param vis_width: Current total display width of *vis_parts*. - :param ellipsis_w: Display width of the ellipsis character. - :param usable: Available display columns. - :param measure: Callable returning the display width of a grapheme. - :returns: Updated visible width after trimming. - """ - while vis_parts and vis_width + ellipsis_w > usable: - vis_width -= measure(vis_parts.pop()) - return vis_width - - -def _apply_hscroll( # pylint: disable=too-many-positional-arguments,too-many-locals - text: str, - suggestion: str, - cursor_col: int, - max_width: int, - ellipsis: str = "\u2026", - scroll_offset: Optional[int] = None, - *, - measure: Callable[[str], int] = wcswidth, -) -> DisplayState: - """Build a :class:`DisplayState` with horizontal scrolling applied.""" - text_w = measure(text) - - if text_w + measure(suggestion) < max_width and cursor_col < max_width: - return DisplayState( - text=text, cursor=cursor_col, suggestion=suggestion) - - if scroll_offset is None: - scroll_offset = _default_scroll_offset(cursor_col, max_width) - - overflow_left = scroll_offset > 0 - ellipsis_w = measure(ellipsis) - usable = max_width - (ellipsis_w if overflow_left else 0) - - vis_parts, vis_width = _clip_graphemes( - text + suggestion, scroll_offset, usable, measure=measure) - - overflow_right = (scroll_offset + usable) < text_w + measure(suggestion) - if overflow_right: - _trim_right_overflow(vis_parts, vis_width, ellipsis_w, usable, - measure=measure) - - vis_text, vis_suggestion = _split_text_suggestion( - vis_parts, scroll_offset, text_w, measure=measure) - - return DisplayState( - text=vis_text, - cursor=cursor_col - scroll_offset + (ellipsis_w if overflow_left else 0), - suggestion=vis_suggestion, - overflow_left=overflow_left, - overflow_right=overflow_right, - ) - - # pylint: disable=protected-access DEFAULT_KEYMAP: Dict[str, Callable[..., LineEditResult]] = { "KEY_ENTER": LineEditor._handle_enter, diff --git a/tests/test_line_editor.py b/tests/test_line_editor.py index e10733bc..79b192c6 100644 --- a/tests/test_line_editor.py +++ b/tests/test_line_editor.py @@ -8,9 +8,7 @@ # local from blessed.line_editor import PASSWORD_CHAR, LineEditor from blessed.line_editor import LineHistory as History -from blessed.line_editor import (DisplayState, - LineEditResult, - _apply_hscroll) +from blessed.line_editor import DisplayState, LineEditResult class MockTerminal: @@ -708,14 +706,14 @@ class TestApplyHscroll: def test_hscroll_basic(self, text, suggestion, cursor, width, exp_cursor, exp_left, exp_right) -> None: """Test basic hscroll cursor and overflow results.""" - ds = _apply_hscroll(text, suggestion, cursor, width) + ds = LineEditor()._apply_hscroll(text, suggestion, cursor, width) assert ds.cursor == exp_cursor assert ds.overflow_left is exp_left assert ds.overflow_right is exp_right def test_text_with_suggestion_fits(self) -> None: """Test text with suggestion fits within width.""" - ds = _apply_hscroll("he", "llo world", 2, 20) + ds = LineEditor()._apply_hscroll("he", "llo world", 2, 20) assert ds.text == "he" assert ds.suggestion == "llo world" assert ds.overflow_left is False @@ -728,31 +726,31 @@ def test_text_with_suggestion_fits(self) -> None: def test_overflow_flags(self, text, cursor, width, exp_left, exp_right) -> None: """Test overflow flags for text exceeding width.""" - ds = _apply_hscroll(text, "", cursor, width) + ds = LineEditor()._apply_hscroll(text, "", cursor, width) assert ds.overflow_left is exp_left assert ds.overflow_right is exp_right def test_suggestion_clipped(self) -> None: """Test suggestion is clipped to fit within width.""" - ds = _apply_hscroll("ab", "cdefghijklmnop", 2, 8) + ds = LineEditor()._apply_hscroll("ab", "cdefghijklmnop", 2, 8) assert ds.overflow_right is True total = wcswidth(ds.text) + wcswidth(ds.suggestion) assert total <= 8 def test_custom_ellipsis(self) -> None: """Test hscroll with custom ellipsis string.""" - ds = _apply_hscroll("a" * 20, "", 20, 10, ellipsis="...") + ds = LineEditor()._apply_hscroll("a" * 20, "", 20, 10, ellipsis="...") assert ds.overflow_left is True def test_explicit_scroll_offset(self) -> None: """Test _apply_hscroll with explicit non-zero scroll offset.""" - ds = _apply_hscroll("a" * 30, "", 25, 10, scroll_offset=20) + ds = LineEditor()._apply_hscroll("a" * 30, "", 25, 10, scroll_offset=20) assert ds.overflow_left is True assert ds.cursor == 25 - 20 + wcswidth("\u2026") def test_scroll_offset_zero(self) -> None: """Test _apply_hscroll with zero scroll offset.""" - ds = _apply_hscroll("a" * 30, "", 3, 10, scroll_offset=0) + ds = LineEditor()._apply_hscroll("a" * 30, "", 3, 10, scroll_offset=0) assert ds.overflow_left is False assert ds.overflow_right is True assert ds.cursor == 3 From a3aca15ec7beee02346a40254bee2ab9486bb739 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Sun, 4 Oct 2026 20:29:57 -0400 Subject: [PATCH 10/12] improve demo programs a bit, match wcwidth 0.9.2 --- bin/display-modes.py | 3 +- bin/display-widths.py | 69 +++++++++++++++++++++++++++++++++++++++++ bin/line_editor_form.py | 3 ++ pyproject.toml | 2 +- 4 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 bin/display-widths.py diff --git a/bin/display-modes.py b/bin/display-modes.py index e5df5176..13d71e49 100755 --- a/bin/display-modes.py +++ b/bin/display-modes.py @@ -118,11 +118,10 @@ def display_sugar_methods(term): version = term.get_software_version() print(f" {_yn(term, version is not None)} XTVERSION (CSI > q)" + term.clear_eol) if version is not None: - # a TERM_PROGRAM value, when defined, is returned without inquiry source = 'TERM_PROGRAM' if os.environ.get('TERM_PROGRAM') else 'inquiry' print(f" Terminal name: {term.bright_cyan(version.name or '--')}") print(f" Version: {version.version or '--'}") - print(f" Source: {source}") + print(f" Id Source: {source}") print(' Testing Kitty graphics...' + term.clear_eol, end='\r', flush=True) print(f" {_yn(term, term.does_kitty_graphics())} " diff --git a/bin/display-widths.py b/bin/display-widths.py new file mode 100644 index 00000000..15787cab --- /dev/null +++ b/bin/display-widths.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python +"""Display graphemes whose rendered width differs by terminal as a labeled grid.""" +# pylint: disable=invalid-name +# Invalid module name "display-terminal-width" + +# local +from blessed import Terminal + +LABEL_WIDTH = 20 +CELL_WIDTH = 12 + +# Examples "problematic" graphemes, derived from wcwidth "correction tables" and +# the ucs-detect project data: graphemes that carry per-terminal width corrections +ROWS = ( + ('Ambiguous width', ('\u00a7', '\u00b1', '\u00d7', '\u00f7')), + ('VS-16', ('\u2764\ufe0f', '\u263a\ufe0f', '\u26a0\ufe0f', '\u2708\ufe0f')), + ('Regional Indicator', ('\U0001f1e6', '\U0001f1fa\U0001f1f8', + '\U0001f1ef\U0001f1f5', '\U0001f1e9\U0001f1ea')), + ('ZWJ', ('\U0001f9d1\u200d\U0001f33e', + '\U0001f468\u200d\U0001f469\u200d\U0001f467\u200d\U0001f466', + '\U0001f9d1\u200d\U0001f393', '\U0001f9d1\u200d\U0001f4bb')), + ('Devanagari', ('\u0924\u093e\u0903', '\u0930\u094d\u200d\u092f\u093e', '\u0915\u094d\u0937', + '\u0915\u094d\u0915')), + ('Bengali', ('\u0995\u09bf\u0982', '\u09b8\u09cd\u09af\u09c7', '\u09b8\u09cd\u09ac\u09c0', + '\u09b8\u09cd\u09a4\u09bf')), + ('Gujarati', ('\u0ab8\u0acd\u0ab8\u0abe', '\u0ab8\u0acd\u0ab5\u0ac0', + '\u0ab8\u0acd\u0ab5\u0abe', '\u0ab8\u0acd\u0ab0\u0ac0')), + ('Gurmukhi', ('\u0a5c\u0a40\u0a02', '\u0a39\u0a40\u0a02', + '\u0a39\u0a3f\u0a71', '\u0a39\u0a3f\u0a70')), + ('Tamil', ('\u0bb5\u0bbe', '\u0bb3\u0bbe', + '\u0bb2\u0bbe', '\u0bb1\u0bbe')), + ('Telugu', ('\u0c2f\u0c41\u0c02', '\u0c28\u0c41\u0c02', + '\u0c26\u0c41\u0c02', '\u0c1f\u0c41\u0c02')), + ('Kannada', ('\u0cb8\u0cbe\u0c82', '\u0cb6\u0cbe\u0c82', + '\u0cb5\u0cc1\u0c82', '\u0cb0\u0cbe\u0c82')), + ('Malayalam', ('\u0d39\u0d3f\u0d02', '\u0d38\u0d4d\u0d38\u0d4b', '\u0d38\u0d4d\u0d38\u0d3f', + '\u0d38\u0d4d\u0d35\u0d40')), + ('Sinhala', ('\u0dc4\u0dd2\u0d82', '\u0daf\u0dd2\u0d82', '\u0dc4\u0dcf', '\u0dc3\u0dcf')), + ('Khmer', ('\u1796\u17c4\u17c7', '\u1794\u17c4\u17c7', + '\u1793\u17c4\u17c7', '\u1793\u17c1\u17c7')), + ('Myanmar', ('\u101c\u103b\u103e\u1031', '\u101c\u103b\u1031', '\u1019\u103c\u1031', + '\u1017\u103c\u1031')), +) + + +def main(): + """Program entry point.""" + term = Terminal() + term.detect_ambiguous_width() + + columns = len(ROWS[0][1]) + border = term.cyan('+' + '-' * LABEL_WIDTH + ('+' + '-' * CELL_WIDTH) * columns + '+') + fill = term.bright_yellow('.') + title = 'Unidentified Terminal' + if (_sw_ver := term.get_software_version()): + title = repr(_sw_ver) + + print(term.center(title)) + print(border) + for label, graphemes in ROWS: + row = term.cyan('|') + term.magenta((' ' + label).ljust(LABEL_WIDTH)) + term.cyan('|') + for grapheme in graphemes: + row += term.magenta(term.center(grapheme, CELL_WIDTH, fill)) + term.cyan('|') + print(row) + print(border) + + +if __name__ == '__main__': + main() diff --git a/bin/line_editor_form.py b/bin/line_editor_form.py index e776c2a4..efe21c29 100644 --- a/bin/line_editor_form.py +++ b/bin/line_editor_form.py @@ -23,6 +23,7 @@ def paste_line(ed): with term.raw(), term.cursor_shape(term.CursorShape.BLINKING_BLOCK), term.bracketed_paste(): + term.detect_ambiguous_width() if has_clipboard: echo("press ^C and ^V for OS clipboard, type 'quit' to exit") else: @@ -39,6 +40,8 @@ def paste_line(ed): keymap['KEY_CTRL_C'] = copy_line keymap['KEY_CTRL_V'] = paste_line ed = LineEditor(history=history, max_width=ed_width, limit=200, + ambiguous_width=term.ambiguous_width, + term_program=term.term_program, bg_sgr=term.on_brown, keymap=keymap or None) echo(term.move_x(col) + prompt, end='') diff --git a/pyproject.toml b/pyproject.toml index 157b5a09..3a5a56ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ classifiers = [ 'Typing :: Typed', ] dependencies = [ - 'wcwidth>=0.9.1', + 'wcwidth>=0.9.2', 'jinxed>=2.1,<3', ] dynamic = ['version'] From b37ba5a6a27bbc837539024350ef0cc690900d3e Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Sun, 4 Oct 2026 20:34:00 -0400 Subject: [PATCH 11/12] linting --- blessed/line_editor.py | 2 +- blessed/terminal.py | 4 +--- blessed/win_terminal.py | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/blessed/line_editor.py b/blessed/line_editor.py index 9876d279..3217e7e2 100644 --- a/blessed/line_editor.py +++ b/blessed/line_editor.py @@ -413,7 +413,7 @@ def _compute_scroll(self, cursor_col: int, content_width: int) -> int: return self._scroll_offset def _at_limit(self) -> bool: - return self.limit > 0 and len(self._buf) >= self.limit + return 0 < self.limit <= len(self._buf) def _fire_limit_bell(self) -> str: if not self._limit_bell_fired: diff --git a/blessed/terminal.py b/blessed/terminal.py index ccb3fec7..55e2c331 100644 --- a/blessed/terminal.py +++ b/blessed/terminal.py @@ -161,7 +161,7 @@ _KEYBOARD_READ_SIZE = 4096 -class Terminal(): # pylint: disable=attribute-defined-outside-init +class Terminal(): """ An abstraction for color, style, positioning, and input in the terminal. @@ -1617,7 +1617,6 @@ def get_dec_mode(self, # This is the first-ever query and it failed! This query returns # NO_RESPONSE to indicate the timeout, subsequent queries will # return NOT_QUERIED. - # pylint: disable=attribute-defined-outside-init self._dec_first_query_failed = True return DecModeResponse(mode, DecModeResponse.NO_RESPONSE) # Rather unusual, we've previously had success with get_dec_mode, @@ -1628,7 +1627,6 @@ def get_dec_mode(self, # parse, cache, and return the response value response_value = int(match.group(1)) - # pylint: disable=attribute-defined-outside-init self._dec_mode_cache[int(mode)] = response_value self._dec_any_query_succeeded = True return DecModeResponse(mode, response_value) diff --git a/blessed/win_terminal.py b/blessed/win_terminal.py index 8ea7f10c..b072fccc 100644 --- a/blessed/win_terminal.py +++ b/blessed/win_terminal.py @@ -464,7 +464,7 @@ def notify_on_resize( self._event_buf.clear() del self._dec_mode_cache[ _DecPrivateMode.IN_BAND_WINDOW_RESIZE] - self._preferred_size_cache = None # pylint: disable=attribute-defined-outside-init + self._preferred_size_cache = None win32.set_console_mode(filehandle, save_mode) def does_inband_resize(self, timeout: float = TERMINAL_QUERY_TIMEOUT_SECONDS) -> bool: From f55f2784cc0031de1611b91870e934e372e39f4a Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Sun, 4 Oct 2026 20:36:53 -0400 Subject: [PATCH 12/12] lint (mypy) --- blessed/terminal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blessed/terminal.py b/blessed/terminal.py index 55e2c331..62ef8d3f 100644 --- a/blessed/terminal.py +++ b/blessed/terminal.py @@ -280,7 +280,7 @@ def __init__(self, f'parameters: kind={kind!r}, stream={stream!r}, force_styling={force_styling!r}, ' f'kind_fallback: {kind_fallback!r}, _xtgettcap_data: {_xtgettcap_data!r}' ] - self._normal = None + self._normal: Optional[str] = None # we assume our input stream to be line-buffered until either the # cbreak of raw context manager methods are entered with an attached tty. @@ -1459,7 +1459,7 @@ def detect_ambiguous_width( sometimes simply as a user preference, for neat and tidy vertical alignment. `configuration `_ is usually required to enable "ambiguous width as wide". - + The result is cached, assigned to :attr:`ambiguous_width`, and returned, and used automatically by subsequent alignment methods, :meth:`length`, :meth:`wrap`, :meth:`ljust`, :meth:`rjust`, and :meth:`center`.