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

1"""Correct color reduction for terminals without truecolor. 

2 

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. 

7 

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. 

13 

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. 

18 

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""" 

23 

24from __future__ import annotations 

25 

26import os 

27import shutil 

28import subprocess 

29from functools import lru_cache 

30from typing import TYPE_CHECKING 

31 

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 

38 

39if TYPE_CHECKING: 

40 from collections.abc import Mapping 

41 

42 from textual.color import Color as TextualColor 

43 

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" 

49 

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" 

56 

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 

64 

65 

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 

69 

70 

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. 

73 

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. 

78 

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 

84 

85 

86@lru_cache(maxsize=1) 

87def draws_block_bars() -> bool: 

88 """One process-wide draws_block_glyphs answer for bar content glyphs. 

89 

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 

95 

96 return draws_block_glyphs(Console().color_system, resolve_term_program(os.environ)) 

97 

98 

99def resolve_term_program(environ: Mapping[str, str]) -> str | None: 

100 """The terminal actually drawing the screen, seeing through tmux where possible. 

101 

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 

126 

127 

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. 

131 

132 Cached because this runs per segment per repaint. 

133 """ 

134 return Color("", ColorType.EIGHT_BIT, number=EIGHT_BIT_PALETTE.match(ColorTriplet(*rgb))) 

135 

136 

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)) 

143 

144 

145class EightBitPalette(LineFilter): 

146 """Resolve truecolor styles to their nearest 256-palette entry. 

147 

148 Applied before Rich sees the segments, so Rich's own cube-snap never runs. 

149 """ 

150 

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