Windows got sixteen million colours, everyone else got 256
prompt_toolkit picks the colour depth per output class, and its two classes disagree: Windows10_Output returns TRUE_COLOR outright, Vt100_Output returns DEPTH_8_BIT for every TERM but linux. Nothing ever passed color_depth, so the same build drawing the same theme was 24-bit on Windows and quantized to the 216-colour cube on macOS and Linux — silently, which is why it 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 are close pastels chosen to be told apart; snapped to the cube, neighbours collide and an identity stops being distinguishable by hue. COLORTERM is not consulted by prompt_toolkit at all — only its own PROMPT_TOOLKIT_COLOR_DEPTH is — so a terminal announcing truecolor the conventional way was still handed 256. The resolver only ever raises the verdict. A terminal that cannot be shown to do better returns None and keeps precisely the depth it had, so a host this has never heard of cannot be made worse by it: guessing 24-bit at a terminal without it costs not a duller palette but the colour entirely. A 16-slot console is never promoted — the theme addresses its palette by index, and RGB has nowhere to land there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4d8c67358c
commit
c008aa5f01
3 files changed
+203
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user