返回 CodeWhale
focus_texture.rs
根目录 / crates / tui / src / tui / focus_texture.rs
1 //! Focus-context texture prototype (#4823).
2 //!
3 //! When a modal view is open, the area *outside* the focused modal can get a
4 //! subtle treatment so the focused region stands out:
5 //!
6 //! - `scrim` dims the already-rendered background toward the theme surface;
7 //! - `grain` sprinkles sparse deterministic dots over blank cells.
8 //!
9 //! Scope and guarantees, by construction:
10 //!
11 //! - **Prototype, bounded to modal contexts.** The only consumer is
12 //! `ViewStack::render`, which passes the top view's `occupied_region` as the
13 //! focus rect. Nothing else in the shell opts in.
14 //! - **Default off.** `FocusTextureMode::Off` (the default) returns zeroed
15 //! stats and leaves the buffer untouched, so the render path stays
16 //! byte-identical to the pre-prototype path.
17 //! - **Static, not animated.** The grain pattern is a pure function of cell
18 //! coordinates with no time component, so it is motion-off-safe: the
19 //! `low_motion` / `MotionPolicy::allows_decorative` path needs no special
20 //! handling here, and two applications over the same buffer produce
21 //! identical output.
22 //! - **Explicit fallbacks.** Off is the fallback for unknown setting values;
23 //! cells whose background is `Color::Reset` (transparent terminals) are
24 //! skipped under Scrim — the terminal owns that background; the grain dot
25 //! falls back to `.` when ASCII-safe mode is on.
26 //! - **The focus rect is never painted** — the caller applies the texture
27 //! before painting the backdrop and views, so the focused modal is drawn
28 //! afterward at full strength and the texture can never overwrite it.
29 //! - **Text is never obscured.** Grain only writes blank/whitespace cells and
30 //! never touches a cell that carries a symbol. Scrim preserves the
31 //! WCAG AA body-text floor (4.5:1) whenever both colors are resolvable:
32 //! after blending, the foreground is lifted with
33 //! `palette::enforce_contrast` against the *new* background. Colors the
34 //! terminal owns (`Reset`, named ANSI) are left alone rather than guessed.
35 //!
36 //! Near-fullscreen focus regions (covering at least
37 //! `FOCUS_COVERAGE_NOOP_PERCENT`% of the frame) and frames smaller than
38 //! [`FOCUS_TEXTURE_MIN_WIDTH`]x[`FOCUS_TEXTURE_MIN_HEIGHT`] refuse the
39 //! treatment entirely: there is no meaningful outside left to texture.
40
41 use ratatui::{buffer::Buffer, layout::Rect, style::Color};
42
43 use codewhale_palette::{self as palette, AA_BODY_CONTRAST, UiTheme};
44
45 /// Minimum frame size that earns the texture. Below this, content and
46 /// controls own every cell. Mirrors the ambient-life floors.
47 pub const FOCUS_TEXTURE_MIN_WIDTH: u16 = crate::tui::ocean::AMBIENT_MIN_WIDTH;
48 pub const FOCUS_TEXTURE_MIN_HEIGHT: u16 = crate::tui::ocean::AMBIENT_MIN_HEIGHT;
49
50 /// Focus regions covering at least this share of the frame's cells leave no
51 /// meaningful outside to texture, so the pass is a no-op.
52 const FOCUS_COVERAGE_NOOP_PERCENT: u64 = 90;
53
54 /// Scrim background blend toward the theme surface.
55 const SCRIM_BG_BLEND: f32 = 0.5;
56 /// Scrim foreground blend toward the theme surface, before the contrast
57 /// floor lifts the result back to legibility.
58 const SCRIM_FG_BLEND: f32 = 0.25;
59
60 /// Grain dot glyph. Deterministic placement (see [`grain_dot_at`]) keeps the
61 /// texture static; `glyphs::ascii_fallback` maps this to `.` in ASCII-safe
62 /// mode.
63 const GRAIN_DOT: &str = "·";
64
65 /// Focus-context texture mode for modal views (#4823 prototype).
66 ///
67 /// Parsed from the `focus_texture` setting at the consumption point; unknown
68 /// values fall back to `Off` and never panic.
69 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70 pub enum FocusTextureMode {
71 /// No treatment. The render path is byte-identical to the pre-prototype
72 /// path in this mode.
73 #[default]
74 Off,
75 /// Dim cells outside the focused modal toward the theme surface.
76 Scrim,
77 /// Sparse deterministic dots on blank cells outside the focused modal.
78 Grain,
79 }
80
81 impl FocusTextureMode {
82 /// Parse a setting value; `None` for anything unknown (callers map that
83 /// to `Off`). Case-insensitive, surrounding whitespace ignored.
84 #[must_use]
85 pub fn parse(value: &str) -> Option<Self> {
86 match value.trim().to_ascii_lowercase().as_str() {
87 "off" => Some(Self::Off),
88 "scrim" => Some(Self::Scrim),
89 "grain" => Some(Self::Grain),
90 _ => None,
91 }
92 }
93 }
94
95 /// Accounting for one [`apply_focus_texture`] pass.
96 ///
97 /// Identity: `cells_examined == cells_scrimmed + cells_dotted
98 /// + cells_skipped_focus + cells_skipped_transparent + cells_skipped_text`.
99 ///
100 /// Scrim examines every cell of the area. Grain examines focus cells, text
101 /// cells, and deterministic dot candidates; blank cells that earn no dot are
102 /// left untouched and unexamined, which keeps the identity exact without a
103 /// "blank but not dotted" bucket.
104 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105 pub struct FocusTextureStats {
106 pub cells_examined: u32,
107 pub cells_scrimmed: u32,
108 pub cells_dotted: u32,
109 pub cells_skipped_focus: u32,
110 pub cells_skipped_transparent: u32,
111 pub cells_skipped_text: u32,
112 }
113
114 impl FocusTextureStats {
115 /// The accounting identity asserted by the unit tests. This type's only
116 /// consumer is the test gate below, hence the `dead_code` allowance.
117 #[cfg(test)]
118 #[must_use]
119 pub fn accounted(&self) -> bool {
120 self.cells_examined
121 == self.cells_scrimmed
122 + self.cells_dotted
123 + self.cells_skipped_focus
124 + self.cells_skipped_transparent
125 + self.cells_skipped_text
126 }
127 }
128
129 /// `true` when `(x, y)` lies inside `rect`.
130 fn rect_contains(rect: Rect, x: u16, y: u16) -> bool {
131 x >= rect.left() && x < rect.right() && y >= rect.top() && y < rect.bottom()
132 }
133
134 /// Deterministic grain placement: a pure function of the cell coordinates,
135 /// so the texture is static (motion-off-safe) and reproducible.
136 fn grain_dot_at(x: u16, y: u16) -> bool {
137 x.wrapping_mul(7)
138 .wrapping_add(y.wrapping_mul(13))
139 .is_multiple_of(11)
140 }
141
142 /// Apply the focus-context texture to `area`, treating `focus` as the
143 /// focused modal's occupied region. See the module docs for the guarantees.
144 ///
145 /// Returns zeroed stats and leaves the buffer untouched when the mode is
146 /// `Off`, the frame is below the minimum size, or the focus rect (clamped to
147 /// the area) covers at least `FOCUS_COVERAGE_NOOP_PERCENT`% of the frame.
148 pub fn apply_focus_texture(
149 area: Rect,
150 buf: &mut Buffer,
151 focus: Rect,
152 theme: &UiTheme,
153 mode: FocusTextureMode,
154 ascii_safe: bool,
155 ) -> FocusTextureStats {
156 let mut stats = FocusTextureStats::default();
157 if mode == FocusTextureMode::Off {
158 return stats;
159 }
160 if area.width < FOCUS_TEXTURE_MIN_WIDTH || area.height < FOCUS_TEXTURE_MIN_HEIGHT {
161 return stats;
162 }
163 let focus = focus.intersection(area);
164 // u64: a full u16-square frame already fits u32, but the *100 coverage
165 // compare would not.
166 let area_cells = u64::from(area.width) * u64::from(area.height);
167 let focus_cells = u64::from(focus.width) * u64::from(focus.height);
168 if area_cells == 0 || focus_cells * 100 >= area_cells * FOCUS_COVERAGE_NOOP_PERCENT {
169 return stats;
170 }
171
172 for y in area.top()..area.bottom() {
173 for x in area.left()..area.right() {
174 if rect_contains(focus, x, y) {
175 stats.cells_examined += 1;
176 stats.cells_skipped_focus += 1;
177 continue;
178 }
179 match mode {
180 FocusTextureMode::Off => unreachable!("off returns early"),
181 FocusTextureMode::Scrim => {
182 stats.cells_examined += 1;
183 let cell = &buf[(x, y)];
184 // Transparent-terminal fallback: the terminal owns this
185 // background, so dimming it would be a guess. Skip.
186 if cell.bg == Color::Reset {
187 stats.cells_skipped_transparent += 1;
188 continue;
189 }
190 let new_bg =
191 crate::tui::ocean::mix_colors(cell.bg, theme.surface_bg, SCRIM_BG_BLEND);
192 let blended_fg =
193 crate::tui::ocean::mix_colors(cell.fg, theme.surface_bg, SCRIM_FG_BLEND);
194 // Text is never obscured by construction: when both
195 // colors are resolvable this lifts the foreground back to
196 // the AA body floor against the *new* background; when
197 // either side is terminal-owned the color is left alone.
198 let new_fg = palette::enforce_contrast(blended_fg, new_bg, AA_BODY_CONTRAST);
199 let cell = &mut buf[(x, y)];
200 cell.bg = new_bg;
201 cell.fg = new_fg;
202 stats.cells_scrimmed += 1;
203 }
204 FocusTextureMode::Grain => {
205 let cell = &buf[(x, y)];
206 // Never write over a cell that carries a symbol: grain is
207 // a background texture, not an ink.
208 if !cell.symbol().trim().is_empty() {
209 stats.cells_examined += 1;
210 stats.cells_skipped_text += 1;
211 continue;
212 }
213 // Blank cells that earn no dot are left untouched and
214 // unexamined (see the stats docs).
215 if !grain_dot_at(x, y) {
216 continue;
217 }
218 stats.cells_examined += 1;
219 let dot = if ascii_safe {
220 crate::tui::glyphs::ascii_fallback(GRAIN_DOT).unwrap_or(".")
221 } else {
222 GRAIN_DOT
223 };
224 let cell = &mut buf[(x, y)];
225 cell.set_symbol(dot);
226 // Set the dim ink only when it is resolvable; a
227 // terminal-owned `text_dim` (the Terminal theme) stays
228 // as-is rather than being guessed.
229 if palette::resolvable_rgb(theme.text_dim).is_some() {
230 cell.set_fg(theme.text_dim);
231 }
232 stats.cells_dotted += 1;
233 }
234 }
235 }
236 }
237 stats
238 }
239
240 #[cfg(test)]
241 mod tests {
242 use super::*;
243 use ratatui::style::Style;
244
245 /// A fully resolvable (Rgb) theme for the texture's color math.
246 ///
247 /// Whale Flat deliberately leaves its shell surface terminal-owned, so it
248 /// is not a valid fixture for tests that exercise RGB scrim blending.
249 fn theme() -> UiTheme {
250 let theme = codewhale_palette::ThemeId::Dracula.ui_theme();
251 assert!(palette::resolvable_rgb(theme.surface_bg).is_some());
252 assert!(palette::resolvable_rgb(theme.text_dim).is_some());
253 theme
254 }
255
256 /// A 60x20 frame: large enough for the texture, small enough to eyeball.
257 fn test_area() -> Rect {
258 Rect::new(0, 0, 60, 20)
259 }
260
261 /// A focus rect well under the 90% coverage threshold (200 of 1200).
262 fn test_focus() -> Rect {
263 Rect::new(10, 5, 20, 10)
264 }
265
266 fn blank_buffer(area: Rect) -> Buffer {
267 Buffer::empty(area)
268 }
269
270 fn assert_accounted(stats: FocusTextureStats) {
271 assert!(stats.accounted(), "accounting identity broken: {stats:?}");
272 }
273
274 #[test]
275 fn mode_parse_covers_every_setting_value() {
276 for (value, mode) in [
277 ("off", FocusTextureMode::Off),
278 ("scrim", FocusTextureMode::Scrim),
279 ("grain", FocusTextureMode::Grain),
280 ] {
281 assert_eq!(FocusTextureMode::parse(value), Some(mode));
282 }
283 assert_eq!(
284 FocusTextureMode::parse(" SCRIM "),
285 Some(FocusTextureMode::Scrim)
286 );
287 assert_eq!(
288 FocusTextureMode::parse("Grain"),
289 Some(FocusTextureMode::Grain)
290 );
291 assert_eq!(FocusTextureMode::parse("static"), None);
292 assert_eq!(FocusTextureMode::parse(""), None);
293 assert_eq!(FocusTextureMode::default(), FocusTextureMode::Off);
294 }
295
296 #[test]
297 fn off_leaves_buffer_untouched() {
298 let area = test_area();
299 let mut buf = blank_buffer(area);
300 buf[(0, 0)].set_symbol("a").set_fg(Color::White);
301 buf[(1, 0)].set_bg(Color::Reset);
302 let original = buf.clone();
303
304 let stats = apply_focus_texture(
305 area,
306 &mut buf,
307 test_focus(),
308 &theme(),
309 FocusTextureMode::Off,
310 false,
311 );
312
313 assert_eq!(stats, FocusTextureStats::default());
314 assert_eq!(buf, original);
315 }
316
317 #[test]
318 fn near_fullscreen_focus_is_noop() {
319 let area = test_area();
320 // 59x20 = 1180 of 1200 cells (98%): over the 90% threshold.
321 for focus in [area, Rect::new(0, 0, 59, 20)] {
322 let mut buf = blank_buffer(area);
323 let original = buf.clone();
324 let stats = apply_focus_texture(
325 area,
326 &mut buf,
327 focus,
328 &theme(),
329 FocusTextureMode::Scrim,
330 false,
331 );
332 assert_eq!(stats, FocusTextureStats::default(), "focus {focus:?}");
333 assert_eq!(buf, original, "focus {focus:?}");
334 }
335 }
336
337 #[test]
338 fn small_area_is_noop() {
339 for area in [Rect::new(0, 0, 39, 20), Rect::new(0, 0, 60, 9)] {
340 let mut buf = blank_buffer(area);
341 let original = buf.clone();
342 let stats = apply_focus_texture(
343 area,
344 &mut buf,
345 Rect::new(0, 0, 4, 2),
346 &theme(),
347 FocusTextureMode::Grain,
348 false,
349 );
350 assert_eq!(stats, FocusTextureStats::default(), "area {area:?}");
351 assert_eq!(buf, original, "area {area:?}");
352 }
353 }
354
355 #[test]
356 fn scrim_preserves_focus_and_transparent_cells() {
357 let area = test_area();
358 let focus = test_focus();
359 let mut buf = blank_buffer(area);
360 let focus_style = Style::default().fg(Color::White).bg(Color::Blue);
361 let mut reset_cells = 0_u32;
362 for y in area.top()..area.bottom() {
363 for x in area.left()..area.right() {
364 if rect_contains(focus, x, y) {
365 buf[(x, y)].set_symbol("F").set_style(focus_style);
366 } else if (x + y) % 2 == 0 {
367 // Transparent-terminal cells outside the focus.
368 buf[(x, y)].set_bg(Color::Reset);
369 reset_cells += 1;
370 } else {
371 buf[(x, y)]
372 .set_symbol("t")
373 .set_fg(Color::Rgb(200, 200, 200))
374 .set_bg(Color::Rgb(40, 40, 40));
375 }
376 }
377 }
378 let original = buf.clone();
379
380 let stats = apply_focus_texture(
381 area,
382 &mut buf,
383 focus,
384 &theme(),
385 FocusTextureMode::Scrim,
386 false,
387 );
388
389 assert_accounted(stats);
390 assert_eq!(stats.cells_examined, 60 * 20);
391 assert_eq!(stats.cells_skipped_focus, 20 * 10);
392 assert_eq!(stats.cells_skipped_transparent, reset_cells);
393 assert_eq!(stats.cells_dotted, 0);
394 for y in area.top()..area.bottom() {
395 for x in area.left()..area.right() {
396 if rect_contains(focus, x, y) {
397 assert_eq!(
398 buf[(x, y)],
399 original[(x, y)],
400 "focus cell ({x},{y}) must stay byte-identical"
401 );
402 } else if (x + y) % 2 == 0 {
403 assert_eq!(
404 buf[(x, y)],
405 original[(x, y)],
406 "Reset-bg cell ({x},{y}) must stay untouched"
407 );
408 }
409 }
410 }
411 }
412
413 #[test]
414 fn scrim_text_keeps_aa_contrast_when_resolvable() {
415 let area = test_area();
416 let focus = test_focus();
417 let theme = theme();
418 let combos = [
419 (Color::Rgb(255, 255, 255), Color::Rgb(30, 30, 30)),
420 (Color::Rgb(200, 200, 200), Color::Rgb(240, 240, 240)),
421 (Color::Rgb(120, 120, 120), Color::Rgb(110, 110, 110)),
422 (Color::Rgb(40, 80, 200), Color::Rgb(20, 20, 30)),
423 ];
424 for (row, (fg, bg)) in combos.iter().enumerate() {
425 let mut buf = blank_buffer(area);
426 let y = row as u16;
427 let mut seeded = Vec::new();
428 for x in area.left()..area.right() {
429 if rect_contains(focus, x, y) {
430 continue;
431 }
432 buf[(x, y)].set_symbol("t").set_fg(*fg).set_bg(*bg);
433 seeded.push(x);
434 }
435
436 let stats = apply_focus_texture(
437 area,
438 &mut buf,
439 focus,
440 &theme,
441 FocusTextureMode::Scrim,
442 false,
443 );
444
445 assert_accounted(stats);
446 for x in seeded {
447 let cell = &buf[(x, y)];
448 assert_eq!(cell.symbol(), "t", "glyph must survive the scrim");
449 let ratio = palette::contrast_ratio(cell.fg, cell.bg)
450 .expect("seeded colors are Rgb and stay resolvable");
451 assert!(
452 ratio >= AA_BODY_CONTRAST,
453 "combo {fg:?}/{bg:?} ended at {ratio}:1, below the AA floor"
454 );
455 }
456 }
457 }
458
459 #[test]
460 fn grain_never_overwrites_text_and_dots_are_deterministic() {
461 let area = test_area();
462 let focus = test_focus();
463 let theme = theme();
464 let mut buf = blank_buffer(area);
465 let mut text_cells = Vec::new();
466 for y in area.top()..area.bottom() {
467 for x in area.left()..area.right() {
468 if !rect_contains(focus, x, y) && (x + y) % 3 == 0 {
469 buf[(x, y)].set_symbol("a").set_fg(Color::White);
470 text_cells.push((x, y));
471 }
472 }
473 }
474 let original = buf.clone();
475
476 let stats = apply_focus_texture(
477 area,
478 &mut buf,
479 focus,
480 &theme,
481 FocusTextureMode::Grain,
482 false,
483 );
484
485 assert_accounted(stats);
486 assert_eq!(stats.cells_skipped_focus, 20 * 10);
487 assert_eq!(stats.cells_skipped_text, text_cells.len() as u32);
488 assert_eq!(stats.cells_scrimmed, 0);
489 // Every seeded text cell is untouched, byte for byte.
490 for (x, y) in &text_cells {
491 assert_eq!(
492 buf[(*x, *y)],
493 original[(*x, *y)],
494 "text cell ({x},{y}) must never be overwritten"
495 );
496 }
497 // Dots land exactly at the deterministic positions on blank cells.
498 let mut expected_dots = 0_u32;
499 for y in area.top()..area.bottom() {
500 for x in area.left()..area.right() {
501 if rect_contains(focus, x, y) || (x + y) % 3 == 0 {
502 continue;
503 }
504 if grain_dot_at(x, y) {
505 expected_dots += 1;
506 assert_eq!(buf[(x, y)].symbol(), GRAIN_DOT, "dot at ({x},{y})");
507 assert_eq!(buf[(x, y)].fg, theme.text_dim);
508 } else {
509 assert_eq!(
510 buf[(x, y)],
511 original[(x, y)],
512 "non-dot blank cell ({x},{y}) must stay untouched"
513 );
514 }
515 }
516 }
517 assert_eq!(stats.cells_dotted, expected_dots);
518 }
519
520 #[test]
521 fn grain_ascii_safe_uses_plain_dot() {
522 let area = test_area();
523 let focus = test_focus();
524 let mut buf = blank_buffer(area);
525
526 let stats = apply_focus_texture(
527 area,
528 &mut buf,
529 focus,
530 &theme(),
531 FocusTextureMode::Grain,
532 true,
533 );
534
535 assert_accounted(stats);
536 assert!(stats.cells_dotted > 0);
537 for y in area.top()..area.bottom() {
538 for x in area.left()..area.right() {
539 if !rect_contains(focus, x, y) && grain_dot_at(x, y) {
540 assert_eq!(buf[(x, y)].symbol(), ".", "ascii dot at ({x},{y})");
541 }
542 }
543 }
544 }
545
546 #[test]
547 fn grain_is_deterministic_across_applications() {
548 let area = test_area();
549 let focus = test_focus();
550 let theme = theme();
551 let mut first = blank_buffer(area);
552 let mut second = blank_buffer(area);
553
554 apply_focus_texture(
555 area,
556 &mut first,
557 focus,
558 &theme,
559 FocusTextureMode::Grain,
560 false,
561 );
562 apply_focus_texture(
563 area,
564 &mut second,
565 focus,
566 &theme,
567 FocusTextureMode::Grain,
568 false,
569 );
570
571 // Static texture: no time component, so motion-off needs no special
572 // path and repeated passes over the same buffer agree exactly.
573 assert_eq!(first, second);
574 }
575 }
576
576 lines RUST