diff --git a/meshterm/core/preferences.py b/meshterm/core/preferences.py index 152841a..a2177de 100644 --- a/meshterm/core/preferences.py +++ b/meshterm/core/preferences.py @@ -157,11 +157,11 @@ _SILENCE_CHOICES: dict[int, str] = { #: scheduler's own choices so the two never drift. _QUIET_CHOICES: dict[int, str] = {s: f"{s} s" for s in QUIET_CHOICES_S} -#: How the terminal-width reclaim can be asked for: follow the platform's own verdict, or +#: How the terminal-width reclaim can be asked for: follow the terminal's own size probe, or #: overrule it in either direction. The row's description asks a yes/no question ("use the #: column ... ?"), so the values answer it in those words rather than naming the mechanism -#: twice. See :func:`meshterm.ui.tui.session._reclaim_last_column` for why a terminal may -#: need to disagree with its platform. +#: twice. See :func:`meshterm.ui.tui.session._reclaim_last_column` for which terminals hide +#: a column, and what reclaiming one that isn't hidden costs. _WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"} #: How many colours to send the terminal. ``auto`` is the honest default and the only diff --git a/meshterm/platforms.py b/meshterm/platforms.py index 91b369f..ced6050 100644 --- a/meshterm/platforms.py +++ b/meshterm/platforms.py @@ -97,10 +97,12 @@ class Platform: own, so one can change without the other following. :attr:`footer_fkeys` is the yes/no reading of it. width_reclaim: Whether the session may report one extra terminal column (see - :class:`~meshterm.ui.tui.session._WidthExtendedOutput`). Correctness-critical - on PicoCalc's exact-width console: a phantom extra column tears the frame, so - this is ``False`` there. ``MESHTERM_FULL_WIDTH`` remains an explicit override - on top of this default. + :class:`~meshterm.ui.tui.session._WidthExtendedOutput`). ``True`` only *lets* + it: the column is reclaimed where the terminal's size probe hid one, which is + prompt_toolkit's Windows console output and nothing else + (:func:`~meshterm.ui.tui.session._probe_hides_last_column`). ``False`` on + PicoCalc's exact-width console, where a phantom extra column tears the frame. + ``MESHTERM_FULL_WIDTH`` remains an explicit override on top of this default. emoji: Whether emoji icons render at all. ``False`` means every icon funnel routes through a compact single-BMP-glyph table instead (P3), and neither half of the emoji alignment runs — the reserve-two measurement diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 8c4784c..f8029cc 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -228,24 +228,28 @@ def _read_clipboard() -> str: return "" -def _reclaim_last_column() -> bool: - """Whether to reclaim the terminal's final column. +def _reclaim_last_column() -> bool | None: + """Whether to reclaim the terminal's final column — or ``None``, to ask the terminal. - Some terminals (and prompt_toolkit's size probe on them) report the window one column - narrower than it really is, so the frame is drawn to ``columns - 1`` and the true last - column sits unused — visibly selectable to the right of the border. When this is on, - :class:`_WidthExtendedOutput` tells both the renderer and the frame compositor the - window is one column wider, and that final column gets drawn. + prompt_toolkit's Windows console output reports the window one column narrower than it + really is, on purpose (see :func:`_probe_hides_last_column`), so the frame is drawn to + ``columns - 1`` and the true last column sits unused — visibly selectable to the right of + the border. When this is on, :class:`_WidthExtendedOutput` tells both the renderer and + the frame compositor the window is one column wider, and that final column gets drawn. - This is a gate, not a certainty: it is *correct* only when the probe under-reports. On a - terminal whose width probe is already right, the extra column falls off the real screen and - the frame would wrap and tear — which is exactly PicoCalc's exact-width console, so - :data:`~meshterm.platforms.PICOCALC_LYRA` defaults this off. + It is *correct* only where the probe hid the column. Everywhere else the extra column + falls off the real screen, and since prompt_toolkit draws with autowrap off, each row's + phantom last cell is written over the real one before it: whatever sits flush right + loses its final character. On Linux that cut the header's battery gauge, so a full pack's + bare ``100`` read ``10`` and 99% read ``9%``. Hence the default is a question rather than + an answer: ``None`` means "reclaim if, and only if, the output this session draws on is + one whose probe hides the column", decided once that output exists. Three sources, in confidence order, because the person at the keyboard can see the - column and the platform can only guess at it: ``MESHTERM_FULL_WIDTH=0``/``=1`` for a - one-off, then the ``full_width`` preference (``yes``/``no``; ``auto`` — the default — - steps aside), then the platform's own verdict. + column and the code can only infer it: ``MESHTERM_FULL_WIDTH=0``/``=1`` for a one-off, + then the ``full_width`` preference (``yes``/``no``; ``auto`` — the default — steps + aside), then the platform, which answers ``False`` outright where its console is exact + (:data:`~meshterm.platforms.PICOCALC_LYRA`) and otherwise leaves it to the terminal. Read fresh on every call (never cached at import time) so it reflects whichever platform :func:`~meshterm.platforms.set_platform` installed for this process — see that module's @@ -259,7 +263,26 @@ def _reclaim_last_column() -> bool: preferred = current_preferences().full_width if preferred != "auto": return preferred == "yes" - return get_platform().width_reclaim + return None if get_platform().width_reclaim else False + + +def _probe_hides_last_column(output: Any) -> bool: + """Whether prompt_toolkit sizes ``output`` one column narrower than its window. + + True of its Windows console output and nothing else. ``Win32Output.get_size`` takes the + visible width as ``srWindow.Right - srWindow.Left`` (an inclusive span, so one short) and + then caps it below the buffer's right margin — "windows will wrap otherwise" — and + ``Windows10_Output`` and ``ConEmuOutput`` both hand their size to the ``Win32Output`` + they hold. A POSIX terminal's ``Vt100_Output`` reads ``TIOCGWINSZ``, which is exact, so + there is nothing there to reclaim. + """ + if sys.platform != "win32": + return False # prompt_toolkit's win32 module asserts the platform on import + from prompt_toolkit.output.win32 import Win32Output + + return isinstance(output, Win32Output) or isinstance( + getattr(output, "win32_output", None), Win32Output + ) #: What each ``color_depth`` preference value asks for. ``auto`` is deliberately absent: it @@ -1789,18 +1812,23 @@ class TuiSession: * :class:`~meshterm.ui.tui.colsnap.PinnedOutput`, where :func:`~meshterm.ui.tui.colsnap.enabled` — every glyph prompt_toolkit's renderer writes lands in the column that renderer measured it into. - * :class:`_WidthExtendedOutput`, where :func:`_reclaim_last_column` — outermost, since - the size it reports is what both renderers and the compositor lay out against. + * :class:`_WidthExtendedOutput`, where :func:`_reclaim_last_column` — or, left to the + terminal, where :func:`_probe_hides_last_column` — outermost, since the size it + reports is what both renderers and the compositor lay out against. With neither, this is ``None`` and prompt_toolkit builds its own output as before. """ pin = colsnap.enabled() reclaim = _reclaim_last_column() - if self._output is not None or not (pin or reclaim): + if self._output is not None or not (pin or reclaim is not False): return self._output from prompt_toolkit.output.defaults import create_output output: Any = create_output() + if reclaim is None: + reclaim = _probe_hides_last_column(output) + if not (pin or reclaim): + return None if pin: output = colsnap.PinnedOutput(output) if reclaim: diff --git a/tests/test_session_width.py b/tests/test_session_width.py index 0c2da51..63891b5 100644 --- a/tests/test_session_width.py +++ b/tests/test_session_width.py @@ -1,24 +1,42 @@ # SPDX-License-Identifier: Apache-2.0 """Session width tests: reclaiming the terminal's final column. -Some terminals (and prompt_toolkit's size probe on them) report the window one column -narrower than it really is, so the frame's right border lands one short and the true last -column sits unused. The session can report one extra column to close that gap. The wrapping -output is pure and the resolver is a small branch, so both are assertable without a terminal. +prompt_toolkit's Windows console output reports the window one column narrower than it +really is, so the frame's right border lands one short and the true last column sits unused. +The session can report one extra column to close that gap. The wrapping output is pure and +the resolver is a small branch, so both are assertable without a terminal. -The default comes from the active :class:`~meshterm.platforms.Platform` (on, desktop; off, -PicoCalc — reclaiming a phantom column on its exact-width console would tear the frame), and -``MESHTERM_FULL_WIDTH`` remains an explicit override on top of that default either way. +The reclaim is right only where the probe hid the column. On an exact-width terminal (every +POSIX one) the phantom column overprints the real last cell, which on Linux turned a full +battery's ``100`` into ``10``. So by default the terminal's own output decides; the platform +can only rule it out (PicoCalc's exact-width console), and ``MESHTERM_FULL_WIDTH`` remains an +explicit override on top of either. """ from __future__ import annotations +import io +import sys from types import SimpleNamespace +import pytest from prompt_toolkit.data_structures import Size +from prompt_toolkit.output.vt100 import Vt100_Output from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform -from meshterm.ui.tui.session import TuiSession, _WidthExtendedOutput +from meshterm.ui.tui.session import TuiSession, _probe_hides_last_column, _WidthExtendedOutput + + +def _vt100(columns: int = 100) -> Vt100_Output: + """A POSIX terminal's output, sized exactly as ``TIOCGWINSZ`` would size it.""" + size = Size(rows=30, columns=columns) + return Vt100_Output(io.StringIO(), lambda: size, term="xterm-256color", enable_cpr=False) + + +@pytest.fixture +def windows_console(monkeypatch): + """Make whatever the session builds read as prompt_toolkit's Windows console output.""" + monkeypatch.setattr("meshterm.ui.tui.session._probe_hides_last_column", lambda _out: True) def test_width_extended_output_reports_one_more_column() -> None: @@ -41,8 +59,8 @@ def test_session_leaves_a_supplied_output_untouched() -> None: assert session._resolve_output() is dummy -def test_session_wraps_the_real_terminal_on_regular(monkeypatch) -> None: - """No supplied output + REGULAR (today's default) → the real terminal is width-extended.""" +def test_session_widens_a_windows_console_on_regular(monkeypatch, windows_console) -> None: + """No supplied output + REGULAR + a probe that hid a column → the column is reclaimed.""" fake = SimpleNamespace(get_size=lambda: Size(rows=30, columns=100)) monkeypatch.setattr("prompt_toolkit.output.defaults.create_output", lambda: fake) monkeypatch.delenv("MESHTERM_FULL_WIDTH", raising=False) @@ -52,7 +70,44 @@ def test_session_wraps_the_real_terminal_on_regular(monkeypatch) -> None: assert out.get_size() == Size(rows=30, columns=101) -def test_session_pins_the_real_terminal_inside_the_width_extension(monkeypatch) -> None: +def test_session_leaves_an_exact_terminal_its_own_width_on_regular(monkeypatch) -> None: + """A POSIX terminal is sized exactly, so REGULAR claims no phantom column on it. + + The phantom column used to be claimed here too. With autowrap off, its cell overprinted the + real last one, so the flush-right battery gauge lost its final character on Linux: a full + pack read ``10``, and 99% read ``9%``. + """ + terminal = _vt100(columns=100) + monkeypatch.setattr("prompt_toolkit.output.defaults.create_output", lambda: terminal) + monkeypatch.delenv("MESHTERM_FULL_WIDTH", raising=False) + monkeypatch.delenv("MESHTERM_COLUMN_SNAP", raising=False) + set_platform(REGULAR) + out = TuiSession()._resolve_output() + assert not isinstance(out, _WidthExtendedOutput) + assert out.get_size() == Size(rows=30, columns=100) + + # And with pinning off too, nothing is left to wrap: prompt_toolkit builds its own. + monkeypatch.setenv("MESHTERM_COLUMN_SNAP", "0") + assert TuiSession()._resolve_output() is None + + +def test_only_the_windows_console_probe_hides_a_column() -> None: + """The probe question is answered by the output's kind: exact everywhere but Win32.""" + assert _probe_hides_last_column(_vt100()) is False + assert _probe_hides_last_column(SimpleNamespace()) is False + if sys.platform != "win32": + return + from prompt_toolkit.output.win32 import Win32Output + + win32 = object.__new__(Win32Output) # no console needed to ask what kind it is + assert _probe_hides_last_column(win32) is True + # Windows10_Output and ConEmuOutput hold one and hand their size to it. + assert _probe_hides_last_column(SimpleNamespace(win32_output=win32)) is True + + +def test_session_pins_the_real_terminal_inside_the_width_extension( + monkeypatch, windows_console +) -> None: """Both wraps on REGULAR, reclaim outermost: its size is what everything lays out against.""" from meshterm.ui.tui.colsnap import PinnedOutput