返回 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 crate::palette::{self, 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 #[allow(dead_code)]
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 /// The Blue Stage theme: fully resolvable (Rgb) surface and dim ink.
246 fn theme() -> UiTheme {
247 let theme = crate::palette::ThemeId::Whale.ui_theme();
248 assert!(palette::resolvable_rgb(theme.surface_bg).is_some());
249 assert!(palette::resolvable_rgb(theme.text_dim).is_some());
250 theme
251 }
252
253 /// A 60x20 frame: large enough for the texture, small enough to eyeball.
254 fn test_area() -> Rect {
255 Rect::new(0, 0, 60, 20)
256 }
257
258 /// A focus rect well under the 90% coverage threshold (200 of 1200).
259 fn test_focus() -> Rect {
260 Rect::new(10, 5, 20, 10)
261 }
262
263 fn blank_buffer(area: Rect) -> Buffer {
264 Buffer::empty(area)
265 }
266
267 fn assert_accounted(stats: FocusTextureStats) {
268 assert!(stats.accounted(), "accounting identity broken: {stats:?}");
269 }
270
271 #[test]
272 fn mode_parse_covers_every_setting_value() {
273 for (value, mode) in [
274 ("off", FocusTextureMode::Off),
275 ("scrim", FocusTextureMode::Scrim),
276 ("grain", FocusTextureMode::Grain),
277 ] {
278 assert_eq!(FocusTextureMode::parse(value), Some(mode));
279 }
280 assert_eq!(
281 FocusTextureMode::parse(" SCRIM "),
282 Some(FocusTextureMode::Scrim)
283 );
284 assert_eq!(
285 FocusTextureMode::parse("Grain"),
286 Some(FocusTextureMode::Grain)
287 );
288 assert_eq!(FocusTextureMode::parse("static"), None);
289 assert_eq!(FocusTextureMode::parse(""), None);
290 assert_eq!(FocusTextureMode::default(), FocusTextureMode::Off);
291 }
292
293 #[test]
294 fn off_leaves_buffer_untouched() {
295 let area = test_area();
296 let mut buf = blank_buffer(area);
297 buf[(0, 0)].set_symbol("a").set_fg(Color::White);
298 buf[(1, 0)].set_bg(Color::Reset);
299 let original = buf.clone();
300
301 let stats = apply_focus_texture(
302 area,
303 &mut buf,
304 test_focus(),
305 &theme(),
306 FocusTextureMode::Off,
307 false,
308 );
309
310 assert_eq!(stats, FocusTextureStats::default());
311 assert_eq!(buf, original);
312 }
313
314 #[test]
315 fn near_fullscreen_focus_is_noop() {
316 let area = test_area();
317 // 59x20 = 1180 of 1200 cells (98%): over the 90% threshold.
318 for focus in [area, Rect::new(0, 0, 59, 20)] {
319 let mut buf = blank_buffer(area);
320 let original = buf.clone();
321 let stats = apply_focus_texture(
322 area,
323 &mut buf,
324 focus,
325 &theme(),
326 FocusTextureMode::Scrim,
327 false,
328 );
329 assert_eq!(stats, FocusTextureStats::default(), "focus {focus:?}");
330 assert_eq!(buf, original, "focus {focus:?}");
331 }
332 }
333
334 #[test]
335 fn small_area_is_noop() {
336 for area in [Rect::new(0, 0, 39, 20), Rect::new(0, 0, 60, 9)] {
337 let mut buf = blank_buffer(area);
338 let original = buf.clone();
339 let stats = apply_focus_texture(
340 area,
341 &mut buf,
342 Rect::new(0, 0, 4, 2),
343 &theme(),
344 FocusTextureMode::Grain,
345 false,
346 );
347 assert_eq!(stats, FocusTextureStats::default(), "area {area:?}");
348 assert_eq!(buf, original, "area {area:?}");
349 }
350 }
351
352 #[test]
353 fn scrim_preserves_focus_and_transparent_cells() {
354 let area = test_area();
355 let focus = test_focus();
356 let mut buf = blank_buffer(area);
357 let focus_style = Style::default().fg(Color::White).bg(Color::Blue);
358 let mut reset_cells = 0_u32;
359 for y in area.top()..area.bottom() {
360 for x in area.left()..area.right() {
361 if rect_contains(focus, x, y) {
362 buf[(x, y)].set_symbol("F").set_style(focus_style);
363 } else if (x + y) % 2 == 0 {
364 // Transparent-terminal cells outside the focus.
365 buf[(x, y)].set_bg(Color::Reset);
366 reset_cells += 1;
367 } else {
368 buf[(x, y)]
369 .set_symbol("t")
370 .set_fg(Color::Rgb(200, 200, 200))
371 .set_bg(Color::Rgb(40, 40, 40));
372 }
373 }
374 }
375 let original = buf.clone();
376
377 let stats = apply_focus_texture(
378 area,
379 &mut buf,
380 focus,
381 &theme(),
382 FocusTextureMode::Scrim,
383 false,
384 );
385
386 assert_accounted(stats);
387 assert_eq!(stats.cells_examined, 60 * 20);
388 assert_eq!(stats.cells_skipped_focus, 20 * 10);
389 assert_eq!(stats.cells_skipped_transparent, reset_cells);
390 assert_eq!(stats.cells_dotted, 0);
391 for y in area.top()..area.bottom() {
392 for x in area.left()..area.right() {
393 if rect_contains(focus, x, y) {
394 assert_eq!(
395 buf[(x, y)],
396 original[(x, y)],
397 "focus cell ({x},{y}) must stay byte-identical"
398 );
399 } else if (x + y) % 2 == 0 {
400 assert_eq!(
401 buf[(x, y)],
402 original[(x, y)],
403 "Reset-bg cell ({x},{y}) must stay untouched"
404 );
405 }
406 }
407 }
408 }
409
410 #[test]
411 fn scrim_text_keeps_aa_contrast_when_resolvable() {
412 let area = test_area();
413 let focus = test_focus();
414 let theme = theme();
415 let combos = [
416 (Color::Rgb(255, 255, 255), Color::Rgb(30, 30, 30)),
417 (Color::Rgb(200, 200, 200), Color::Rgb(240, 240, 240)),
418 (Color::Rgb(120, 120, 120), Color::Rgb(110, 110, 110)),
419 (Color::Rgb(40, 80, 200), Color::Rgb(20, 20, 30)),
420 ];
421 for (row, (fg, bg)) in combos.iter().enumerate() {
422 let mut buf = blank_buffer(area);
423 let y = row as u16;
424 let mut seeded = Vec::new();
425 for x in area.left()..area.right() {
426 if rect_contains(focus, x, y) {
427 continue;
428 }
429 buf[(x, y)].set_symbol("t").set_fg(*fg).set_bg(*bg);
430 seeded.push(x);
431 }
432
433 let stats = apply_focus_texture(
434 area,
435 &mut buf,
436 focus,
437 &theme,
438 FocusTextureMode::Scrim,
439 false,
440 );
441
442 assert_accounted(stats);
443 for x in seeded {
444 let cell = &buf[(x, y)];
445 assert_eq!(cell.symbol(), "t", "glyph must survive the scrim");
446 let ratio = palette::contrast_ratio(cell.fg, cell.bg)
447 .expect("seeded colors are Rgb and stay resolvable");
448 assert!(
449 ratio >= AA_BODY_CONTRAST,
450 "combo {fg:?}/{bg:?} ended at {ratio}:1, below the AA floor"
451 );
452 }
453 }
454 }
455
456 #[test]
457 fn grain_never_overwrites_text_and_dots_are_deterministic() {
458 let area = test_area();
459 let focus = test_focus();
460 let theme = theme();
461 let mut buf = blank_buffer(area);
462 let mut text_cells = Vec::new();
463 for y in area.top()..area.bottom() {
464 for x in area.left()..area.right() {
465 if !rect_contains(focus, x, y) && (x + y) % 3 == 0 {
466 buf[(x, y)].set_symbol("a").set_fg(Color::White);
467 text_cells.push((x, y));
468 }
469 }
470 }
471 let original = buf.clone();
472
473 let stats = apply_focus_texture(
474 area,
475 &mut buf,
476 focus,
477 &theme,
478 FocusTextureMode::Grain,
479 false,
480 );
481
482 assert_accounted(stats);
483 assert_eq!(stats.cells_skipped_focus, 20 * 10);
484 assert_eq!(stats.cells_skipped_text, text_cells.len() as u32);
485 assert_eq!(stats.cells_scrimmed, 0);
486 // Every seeded text cell is untouched, byte for byte.
487 for (x, y) in &text_cells {
488 assert_eq!(
489 buf[(*x, *y)],
490 original[(*x, *y)],
491 "text cell ({x},{y}) must never be overwritten"
492 );
493 }
494 // Dots land exactly at the deterministic positions on blank cells.
495 let mut expected_dots = 0_u32;
496 for y in area.top()..area.bottom() {
497 for x in area.left()..area.right() {
498 if rect_contains(focus, x, y) || (x + y) % 3 == 0 {
499 continue;
500 }
501 if grain_dot_at(x, y) {
502 expected_dots += 1;
503 assert_eq!(buf[(x, y)].symbol(), GRAIN_DOT, "dot at ({x},{y})");
504 assert_eq!(buf[(x, y)].fg, theme.text_dim);
505 } else {
506 assert_eq!(
507 buf[(x, y)],
508 original[(x, y)],
509 "non-dot blank cell ({x},{y}) must stay untouched"
510 );
511 }
512 }
513 }
514 assert_eq!(stats.cells_dotted, expected_dots);
515 }
516
517 #[test]
518 fn grain_ascii_safe_uses_plain_dot() {
519 let area = test_area();
520 let focus = test_focus();
521 let mut buf = blank_buffer(area);
522
523 let stats = apply_focus_texture(
524 area,
525 &mut buf,
526 focus,
527 &theme(),
528 FocusTextureMode::Grain,
529 true,
530 );
531
532 assert_accounted(stats);
533 assert!(stats.cells_dotted > 0);
534 for y in area.top()..area.bottom() {
535 for x in area.left()..area.right() {
536 if !rect_contains(focus, x, y) && grain_dot_at(x, y) {
537 assert_eq!(buf[(x, y)].symbol(), ".", "ascii dot at ({x},{y})");
538 }
539 }
540 }
541 }
542
543 #[test]
544 fn grain_is_deterministic_across_applications() {
545 let area = test_area();
546 let focus = test_focus();
547 let theme = theme();
548 let mut first = blank_buffer(area);
549 let mut second = blank_buffer(area);
550
551 apply_focus_texture(
552 area,
553 &mut first,
554 focus,
555 &theme,
556 FocusTextureMode::Grain,
557 false,
558 );
559 apply_focus_texture(
560 area,
561 &mut second,
562 focus,
563 &theme,
564 FocusTextureMode::Grain,
565 false,
566 );
567
568 // Static texture: no time component, so motion-off needs no special
569 // path and repeated passes over the same buffer agree exactly.
570 assert_eq!(first, second);
571 }
572 }
573
573 lines RUST