use ratatui::{buffer::Buffer, layout::Rect, style::Color};
use crate::palette::{self, AA_BODY_CONTRAST, UiTheme};
pub const FOCUS_TEXTURE_MIN_WIDTH: u16 = crate::tui::ocean::AMBIENT_MIN_WIDTH;
pub const FOCUS_TEXTURE_MIN_HEIGHT: u16 = crate::tui::ocean::AMBIENT_MIN_HEIGHT;
const FOCUS_COVERAGE_NOOP_PERCENT: u64 = 90;
const SCRIM_BG_BLEND: f32 = 0.5;
const SCRIM_FG_BLEND: f32 = 0.25;
const GRAIN_DOT: &str = "·";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FocusTextureMode {
#[default]
Off,
Scrim,
Grain,
}
impl FocusTextureMode {
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"off" => Some(Self::Off),
"scrim" => Some(Self::Scrim),
"grain" => Some(Self::Grain),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FocusTextureStats {
pub cells_examined: u32,
pub cells_scrimmed: u32,
pub cells_dotted: u32,
pub cells_skipped_focus: u32,
pub cells_skipped_transparent: u32,
pub cells_skipped_text: u32,
}
impl FocusTextureStats {
#[allow(dead_code)]
#[must_use]
pub fn accounted(&self) -> bool {
self.cells_examined
== self.cells_scrimmed
+ self.cells_dotted
+ self.cells_skipped_focus
+ self.cells_skipped_transparent
+ self.cells_skipped_text
}
}
fn rect_contains(rect: Rect, x: u16, y: u16) -> bool {
x >= rect.left() && x < rect.right() && y >= rect.top() && y < rect.bottom()
}
fn grain_dot_at(x: u16, y: u16) -> bool {
x.wrapping_mul(7)
.wrapping_add(y.wrapping_mul(13))
.is_multiple_of(11)
}
pub fn apply_focus_texture(
area: Rect,
buf: &mut Buffer,
focus: Rect,
theme: &UiTheme,
mode: FocusTextureMode,
ascii_safe: bool,
) -> FocusTextureStats {
let mut stats = FocusTextureStats::default();
if mode == FocusTextureMode::Off {
return stats;
}
if area.width < FOCUS_TEXTURE_MIN_WIDTH || area.height < FOCUS_TEXTURE_MIN_HEIGHT {
return stats;
}
let focus = focus.intersection(area);
let area_cells = u64::from(area.width) * u64::from(area.height);
let focus_cells = u64::from(focus.width) * u64::from(focus.height);
if area_cells == 0 || focus_cells * 100 >= area_cells * FOCUS_COVERAGE_NOOP_PERCENT {
return stats;
}
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
if rect_contains(focus, x, y) {
stats.cells_examined += 1;
stats.cells_skipped_focus += 1;
continue;
}
match mode {
FocusTextureMode::Off => unreachable!("off returns early"),
FocusTextureMode::Scrim => {
stats.cells_examined += 1;
let cell = &buf[(x, y)];
if cell.bg == Color::Reset {
stats.cells_skipped_transparent += 1;
continue;
}
let new_bg =
crate::tui::ocean::mix_colors(cell.bg, theme.surface_bg, SCRIM_BG_BLEND);
let blended_fg =
crate::tui::ocean::mix_colors(cell.fg, theme.surface_bg, SCRIM_FG_BLEND);
let new_fg = palette::enforce_contrast(blended_fg, new_bg, AA_BODY_CONTRAST);
let cell = &mut buf[(x, y)];
cell.bg = new_bg;
cell.fg = new_fg;
stats.cells_scrimmed += 1;
}
FocusTextureMode::Grain => {
let cell = &buf[(x, y)];
if !cell.symbol().trim().is_empty() {
stats.cells_examined += 1;
stats.cells_skipped_text += 1;
continue;
}
if !grain_dot_at(x, y) {
continue;
}
stats.cells_examined += 1;
let dot = if ascii_safe {
crate::tui::glyphs::ascii_fallback(GRAIN_DOT).unwrap_or(".")
} else {
GRAIN_DOT
};
let cell = &mut buf[(x, y)];
cell.set_symbol(dot);
if palette::resolvable_rgb(theme.text_dim).is_some() {
cell.set_fg(theme.text_dim);
}
stats.cells_dotted += 1;
}
}
}
}
stats
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Style;
fn theme() -> UiTheme {
let theme = crate::palette::ThemeId::Whale.ui_theme();
assert!(palette::resolvable_rgb(theme.surface_bg).is_some());
assert!(palette::resolvable_rgb(theme.text_dim).is_some());
theme
}
fn test_area() -> Rect {
Rect::new(0, 0, 60, 20)
}
fn test_focus() -> Rect {
Rect::new(10, 5, 20, 10)
}
fn blank_buffer(area: Rect) -> Buffer {
Buffer::empty(area)
}
fn assert_accounted(stats: FocusTextureStats) {
assert!(stats.accounted(), "accounting identity broken: {stats:?}");
}
#[test]
fn mode_parse_covers_every_setting_value() {
for (value, mode) in [
("off", FocusTextureMode::Off),
("scrim", FocusTextureMode::Scrim),
("grain", FocusTextureMode::Grain),
] {
assert_eq!(FocusTextureMode::parse(value), Some(mode));
}
assert_eq!(
FocusTextureMode::parse(" SCRIM "),
Some(FocusTextureMode::Scrim)
);
assert_eq!(
FocusTextureMode::parse("Grain"),
Some(FocusTextureMode::Grain)
);
assert_eq!(FocusTextureMode::parse("static"), None);
assert_eq!(FocusTextureMode::parse(""), None);
assert_eq!(FocusTextureMode::default(), FocusTextureMode::Off);
}
#[test]
fn off_leaves_buffer_untouched() {
let area = test_area();
let mut buf = blank_buffer(area);
buf[(0, 0)].set_symbol("a").set_fg(Color::White);
buf[(1, 0)].set_bg(Color::Reset);
let original = buf.clone();
let stats = apply_focus_texture(
area,
&mut buf,
test_focus(),
&theme(),
FocusTextureMode::Off,
false,
);
assert_eq!(stats, FocusTextureStats::default());
assert_eq!(buf, original);
}
#[test]
fn near_fullscreen_focus_is_noop() {
let area = test_area();
for focus in [area, Rect::new(0, 0, 59, 20)] {
let mut buf = blank_buffer(area);
let original = buf.clone();
let stats = apply_focus_texture(
area,
&mut buf,
focus,
&theme(),
FocusTextureMode::Scrim,
false,
);
assert_eq!(stats, FocusTextureStats::default(), "focus {focus:?}");
assert_eq!(buf, original, "focus {focus:?}");
}
}
#[test]
fn small_area_is_noop() {
for area in [Rect::new(0, 0, 39, 20), Rect::new(0, 0, 60, 9)] {
let mut buf = blank_buffer(area);
let original = buf.clone();
let stats = apply_focus_texture(
area,
&mut buf,
Rect::new(0, 0, 4, 2),
&theme(),
FocusTextureMode::Grain,
false,
);
assert_eq!(stats, FocusTextureStats::default(), "area {area:?}");
assert_eq!(buf, original, "area {area:?}");
}
}
#[test]
fn scrim_preserves_focus_and_transparent_cells() {
let area = test_area();
let focus = test_focus();
let mut buf = blank_buffer(area);
let focus_style = Style::default().fg(Color::White).bg(Color::Blue);
let mut reset_cells = 0_u32;
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
if rect_contains(focus, x, y) {
buf[(x, y)].set_symbol("F").set_style(focus_style);
} else if (x + y) % 2 == 0 {
buf[(x, y)].set_bg(Color::Reset);
reset_cells += 1;
} else {
buf[(x, y)]
.set_symbol("t")
.set_fg(Color::Rgb(200, 200, 200))
.set_bg(Color::Rgb(40, 40, 40));
}
}
}
let original = buf.clone();
let stats = apply_focus_texture(
area,
&mut buf,
focus,
&theme(),
FocusTextureMode::Scrim,
false,
);
assert_accounted(stats);
assert_eq!(stats.cells_examined, 60 * 20);
assert_eq!(stats.cells_skipped_focus, 20 * 10);
assert_eq!(stats.cells_skipped_transparent, reset_cells);
assert_eq!(stats.cells_dotted, 0);
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
if rect_contains(focus, x, y) {
assert_eq!(
buf[(x, y)],
original[(x, y)],
"focus cell ({x},{y}) must stay byte-identical"
);
} else if (x + y) % 2 == 0 {
assert_eq!(
buf[(x, y)],
original[(x, y)],
"Reset-bg cell ({x},{y}) must stay untouched"
);
}
}
}
}
#[test]
fn scrim_text_keeps_aa_contrast_when_resolvable() {
let area = test_area();
let focus = test_focus();
let theme = theme();
let combos = [
(Color::Rgb(255, 255, 255), Color::Rgb(30, 30, 30)),
(Color::Rgb(200, 200, 200), Color::Rgb(240, 240, 240)),
(Color::Rgb(120, 120, 120), Color::Rgb(110, 110, 110)),
(Color::Rgb(40, 80, 200), Color::Rgb(20, 20, 30)),
];
for (row, (fg, bg)) in combos.iter().enumerate() {
let mut buf = blank_buffer(area);
let y = row as u16;
let mut seeded = Vec::new();
for x in area.left()..area.right() {
if rect_contains(focus, x, y) {
continue;
}
buf[(x, y)].set_symbol("t").set_fg(*fg).set_bg(*bg);
seeded.push(x);
}
let stats = apply_focus_texture(
area,
&mut buf,
focus,
&theme,
FocusTextureMode::Scrim,
false,
);
assert_accounted(stats);
for x in seeded {
let cell = &buf[(x, y)];
assert_eq!(cell.symbol(), "t", "glyph must survive the scrim");
let ratio = palette::contrast_ratio(cell.fg, cell.bg)
.expect("seeded colors are Rgb and stay resolvable");
assert!(
ratio >= AA_BODY_CONTRAST,
"combo {fg:?}/{bg:?} ended at {ratio}:1, below the AA floor"
);
}
}
}
#[test]
fn grain_never_overwrites_text_and_dots_are_deterministic() {
let area = test_area();
let focus = test_focus();
let theme = theme();
let mut buf = blank_buffer(area);
let mut text_cells = Vec::new();
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
if !rect_contains(focus, x, y) && (x + y) % 3 == 0 {
buf[(x, y)].set_symbol("a").set_fg(Color::White);
text_cells.push((x, y));
}
}
}
let original = buf.clone();
let stats = apply_focus_texture(
area,
&mut buf,
focus,
&theme,
FocusTextureMode::Grain,
false,
);
assert_accounted(stats);
assert_eq!(stats.cells_skipped_focus, 20 * 10);
assert_eq!(stats.cells_skipped_text, text_cells.len() as u32);
assert_eq!(stats.cells_scrimmed, 0);
for (x, y) in &text_cells {
assert_eq!(
buf[(*x, *y)],
original[(*x, *y)],
"text cell ({x},{y}) must never be overwritten"
);
}
let mut expected_dots = 0_u32;
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
if rect_contains(focus, x, y) || (x + y) % 3 == 0 {
continue;
}
if grain_dot_at(x, y) {
expected_dots += 1;
assert_eq!(buf[(x, y)].symbol(), GRAIN_DOT, "dot at ({x},{y})");
assert_eq!(buf[(x, y)].fg, theme.text_dim);
} else {
assert_eq!(
buf[(x, y)],
original[(x, y)],
"non-dot blank cell ({x},{y}) must stay untouched"
);
}
}
}
assert_eq!(stats.cells_dotted, expected_dots);
}
#[test]
fn grain_ascii_safe_uses_plain_dot() {
let area = test_area();
let focus = test_focus();
let mut buf = blank_buffer(area);
let stats = apply_focus_texture(
area,
&mut buf,
focus,
&theme(),
FocusTextureMode::Grain,
true,
);
assert_accounted(stats);
assert!(stats.cells_dotted > 0);
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
if !rect_contains(focus, x, y) && grain_dot_at(x, y) {
assert_eq!(buf[(x, y)].symbol(), ".", "ascii dot at ({x},{y})");
}
}
}
}
#[test]
fn grain_is_deterministic_across_applications() {
let area = test_area();
let focus = test_focus();
let theme = theme();
let mut first = blank_buffer(area);
let mut second = blank_buffer(area);
apply_focus_texture(
area,
&mut first,
focus,
&theme,
FocusTextureMode::Grain,
false,
);
apply_focus_texture(
area,
&mut second,
focus,
&theme,
FocusTextureMode::Grain,
false,
);
assert_eq!(first, second);
}
}