aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-server/src/ui/grayscale.rs
blob: 387fe70c5a0f95fd7d2cf0ca7854181f0140a7c1 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
//! 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<AnsiCode> {
    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<AnsiCode>) -> (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());
    }
}