Coverage for src/lilbee/cli/tui/color_compat.py: 100%
63 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Correct color reduction for terminals without truecolor.
3Without truecolor (macOS Terminal.app; any SSH session, since OpenSSH does not
4forward COLORTERM) Rich reduces colors with ``Color.downgrade``, which uses the
5grey ramp only below 15% saturation and otherwise rounds each channel into the
66x6x6 cube, whose first two steps are 0 and 95.
8Dark theme surfaces straddle that line. 18 of the 33 surfaces across the themes
9lilbee ships sit above it and get cube-rounded, and their channels are far
10enough below 95 that each rounds to 0 or up to 95; 8 come back more saturated
11than they went in. On rose-pine, $background #191724 and $surface #1f1d2e both
12land on slot 16 and $panel #26233a lands on slot 17, a navy in no theme.
14Build vs buy: Textual owns the filter pipeline (``App.get_line_filters``) and
15Rich owns a correct perceptual matcher (``Palette.match``), which ``downgrade``
16does not call. This module only connects the two. A style resolved to 8-bit here
17reaches Rich already 8-bit, so its reduction is a no-op.
19The 256 palette holds no dark tinted colors, so surfaces land on the greyscale
20ramp and the theme tint survives only in the accents. The ramp's ten-unit steps
21still separate $background, $surface and $panel on most themes.
22"""
24from __future__ import annotations
26import os
27import shutil
28import subprocess
29from functools import lru_cache
30from typing import TYPE_CHECKING
32from rich._palettes import EIGHT_BIT_PALETTE
33from rich.color import Color, ColorType
34from rich.color_triplet import ColorTriplet
35from rich.segment import Segment
36from rich.style import Style
37from textual.filter import LineFilter
39if TYPE_CHECKING:
40 from collections.abc import Mapping
42 from textual.color import Color as TextualColor
44# Rich's name for the color system that needs correcting. Its 16-color path
45# already matches against the standard palette properly, and routing that through
46# 8-bit first only adds a rounding step, so "standard" is deliberately excluded.
47EIGHT_BIT_COLOR_SYSTEM = "256"
48TRUECOLOR_COLOR_SYSTEM = "truecolor"
50# macOS Terminal.app exports COLORTERM=truecolor but cannot render 24-bit SGR: it
51# reads "48;2;r;g;b" as separate codes, so #191724 turns cyan because the trailing
52# 36 lands as "foreground cyan". Measured on Terminal 453 / macOS 14.6.1, where a
53# 24-bit gradient comes out alternating green and magenta while the 256-color one
54# is correct. Rich believes COLORTERM, so the color system alone cannot detect it.
55APPLE_TERMINAL = "Apple_Terminal"
57# tmux overwrites TERM_PROGRAM with its own name, hiding the terminal underneath,
58# and passes COLORTERM through, so a tmux session inside Terminal.app looks
59# truecolor-capable from both signals. It keeps the original in its global
60# environment, which is the only way back to the real terminal. tmux forwards
61# 8-bit colors unchanged, so resolving this is enough to fix the nested case.
62TMUX_TERM_PROGRAM = "tmux"
63_TMUX_ENV_TIMEOUT_S = 1.0
66def needs_eight_bit(color_system: str | None, term_program: str | None) -> bool:
67 """Whether truecolor styles must be resolved to the 256 palette before output."""
68 return color_system == EIGHT_BIT_COLOR_SYSTEM or term_program == APPLE_TERMINAL
71def draws_block_glyphs(color_system: str | None, term_program: str | None) -> bool:
72 """Whether the terminal can be trusted to tile partial-block border glyphs.
74 Not the same question as needs_eight_bit, and not the same slope: a 16-colour
75 terminal needs no colour correction, because Rich's standard-palette path is
76 already nearest neighbour, but it is *less* likely to draw U+2580..U+259F
77 cell-exact, not more.
79 Font metrics cannot be queried, so this goes on terminal identity: the
80 terminals that advertise truecolor ship a font that tiles, minus Terminal.app,
81 which advertises it falsely.
82 """
83 return color_system == TRUECOLOR_COLOR_SYSTEM and term_program != APPLE_TERMINAL
86@lru_cache(maxsize=1)
87def draws_block_bars() -> bool:
88 """One process-wide draws_block_glyphs answer for bar content glyphs.
90 Bars are widget content, not CSS, so the app's border decision cannot
91 carry them. Cached because resolve_term_program can shell out to tmux;
92 the app primes this in __init__ so no repaint pays that cost.
93 """
94 from rich.console import Console
96 return draws_block_glyphs(Console().color_system, resolve_term_program(os.environ))
99def resolve_term_program(environ: Mapping[str, str]) -> str | None:
100 """The terminal actually drawing the screen, seeing through tmux where possible.
102 Reports the client that started the tmux server, so attaching one server from
103 two different terminals can still get this wrong; there is no per-client answer
104 to ask for.
105 """
106 term_program = environ.get("TERM_PROGRAM")
107 if term_program != TMUX_TERM_PROGRAM:
108 return term_program
109 tmux = shutil.which("tmux")
110 if tmux is None:
111 return term_program
112 try:
113 result = subprocess.run( # noqa: S603 - fixed argv, path resolved by which
114 [tmux, "show-environment", "-g", "TERM_PROGRAM"],
115 capture_output=True,
116 encoding="utf-8",
117 timeout=_TMUX_ENV_TIMEOUT_S,
118 check=False,
119 )
120 except (OSError, subprocess.SubprocessError):
121 return term_program
122 if result.returncode != 0:
123 return term_program
124 _, _, value = result.stdout.strip().partition("=")
125 return value or term_program
128@lru_cache(maxsize=4096)
129def nearest_eight_bit(rgb: tuple[int, int, int]) -> Color:
130 """The 256-palette color closest to *rgb*, by true nearest neighbour.
132 Cached because this runs per segment per repaint.
133 """
134 return Color("", ColorType.EIGHT_BIT, number=EIGHT_BIT_PALETTE.match(ColorTriplet(*rgb)))
137def _reduce(color: Color | None) -> Color | None:
138 """Resolve *color* to an explicit 8-bit color, or None if it needs no change."""
139 if color is None or color.type != ColorType.TRUECOLOR:
140 return None
141 triplet = color.get_truecolor()
142 return nearest_eight_bit((triplet.red, triplet.green, triplet.blue))
145class EightBitPalette(LineFilter):
146 """Resolve truecolor styles to their nearest 256-palette entry.
148 Applied before Rich sees the segments, so Rich's own cube-snap never runs.
149 """
151 def apply(self, segments: list[Segment], background: TextualColor) -> list[Segment]:
152 output: list[Segment] = []
153 for segment in segments:
154 style = segment.style
155 if style is None:
156 output.append(segment)
157 continue
158 color = _reduce(style.color)
159 bgcolor = _reduce(style.bgcolor)
160 if color is None and bgcolor is None:
161 output.append(segment)
162 continue
163 output.append(
164 Segment(
165 segment.text,
166 style + Style.from_color(color or style.color, bgcolor or style.bgcolor),
167 segment.control,
168 )
169 )
170 return output