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