aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-08 22:36:30 +0200
committeruser <user@clank>2026-08-08 22:36:30 +0200
commitcfae103a7afae1541a46cbfe21b011ffe4d09f2d (patch)
tree35db8eac79e73d6cf2a9cb9ccdbf885b815072cc
parentchore: stop tracking transient bgrun job artifacts (diff)
downloadsl0ppij-cfae103a7afae1541a46cbfe21b011ffe4d09f2d.tar.gz
sl0ppij-cfae103a7afae1541a46cbfe21b011ffe4d09f2d.tar.xz
sl0ppij-cfae103a7afae1541a46cbfe21b011ffe4d09f2d.zip
sl0p: grayscale the contents of unfocused panes
`ui { pane_frames { inactive_grayscale 0-100 } }`, default 0 (off). Real colour transformation, not the SGR 'faint' attribute zellij already uses for frames: faint is terminal-dependent, several terminals only apply it to the 16 ANSI colours, and it does not remove colour. Each cell's colour is resolved to RGB and blended toward its own Rec.709 luminance, so an unfocused pane keeps its brightness but drains of colour. Colour resolution covers the cases that actually occur: RgbCode directly, ColorIndex via eightbit_to_rgb, and NamedColor by mapping to its 0-15 index. Cells with NO explicit colour are deliberately left alone - they render in the terminal default, which is already unsaturated, and rewriting them would mean emitting an explicit colour for nearly every cell for no visible gain. Two non-obvious pieces were needed to make it actually work: - The output buffer only emits CHANGED cells, so an idle pane would keep its old colours indefinitely after losing focus. Pane::refresh_for_focus_change forces a full repaint, called from ActivePanes::insert (the single place focus changes). It is a no-op when the feature is off, so it costs nothing by default. - Terminal panes render through the SHARED multi-client path. Focus is per client, so once unfocused panes are desaturated two clients may need different output for the same pane. Both tiled and floating panes now fall back to per-client rendering when grayscale is enabled - the reason this is opt-in. Known limitation: sixel/kitty image chunks are not colour-transformed and stay in colour. Hot-reloads like the other sl0p options. Tests cover the transform: full strength yields a true neutral grey, partial sits between original and grey, indexed and named colours resolve rather than being skipped, luminance is perceptual (green brighter than blue) not a flat average, already-grey is unchanged, and non-colour codes are untouched. Baselines held: zellij-server 1436/30, zellij-utils 436/5.
-rw-r--r--README.md11
-rw-r--r--zellij-client/src/web_client/server_listener.rs1
-rw-r--r--zellij-server/src/lib.rs3
-rw-r--r--zellij-server/src/panes/active_panes.rs8
-rw-r--r--zellij-server/src/panes/floating_panes/mod.rs12
-rw-r--r--zellij-server/src/panes/plugin_pane.rs7
-rw-r--r--zellij-server/src/panes/terminal_pane.rs7
-rw-r--r--zellij-server/src/panes/tiled_panes/mod.rs15
-rw-r--r--zellij-server/src/screen.rs12
-rw-r--r--zellij-server/src/tab/mod.rs17
-rw-r--r--zellij-server/src/ui/grayscale.rs170
-rw-r--r--zellij-server/src/ui/mod.rs1
-rw-r--r--zellij-server/src/ui/pane_contents_and_ui.rs11
-rw-r--r--zellij-utils/src/data.rs3
-rw-r--r--zellij-utils/src/input/config.rs1
-rw-r--r--zellij-utils/src/input/theme.rs5
-rw-r--r--zellij-utils/src/kdl/mod.rs11
-rw-r--r--zellij-utils/src/plugin_api/event.rs1
-rw-r--r--zellij-utils/src/plugin_api/style.rs1
19 files changed, 290 insertions, 7 deletions
diff --git a/README.md b/README.md
index d0de0a74..77d77c54 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,17 @@ ui {
// padding 1 2 3 4 up=1 down=2 left=3 right=4
padding 1 // default: 0
+ // Desaturate the CONTENT of unfocused panes, 0-100 (default 0 = off).
+ // Colours are blended toward their own perceptual luminance, so an
+ // unfocused pane keeps its brightness but drains of colour. This is
+ // real colour transformation, not the SGR "faint" attribute.
+ //
+ // Cost: focus is per client, so clients can no longer share rendered
+ // output and each renders separately. Fine for a single user, worth
+ // knowing about in a multi-user session. Sixel/kitty IMAGES are not
+ // affected and stay in colour.
+ inactive_grayscale 85
+
// Columns per row. Terminal cells are ~2x taller than they are wide, so
// 1 row of gap needs ~2 columns to look like the same distance.
// Applies to `gap`, and to the 1-value form of `padding`. The 2- and
diff --git a/zellij-client/src/web_client/server_listener.rs b/zellij-client/src/web_client/server_listener.rs
index 398c9738..97270872 100644
--- a/zellij-client/src/web_client/server_listener.rs
+++ b/zellij-client/src/web_client/server_listener.rs
@@ -87,6 +87,7 @@ pub fn zellij_server_listener(
pane_gap_x: config.ui.pane_frames.resolved_gap().0,
pane_gap_y: config.ui.pane_frames.resolved_gap().1,
pane_padding: config.ui.pane_frames.resolved_padding(),
+ inactive_pane_grayscale: config.ui.pane_frames.inactive_grayscale,
},
};
diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs
index fe5b3b19..aaf8c074 100644
--- a/zellij-server/src/lib.rs
+++ b/zellij-server/src/lib.rs
@@ -463,6 +463,7 @@ impl SessionMetaData {
pane_gap_x: new_config.ui.pane_frames.resolved_gap().0,
pane_gap_y: new_config.ui.pane_frames.resolved_gap().1,
pane_padding: new_config.ui.pane_frames.resolved_padding(),
+ inactive_pane_grayscale: new_config.ui.pane_frames.inactive_grayscale,
stacked_resize: new_config.options.stacked_resize.unwrap_or(true),
stacked_pane_list: new_config.options.stacked_pane_list.unwrap_or(true),
default_editor: new_config.options.scrollback_editor.clone(),
@@ -1008,6 +1009,7 @@ pub fn start_server_impl(
pane_gap_x: config.ui.pane_frames.resolved_gap().0,
pane_gap_y: config.ui.pane_frames.resolved_gap().1,
pane_padding: config.ui.pane_frames.resolved_padding(),
+ inactive_pane_grayscale: config.ui.pane_frames.inactive_grayscale,
},
};
@@ -1195,6 +1197,7 @@ pub fn start_server_impl(
pane_gap_x: config.ui.pane_frames.resolved_gap().0,
pane_gap_y: config.ui.pane_frames.resolved_gap().1,
pane_padding: config.ui.pane_frames.resolved_padding(),
+ inactive_pane_grayscale: config.ui.pane_frames.inactive_grayscale,
},
};
diff --git a/zellij-server/src/panes/active_panes.rs b/zellij-server/src/panes/active_panes.rs
index 159412b5..0c16b160 100644
--- a/zellij-server/src/panes/active_panes.rs
+++ b/zellij-server/src/panes/active_panes.rs
@@ -50,9 +50,17 @@ impl ActivePanes {
panes: &mut BTreeMap<PaneId, Box<dyn Pane>>,
) {
self.shadow_clients.remove(&client_id);
+ let previously_focused = self.active_panes.get(&client_id).copied();
self.unfocus_pane_for_client(client_id, panes);
self.active_panes.insert(client_id, pane_id);
self.focus_pane(pane_id, panes);
+ // Both the pane losing focus and the pane gaining it may need to be
+ // repainted in full; see Pane::refresh_for_focus_change.
+ for id in previously_focused.into_iter().chain(std::iter::once(pane_id)) {
+ if let Some(pane) = panes.get_mut(&id) {
+ pane.refresh_for_focus_change();
+ }
+ }
}
pub fn insert_silent(&mut self, client_id: ClientId, pane_id: PaneId) {
self.active_panes.insert(client_id, pane_id);
diff --git a/zellij-server/src/panes/floating_panes/mod.rs b/zellij-server/src/panes/floating_panes/mod.rs
index 3e5e80f8..fba3d536 100644
--- a/zellij-server/src/panes/floating_panes/mod.rs
+++ b/zellij-server/src/panes/floating_panes/mod.rs
@@ -662,6 +662,14 @@ impl FloatingPanes {
.render_guest_modal_for_client(client_id)
.with_context(err_context)?;
}
+ } else if self.style.inactive_pane_grayscale > 0 {
+ // see tiled_panes: per-client focus means chunks cannot be
+ // shared once unfocused panes are desaturated
+ for client_id in &connected_clients {
+ pane_contents_and_ui
+ .render_pane_contents_for_client(*client_id)
+ .with_context(err_context)?;
+ }
} else {
pane_contents_and_ui
.render_pane_contents_to_multiple_clients(connected_clients.iter().copied())
@@ -1726,13 +1734,15 @@ impl FloatingPanes {
gap_x: usize,
gap_y: usize,
padding: zellij_utils::data::PanePadding,
+ inactive_grayscale: u8,
) {
self.style.pane_title_alignment = title_alignment;
self.style.pane_gap_x = gap_x;
self.style.pane_gap_y = gap_y;
self.style.pane_padding = padding;
+ self.style.inactive_pane_grayscale = inactive_grayscale;
for pane in self.panes.values_mut() {
- pane.update_frame_layout(title_alignment, gap_x, gap_y, padding);
+ pane.update_frame_layout(title_alignment, gap_x, gap_y, padding, inactive_grayscale);
}
// Unlike the tiled panes, `set_pane_frame_style` only re-runs the
// content-offset sweep for a fullscreen floating pane, so gap/padding
diff --git a/zellij-server/src/panes/plugin_pane.rs b/zellij-server/src/panes/plugin_pane.rs
index 1d77f1b0..75a48ffc 100644
--- a/zellij-server/src/panes/plugin_pane.rs
+++ b/zellij-server/src/panes/plugin_pane.rs
@@ -378,6 +378,11 @@ impl Pane for PluginPane {
.values_mut()
.for_each(|v| *v = should_render);
}
+ fn refresh_for_focus_change(&mut self) {
+ if self.style.inactive_pane_grayscale > 0 {
+ self.render_full_viewport();
+ }
+ }
fn render_full_viewport(&mut self) {
// this marks the pane for a full re-render, rather than just rendering the
// diff as it usually does with the OutputBuffer
@@ -927,11 +932,13 @@ impl Pane for PluginPane {
gap_x: usize,
gap_y: usize,
padding: zellij_utils::data::PanePadding,
+ inactive_grayscale: u8,
) {
self.style.pane_title_alignment = title_alignment;
self.style.pane_gap_x = gap_x;
self.style.pane_gap_y = gap_y;
self.style.pane_padding = padding;
+ self.style.inactive_pane_grayscale = inactive_grayscale;
self.frame.clear();
}
fn set_should_be_suppressed(&mut self, should_be_suppressed: bool) {
diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs
index 804291df..1b7c66d4 100644
--- a/zellij-server/src/panes/terminal_pane.rs
+++ b/zellij-server/src/panes/terminal_pane.rs
@@ -472,6 +472,11 @@ impl Pane for TerminalPane {
fn set_should_render(&mut self, should_render: bool) {
self.grid.should_render = should_render;
}
+ fn refresh_for_focus_change(&mut self) {
+ if self.style.inactive_pane_grayscale > 0 {
+ self.render_full_viewport();
+ }
+ }
fn render_full_viewport(&mut self) {
// this marks the pane for a full re-render, rather than just rendering the
// diff as it usually does with the OutputBuffer
@@ -1280,11 +1285,13 @@ impl Pane for TerminalPane {
gap_x: usize,
gap_y: usize,
padding: zellij_utils::data::PanePadding,
+ inactive_grayscale: u8,
) {
self.style.pane_title_alignment = title_alignment;
self.style.pane_gap_x = gap_x;
self.style.pane_gap_y = gap_y;
self.style.pane_padding = padding;
+ self.style.inactive_pane_grayscale = inactive_grayscale;
self.frame.clear();
}
fn drain_fake_cursors(&mut self) -> Option<HashSet<(usize, usize)>> {
diff --git a/zellij-server/src/panes/tiled_panes/mod.rs b/zellij-server/src/panes/tiled_panes/mod.rs
index 4d8f0a4a..23eb4aed 100644
--- a/zellij-server/src/panes/tiled_panes/mod.rs
+++ b/zellij-server/src/panes/tiled_panes/mod.rs
@@ -1389,6 +1389,17 @@ impl TiledPanes {
.render_guest_modal_for_client(client_id)
.with_context(err_context)?;
}
+ } else if self.style.inactive_pane_grayscale > 0 {
+ // Clients cannot share rendered chunks when
+ // unfocused panes are desaturated: focus is per
+ // client, so the same pane may need colour for one
+ // client and grey for another. Costs a render per
+ // client, which is why grayscale is opt-in.
+ for client_id in &connected_clients {
+ pane_contents_and_ui
+ .render_pane_contents_for_client(*client_id)
+ .with_context(err_context)?;
+ }
} else {
pane_contents_and_ui
.render_pane_contents_to_multiple_clients(
@@ -3157,13 +3168,15 @@ impl TiledPanes {
gap_x: usize,
gap_y: usize,
padding: zellij_utils::data::PanePadding,
+ inactive_grayscale: u8,
) {
self.style.pane_title_alignment = title_alignment;
self.style.pane_gap_x = gap_x;
self.style.pane_gap_y = gap_y;
self.style.pane_padding = padding;
+ self.style.inactive_pane_grayscale = inactive_grayscale;
for pane in self.panes.values_mut() {
- pane.update_frame_layout(title_alignment, gap_x, gap_y, padding);
+ pane.update_frame_layout(title_alignment, gap_x, gap_y, padding, inactive_grayscale);
}
}
pub fn stack_panes(
diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs
index 438fee2d..0a1c07e7 100644
--- a/zellij-server/src/screen.rs
+++ b/zellij-server/src/screen.rs
@@ -780,6 +780,7 @@ pub enum ScreenInstruction {
pane_gap_x: usize,
pane_gap_y: usize,
pane_padding: PanePadding,
+ inactive_pane_grayscale: u8,
stacked_resize: bool,
stacked_pane_list: bool,
default_editor: Option<PathBuf>,
@@ -6270,6 +6271,7 @@ impl Screen {
pane_gap_x: usize,
pane_gap_y: usize,
pane_padding: PanePadding,
+ inactive_pane_grayscale: u8,
stacked_resize: bool,
stacked_pane_list: bool,
default_editor: Option<PathBuf>,
@@ -6329,7 +6331,13 @@ impl Screen {
tab.update_copy_options(&self.copy_options);
// must precede set_pane_frames: gap/padding are baked into
// content_offset by the sweep that set_pane_frames triggers
- tab.update_frame_layout(pane_title_alignment, pane_gap_x, pane_gap_y, pane_padding);
+ tab.update_frame_layout(
+ pane_title_alignment,
+ pane_gap_x,
+ pane_gap_y,
+ pane_padding,
+ inactive_pane_grayscale,
+ );
tab.set_pane_frames(pane_frame_style);
tab.update_arrow_fonts(should_support_arrow_fonts);
tab.update_advanced_mouse_actions(advanced_mouse_actions);
@@ -10849,6 +10857,7 @@ pub(crate) fn screen_thread_main(
pane_gap_x,
pane_gap_y,
pane_padding,
+ inactive_pane_grayscale,
stacked_resize,
stacked_pane_list,
default_editor,
@@ -10880,6 +10889,7 @@ pub(crate) fn screen_thread_main(
pane_gap_x,
pane_gap_y,
pane_padding,
+ inactive_pane_grayscale,
stacked_resize,
stacked_pane_list,
default_editor,
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs
index be326868..18945a68 100644
--- a/zellij-server/src/tab/mod.rs
+++ b/zellij-server/src/tab/mod.rs
@@ -547,6 +547,14 @@ pub trait Pane {
None
}
fn render_full_viewport(&mut self) {}
+ /// Called when this pane gains or loses focus.
+ ///
+ /// The output buffer only emits CHANGED cells, so focus-dependent styling
+ /// (desaturating unfocused panes) would otherwise not appear until the pane
+ /// happened to print something - an idle pane would keep its old colours
+ /// indefinitely. Panes that have no focus-dependent styling do nothing
+ /// here, so this costs nothing when the feature is off.
+ fn refresh_for_focus_change(&mut self) {}
fn relative_position(&self, position_on_screen: &Position) -> Position {
position_on_screen.relative_to(self.get_content_y(), self.get_content_x())
}
@@ -769,6 +777,7 @@ pub trait Pane {
_gap_x: usize,
_gap_y: usize,
_padding: zellij_utils::data::PanePadding,
+ _inactive_grayscale: u8,
) {
}
fn set_should_be_suppressed(&mut self, _should_be_suppressed: bool) {}
@@ -7438,17 +7447,19 @@ impl Tab {
gap_x: usize,
gap_y: usize,
padding: zellij_utils::data::PanePadding,
+ inactive_grayscale: u8,
) {
self.style.pane_title_alignment = title_alignment;
self.style.pane_gap_x = gap_x;
self.style.pane_gap_y = gap_y;
self.style.pane_padding = padding;
+ self.style.inactive_pane_grayscale = inactive_grayscale;
self.floating_panes
- .update_pane_frame_layout(title_alignment, gap_x, gap_y, padding);
+ .update_pane_frame_layout(title_alignment, gap_x, gap_y, padding, inactive_grayscale);
self.tiled_panes
- .update_pane_frame_layout(title_alignment, gap_x, gap_y, padding);
+ .update_pane_frame_layout(title_alignment, gap_x, gap_y, padding, inactive_grayscale);
for (_, pane) in self.suppressed_panes.values_mut() {
- pane.update_frame_layout(title_alignment, gap_x, gap_y, padding);
+ pane.update_frame_layout(title_alignment, gap_x, gap_y, padding, inactive_grayscale);
}
}
pub fn update_arrow_fonts(&mut self, should_support_arrow_fonts: bool) {
diff --git a/zellij-server/src/ui/grayscale.rs b/zellij-server/src/ui/grayscale.rs
new file mode 100644
index 00000000..387fe70c
--- /dev/null
+++ b/zellij-server/src/ui/grayscale.rs
@@ -0,0 +1,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());
+ }
+}
diff --git a/zellij-server/src/ui/mod.rs b/zellij-server/src/ui/mod.rs
index 1d3ba1f6..450f2246 100644
--- a/zellij-server/src/ui/mod.rs
+++ b/zellij-server/src/ui/mod.rs
@@ -1,3 +1,4 @@
+pub mod grayscale;
pub mod boundaries;
pub mod components;
pub mod hint_text;
diff --git a/zellij-server/src/ui/pane_contents_and_ui.rs b/zellij-server/src/ui/pane_contents_and_ui.rs
index 2b72fbf9..9c9420a4 100644
--- a/zellij-server/src/ui/pane_contents_and_ui.rs
+++ b/zellij-server/src/ui/pane_contents_and_ui.rs
@@ -2,6 +2,7 @@ use crate::output::{CharacterChunk, Output};
use crate::panes::terminal_character::AnsiCode;
use crate::panes::PaneId;
use crate::tab::Pane;
+use crate::ui::grayscale::grayscale_character_chunks;
use crate::ui::boundaries::Boundaries;
use crate::ui::pane_boundaries_frame::{FrameParams, StackListEntry};
use crate::ClientId;
@@ -170,11 +171,19 @@ impl<'a> PaneContentsAndUi<'a> {
pub fn render_pane_contents_for_client(&mut self, client_id: ClientId) -> Result<()> {
let err_context = || format!("failed to render pane contents for client {client_id}");
- if let Some((character_chunks, raw_vte_output, sixel_image_chunks, kitty_image_chunks)) =
+ if let Some((mut character_chunks, raw_vte_output, sixel_image_chunks, kitty_image_chunks)) =
self.pane
.render(Some(client_id))
.with_context(err_context)?
{
+ // Drain the colour out of panes this client is not focused on.
+ // NOTE this cannot be done on the shared multi-client path: focus
+ // is per client, so two clients looking at the same pane may need
+ // different output.
+ let grayscale = self.style.inactive_pane_grayscale;
+ if grayscale > 0 && !self.focused_clients.contains(&client_id) {
+ grayscale_character_chunks(&mut character_chunks, grayscale);
+ }
self.output
.add_character_chunks_to_client(client_id, character_chunks, self.z_index)
.with_context(err_context)?;
diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs
index f424a3fc..9f9205db 100644
--- a/zellij-utils/src/data.rs
+++ b/zellij-utils/src/data.rs
@@ -1465,6 +1465,9 @@ pub struct Style {
/// INNER padding: cells between a pane's frame and its content, per side.
/// Purely cosmetic breathing room inside the pane; does not move the frame.
pub pane_padding: PanePadding,
+ /// Desaturation strength (0-100) applied to the CONTENT of panes this
+ /// client is not focused on. 0 = off.
+ pub inactive_pane_grayscale: u8,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
diff --git a/zellij-utils/src/input/config.rs b/zellij-utils/src/input/config.rs
index db86de54..77260ff8 100644
--- a/zellij-utils/src/input/config.rs
+++ b/zellij-utils/src/input/config.rs
@@ -1456,6 +1456,7 @@ mod config_test {
gap: Default::default(),
padding: Default::default(),
gap_aspect: 2, // parser default; see FrameConfig::default()
+ inactive_grayscale: 0,
},
};
assert_eq!(config.ui, expected_ui_config, "Ui config defined in config");
diff --git a/zellij-utils/src/input/theme.rs b/zellij-utils/src/input/theme.rs
index f1e0adf1..e1371f85 100644
--- a/zellij-utils/src/input/theme.rs
+++ b/zellij-utils/src/input/theme.rs
@@ -37,6 +37,9 @@ pub struct FrameConfig {
/// wide, so 1 row of gap needs ~2 columns to look like the same distance.
/// 0 is treated as 1 (no correction).
pub gap_aspect: usize,
+ /// How strongly to desaturate the CONTENT of unfocused panes, 0-100.
+ /// 0 disables it entirely (the default).
+ pub inactive_grayscale: u8,
}
/// Hand-written rather than derived: `gap_aspect` must default to 2 to match
@@ -52,6 +55,7 @@ impl Default for FrameConfig {
gap: 0,
padding: Default::default(),
gap_aspect: 2,
+ inactive_grayscale: 0,
}
}
}
@@ -78,6 +82,7 @@ impl FrameConfig {
merged.gap = other.gap;
merged.padding = other.padding;
merged.gap_aspect = other.gap_aspect;
+ merged.inactive_grayscale = other.inactive_grayscale;
merged
}
}
diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs
index b5670195..cae59ff6 100644
--- a/zellij-utils/src/kdl/mod.rs
+++ b/zellij-utils/src/kdl/mod.rs
@@ -5595,6 +5595,10 @@ impl UiConfig {
},
None => Default::default(),
};
+ let inactive_grayscale =
+ kdl_children_property_first_arg_as_i64!(pane_frames, "inactive_grayscale")
+ .unwrap_or(0)
+ .clamp(0, 100) as u8;
let frame_config = FrameConfig {
rounded_corners,
hide_session_name,
@@ -5602,6 +5606,7 @@ impl UiConfig {
gap,
padding,
gap_aspect,
+ inactive_grayscale,
};
ui_config.pane_frames = frame_config;
}
@@ -5659,6 +5664,12 @@ impl UiConfig {
}
frame_config_children.nodes_mut().push(padding);
}
+ if self.pane_frames.inactive_grayscale > 0 {
+ has_ui_config = true;
+ let mut node = KdlNode::new("inactive_grayscale");
+ node.push(KdlValue::Base10(self.pane_frames.inactive_grayscale as i64));
+ frame_config_children.nodes_mut().push(node);
+ }
if self.pane_frames.gap_aspect != 2 {
has_ui_config = true;
let mut gap_aspect = KdlNode::new("gap_aspect");
diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs
index 7bb1c7ac..0303ec1f 100644
--- a/zellij-utils/src/plugin_api/event.rs
+++ b/zellij-utils/src/plugin_api/event.rs
@@ -2496,6 +2496,7 @@ fn serialize_mode_update_event_with_non_default_values() {
pane_gap_x: Default::default(),
pane_gap_y: Default::default(),
pane_padding: Default::default(),
+ inactive_pane_grayscale: Default::default(),
},
capabilities: PluginCapabilities { arrow_fonts: false },
session_name: Some("my awesome test session".to_owned()),
diff --git a/zellij-utils/src/plugin_api/style.rs b/zellij-utils/src/plugin_api/style.rs
index 527f9841..cb896c76 100644
--- a/zellij-utils/src/plugin_api/style.rs
+++ b/zellij-utils/src/plugin_api/style.rs
@@ -29,6 +29,7 @@ impl TryFrom<ProtobufStyle> for Style {
pane_gap_x: Default::default(),
pane_gap_y: Default::default(),
pane_padding: Default::default(),
+ inactive_pane_grayscale: Default::default(),
})
}
}