diff --git a/meshterm/core/preferences.py b/meshterm/core/preferences.py index 4bfc1b2..779e69e 100644 --- a/meshterm/core/preferences.py +++ b/meshterm/core/preferences.py @@ -126,6 +126,20 @@ _SILENCE_CHOICES: dict[int, str] = { #: need to disagree with its platform. _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 +#: value that is not itself a depth: it means "take prompt_toolkit's verdict unless we can +#: positively establish better", which is the difference between fixing a host and breaking +#: one. The rest are named for the count a reader can see rather than for the bit depth, +#: because "24-bit" is a fact about the wire and "16 million" is a fact about the screen. +#: See :func:`meshterm.ui.tui.session._color_depth` for why auto is not simply "the most +#: this terminal can do". +_COLOR_DEPTH_CHOICES: dict[str, str] = { + "auto": "auto", + "truecolor": "16 million", + "256": "256", + "16": "16", +} + #: Every preference MeshTerm has, in page order. Adding one here gives it a row on the #: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a #: default — nothing else has to follow. @@ -319,6 +333,20 @@ PREFERENCES: tuple[PrefSpec, ...] = ( default="auto", choices=_WIDTH_CHOICES, ), + PrefSpec( + key="color_depth", + label="Colours", + help="How many colours to send this terminal", + group="Display", + value_type="enum", + # Auto, because the count a terminal accepts is a fact about the terminal and the + # reader should not have to know it. It is only ever consulted to *raise* the + # verdict prompt_toolkit already reached, never to lower it — a terminal that + # cannot be shown to do better keeps exactly what it had. + default="auto", + choices=_COLOR_DEPTH_CHOICES, + relaunch=True, + ), PrefSpec( key="console_setup", label="Console setup", diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 500d30b..51ce64c 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -25,6 +25,7 @@ from prompt_toolkit.formatted_text import ANSI from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.keys import Keys from prompt_toolkit.layout import ConditionalContainer, Float, FloatContainer, Layout, Window +from prompt_toolkit.output.color_depth import ColorDepth from prompt_toolkit.utils import get_cwidth from rich.console import RenderableType from rich.text import Text @@ -252,6 +253,70 @@ def _reclaim_last_column() -> bool: return get_platform().width_reclaim +#: What each ``color_depth`` preference value asks for. ``auto`` is deliberately absent: it +#: is the one answer that is not a depth, and :func:`_color_depth` resolves it separately. +_DEPTH_BY_NAME = { + "truecolor": ColorDepth.DEPTH_24_BIT, + "256": ColorDepth.DEPTH_8_BIT, + "16": ColorDepth.DEPTH_4_BIT, +} + +#: What ``COLORTERM`` says on a terminal that means it about 24-bit colour. There is no +#: in-band way to ask — a terminal that cannot do truecolor answers a truecolor SGR by +#: quietly approximating it, which looks from here exactly like one that can — so this +#: convention, which every truecolor terminal follows, is the whole of the evidence. +_TRUECOLOR_COLORTERM = frozenset({"truecolor", "24bit"}) + + +def _color_depth() -> ColorDepth | None: + """How many colours to send this terminal, or ``None`` to keep prompt_toolkit's verdict. + + prompt_toolkit picks a depth per *output class*, and its two classes disagree: + ``Windows10_Output`` returns ``TRUE_COLOR`` outright, while ``Vt100_Output`` returns + ``DEPTH_8_BIT`` for every ``TERM`` but ``linux`` and a dumb terminal. So the same build + of MeshTerm, drawing the same theme, is 24-bit on Windows and 256 colours on macOS and + Linux — and it is quantized *silently*, which is why this went unnoticed: the app looks + fine, just flatter. It costs exactly what this palette is made of. The seven ``heat.*`` + steps and the per-node hue wheel (:func:`~meshterm.ui.theme.node_style`) are close + pastels chosen to be told apart; snapped to the 216-colour cube, neighbours collide and + identities stop being distinguishable by hue. ``COLORTERM`` is not consulted by + prompt_toolkit at all — only ``PROMPT_TOOLKIT_COLOR_DEPTH`` is — so a terminal + announcing truecolor in the conventional way is still handed 256. + + Three sources, in confidence order, the same shape as :func:`_reclaim_last_column`: + ``MESHTERM_COLOR_DEPTH`` for a one-off, then the ``color_depth`` preference, then a + reading of the environment. + + The reading only ever *raises* the verdict, never lowers it. A terminal we cannot get a + positive claim from returns ``None`` and keeps precisely the depth it had, so a host + this function has never heard of cannot be made worse by it — which matters more than + being right everywhere, because the failure mode of guessing 24-bit at a terminal + without it is not a duller palette but no colour at all. + + Returns: + The depth to force, or ``None`` to leave prompt_toolkit's own default in place. + """ + from ...core.preferences import current as current_preferences + + override = os.environ.get("MESHTERM_COLOR_DEPTH") + preferred = override if override is not None else current_preferences().color_depth + if preferred in _DEPTH_BY_NAME: + return _DEPTH_BY_NAME[preferred] + if preferred != "auto": + return None + # A 16-slot console is already right and has no truecolor to claim: the theme addresses + # its palette by index (see theme._VT_SLOTS), and promoting the depth would send RGB to + # a screen with sixteen colours to put it in. + if not get_platform().truecolor: + return None + if os.environ.get("COLORTERM", "").strip().lower() in _TRUECOLOR_COLORTERM: + return ColorDepth.DEPTH_24_BIT + # terminfo's own spelling for a direct-colour entry (``xterm-direct``, ``*-direct16m``). + if os.environ.get("TERM", "").endswith(("-direct", "-direct16m")): + return ColorDepth.DEPTH_24_BIT + return None + + #: How many stacked dialog layers the layout can float over the background at once. A fixed #: pool of centered-box floats (see :meth:`TuiSession._build_app`), sized well past the deepest #: real nesting — a tool's list, an item's detail popup, and a confirm over that is only three. @@ -1445,6 +1510,9 @@ class TuiSession: max_render_postpone_time=None, input=self._input, output=self._resolve_output(), + # None leaves the output's own default alone; see _color_depth for why the + # two prompt_toolkit outputs disagree and why only a raise is ever applied. + color_depth=_color_depth(), ) if fastrender.enabled(): # Swap in the row-diff renderer for plain full-screen frames. Built with the diff --git a/tests/test_session_color_depth.py b/tests/test_session_color_depth.py new file mode 100644 index 0000000..3db4c8e --- /dev/null +++ b/tests/test_session_color_depth.py @@ -0,0 +1,107 @@ +"""Session colour tests: how many colours the terminal is actually sent. + +prompt_toolkit decides this per *output class*, and its two classes disagree — +``Windows10_Output`` returns true colour outright, ``Vt100_Output`` returns 256 for every +``TERM`` but ``linux`` — so the same theme was drawn at 24 bits on Windows and quantized to +the 216-colour cube on macOS and Linux, silently. The resolver closes that gap, and these +tests pin the one property that makes it safe to run everywhere: it only ever *raises* the +verdict. A terminal that cannot be shown to do better returns ``None`` and keeps exactly the +depth prompt_toolkit gave it, because guessing 24-bit at a terminal without it costs not a +duller palette but the colour entirely. +""" + +from __future__ import annotations + +from prompt_toolkit.output.color_depth import ColorDepth + +from meshterm.platforms import PICOCALC, REGULAR, set_platform +from meshterm.ui.tui.session import _color_depth + + +def _auto(monkeypatch) -> None: + """Clear both overrides so the environment reading is what is under test.""" + monkeypatch.delenv("MESHTERM_COLOR_DEPTH", raising=False) + monkeypatch.delenv("COLORTERM", raising=False) + monkeypatch.delenv("TERM", raising=False) + + +def test_an_unremarkable_terminal_keeps_prompt_toolkits_own_verdict(monkeypatch) -> None: + """Nothing claimed → ``None``, so a host we have never heard of is never made worse.""" + _auto(monkeypatch) + set_platform(REGULAR) + assert _color_depth() is None + + +def test_colorterm_truecolor_raises_the_depth(monkeypatch) -> None: + """The convention every truecolor terminal follows — and the one prompt_toolkit ignores.""" + _auto(monkeypatch) + monkeypatch.setenv("COLORTERM", "truecolor") + set_platform(REGULAR) + assert _color_depth() is ColorDepth.DEPTH_24_BIT + + +def test_colorterm_24bit_is_the_same_claim(monkeypatch) -> None: + """``24bit`` is the other spelling in circulation; case is not part of the claim.""" + _auto(monkeypatch) + monkeypatch.setenv("COLORTERM", "24BIT") + set_platform(REGULAR) + assert _color_depth() is ColorDepth.DEPTH_24_BIT + + +def test_a_direct_colour_terminfo_entry_raises_the_depth(monkeypatch) -> None: + """``*-direct`` is terminfo's own spelling for a direct-colour terminal.""" + _auto(monkeypatch) + monkeypatch.setenv("TERM", "xterm-direct") + set_platform(REGULAR) + assert _color_depth() is ColorDepth.DEPTH_24_BIT + + +def test_picocalc_is_never_promoted(monkeypatch) -> None: + """A 16-slot console addresses its palette by index; RGB has nowhere to land there.""" + _auto(monkeypatch) + monkeypatch.setenv("COLORTERM", "truecolor") + set_platform(PICOCALC) + assert _color_depth() is None + + +def test_env_override_names_a_depth_outright(monkeypatch) -> None: + """``MESHTERM_COLOR_DEPTH`` wins over the reading, in either direction.""" + _auto(monkeypatch) + monkeypatch.setenv("COLORTERM", "truecolor") + set_platform(REGULAR) + monkeypatch.setenv("MESHTERM_COLOR_DEPTH", "256") + assert _color_depth() is ColorDepth.DEPTH_8_BIT + monkeypatch.setenv("MESHTERM_COLOR_DEPTH", "16") + assert _color_depth() is ColorDepth.DEPTH_4_BIT + + +def test_the_preference_is_consulted_below_the_env_override(monkeypatch) -> None: + """A stored preference names a depth the same way; ``auto`` falls through to the reading.""" + from types import SimpleNamespace + + _auto(monkeypatch) + set_platform(REGULAR) + monkeypatch.setattr( + "meshterm.core.preferences.current", + lambda: SimpleNamespace(color_depth="truecolor"), + ) + assert _color_depth() is ColorDepth.DEPTH_24_BIT + monkeypatch.setattr( + "meshterm.core.preferences.current", + lambda: SimpleNamespace(color_depth="auto"), + ) + assert _color_depth() is None + + +def test_an_unrecognised_preference_value_changes_nothing(monkeypatch) -> None: + """A hand-edited YAML value we cannot read is not grounds for guessing.""" + from types import SimpleNamespace + + _auto(monkeypatch) + monkeypatch.setenv("COLORTERM", "truecolor") + set_platform(REGULAR) + monkeypatch.setattr( + "meshterm.core.preferences.current", + lambda: SimpleNamespace(color_depth="lots"), + ) + assert _color_depth() is None