From 201f43ef34e66dbf0f4147f444a5152177efd779 Mon Sep 17 00:00:00 2001 From: elwrcl Date: Sat, 3 Oct 2026 20:11:26 +0300 Subject: [PATCH] feat: theme-coloured region dim --- src/main.rs | 2 +- src/region.rs | 14 +++++++++++--- src/render.rs | 49 ++++++++++++++++++++++++++++-------------------- src/theme/mod.rs | 49 +++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 83 insertions(+), 31 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0dbca88..1e84607 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ mod selection; mod shadow; mod sound; mod stack; -// The theme lands in pieces; Task 2 of the theme plan removes this. +// Parts of the theme are used from Task 4 of the theme plan on; that task removes this. #[allow(dead_code)] mod theme; mod thumbnail; diff --git a/src/region.rs b/src/region.rs index 6ad1504..87ee4e9 100644 --- a/src/region.rs +++ b/src/region.rs @@ -87,6 +87,8 @@ pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result< .context("compositor does not support wp-viewporter") .hint("run `valw doctor` to see what the compositor supports")?; + // The theme's colour for the overlay outside the selection. + let palette = crate::theme::current(); let mut surfaces = Vec::new(); for (output, frame) in outputs.iter().zip(frames) { let surface = s.compositor.create_surface(&qh); @@ -102,7 +104,7 @@ pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result< layer.set_keyboard_interactivity(KeyboardInteractivity::Exclusive); layer.commit(); let viewport = viewporter.get_viewport(layer.wl_surface(), &qh, ()); - surfaces.push(Surface::new(layer, viewport, frame, &s.shm)?); + surfaces.push(Surface::new(layer, viewport, frame, &palette, &s.shm)?); } tracing::debug!( @@ -126,7 +128,13 @@ pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result< } impl Surface { - fn new(layer: LayerSurface, viewport: WpViewport, frame: &Frame, shm: &Shm) -> Result { + fn new( + layer: LayerSurface, + viewport: WpViewport, + frame: &Frame, + palette: &crate::theme::Palette, + shm: &Shm, + ) -> Result { let (width, height) = frame.pixels.dimensions(); let len = (width * height * 4) as usize; Ok(Surface { @@ -135,7 +143,7 @@ impl Surface { geom: frame.output.clone(), width, height, - dark: render::dim(frame.pixels.as_raw(), render::DIM), + dark: render::tint(frame.pixels.as_raw(), palette.base, render::TINT), bright: Arc::clone(&frame.pixels), // Room for two buffers so one can be on screen while we draw the next. pool: SlotPool::new(len * 2, shm).context("could not create shm pool")?, diff --git a/src/render.rs b/src/render.rs index 20dd70b..4c996e0 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,19 +1,25 @@ use crate::frame::PixelRect; -/// Overlay brightness outside the selection (60%, i.e. darkened by 40%). -pub const DIM: u8 = 153; +/// How far the overlay outside the selection leans towards the theme colour. +pub const TINT: f32 = 0.55; -/// B, G, R, X pixels with every byte scaled by about `brightness / 255`. -/// -/// This runs on every output before the overlay can appear, so it is one -/// branch-free pass the compiler can vectorise. Scaling X too is harmless: -/// the overlay buffer is XRGB8888, which ignores it. -pub fn dim(pixels: &[u8], brightness: u8) -> Vec { - let factor = brightness as u16 + 1; - pixels - .iter() - .map(|&b| ((b as u16 * factor) >> 8) as u8) - .collect() +/// B, G, R, X pixels blended `amount` of the way towards `colour` (RGB 0–1). +/// Runs once per output before the overlay shows: a lookup table per +/// channel keeps it as cheap as the old integer dim. +pub fn tint(pixels: &[u8], colour: [f32; 3], amount: f32) -> Vec { + let keep = 1.0 - amount; + let table = |c: f32| -> [u8; 256] { + std::array::from_fn(|v| (v as f32 * keep + c * 255.0 * amount).round() as u8) + }; + let [r, g, b] = colour; + let tables = [table(b), table(g), table(r)]; + let mut out = pixels.to_vec(); + for px in out.as_chunks_mut::<4>().0 { + for (v, t) in px.iter_mut().zip(&tables) { + *v = t[*v as usize]; + } + } + out } /// Draws one overlay frame into `dst`: `dark` everywhere, `bright` inside @@ -92,18 +98,21 @@ pub fn blend(dst: &mut [u8], dst_width: u32, src: &tiny_skia::Pixmap, at: (u32, mod tests { use super::*; - #[test] - fn dims_every_byte() { - assert_eq!(dim(&[200, 100, 50, 0], 255), vec![200, 100, 50, 0]); - assert_eq!(dim(&[200, 100, 50, 255], DIM), vec![120, 60, 30, 153]); - assert_eq!(dim(&[255, 0, 1, 2], 0), vec![0, 0, 0, 0]); - } - fn px(buf: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] { let i = ((y * width + x) * 4) as usize; buf[i..i + 4].try_into().unwrap() } + #[test] + fn tint_blends_towards_the_colour() { + // B, G, R, X = 200, 100, 0, 255; colour pure red (R = 1). + let out = tint(&[200, 100, 0, 255], [1.0, 0.0, 0.0], 0.55); + assert_eq!(out[0], (200.0 * 0.45f32).round() as u8, "blue fades"); + assert_eq!(out[1], (100.0 * 0.45f32).round() as u8); + assert_eq!(out[2], (255.0 * 0.55f32).round() as u8, "red rises"); + assert_eq!(tint(&[10, 20, 30, 0], [0.0; 3], 0.0), vec![10, 20, 30, 0]); + } + #[test] fn compose_outline_and_inside() { let (w, h) = (6, 6); diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 445c5f8..32a3e12 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -150,21 +150,41 @@ pub fn current() -> Palette { } fn noctalia_palette() -> Option { - let scheme = noctalia(&["color-scheme-get"])?; // " " - let (source, name) = scheme.split_once(' ')?; + // Noctalia keeps the live choice in its state settings (theme-mode-set + // writes there); its config holds the initial one. Reading the files is + // instant, unlike `noctalia msg`, and this runs before every overlay. + let state = state_dir().join("noctalia/settings.toml"); + let config = crate::config::home().join(".config/noctalia/config.toml"); + let (source, name, mode) = [state, config] + .iter() + .find_map(|p| theme_choice(&std::fs::read_to_string(p).ok()?))?; if source != "community" { return None; } - let mode = noctalia(&["theme-mode-get"])?; - let dir = std::env::var_os("XDG_STATE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| crate::config::home().join(".local/state")); - let file = dir + let file = state_dir() .join("noctalia/community-palettes") .join(format!("{}.json", name.replace(' ', "%20"))); from_noctalia_file(&std::fs::read_to_string(file).ok()?, &mode) } +fn state_dir() -> PathBuf { + std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| crate::config::home().join(".local/state")) +} + +/// `(source, community palette, mode)` from a Noctalia settings file. +fn theme_choice(text: &str) -> Option<(String, String, String)> { + let doc: toml::Table = text.parse().ok()?; + let theme = doc.get("theme")?.as_table()?; + let get = |k: &str| theme.get(k)?.as_str().map(str::to_string); + Some(( + get("source")?, + get("community_palette").unwrap_or_default(), + get("mode").unwrap_or_else(|| "dark".into()), + )) +} + /// The wallpaper Noctalia shows on `connector`. pub fn wallpaper(connector: &str) -> Option { noctalia(&["wallpaper-get", connector]) @@ -236,6 +256,21 @@ mod tests { } } + #[test] + fn theme_choice_from_settings() { + let text = "[bar]\nx = 1\n\n[theme]\ncommunity_palette = \"Kemuri Susu\"\nmode = \"dark\"\nsource = \"community\"\n\n[wallpaper]\n"; + assert_eq!( + theme_choice(text), + Some(("community".into(), "Kemuri Susu".into(), "dark".into())) + ); + assert_eq!( + theme_choice("[theme]\nmode = \"dark\"\n"), + None, + "no source" + ); + assert_eq!(theme_choice("not toml ["), None); + } + #[test] fn lerp_eases() { let a = Palette::DEFAULT;