返回 CodeWhale
ombre.rs
1 //! An opt-in atmosphere: one spatial ombre painted over a finished scene.
2 //!
3 //! A host paints its components through [`Theme`] as usual and then calls
4 //! [`Ombre::apply`] once, last, over the whole scene. That order is the whole
5 //! contract: every ground a component paints is already on the buffer, so
6 //! the pass repaints the cells whose background is one of the five structural
7 //! grounds (Sidebar, Background, Surface, Hover, Selected) and leaves glyphs,
8 //! every foreground, content and diff tints, action fills and any color the
9 //! tokens do not name exactly as they were painted.
10 //!
11 //! The wash is spatial, not a flat theme swatch: each cell is tinted toward
12 //! the blend of two token hues at its own position along the requested
13 //! [`Rect`], anchored to that rect even when only its visible intersection is
14 //! painted. The luminance the source ground was audited with is restored
15 //! afterwards ([`crate::color::tint_keeping_luminance`]), so every contrast
16 //! the tokens passed still holds. A nonblank cell is repainted only when the
17 //! result keeps its ink at `min(original contrast, the ink's audited floor)`;
18 //! blank cells carry the gradient without that proof, so the wash stays
19 //! continuous where nothing is written on it.
20 //!
21 //! | Palette | Runs between | Reads as |
22 //! |---|---|---|
23 //! | [`WaterPalette::Ocean`] | the logo ombre `#1E8FD8` → `#0B48BB` | deep water |
24 //! | [`WaterPalette::Lagoon`] | `Live` → `Primary` | the shallows |
25 //! | [`WaterPalette::Dusk`] | `Primary` → `Attention` | blue hour into a warm horizon |
26 //! | [`WaterPalette::Coral`] | `Danger` → `Attention` | a reef |
27 //! | [`WaterPalette::Graphite`] | the exact token grounds, no tint | graphite |
28 //!
29 //! In a light appearance the hues for Lagoon, Dusk and Coral are read from
30 //! the light token table, so paper gets pale washes of its own tokens; Ocean
31 //! keeps the logo pair, as a pale wash. Where a ground cannot hold a hue and
32 //! keep the luminance it was audited with (paper's pure white surface), it is
33 //! left exactly as the theme painted it.
34 //!
35 //! The pass runs at truecolor on a measured appearance only. At 256 colors,
36 //! 16 colors, `NO_COLOR`, ASCII-safe output and an unmeasured ground the
37 //! audited [`Theme`] is painted exactly as everywhere else.
38 //!
39 //! [`WaterPalette::Graphite`] does not tint: on a dark truecolor terminal it
40 //! remaps the five structural grounds to the graphite token grounds (the ones
41 //! the desktop app paints), so an Ocean-themed host can show real graphite.
42 //! On a light terminal it is pass-through, because paper already is the light
43 //! token table. A theme already at [`crate::Ground::Graphite`] is left alone.
44 //!
45 //! The palette is atmosphere, never state: no state word or mark is given a
46 //! new hue, and nothing here runs on a clock or animates by itself.
47
48 use ratatui::{
49 buffer::Buffer,
50 layout::Rect,
51 style::{Color, Modifier, Style},
52 };
53
54 use crate::{
55 Paint, Role, Theme,
56 color::{ColorDepth, blend, relative_luminance, rgb, tint_keeping_luminance},
57 detect::Appearance,
58 theme::{Ground, LOGO_BOTTOM, LOGO_TOP},
59 };
60
61 /// The five structural grounds this pass may repaint, in elevation order.
62 const GROUNDS: [Role; 5] = [
63 Role::Sidebar,
64 Role::Background,
65 Role::Surface,
66 Role::Hover,
67 Role::Selected,
68 ];
69
70 /// The dark wash: the audited strength [`crate::theme::OCEAN_TINT`] uses.
71 const OMBRE_TINT: f64 = 0.5;
72
73 /// Paper washes, strongest first. The pass takes the strongest one whose
74 /// result keeps the source ground's luminance; `0.0` means "leave it alone".
75 const PAPER_TINTS: [f64; 3] = [0.16, 0.10, 0.06];
76
77 /// How far a wash may move a ground's WCAG luminance: one 8-bit channel step
78 /// near the middle of the range. A wash that cannot stay inside this is not
79 /// offered for that ground.
80 const LUMINANCE_ROUNDING: f32 = 0.005;
81
82 /// Floating-point comparison tolerance; channel rounding never permits a
83 /// wash to cross an audited contrast floor.
84 const CONTRAST_ROUNDING: f32 = 1e-5;
85
86 /// The body-text floor the tokens audit (`generate.py`).
87 const TEXT_FLOOR: f32 = 4.5;
88
89 /// The control-edge floor the tokens audit.
90 const CONTROL_FLOOR: f32 = 3.0;
91
92 /// Samples across the ramp used to prove a wash strength before it is used.
93 const STRENGTH_SAMPLES: u32 = 32;
94
95 /// The five atmospheres a scene can be finished with.
96 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
97 pub enum WaterPalette {
98 /// The logo ombre: `#1E8FD8` into `#0B48BB`.
99 Ocean,
100 /// The shallows: `Live` into `Primary`.
101 Lagoon,
102 /// Blue hour: `Primary` into `Attention`.
103 Dusk,
104 /// A reef: `Danger` into `Attention`.
105 Coral,
106 /// The exact token grounds, no tint.
107 Graphite,
108 }
109
110 impl WaterPalette {
111 /// Every palette, in the order the gallery shows them.
112 pub const ALL: [WaterPalette; 5] = [
113 WaterPalette::Ocean,
114 WaterPalette::Lagoon,
115 WaterPalette::Dusk,
116 WaterPalette::Coral,
117 WaterPalette::Graphite,
118 ];
119
120 /// The palette's name, as a label reads it.
121 #[must_use]
122 pub const fn name(self) -> &'static str {
123 match self {
124 WaterPalette::Ocean => "Ocean",
125 WaterPalette::Lagoon => "Lagoon",
126 WaterPalette::Dusk => "Dusk",
127 WaterPalette::Coral => "Coral",
128 WaterPalette::Graphite => "Graphite",
129 }
130 }
131
132 /// The two hues this ombre runs between, read from the theme's own token
133 /// table so paper gets light-token washes. `None` for graphite, which
134 /// maps grounds instead of tinting them.
135 fn stops(self, theme: &Theme) -> Option<(u32, u32)> {
136 Some(match self {
137 WaterPalette::Graphite => return None,
138 // The logo pair is the brand ombre in both appearances.
139 WaterPalette::Ocean => (LOGO_TOP, LOGO_BOTTOM),
140 WaterPalette::Lagoon => (theme.token_hex(Role::Live), theme.token_hex(Role::Primary)),
141 WaterPalette::Dusk => (
142 theme.token_hex(Role::Primary),
143 theme.token_hex(Role::Attention),
144 ),
145 WaterPalette::Coral => (
146 theme.token_hex(Role::Danger),
147 theme.token_hex(Role::Attention),
148 ),
149 })
150 }
151 }
152
153 /// Which way the wash travels across the requested area.
154 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
155 pub enum OmbreDirection {
156 /// Runs down the area: the same wash on every column.
157 Vertical,
158 /// Runs from the top-left corner to the bottom-right corner (the
159 /// default).
160 #[default]
161 Diagonal,
162 }
163
164 /// A finishing pass: one palette, one direction.
165 ///
166 /// ```no_run
167 /// use codewhale_ratatui::{Ombre, Theme, WaterPalette};
168 /// # fn draw(area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
169 /// let theme = Theme::detect();
170 /// // ... the host paints its scene through `theme` first ...
171 /// Ombre::new(WaterPalette::Ocean).apply(area, buf, &theme);
172 /// # }
173 /// ```
174 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
175 pub struct Ombre {
176 pub palette: WaterPalette,
177 pub direction: OmbreDirection,
178 }
179
180 impl Ombre {
181 /// The palette at its default [`OmbreDirection::Diagonal`].
182 #[must_use]
183 pub const fn new(palette: WaterPalette) -> Self {
184 Self {
185 palette,
186 direction: OmbreDirection::Diagonal,
187 }
188 }
189
190 /// Choose the direction the wash travels.
191 #[must_use]
192 pub const fn direction(mut self, direction: OmbreDirection) -> Self {
193 self.direction = direction;
194 self
195 }
196
197 /// Repaint the recognized structural grounds in the buffer.
198 ///
199 /// Call this last, after the scene has been painted through `theme`.
200 /// `area` is the area the gradient is anchored to: callers that clip a
201 /// larger scene still pass the full rect, and the visible part keeps the
202 /// colors it would have had at full size. Areas that do not intersect the
203 /// buffer, zero-sized areas and `u16::MAX` extents are all safe.
204 pub fn apply(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
205 // Only truecolor on a measured appearance has audited grounds to
206 // move; every other profile keeps the theme exactly as it is.
207 if theme.depth() != ColorDepth::TrueColor || !theme.caps().paints_tokens() {
208 return;
209 }
210 let visible = area.intersection(*buf.area());
211 if visible.is_empty() {
212 return;
213 }
214 match self.palette {
215 // Paper is already the light token table.
216 WaterPalette::Graphite if matches!(theme.caps().appearance, Appearance::Light) => {}
217 WaterPalette::Graphite if theme.ground_kind() == Ground::Graphite => {}
218 WaterPalette::Graphite => remap_graphite(area, visible, buf, theme),
219 _ => self.wash(area, visible, buf, theme),
220 }
221 }
222
223 /// Tint each structural ground toward the palette hue at its own ramp
224 /// position.
225 fn wash(&self, requested: Rect, visible: Rect, buf: &mut Buffer, theme: &Theme) {
226 let Some((start, end)) = self.palette.stops(theme) else {
227 return;
228 };
229 let light = matches!(theme.caps().appearance, Appearance::Light);
230 let mut present = [false; GROUNDS.len()];
231 for y in visible.top()..visible.bottom() {
232 for x in visible.left()..visible.right() {
233 if let Some(ground) = ground_index(buf[(x, y)].bg, theme) {
234 present[ground] = true;
235 }
236 }
237 }
238 let mut sources = [0_u32; GROUNDS.len()];
239 let mut grounds = [0.0_f32; GROUNDS.len()];
240 let mut strengths = [0.0_f64; GROUNDS.len()];
241 for (ground, on) in present.iter().enumerate() {
242 if !*on {
243 continue;
244 }
245 sources[ground] = theme.token_hex(GROUNDS[ground]);
246 grounds[ground] = relative_luminance(rgb(sources[ground])).unwrap_or(0.0);
247 strengths[ground] = wash_strength(sources[ground], start, end, light);
248 }
249 let (base, rows) = ramp_rows(visible, requested, self.direction);
250 let den = ramp_den(requested, self.direction);
251 let mut table: Vec<[Option<Wash>; GROUNDS.len()]> = vec![[None; GROUNDS.len()]; rows];
252 for (ground, on) in present.iter().enumerate() {
253 if !*on || strengths[ground] == 0.0 {
254 continue;
255 }
256 for (row, washes) in table.iter_mut().enumerate() {
257 let sample = base + row as u32;
258 let t = if den == 0 {
259 0.0
260 } else {
261 sample.min(den) as f32 / den as f32
262 };
263 let Some(hue) = hex_of(blend(rgb(end), rgb(start), t)) else {
264 continue;
265 };
266 washes[ground] = Wash::of(tint_keeping_luminance(
267 sources[ground],
268 hue,
269 strengths[ground],
270 ));
271 }
272 }
273 repaint(
274 visible,
275 requested,
276 self.direction,
277 buf,
278 theme,
279 &Washes {
280 base,
281 rows: table,
282 grounds,
283 },
284 );
285 }
286 }
287
288 impl Paint for Ombre {
289 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
290 self.apply(area, buf, theme);
291 }
292 }
293
294 /// Map the five structural grounds to the graphite token grounds, keeping
295 /// any cell whose ink would not survive the move.
296 fn remap_graphite(requested: Rect, visible: Rect, buf: &mut Buffer, theme: &Theme) {
297 let graphite = theme.ground(Ground::Graphite);
298 let mut entries: [Option<Wash>; GROUNDS.len()] = [None; GROUNDS.len()];
299 let mut grounds = [0.0_f32; GROUNDS.len()];
300 for (index, role) in GROUNDS.iter().enumerate() {
301 entries[index] = graphite.color(*role).and_then(hex_of).and_then(Wash::of);
302 grounds[index] = relative_luminance(rgb(theme.token_hex(*role))).unwrap_or(0.0);
303 }
304 let (base, rows) = ramp_rows(visible, requested, OmbreDirection::Vertical);
305 repaint(
306 visible,
307 requested,
308 OmbreDirection::Vertical,
309 buf,
310 theme,
311 &Washes {
312 base,
313 rows: vec![entries; rows],
314 grounds,
315 },
316 );
317 }
318
319 /// One repainted ground color and the luminance it carries.
320 #[derive(Clone, Copy)]
321 struct Wash {
322 hex: u32,
323 luminance: f32,
324 }
325
326 impl Wash {
327 fn of(hex: u32) -> Option<Self> {
328 Some(Self {
329 hex,
330 luminance: relative_luminance(rgb(hex))?,
331 })
332 }
333 }
334
335 /// The washes for one scene: one row per ramp sample, one entry per ground.
336 struct Washes {
337 /// The ramp sample row `0` stands for.
338 base: u32,
339 rows: Vec<[Option<Wash>; GROUNDS.len()]>,
340 /// The luminance each source ground was audited with.
341 grounds: [f32; GROUNDS.len()],
342 }
343
344 impl Washes {
345 /// The wash row for a cell inside `requested`.
346 fn at(&self, x: u16, y: u16, area: Rect, direction: OmbreDirection) -> Option<&[Option<Wash>]> {
347 let sample = ramp_offset(x, y, area, direction).checked_sub(self.base)?;
348 self.rows
349 .get(usize::try_from(sample).ok()?)
350 .map(|row| row.as_slice())
351 }
352 }
353
354 /// Repaint every visible cell of a recognized ground, keeping any cell whose
355 /// ink the wash would make unreadable.
356 fn repaint(
357 visible: Rect,
358 requested: Rect,
359 direction: OmbreDirection,
360 buf: &mut Buffer,
361 theme: &Theme,
362 washes: &Washes,
363 ) {
364 let mut ink = Ink::default();
365 for y in visible.top()..visible.bottom() {
366 for x in visible.left()..visible.right() {
367 let Some(ground) = ground_index(buf[(x, y)].bg, theme) else {
368 continue;
369 };
370 let Some(row) = washes.at(x, y, requested, direction) else {
371 continue;
372 };
373 let Some(wash) = row.get(ground).copied().flatten() else {
374 continue;
375 };
376 let cell = &buf[(x, y)];
377 // Reversed cells display the foreground as their background.
378 // Preserve that caller-authored selection instead of proving
379 // contrast against the wrong side of the swap.
380 if cell.modifier.contains(Modifier::REVERSED) {
381 continue;
382 }
383 if !cell.symbol().trim().is_empty()
384 && !ink.allows(theme, cell.fg, washes.grounds[ground], wash.luminance)
385 {
386 continue;
387 }
388 buf[(x, y)].set_style(Style::default().bg(rgb(wash.hex)));
389 }
390 }
391 }
392
393 /// The last ink examined: paintings come in runs, so one slot is enough and
394 /// the per-cell work stays a comparison.
395 #[derive(Default)]
396 struct Ink {
397 color: Option<Color>,
398 luminance: Option<f32>,
399 floor: f32,
400 }
401
402 impl Ink {
403 /// Whether replacing `old` with `new` keeps this ink at
404 /// `min(original contrast, the ink's floor)`, within rounding.
405 fn allows(&mut self, theme: &Theme, ink: Color, old: f32, new: f32) -> bool {
406 if self.color != Some(ink) {
407 self.color = Some(ink);
408 self.luminance = relative_luminance(ink);
409 self.floor = ink_floor(theme, ink);
410 }
411 // Ink the terminal owns (`Reset`, indices 0..=15, `None`) cannot be
412 // measured; its cell keeps the screen it already had.
413 let Some(luminance) = self.luminance else {
414 return false;
415 };
416 ratio(luminance, new) + CONTRAST_ROUNDING >= ratio(luminance, old).min(self.floor)
417 }
418 }
419
420 /// The floor an ink was audited against: body text, or a control edge for
421 /// the roles the tokens hold to 3:1. Ink the tokens do not name is held to
422 /// body text, the strictest default.
423 fn ink_floor(theme: &Theme, ink: Color) -> f32 {
424 let roles = theme.roles_of(ink, false);
425 if roles.is_empty() {
426 return TEXT_FLOOR;
427 }
428 roles
429 .iter()
430 .copied()
431 .map(|role| match role {
432 Role::Dim | Role::Border | Role::BorderStrong => CONTROL_FLOOR,
433 _ => TEXT_FLOOR,
434 })
435 .fold(0.0_f32, f32::max)
436 }
437
438 /// WCAG contrast from two relative luminances, as
439 /// [`crate::color::contrast_ratio`] computes it from colors; the pass already
440 /// holds both, so no channel math repeats per cell.
441 fn ratio(a: f32, b: f32) -> f32 {
442 let (hi, lo) = if a >= b { (a, b) } else { (b, a) };
443 (hi + 0.05) / (lo + 0.05)
444 }
445
446 /// The strongest wash `source` can take toward the `start` → `end` hues while
447 /// keeping the luminance it was audited with. `0.0` means the ground cannot
448 /// hold a hue at all and is left exactly as painted.
449 fn wash_strength(source: u32, start: u32, end: u32, light: bool) -> f64 {
450 let Some(want) = relative_luminance(rgb(source)) else {
451 return 0.0;
452 };
453 let ladder: &[f64] = if light { &PAPER_TINTS } else { &[OMBRE_TINT] };
454 for amount in ladder {
455 let keeps = (0..=STRENGTH_SAMPLES).all(|i| {
456 let t = i as f32 / STRENGTH_SAMPLES as f32;
457 let Some(hue) = hex_of(blend(rgb(end), rgb(start), t)) else {
458 return false;
459 };
460 let tinted = tint_keeping_luminance(source, hue, *amount);
461 relative_luminance(rgb(tinted))
462 .is_some_and(|have| (have - want).abs() <= LUMINANCE_ROUNDING)
463 });
464 if keeps {
465 return *amount;
466 }
467 }
468 0.0
469 }
470
471 /// The ramp rows the visible intersection needs, and the sample its first
472 /// row stands for. Anchored to `requested`, so clipping never re-scales the
473 /// gradient.
474 fn ramp_rows(visible: Rect, requested: Rect, direction: OmbreDirection) -> (u32, usize) {
475 let first = ramp_offset(visible.x, visible.y, requested, direction);
476 let last_x = u32::from(visible.x) + u32::from(visible.width).saturating_sub(1);
477 let last_y = u32::from(visible.y) + u32::from(visible.height).saturating_sub(1);
478 let last = ramp_offset_value(last_x, last_y, requested, direction);
479 (first, usize::try_from(last - first).unwrap_or(0) + 1)
480 }
481
482 /// How far along the requested area's ramp a coordinate is.
483 fn ramp_offset(x: u16, y: u16, area: Rect, direction: OmbreDirection) -> u32 {
484 ramp_offset_value(u32::from(x), u32::from(y), area, direction)
485 }
486
487 /// [`ramp_offset`] for coordinates that may exceed `u16` during the
488 /// arithmetic (the last cell of a clipped intersection).
489 fn ramp_offset_value(x: u32, y: u32, area: Rect, direction: OmbreDirection) -> u32 {
490 let dx = x.saturating_sub(u32::from(area.x));
491 let dy = y.saturating_sub(u32::from(area.y));
492 match direction {
493 OmbreDirection::Vertical => dy,
494 OmbreDirection::Diagonal => dx + dy,
495 }
496 }
497
498 /// The largest ramp sample the requested area spans; `0` when it cannot vary.
499 fn ramp_den(area: Rect, direction: OmbreDirection) -> u32 {
500 match direction {
501 OmbreDirection::Vertical => u32::from(area.height.saturating_sub(1)),
502 OmbreDirection::Diagonal => {
503 u32::from(area.width.saturating_sub(1)) + u32::from(area.height.saturating_sub(1))
504 }
505 }
506 }
507
508 /// Which structural ground a background is, exactly as the theme resolves
509 /// it. `Reset`, content tints, action fills and custom colors match nothing.
510 fn ground_index(bg: Color, theme: &Theme) -> Option<usize> {
511 GROUNDS
512 .iter()
513 .position(|role| theme.color(*role) == Some(bg))
514 }
515
516 /// `0xRRGGBB` for an RGB color; `None` for anything the terminal owns.
517 fn hex_of(color: Color) -> Option<u32> {
518 match color {
519 Color::Rgb(r, g, b) => Some(u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b)),
520 _ => None,
521 }
522 }
523
523 lines RUST