From 3f529f3a650340eb9ead29e23c5bf8e5f96d3e26 Mon Sep 17 00:00:00 2001 From: elwrcl Date: Sat, 3 Oct 2026 20:37:44 +0300 Subject: [PATCH] feat: valw backdrop --- src/backdrop.rs | 269 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 7 +- src/theme/gl.rs | 25 ++++- src/wayland.rs | 32 +++++- 4 files changed, 326 insertions(+), 7 deletions(-) create mode 100644 src/backdrop.rs diff --git a/src/backdrop.rs b/src/backdrop.rs new file mode 100644 index 0000000..f57dc3b --- /dev/null +++ b/src/backdrop.rs @@ -0,0 +1,269 @@ +//! `valw backdrop`: the paint shader, slow and calm, behind niri's +//! overview (niri's `place-within-backdrop` layer rule), coloured from each +//! output's wallpaper. It draws only when niri asks for frames, so it costs +//! nothing while the overview is closed. + +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use smithay_client_toolkit::{ + compositor::{FrameCallbackData, Region}, + shell::{ + WaylandSurface, + wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerSurface}, + }, +}; +use wayland_client::{ + Connection, QueueHandle, + protocol::{wl_output::WlOutput, wl_surface::WlSurface}, +}; +use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport; + +use crate::lock::Lock; +use crate::theme::Palette; +use crate::theme::gl::{Frame, Paint}; +use crate::wayland::{State, Wayland}; + +pub const NAMESPACE: &str = "valw-backdrop"; +const SPEED: f32 = 0.35; +/// A pause this long means the overview was closed: re-read the wallpaper. +const PAUSE: Duration = Duration::from_secs(2); +const EASE: Duration = Duration::from_secs(1); + +/// Whether to re-read the wallpaper's colours before this frame. +pub fn should_refresh(last_frame: Option, now: Instant) -> bool { + last_frame.is_none_or(|t| now.saturating_duration_since(t) > PAUSE) +} + +/// `from` easing into `to`, `since` after the change started. +pub fn ease(from: &Palette, to: &Palette, since: Duration) -> Palette { + from.lerp(to, since.as_secs_f32() / EASE.as_secs_f32()) +} + +/// The wallpaper's palette on `connector`, if Noctalia tells us one. +fn wallpaper_palette(connector: &str) -> Option { + let path = crate::theme::wallpaper(connector)?; + let img = image::ImageReader::open(&path) + .ok()? + .with_guessed_format() + .ok()? + .decode() + .ok()? + .thumbnail(256, 256) + .to_rgba8(); + Some(crate::theme::from_image(&img, false)) +} + +struct Surface { + // Dropped first: the EGL surface goes before the wl_surface under it. + paint: Option, + layer: LayerSurface, + viewport: WpViewport, + output: WlOutput, + name: String, + scale: f64, + start: Instant, + last_frame: Option, + from: Palette, + to: Palette, + changed: Instant, + /// A wallpaper palette being worked out on a thread. + incoming: Option>>, +} + +#[derive(Default)] +pub struct Backdrop { + surfaces: Vec, +} + +/// Runs until the compositor goes away. A second instance exits at once. +pub fn run() -> Result<()> { + let path = crate::lock::default_path().with_file_name("valw-backdrop.lock"); + let Ok(_lock) = Lock::acquire(&path) else { + tracing::info!("another backdrop is running"); + return Ok(()); + }; + let mut wl = Wayland::connect()?; + wl.state.backdrop = Some(Backdrop::default()); + let qh = wl.queue.handle(); + for output in wl.outputs() { + add(&mut wl.state, &qh, &output.wl); + } + wl.dispatch_blocking(|_| false) +} + +/// Puts a backdrop surface on `output`. +pub fn add(state: &mut State, qh: &QueueHandle, output: &WlOutput) { + let Some(info) = state.output_list().into_iter().find(|o| &o.wl == output) else { + return; + }; + let (Some(layer_shell), Some(viewporter)) = (&state.layer_shell, &state.viewporter) else { + tracing::warn!("no layer-shell or viewporter: no backdrop"); + return; + }; + let surface = state.compositor.create_surface(qh); + let layer = layer_shell.create_layer_surface( + qh, + surface, + Layer::Background, + Some(NAMESPACE), + Some(output), + ); + layer.set_anchor(Anchor::all()); + layer.set_exclusive_zone(-1); + layer.set_keyboard_interactivity(KeyboardInteractivity::None); + // Clicks go to niri, not to the backdrop. + if let Ok(empty) = Region::new(&state.compositor) { + layer.wl_surface().set_input_region(Some(empty.wl_region())); + } + layer.commit(); + let viewport = viewporter.get_viewport(layer.wl_surface(), qh, ()); + let now = Instant::now(); + tracing::info!("backdrop on {}", info.geom.name); + let palette = crate::theme::current(); + if let Some(b) = &mut state.backdrop { + b.surfaces.push(Surface { + paint: None, + layer, + viewport, + output: output.clone(), + name: info.geom.name, + scale: info.scale, + start: now, + last_frame: None, + from: palette, + to: palette, + changed: now, + incoming: None, + }); + } +} + +impl Backdrop { + fn index(&self, surface: &WlSurface) -> Option { + self.surfaces + .iter() + .position(|s| s.layer.wl_surface() == surface) + } + + pub fn remove(&mut self, output: &WlOutput) { + self.surfaces.retain(|s| &s.output != output); + } + + pub fn closed(&mut self, layer: &LayerSurface) { + self.surfaces + .retain(|s| s.layer.wl_surface() != layer.wl_surface()); + } + + pub fn configure( + &mut self, + conn: &Connection, + layer: &LayerSurface, + (w, h): (u32, u32), + qh: &QueueHandle, + ) { + let Some(i) = self.index(layer.wl_surface()) else { + return; + }; + let s = &mut self.surfaces[i]; + if w == 0 || h == 0 { + return; + } + let px = ( + (w as f64 * s.scale).round() as u32, + (h as f64 * s.scale).round() as u32, + ); + s.viewport.set_destination(w as i32, h as i32); + match &mut s.paint { + Some(paint) => paint.resize(px), + None => match Paint::new(conn, s.layer.wl_surface(), px) { + Ok(paint) => s.paint = Some(paint), + Err(e) => { + tracing::warn!("no backdrop on {}: {e:#}", s.name); + return; + } + }, + } + s.draw(qh); + } + + pub fn frame_done(&mut self, surface: &WlSurface, qh: &QueueHandle) { + if let Some(i) = self.index(surface) { + self.surfaces[i].draw(qh); + } + } +} + +impl Surface { + fn draw(&mut self, qh: &QueueHandle) { + let now = Instant::now(); + if should_refresh(self.last_frame, now) && self.incoming.is_none() { + let (tx, rx) = mpsc::channel(); + let name = self.name.clone(); + std::thread::spawn(move || { + let _ = tx.send(wallpaper_palette(&name)); + }); + self.incoming = Some(rx); + } + if let Some(rx) = &self.incoming + && let Ok(result) = rx.try_recv() + { + match &result { + Some(p) => tracing::info!("backdrop on {}: wallpaper palette {p:?}", self.name), + None => tracing::info!("backdrop on {}: no wallpaper palette", self.name), + } + if let Some(palette) = result { + self.from = ease(&self.from, &self.to, now - self.changed); + self.to = palette; + self.changed = now; + } + self.incoming = None; + } + self.last_frame = Some(now); + let Some(paint) = &self.paint else { + return; + }; + let surface = self.layer.wl_surface(); + surface.frame(qh, FrameCallbackData(surface.clone())); + let frame = Frame { + time: (now - self.start).as_secs_f32(), + motion: 0.0, + speed: SPEED, + scale: 1.0, + palette: ease(&self.from, &self.to, now - self.changed), + }; + if let Err(e) = paint.draw(&frame) { + tracing::warn!("backdrop on {}: {e:#}", self.name); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refresh_after_a_pause() { + let now = Instant::now(); + assert!(should_refresh(None, now), "first frame"); + assert!(!should_refresh(Some(now - Duration::from_secs(1)), now)); + assert!(should_refresh(Some(now - Duration::from_secs(3)), now)); + } + + #[test] + fn colours_ease_over_a_second() { + let from = Palette::DEFAULT; + let to = Palette { + base: [1.0; 3], + body: [1.0; 3], + highlight: [1.0; 3], + }; + assert_eq!(ease(&from, &to, Duration::ZERO), from); + assert_eq!( + ease(&from, &to, Duration::from_millis(500)), + from.lerp(&to, 0.5) + ); + assert_eq!(ease(&from, &to, Duration::from_secs(2)), to); + } +} diff --git a/src/main.rs b/src/main.rs index 1e84607..347b8f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod backdrop; mod capture; mod config; mod detach; @@ -18,8 +19,6 @@ mod selection; mod shadow; mod sound; mod stack; -// 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; mod toolbar; @@ -82,6 +81,8 @@ enum Command { }, /// Pick a mode from a floating bar (Cmd+Shift+5). Toolbar, + /// The paint shader behind niri's overview (keep it running; see the README). + Backdrop, /// Report what the compositor and system support. Doctor, /// Internal: the process that shows preview thumbnails. @@ -198,6 +199,7 @@ fn main() -> ExitCode { fn run(cli: Cli) -> Result<()> { match cli.command { Command::Doctor => doctor::run(), + Command::Backdrop => backdrop::run(), Command::PreviewHost => host::run(), Command::Screen { all, common } => capture(Mode::Screen { all }, common), Command::Region { common } => capture(Mode::Region, common), @@ -446,6 +448,7 @@ mod tests { assert!(!parses(&["edit"])); assert!(parses(&["__clipboard"])); assert!(parses(&["toolbar"])); + assert!(parses(&["backdrop"])); assert!(parses(&["__combo-demo"])); assert!(parses(&["__check-config", "c.toml"])); assert!(!parses(&["__check-config"])); diff --git a/src/theme/gl.rs b/src/theme/gl.rs index f74a71a..b4c9bba 100644 --- a/src/theme/gl.rs +++ b/src/theme/gl.rs @@ -48,6 +48,8 @@ struct Uniforms { pub struct Paint { gl: glow::Context, u: Uniforms, + // Read by set_overlay, which the picker (theme plan Task 6) starts using. + #[allow(dead_code)] overlay: glow::Texture, size: (u32, u32), egl: Egl, @@ -104,9 +106,22 @@ impl Paint { }) } + /// Several renderers (one per output) share the EGL display: each makes + /// its own context current before touching GL. + fn current(&self) -> Result<()> { + self.egl + .make_current( + self.display, + Some(self.surface), + Some(self.surface), + Some(self.context), + ) + .context("eglMakeCurrent failed") + } + /// The surface's buffer size changed. pub fn resize(&mut self, size: (u32, u32)) { - if size != self.size { + if size != self.size && self.current().is_ok() { self.window.resize(size.0 as i32, size.1 as i32, 0, 0); unsafe { self.gl.viewport(0, 0, size.0 as i32, size.1 as i32) }; self.size = size; @@ -115,7 +130,11 @@ impl Paint { /// Shows `pixmap` (premultiplied RGBA, the surface's size) over the /// paint, or nothing. + #[allow(dead_code)] // the picker (theme plan Task 6) removes this pub fn set_overlay(&mut self, pixmap: Option<&tiny_skia::Pixmap>) { + if self.current().is_err() { + return; + } unsafe { self.gl .uniform_1_i32(Some(&self.u.use_overlay), pixmap.is_some() as i32); @@ -139,6 +158,7 @@ impl Paint { /// Draws `frame` and swaps (which commits the surface). pub fn draw(&self, frame: &Frame) -> Result<()> { + self.current()?; let p = frame.palette; unsafe { let gl = &self.gl; @@ -168,7 +188,8 @@ impl Drop for Paint { let _ = self.egl.make_current(self.display, None, None, None); let _ = self.egl.destroy_surface(self.display, self.surface); let _ = self.egl.destroy_context(self.display, self.context); - let _ = self.egl.terminate(self.display); + // No eglTerminate: the display is shared by every renderer on this + // connection, and terminating it would break the others. } } diff --git a/src/wayland.rs b/src/wayland.rs index d9f4864..9692fd2 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -36,6 +36,7 @@ use wayland_protocols::wp::viewporter::client::{ }; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; +use crate::backdrop::Backdrop; use crate::capture::Pending; use crate::dnd::Outcome; use crate::error::HintExt; @@ -87,6 +88,8 @@ pub struct State { /// The toolbar and its countdown, while they are open. pub toolbar: Option, pub pill: Option, + /// `valw backdrop`'s surfaces. + pub backdrop: Option, /// The preview thumbnails, in the preview host process. pub preview: Option, } @@ -121,6 +124,7 @@ impl Wayland { zoom: None, toolbar: None, pill: None, + backdrop: None, preview: None, }; // Two round trips: one for wl_output, one for the xdg-output details. @@ -310,6 +314,9 @@ impl CompositorHandler for State { if let Some(zoom) = &mut self.zoom { zoom.frame_done(surface, qh); } + if let Some(backdrop) = &mut self.backdrop { + backdrop.frame_done(surface, qh); + } if let Some(preview) = &mut self.preview { preview.frame_done(surface, qh); } @@ -339,11 +346,24 @@ impl OutputHandler for State { &mut self.outputs } - fn new_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} + fn new_output(&mut self, _: &Connection, qh: &QueueHandle, output: wl_output::WlOutput) { + if self.backdrop.is_some() { + crate::backdrop::add(self, qh, &output); + } + } fn update_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} - fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} + fn output_destroyed( + &mut self, + _: &Connection, + _: &QueueHandle, + output: wl_output::WlOutput, + ) { + if let Some(backdrop) = &mut self.backdrop { + backdrop.remove(&output); + } + } } impl LayerShellHandler for State { @@ -360,6 +380,9 @@ impl LayerShellHandler for State { if let Some(pill) = &mut self.pill { pill.closed(layer); } + if let Some(backdrop) = &mut self.backdrop { + backdrop.closed(layer); + } if let Some(preview) = &mut self.preview { preview.closed(layer); } @@ -367,7 +390,7 @@ impl LayerShellHandler for State { fn configure( &mut self, - _: &Connection, + conn: &Connection, qh: &QueueHandle, layer: &LayerSurface, configure: LayerSurfaceConfigure, @@ -385,6 +408,9 @@ impl LayerShellHandler for State { if let Some(pill) = &mut self.pill { pill.configure(layer); } + if let Some(backdrop) = &mut self.backdrop { + backdrop.configure(conn, layer, configure.new_size, qh); + } if let Some(preview) = &mut self.preview { preview.configure(layer, qh); }