返回 CodeWhale
contrast.rs
根目录 / crates / tui / src / palette / contrast.rs
1 //! WCAG relative-luminance and contrast enforcement.
2 //!
3 //! The palette's dark→light adaptation ([`super::adapt`]) is an equality
4 //! whitelist: a token that isn't literally listed passes through unchanged.
5 //! That is fine when the surface is the one the tokens were tuned for, and
6 //! illegible when it isn't — a near-white body token landing on a near-white
7 //! terminal background (#4833).
8 //!
9 //! This module is the enumeration-independent backstop. It works on the pair
10 //! that actually matters — *resolved foreground* and *effective surface* — and
11 //! lifts any foreground that falls under the floor, whether or not anyone
12 //! remembered to add it to a whitelist.
13 //!
14 //! Everything here is a pure function over colors, so contrast can be asserted
15 //! in unit tests without a terminal.
16
17 use ratatui::style::Color;
18
19 use super::adapt::blend;
20 use super::themes::UiTheme;
21
22 /// WCAG 2.x AA contrast floor for body text. Applied to every resolved
23 /// foreground we can reason about.
24 pub const AA_BODY_CONTRAST: f32 = 4.5;
25
26 /// Contrast floor for secondary chrome: hint/dim text and status roles.
27 /// Matches the WCAG 2.x AA threshold for large text and UI components (3:1).
28 /// Status roles qualify because they are redundant by design — every status
29 /// also carries a glyph and a word label, so color is never the only channel.
30 ///
31 /// Consumed by the theme audit below, which runs as a test gate rather than
32 /// at runtime — hence the `dead_code` allowance on this audit surface.
33 #[allow(dead_code)]
34 pub const SECONDARY_CHROME_CONTRAST: f32 = 3.0;
35
36 /// Relative luminance per WCAG 2.x, in `0.0..=1.0`.
37 ///
38 /// Returns `None` for colors whose true RGB we cannot know:
39 /// - [`Color::Reset`] — the terminal decides.
40 /// - Named ANSI colors and `Indexed(0..=15)` — remapped by the user's terminal
41 /// profile, so any RGB we assumed would be a guess.
42 ///
43 /// `Indexed(16..=255)` is resolvable: the 6x6x6 cube and the grayscale ramp are
44 /// fixed by the xterm specification, not user-configurable.
45 #[must_use]
46 pub fn relative_luminance(color: Color) -> Option<f32> {
47 let (r, g, b) = resolvable_rgb(color)?;
48 Some(luminance_rgb(r, g, b))
49 }
50
51 /// The RGB triple a color is *known* to render as, or `None` when the terminal
52 /// owns that decision. See [`relative_luminance`].
53 #[must_use]
54 pub fn resolvable_rgb(color: Color) -> Option<(u8, u8, u8)> {
55 match color {
56 Color::Rgb(r, g, b) => Some((r, g, b)),
57 Color::Indexed(index) if index >= 16 => Some(indexed_rgb(index)),
58 _ => None,
59 }
60 }
61
62 fn luminance_rgb(r: u8, g: u8, b: u8) -> f32 {
63 fn channel(value: u8) -> f32 {
64 let c = f32::from(value) / 255.0;
65 if c <= 0.03928 {
66 c / 12.92
67 } else {
68 ((c + 0.055) / 1.055).powf(2.4)
69 }
70 }
71 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
72 }
73
74 /// Contrast ratio between two relative luminances, in `1.0..=21.0`.
75 #[must_use]
76 pub fn contrast_from_luminance(a: f32, b: f32) -> f32 {
77 let (hi, lo) = if a >= b { (a, b) } else { (b, a) };
78 (hi + 0.05) / (lo + 0.05)
79 }
80
81 /// WCAG contrast ratio between two colors, or `None` if either side is
82 /// terminal-defined (see [`relative_luminance`]).
83 #[must_use]
84 pub fn contrast_ratio(fg: Color, bg: Color) -> Option<f32> {
85 Some(contrast_from_luminance(
86 relative_luminance(fg)?,
87 relative_luminance(bg)?,
88 ))
89 }
90
91 /// `true` when the pair is known to clear `min_ratio`. An unknowable pair is
92 /// *not* reported as passing — callers use this to decide whether to intervene,
93 /// and we only intervene on evidence.
94 #[must_use]
95 pub fn meets_contrast(fg: Color, bg: Color, min_ratio: f32) -> bool {
96 contrast_ratio(fg, bg).is_some_and(|ratio| ratio >= min_ratio)
97 }
98
99 /// Lift `fg` until it clears `min_ratio` against `surface`, preserving hue as
100 /// far as the floor allows.
101 ///
102 /// Returns `fg` unchanged when:
103 /// - the pair already clears the floor,
104 /// - either side is terminal-defined (we refuse to rewrite colors whose
105 /// rendering the user's terminal profile owns — that is what the `Terminal`
106 /// theme is *for*),
107 /// - or `fg` is not [`Color::Rgb`], since blending an indexed color would
108 /// silently opt it out of the depth-adaptation stage.
109 ///
110 /// Otherwise the color is blended toward whichever pole (black or white) has
111 /// more contrast headroom against the surface, by the smallest amount that
112 /// satisfies the floor. Blending is monotonic in luminance, so a bisection
113 /// finds that minimum. If even the pole cannot reach `min_ratio` — a
114 /// mid-luminance surface — the pole is returned as the best available.
115 #[must_use]
116 pub fn enforce_contrast(fg: Color, surface: Color, min_ratio: f32) -> Color {
117 if !matches!(fg, Color::Rgb(..)) {
118 return fg;
119 }
120 let (Some(fg_luma), Some(bg_luma)) = (relative_luminance(fg), relative_luminance(surface))
121 else {
122 return fg;
123 };
124 if contrast_from_luminance(fg_luma, bg_luma) >= min_ratio {
125 return fg;
126 }
127
128 const BLACK: Color = Color::Rgb(0, 0, 0);
129 const WHITE: Color = Color::Rgb(255, 255, 255);
130 let black_ratio = contrast_from_luminance(0.0, bg_luma);
131 let white_ratio = contrast_from_luminance(1.0, bg_luma);
132 let (pole, pole_ratio) = if white_ratio >= black_ratio {
133 (WHITE, white_ratio)
134 } else {
135 (BLACK, black_ratio)
136 };
137 if pole_ratio < min_ratio {
138 return pole;
139 }
140
141 // Bisect the blend factor: 0.0 keeps `fg`, 1.0 is the pole. Contrast is
142 // monotonically non-decreasing along this path, so the invariant "lo fails,
143 // hi passes" holds and converges on the least-shifted compliant color.
144 let mut lo = 0.0_f32;
145 let mut hi = 1.0_f32;
146 let mut best = pole;
147 for _ in 0..20 {
148 let mid = f32::midpoint(lo, hi);
149 let candidate = blend(pole, fg, mid);
150 if meets_contrast(candidate, surface, min_ratio) {
151 best = candidate;
152 hi = mid;
153 } else {
154 lo = mid;
155 }
156 }
157 best
158 }
159
160 /// Pick the surface a foreground is actually drawn on.
161 ///
162 /// Cells frequently carry [`Color::Reset`] for the background — meaning "let
163 /// the terminal show through". In that case the real surface is the terminal's
164 /// own background, which is exactly what [`super::detect::TerminalBackground`]
165 /// carries.
166 ///
167 /// There is deliberately no theme-surface fallback. The theme surface is what
168 /// we *intended* to paint; on a `Reset` cell it is precisely what the user is
169 /// not seeing, and #4833 is what happens when you reason against it. With no
170 /// painted background and no measurement we return `None` and leave the color
171 /// alone — declining to act beats acting on a guess.
172 #[must_use]
173 pub fn effective_surface(cell_bg: Color, detected_background: Option<Color>) -> Option<Color> {
174 if resolvable_rgb(cell_bg).is_some() {
175 return Some(cell_bg);
176 }
177 detected_background.filter(|color| resolvable_rgb(*color).is_some())
178 }
179
180 /// Whether a cell's symbol carries text, and therefore needs the body-text
181 /// contrast floor.
182 ///
183 /// Box-drawing, block, and geometric-shape glyphs are frame chrome. This
184 /// palette uses deliberately quiet borders (`BORDER_COLOR` sits at 1.9:1 on the
185 /// dark stage — a design choice, not a defect), and clamping them to a text
186 /// floor would rewrite the visual weight of every frame. Blank cells have no
187 /// foreground to speak of. #4833 is a body-text bug; the floor stays on text.
188 #[must_use]
189 pub fn symbol_needs_text_contrast(symbol: &str) -> bool {
190 symbol.chars().any(|ch| {
191 !ch.is_whitespace()
192 && !matches!(
193 ch,
194 // Box Drawing, Block Elements, Geometric Shapes,
195 // Miscellaneous Symbols/Arrows drawing glyphs, Braille.
196 '\u{2500}'..='\u{259F}'
197 | '\u{25A0}'..='\u{25FF}'
198 | '\u{2800}'..='\u{28FF}'
199 )
200 })
201 }
202
203 /// RGB for an xterm palette index `>= 16` (6x6x6 cube then grayscale ramp).
204 fn indexed_rgb(index: u8) -> (u8, u8, u8) {
205 const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
206 if index < 232 {
207 let i = u16::from(index) - 16;
208 let r = CUBE_LEVELS[(i / 36) as usize];
209 let g = CUBE_LEVELS[((i / 6) % 6) as usize];
210 let b = CUBE_LEVELS[(i % 6) as usize];
211 (r, g, b)
212 } else {
213 let level = 8 + 10 * (index - 232);
214 (level, level, level)
215 }
216 }
217
218 /// A single theme color pair that fails its contrast floor. See
219 /// [`theme_contrast_violations`].
220 #[allow(dead_code)]
221 #[derive(Debug, Clone, Copy, PartialEq)]
222 pub struct ThemeContrastViolation {
223 /// Static name of the failing pair, e.g. `"text_muted on panel_bg"`.
224 pub pair: &'static str,
225 /// The foreground as shipped by the theme.
226 pub fg: Color,
227 /// The surface it sits on.
228 pub bg: Color,
229 /// The measured WCAG contrast ratio (always below `floor`).
230 pub ratio: f32,
231 /// The floor the pair was audited against.
232 pub floor: f32,
233 }
234
235 /// `true` when the theme's surfaces belong to the terminal, not to us.
236 ///
237 /// The `Terminal` theme paints [`Color::Reset`] surfaces so the host
238 /// terminal's own scheme shows through. Those colors are terminal-owned and
239 /// not enforceable: we cannot know their RGB, so the audit neither passes nor
240 /// fails them — it stands down. This function is how callers tell that
241 /// exemption apart from a clean bill of health.
242 #[allow(dead_code)]
243 #[must_use]
244 pub fn theme_uses_terminal_owned_surfaces(theme: &UiTheme) -> bool {
245 theme.surface_bg == Color::Reset
246 }
247
248 /// Audit a theme's text and status color pairs against their contrast floors.
249 ///
250 /// Pair table and floors:
251 /// - `text_body`, `text_soft`, `text_muted` on each of `surface_bg`,
252 /// `panel_bg`, `composer_bg`, `elevated_bg` — [`AA_BODY_CONTRAST`] (4.5:1).
253 /// - `text_hint` on the same four surfaces, and `text_dim` on `surface_bg` —
254 /// [`SECONDARY_CHROME_CONTRAST`] (3:1). Hint/dim are secondary chrome:
255 /// de-emphasized metadata, never the sole carrier of meaning.
256 /// - `status_ready` / `status_working` / `status_warning` and
257 /// `warning` / `success` / `info` on `surface_bg` — 3:1, because these
258 /// roles are redundant: every status is also spelled out by a glyph and a
259 /// word label, so color is never the only signal.
260 /// - `text_body` on `selection_bg` — 4.5:1 (the theme picker renders body
261 /// text on the selection surface).
262 /// - `diff_added_fg` on `diff_added_bg`, `diff_deleted_fg` on
263 /// `diff_deleted_bg` — 3:1.
264 /// - `error_text` on `error_surface` — 4.5:1.
265 ///
266 /// Pairs where either color is terminal-defined ([`Color::Reset`], named
267 /// ANSI, `Indexed(0..=15)`) are *skipped*, not passed: their real RGB is
268 /// owned by the user's terminal profile and cannot be audited. The
269 /// `Terminal` theme is therefore largely exempt by design — see
270 /// [`theme_uses_terminal_owned_surfaces`], which makes that exemption
271 /// explicit rather than silent.
272 #[allow(dead_code)]
273 #[must_use]
274 pub fn theme_contrast_violations(theme: &UiTheme) -> Vec<ThemeContrastViolation> {
275 let mut violations = Vec::new();
276 let mut check = |pair: &'static str, fg: Color, bg: Color, floor: f32| {
277 // An unresolvable side means the terminal owns the color: skip the
278 // pair rather than recording a pass we cannot substantiate.
279 if let Some(ratio) = contrast_ratio(fg, bg)
280 && ratio < floor
281 {
282 violations.push(ThemeContrastViolation {
283 pair,
284 fg,
285 bg,
286 ratio,
287 floor,
288 });
289 }
290 };
291 macro_rules! audit {
292 ($floor:expr; $(($fg:ident, $bg:ident)),+ $(,)?) => {
293 $(check(
294 concat!(stringify!($fg), " on ", stringify!($bg)),
295 theme.$fg,
296 theme.$bg,
297 $floor,
298 );)+
299 };
300 }
301 audit!(AA_BODY_CONTRAST;
302 (text_body, surface_bg),
303 (text_body, panel_bg),
304 (text_body, composer_bg),
305 (text_body, elevated_bg),
306 (text_soft, surface_bg),
307 (text_soft, panel_bg),
308 (text_soft, composer_bg),
309 (text_soft, elevated_bg),
310 (text_muted, surface_bg),
311 (text_muted, panel_bg),
312 (text_muted, composer_bg),
313 (text_muted, elevated_bg),
314 (text_body, selection_bg),
315 (error_text, error_surface),
316 );
317 audit!(SECONDARY_CHROME_CONTRAST;
318 (text_hint, surface_bg),
319 (text_hint, panel_bg),
320 (text_hint, composer_bg),
321 (text_hint, elevated_bg),
322 (text_dim, surface_bg),
323 (status_ready, surface_bg),
324 (status_working, surface_bg),
325 (status_warning, surface_bg),
326 (warning, surface_bg),
327 (success, surface_bg),
328 (info, surface_bg),
329 (diff_added_fg, diff_added_bg),
330 (diff_deleted_fg, diff_deleted_bg),
331 );
332 violations
333 }
334
334 lines RUST