//! Desaturating the contents of unfocused panes. //! //! Zellij can already dim a pane's FRAME (see `dimmed_foreground_color`), which //! uses the SGR "faint" attribute. That is not enough here: faint is //! terminal-dependent, several terminals only apply it to the 16 ANSI colours, //! and it does not remove colour. To actually drain the colour out of an //! unfocused pane we have to resolve each cell's colour to RGB and blend it //! toward its own luminance. //! //! Cells with NO explicit colour are deliberately left alone. They render in the //! terminal's default foreground, which is already an unsaturated grey in any //! sane theme, and rewriting them would mean emitting an explicit colour for //! nearly every cell on screen - much larger diffs over the wire for no visible //! benefit. use crate::output::CharacterChunk; use crate::panes::{AnsiCode, NamedColor}; use zellij_utils::shared::eightbit_to_rgb; /// Rec. 709 luma coefficients: the standard perceptual weighting. Using a plain /// average instead makes greens too dark and blues too light. fn luminance((r, g, b): (u8, u8, u8)) -> u8 { let y = 0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32; y.round().clamp(0.0, 255.0) as u8 } fn blend(from: u8, to: u8, strength: u8) -> u8 { let s = strength.min(100) as f32 / 100.0; (from as f32 + (to as f32 - from as f32) * s) .round() .clamp(0.0, 255.0) as u8 } fn named_color_to_index(color: NamedColor) -> u8 { match color { NamedColor::Black => 0, NamedColor::Red => 1, NamedColor::Green => 2, NamedColor::Yellow => 3, NamedColor::Blue => 4, NamedColor::Magenta => 5, NamedColor::Cyan => 6, NamedColor::White => 7, NamedColor::BrightBlack => 8, NamedColor::BrightRed => 9, NamedColor::BrightGreen => 10, NamedColor::BrightYellow => 11, NamedColor::BrightBlue => 12, NamedColor::BrightMagenta => 13, NamedColor::BrightCyan => 14, NamedColor::BrightWhite => 15, } } /// Blend a colour toward its own luminance by `strength` percent. /// /// Returns None for codes that do not carry a colour (On/Reset/Underline), so /// callers can leave them untouched. pub fn grayscale_ansi_code(code: AnsiCode, strength: u8) -> Option { if strength == 0 { return None; } let rgb = match code { AnsiCode::RgbCode(rgb) => rgb, AnsiCode::ColorIndex(i) => eightbit_to_rgb(i), AnsiCode::NamedColor(c) => eightbit_to_rgb(named_color_to_index(c)), // On / Reset / Underline carry no colour of their own _ => return None, }; let gray = luminance(rgb); Some(AnsiCode::RgbCode(( blend(rgb.0, gray, strength), blend(rgb.1, gray, strength), blend(rgb.2, gray, strength), ))) } /// Desaturate every coloured cell in these chunks, in place. pub fn grayscale_character_chunks(chunks: &mut [CharacterChunk], strength: u8) { if strength == 0 { return; } for chunk in chunks.iter_mut() { for character in chunk.terminal_characters.iter_mut() { character.styles.update(|styles| { if let Some(fg) = styles.foreground { if let Some(gray) = grayscale_ansi_code(fg, strength) { styles.foreground = Some(gray); } } if let Some(bg) = styles.background { if let Some(gray) = grayscale_ansi_code(bg, strength) { styles.background = Some(gray); } } }); } } } #[cfg(test)] mod tests { use super::*; fn rgb_of(code: Option) -> (u8, u8, u8) { match code { Some(AnsiCode::RgbCode(rgb)) => rgb, other => panic!("expected an RgbCode, got {:?}", other), } } #[test] fn full_strength_produces_a_true_grey() { // all three channels equal == no colour left let (r, g, b) = rgb_of(grayscale_ansi_code(AnsiCode::RgbCode((200, 40, 40)), 100)); assert_eq!((r, g, b), (r, r, r), "fully desaturated must be neutral grey"); } #[test] fn zero_strength_is_a_no_op() { assert!(grayscale_ansi_code(AnsiCode::RgbCode((200, 40, 40)), 0).is_none()); } #[test] fn partial_strength_sits_between_original_and_grey() { let orig = (200u8, 40u8, 40u8); let full = rgb_of(grayscale_ansi_code(AnsiCode::RgbCode(orig), 100)); let half = rgb_of(grayscale_ansi_code(AnsiCode::RgbCode(orig), 50)); assert!( half.0 < orig.0 && half.0 > full.0, "red channel {} should sit between original {} and grey {}", half.0, orig.0, full.0 ); } #[test] fn luminance_is_perceptual_not_a_flat_average() { // pure green is perceived as much brighter than pure blue; a naive // average would make them identical let green = luminance((0, 255, 0)); let blue = luminance((0, 0, 255)); assert!( green > blue, "green ({}) must be brighter than blue ({})", green, blue ); } #[test] fn indexed_and_named_colours_are_resolved_not_skipped() { // the interesting case: most terminal output uses these, not RGB assert!(grayscale_ansi_code(AnsiCode::ColorIndex(196), 100).is_some()); assert!(grayscale_ansi_code(AnsiCode::NamedColor(NamedColor::Red), 100).is_some()); } #[test] fn already_grey_colours_are_unchanged() { let grey = (128, 128, 128); assert_eq!(rgb_of(grayscale_ansi_code(AnsiCode::RgbCode(grey), 100)), grey); } #[test] fn non_colour_codes_are_left_alone() { assert!(grayscale_ansi_code(AnsiCode::Reset, 100).is_none()); assert!(grayscale_ansi_code(AnsiCode::On, 100).is_none()); } }