diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index c4040af022a1ae..84908a267c2714 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -64,6 +64,8 @@ gpui_wgpu = { workspace = true, optional = true, features = ["font-kit"] } http_client.workspace = true libc.workspace = true log.workspace = true +image.workspace = true +serde_json.workspace = true parking_lot.workspace = true smallvec.workspace = true smol.workspace = true @@ -117,6 +119,7 @@ x11rb = { version = "0.13.1", features = [ "allow-unsafe-code", "xkb", "randr", + "dpms", "xinput", "cursor", "resource_manager", diff --git a/crates/gpui_linux/src/linux.rs b/crates/gpui_linux/src/linux.rs index 33393f7e9ee41e..045f08ae4cc3b1 100644 --- a/crates/gpui_linux/src/linux.rs +++ b/crates/gpui_linux/src/linux.rs @@ -1,4 +1,6 @@ mod dispatcher; +#[cfg(any(feature = "wayland", feature = "x11"))] +mod glass; mod headless; mod keyboard; mod platform; diff --git a/crates/gpui_linux/src/linux/glass.rs b/crates/gpui_linux/src/linux/glass.rs new file mode 100644 index 00000000000000..51335198265288 --- /dev/null +++ b/crates/gpui_linux/src/linux/glass.rs @@ -0,0 +1,406 @@ +//! Linux glass asset loading and playback. Both display servers use the same clock, +//! image preparation and power policy; only desktop/window discovery is platform-specific. +//! +//! CDXC:Theming 2026-09-27 SEE-ALSO: +//! apps/desktop/src/app/helpers/window_glass.rs selects the same sources and tint rules on macOS and Linux. gpui_wgpu/src/glass.wgsl ports gpui_macos/src/live_backdrop.metal, including its eight gains and two-minute loops. Both Linux backends must keep these background setters and focus/visibility notifications in step. +use gpui::{Bounds, LiveBackground, Pixels, Point, Size, px}; +use gpui_wgpu::{GlassFrame, GlassImage, GlassSource}; +use std::{ + path::PathBuf, + sync::{Arc, mpsc}, + time::{Duration, Instant}, +}; + +mod system; +mod video; + +#[derive(Default)] +pub(crate) struct Glass { + pub enabled: bool, + pub image: Option, + pub video: Option, + pub live: Option, + pub only_on_power: bool, + pub follows_screen: bool, + pub cover: Option>, + pub title: String, + pub wake: Option, + worker: Option, + observed_revision: u64, + current: GlassSource, + selection: Option, + fade_started: Option, + phase: f32, + transition: u64, + dirty: bool, +} + +#[derive(Clone, PartialEq)] +enum Selection { + Image(Option), + Video(PathBuf), + Live(LiveBackground), +} + +static LIVE_CLOCK: std::sync::Mutex<(Option, f32)> = std::sync::Mutex::new((None, 0.0)); + +impl Glass { + pub fn with_wake(wake: calloop::ping::Ping) -> Self { + Self { + wake: Some(wake), + ..Default::default() + } + } + + pub fn frame( + &mut self, + active: bool, + bounds: Bounds, + screen: Bounds, + monitor: Option, + light: bool, + wayland: bool, + ) -> Option { + if !self.enabled { + self.worker = None; + self.current = GlassSource::default(); + self.selection = None; + self.fade_started = None; + return None; + } + let selection = if let Some(live) = &self.live { + Selection::Live(live.clone()) + } else if let Some(video) = &self.video { + Selection::Video(video.clone()) + } else { + Selection::Image(self.image.clone()) + }; + let wake = self.wake.clone(); + let worker = self.worker.get_or_insert_with(|| Worker::new(wake)); + let request = Request { + selection: selection.clone(), + active, + only_on_power: self + .live + .as_ref() + .map_or(self.only_on_power, |live| live.only_on_power), + title: self.title.clone(), + monitor, + light, + follows_screen: self.follows_screen + && (wayland || std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some()), + cover_width: f32::from( + self.cover + .unwrap_or(if self.follows_screen { screen } else { bounds }) + .size + .width, + ) + .max(1.0), + }; + worker.request(request); + let output = worker.output.lock().unwrap().clone(); + self.observed_revision = output.revision; + let now = Instant::now(); + let running = active && output.may_animate; + if self.live.is_some() { + let mut clock = LIVE_CLOCK.lock().unwrap(); + if running { + let delta = clock + .0 + .replace(now) + .map_or(0.0, |last| now.duration_since(last).as_secs_f32()); + if delta < 0.1 { + clock.1 = + (clock.1 + delta * self.live.as_ref().unwrap().speed).rem_euclid(120.0); + } + } + self.phase = clock.1; + } + let image = if output.selection.as_ref() == Some(&selection) { + output.image.clone() + } else { + None + }; + let pending = output.selection.as_ref() != Some(&selection) || output.loading; + let ready = self.live.is_some() || image.is_some(); + if ready && self.selection.as_ref() != Some(&selection) { + self.transition = self.transition.wrapping_add(1); + self.fade_started = (!output.reduce_motion + && (self.current.image.is_some() || self.current.live.is_some())) + .then_some(now); + self.selection = Some(selection.clone()); + } + if ready { + self.current = GlassSource { + image, + live: self.live.clone(), + phase: if output.reduce_motion { + 0.0 + } else { + self.phase + }, + }; + } else if !pending { + self.current = GlassSource::default(); + } + let fade = self.fade_started.map_or(1.0, |start| { + (now.duration_since(start).as_secs_f32() / 0.75).min(1.0) + }); + if fade == 1.0 { + self.fade_started = None; + } + self.dirty = fade < 1.0; + if self.current.image.is_none() && self.current.live.is_none() { + return None; + } + let cover = if self.follows_screen { + if let Some(cover) = output.desktop_cover { + cover + } else if wayland { + Bounds::new(Point::default(), bounds.size) + } else { + Bounds::new(screen.origin - bounds.origin, screen.size) + } + } else { + self.cover + .unwrap_or(Bounds::new(Point::default(), bounds.size)) + }; + Some(GlassFrame { + current: self.current.clone(), + fade, + transition: self.transition, + view_size: [bounds.size.width.into(), bounds.size.height.into()], + cover, + }) + } + + pub fn needs_frame(&self) -> bool { + self.enabled + && (self.dirty + || self.worker.as_ref().is_some_and(|worker| { + worker.output.lock().unwrap().revision != self.observed_revision + })) + } + + pub fn set_active(&mut self, active: bool) { + if let Some(worker) = &mut self.worker { + if let Some(mut request) = worker.last_request.clone() { + request.active = active; + worker.request(request); + } + } + } +} + +#[derive(Clone, PartialEq)] +struct Request { + selection: Selection, + active: bool, + only_on_power: bool, + title: String, + monitor: Option, + light: bool, + follows_screen: bool, + cover_width: f32, +} + +#[derive(Clone, Default)] +struct Output { + revision: u64, + selection: Option, + image: Option>, + loading: bool, + may_animate: bool, + reduce_motion: bool, + desktop_cover: Option>, +} + +struct Worker { + sender: Option>, + output: Arc>, + last_request: Option, +} + +impl Worker { + fn new(wake: Option) -> Self { + let (sender, receiver) = mpsc::channel(); + let output = Arc::new(std::sync::Mutex::new(Output { + loading: true, + ..Default::default() + })); + let shared = output.clone(); + std::thread::Builder::new() + .name("glass-backdrop".into()) + .spawn(move || run(receiver, shared, wake)) + .expect("glass worker"); + Self { + sender: Some(sender), + output, + last_request: None, + } + } + + fn request(&mut self, request: Request) { + if self.last_request.as_ref() != Some(&request) { + if let Some(sender) = &self.sender { + let _ = sender.send(request.clone()); + } + self.last_request = Some(request); + } + } +} + +impl Drop for Worker { + fn drop(&mut self) { + self.sender.take(); + } +} + +fn run( + receiver: mpsc::Receiver, + output: Arc>, + wake: Option, +) { + let Ok(mut request) = receiver.recv() else { + return; + }; + let mut system = system::System::default(); + let mut applied = None; + let mut image_key = None; + let mut player: Option = None; + loop { + let before = output.lock().unwrap().clone(); + let changed = applied.as_ref() != Some(&request.selection); + system.refresh(&request); + let may_animate = system.may_animate(request.only_on_power); + let playing = request.active && may_animate; + if changed { + player = None; + image_key = None; + let mut result = output.lock().unwrap(); + result.loading = true; + result.selection = Some(request.selection.clone()); + result.image = None; + } + match &request.selection { + Selection::Live(_) => {} + Selection::Image(custom) => { + let path = custom.clone().or_else(|| system.wallpaper.clone()); + let key = path.as_ref().map(|path| { + ( + path.clone(), + std::fs::metadata(path).and_then(|m| m.modified()).ok(), + request.cover_width.to_bits(), + ) + }); + if changed || key != image_key { + let image = path + .as_ref() + .and_then(|path| load_image(path, request.cover_width)); + output.lock().unwrap().image = image.map(Arc::new); + image_key = key; + } + } + Selection::Video(path) => { + if changed || (!before.reduce_motion && system.reduce_motion) { + player = video::Video::open(path, request.cover_width); + if player.is_none() { + log::warn!( + "Cannot open glass video; FFmpeg and ffprobe must be installed and the file must be readable" + ); + } + output.lock().unwrap().image = None; + } + if let Some(video) = &mut player { + video.set_playing(playing || output.lock().unwrap().image.is_none()); + if let Some(frame) = video.frame() { + output.lock().unwrap().image = Some(Arc::new(frame)); + } + } + } + } + { + let mut result = output.lock().unwrap(); + result.loading = player.as_ref().is_some_and(|player| player.loading()); + result.may_animate = may_animate; + result.reduce_motion = system.reduce_motion; + result.desktop_cover = system.desktop_cover; + } + { + let mut result = output.lock().unwrap(); + let image_changed = match (&result.image, &before.image) { + (Some(a), Some(b)) => !Arc::ptr_eq(a, b), + (None, None) => false, + _ => true, + }; + if changed + || image_changed + || before.loading != result.loading + || before.may_animate != result.may_animate + || before.desktop_cover != result.desktop_cover + || (playing && matches!(request.selection, Selection::Live(_))) + { + result.revision += 1; + if let Some(wake) = &wake { + wake.ping(); + } + } + } + applied = Some(request.selection.clone()); + let first_frame_pending = player.as_ref().is_some_and(|player| player.loading()); + let interval = if (playing || first_frame_pending) + && !matches!(request.selection, Selection::Image(_)) + { + Duration::from_millis(42) + } else if request.follows_screen && request.active { + Duration::from_millis(40) + } else { + Duration::from_millis(500) + }; + match receiver.recv_timeout(interval) { + Ok(next) => { + request = next; + while let Ok(next) = receiver.try_recv() { + request = next; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } +} + +fn load_image(path: &PathBuf, cover_width: f32) -> Option { + let reader = image::ImageReader::open(path) + .ok()? + .with_guessed_format() + .ok()?; + let mut image = reader.decode().ok()?; + // Preserve EXIF orientation before fitting and blurring, like Core Image on macOS. + if let Ok(mut decoder) = image::ImageReader::open(path) + .ok()? + .with_guessed_format() + .ok()? + .into_decoder() + { + use image::ImageDecoder; + if let Ok(orientation) = decoder.orientation() { + image.apply_orientation(orientation); + } + } + let mut image = image.thumbnail(640, 640).to_rgba8(); + for pixel in image.pixels_mut() { + let alpha = u16::from(pixel[3]); + for channel in &mut pixel.0[..3] { + *channel = (u16::from(*channel) * alpha / 255) as u8; + } + pixel[3] = 255; + } + let sigma = 60.0 * image.width() as f32 / cover_width; + let blurred = image::imageops::blur(&image, sigma.max(0.1)); + Some(GlassImage { + width: blurred.width(), + height: blurred.height(), + rgba: blurred.into_raw(), + }) +} diff --git a/crates/gpui_linux/src/linux/glass/system.rs b/crates/gpui_linux/src/linux/glass/system.rs new file mode 100644 index 00000000000000..a69a2c6055a885 --- /dev/null +++ b/crates/gpui_linux/src/linux/glass/system.rs @@ -0,0 +1,326 @@ +//! Desktop discovery runs on the backdrop worker, never on the UI/render thread. +use super::*; +use std::{ + io::{Read, Write}, + os::unix::net::UnixStream, + process::{Command, Stdio}, +}; + +#[derive(Default)] +pub(super) struct System { + pub wallpaper: Option, + pub desktop_cover: Option>, + pub reduce_motion: bool, + on_battery: bool, + low_power: bool, + display_asleep: bool, + last_settings: Option, + last_position: Option, + appearance: Option, + monitor: Option, +} + +impl System { + pub fn may_animate(&self, only_on_power: bool) -> bool { + !self.reduce_motion + && !self.low_power + && !self.display_asleep + && !(only_on_power && self.on_battery) + } + + pub fn refresh(&mut self, request: &Request) { + let now = Instant::now(); + if self + .last_settings + .is_none_or(|time| now.duration_since(time) > Duration::from_secs(2)) + || self.appearance != Some(request.light) + || self.monitor != request.monitor + { + self.last_settings = Some(now); + self.appearance = Some(request.light); + self.monitor = request.monitor.clone(); + self.on_battery = on_battery(); + self.low_power = + command("powerprofilesctl", &["get"]).is_some_and(|v| v.trim() == "power-saver"); + self.reduce_motion = command( + "gsettings", + &["get", "org.gnome.desktop.interface", "enable-animations"], + ) + .is_some_and(|v| v.trim() == "false"); + if let Some(value) = hypr("j/getoption animations:enabled") { + self.reduce_motion = value["bool"] + .as_bool() + .map(|enabled| !enabled) + .or_else(|| value["int"].as_i64().map(|enabled| enabled == 0)) + .unwrap_or(self.reduce_motion); + } + if std::env::var("XDG_CURRENT_DESKTOP") + .is_ok_and(|desktop| desktop.to_lowercase().contains("kde")) + { + self.reduce_motion |= command( + "kreadconfig6", + &[ + "--file", + "kdeglobals", + "--group", + "KDE", + "--key", + "AnimationDurationFactor", + ], + ) + .and_then(|value| value.trim().parse::().ok()) + == Some(0.0); + } + self.display_asleep = display_asleep(); + let hypr_window = hypr_cover(&request.title); + if let Some((_, asleep, _)) = &hypr_window { + self.display_asleep = *asleep; + } + if matches!(request.selection, Selection::Image(None)) { + let monitor = request.monitor.as_deref().or_else(|| { + hypr_window + .as_ref() + .and_then(|(_, _, name)| name.as_deref()) + }); + self.wallpaper = wallpaper(request.light, monitor); + } + } + if request.follows_screen + && request.active + && self + .last_position + .is_none_or(|time| now.duration_since(time) > Duration::from_millis(40)) + { + self.last_position = Some(now); + self.desktop_cover = hypr_cover(&request.title).map(|(bounds, asleep, _)| { + self.display_asleep = asleep; + bounds + }); + } + } +} + +fn on_battery() -> bool { + let Ok(supplies) = std::fs::read_dir("/sys/class/power_supply") else { + return false; + }; + let mut battery = false; + let mut plugged_in = false; + for supply in supplies.flatten() { + let path = supply.path(); + let kind = std::fs::read_to_string(path.join("type")).unwrap_or_default(); + if kind.trim() == "Battery" { + battery |= std::fs::read_to_string(path.join("status")) + .is_ok_and(|s| s.trim() == "Discharging"); + } else { + plugged_in |= + std::fs::read_to_string(path.join("online")).is_ok_and(|s| s.trim() == "1"); + } + } + battery && !plugged_in +} + +/// Hyprland is the one supported Wayland backend that exposes global client positions. +/// Match our PID and exact title rather than reading the currently focused foreign window. +fn hypr_cover(title: &str) -> Option<(Bounds, bool, Option)> { + let clients = hypr("j/clients")?; + let mut matches = clients.as_array()?.iter().filter(|client| { + client["pid"].as_u64() == Some(std::process::id() as u64) + && client["title"].as_str() == Some(title) + }); + let client = matches.next()?; + if matches.next().is_some() { + return None; + } + let monitors = hypr("j/monitors")?; + let monitor = monitors + .as_array()? + .iter() + .find(|monitor| monitor["id"] == client["monitor"])?; + let number = |value: &serde_json::Value| value.as_f64().map(|v| v as f32); + let scale = number(&monitor["scale"])?; + let mut width = number(&monitor["width"])?; + let mut height = number(&monitor["height"])?; + if monitor["transform"].as_u64().unwrap_or(0) % 2 == 1 { + std::mem::swap(&mut width, &mut height); + } + Some(( + Bounds::new( + Point::new( + px(number(&monitor["x"])? - number(&client["at"][0])?), + px(number(&monitor["y"])? - number(&client["at"][1])?), + ), + Size::new(px(width / scale), px(height / scale)), + ), + monitor["dpmsStatus"].as_bool() == Some(false), + monitor["name"].as_str().map(str::to_owned), + )) +} + +fn hypr(request: &str) -> Option { + let signature = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; + let runtime = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(format!("/run/user/{}", unsafe { libc::getuid() }))); + let mut socket = + UnixStream::connect(runtime.join("hypr").join(signature).join(".socket.sock")).ok()?; + socket + .set_read_timeout(Some(Duration::from_millis(200))) + .ok()?; + socket + .set_write_timeout(Some(Duration::from_millis(200))) + .ok()?; + socket.write_all(request.as_bytes()).ok()?; + let mut bytes = Vec::new(); + socket.take(4 * 1024 * 1024).read_to_end(&mut bytes).ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn wallpaper(light: bool, monitor: Option<&str>) -> Option { + if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() { + if let Some(active) = command("hyprctl", &["hyprpaper", "listactive"]) { + for line in active.lines() { + if let Some((output, path)) = line.split_once(" = ") { + if monitor.is_none_or(|monitor| output.trim() == monitor) { + return file_path(path.trim()); + } + } + } + } + for program in ["swww", "awww"] { + if let Some(active) = command(program, &["query"]) { + for line in active.lines() { + if monitor.is_none_or(|monitor| line.starts_with(&format!("{monitor}:"))) { + if let Some((_, path)) = line.split_once("image: ") { + return file_path(path.trim()); + } + } + } + } + } + return None; + } + let desktop = std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .to_lowercase(); + if desktop.contains("gnome") + || desktop.contains("cinnamon") + || desktop.contains("mate") + || desktop.contains("unity") + { + let schema = if desktop.contains("cinnamon") { + "org.cinnamon.desktop.background" + } else if desktop.contains("mate") { + "org.mate.background" + } else { + "org.gnome.desktop.background" + }; + let key = if desktop.contains("mate") { + "picture-filename" + } else if light || desktop.contains("cinnamon") { + "picture-uri" + } else { + "picture-uri-dark" + }; + return command("gsettings", &["get", schema, key]) + .and_then(|value| file_path(value.trim().trim_matches('\''))); + } + if desktop.contains("kde") { + // Ask Plasma for the active image on the matching output; this is read-only scripting. + let script = "var r=[]; for (var d of desktops()) { d.currentConfigGroup=['Wallpaper','org.kde.image','General']; r.push({screen:d.screen,image:d.readConfig('Image','')}); } print(JSON.stringify(r));"; + let reply = command( + "gdbus", + &[ + "call", + "--session", + "--dest", + "org.kde.plasmashell", + "--object-path", + "/PlasmaShell", + "--method", + "org.kde.PlasmaShell.evaluateScript", + script, + ], + )?; + let start = reply.find('[')?; + let end = reply.rfind(']')?; + let rows: serde_json::Value = serde_json::from_str(&reply[start..=end]).ok()?; + // Without an output mapping only an unambiguous single desktop is usable. + let rows = rows.as_array()?; + if rows.len() == 1 { + return rows[0]["image"].as_str().and_then(file_path); + } + } + None +} + +fn file_path(value: &str) -> Option { + let path = if value.starts_with("file:") { + url::Url::parse(value).ok()?.to_file_path().ok()? + } else { + PathBuf::from(value) + }; + path.is_file().then_some(path) +} + +/// Bounded reads and a deadline keep an absent/hung desktop service from holding a worker alive. +pub(super) fn command(program: &str, args: &[&str]) -> Option { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let stdout = child.stdout.take()?; + use std::os::fd::AsRawFd; + unsafe { + libc::fcntl(stdout.as_raw_fd(), libc::F_SETFL, libc::O_NONBLOCK); + } + let mut stdout = stdout; + let mut bytes = Vec::new(); + let deadline = Instant::now() + Duration::from_millis(800); + loop { + let mut buffer = [0; 8192]; + match stdout.read(&mut buffer) { + Ok(n) if n > 0 => { + bytes.extend_from_slice(&buffer[..n]); + if bytes.len() > 4 * 1024 * 1024 { + break; + } + } + Err(error) if error.kind() != std::io::ErrorKind::WouldBlock => break, + _ => {} + } + if let Ok(Some(status)) = child.try_wait() { + let _ = stdout.read_to_end(&mut bytes); + return status + .success() + .then(|| String::from_utf8(bytes).ok()) + .flatten(); + } + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + let _ = child.kill(); + let _ = child.wait(); + None +} + +#[cfg(feature = "x11")] +fn display_asleep() -> bool { + use x11rb::protocol::dpms::{ConnectionExt, DPMSMode}; + let asleep = || -> Option { + let (connection, _) = x11rb::connect(None).ok()?; + let reply = connection.dpms_info().ok()?.reply().ok()?; + Some(reply.state && reply.power_level != DPMSMode::ON) + }; + asleep().unwrap_or(false) +} + +#[cfg(not(feature = "x11"))] +fn display_asleep() -> bool { + false +} diff --git a/crates/gpui_linux/src/linux/glass/video.rs b/crates/gpui_linux/src/linux/glass/video.rs new file mode 100644 index 00000000000000..e3c426a9e3fd8b --- /dev/null +++ b/crates/gpui_linux/src/linux/glass/video.rs @@ -0,0 +1,149 @@ +//! Muted, looping video decoded by the distribution's FFmpeg, with a bounded one-frame pipe. +//! SIGSTOP pauses decoding too: a paused backdrop must not burn CPU in a hidden decoder. +use super::*; +use std::{ + io::Read, + os::fd::AsRawFd, + process::{Child, ChildStdout, Command, Stdio}, +}; + +pub(super) struct Video { + child: Child, + stdout: ChildStdout, + width: u32, + height: u32, + bytes: Vec, + paused: bool, + first_frame: bool, + started: Instant, +} + +impl Video { + pub fn open(path: &std::path::Path, cover_width: f32) -> Option { + let path_text = path.to_str()?; + let metadata = system::command( + "ffprobe", + &[ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height:stream_side_data=rotation", + "-of", + "json", + path_text, + ], + )?; + let metadata: serde_json::Value = serde_json::from_str(&metadata).ok()?; + let stream = &metadata["streams"][0]; + let mut width = stream["width"].as_u64()? as f32; + let mut height = stream["height"].as_u64()? as f32; + let rotated = stream["side_data_list"].as_array().is_some_and(|rows| { + rows.iter().any(|row| { + row["rotation"] + .as_i64() + .is_some_and(|r| r.abs() % 180 == 90) + }) + }); + if rotated { + std::mem::swap(&mut width, &mut height); + } + if width < 1.0 || height < 1.0 { + return None; + } + let scale = 512.0 / width.max(height); + let width = (width * scale).round().max(1.0) as u32; + let height = (height * scale).round().max(1.0) as u32; + let sigma = (60.0 * width as f32 / cover_width.max(1.0)).clamp(0.1, 64.0); + let filter = + format!("fps=24,scale={width}:{height},gblur=sigma={sigma}:steps=3,format=rgba"); + let mut child = Command::new("ffmpeg") + .args([ + "-nostdin", + "-v", + "error", + "-threads", + "1", + "-filter_threads", + "1", + "-stream_loop", + "-1", + "-i", + ]) + .arg(path) + .args([ + "-an", "-sn", "-dn", "-vf", &filter, "-f", "rawvideo", "-pix_fmt", "rgba", "pipe:1", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let stdout = child.stdout.take()?; + unsafe { + libc::fcntl(stdout.as_raw_fd(), libc::F_SETFL, libc::O_NONBLOCK); + } + Some(Self { + child, + stdout, + width, + height, + bytes: Vec::new(), + paused: false, + first_frame: true, + started: Instant::now(), + }) + } + + pub fn set_playing(&mut self, playing: bool) { + if self.paused == playing { + // This PID belongs to the Child we retain; it cannot be reused until wait(). + unsafe { + libc::kill( + self.child.id() as i32, + if playing { + libc::SIGCONT + } else { + libc::SIGSTOP + }, + ); + } + self.paused = !playing; + } + } + + pub fn loading(&self) -> bool { + self.first_frame && self.started.elapsed() < Duration::from_secs(5) + } + + pub fn frame(&mut self) -> Option { + if self.paused { + return None; + } + let frame_len = (self.width * self.height * 4) as usize; + let mut buffer = [0; 16384]; + while self.bytes.len() < frame_len { + let available = (frame_len - self.bytes.len()).min(buffer.len()); + match self.stdout.read(&mut buffer[..available]) { + Ok(0) => return None, + Ok(n) => self.bytes.extend_from_slice(&buffer[..n]), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return None, + Err(_) => return None, + } + } + self.first_frame = false; + Some(GlassImage { + width: self.width, + height: self.height, + rgba: std::mem::take(&mut self.bytes), + }) + } +} + +impl Drop for Video { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 469eb444710e9a..9de0ec8257d21a 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -37,6 +37,9 @@ use wayland_client::{ wl_shm_pool, wl_surface, }, }; +use wayland_protocols::ext::background_effect::v1::client::{ + ext_background_effect_manager_v1, ext_background_effect_surface_v1, +}; use wayland_protocols::wp::pointer_gestures::zv1::client::{ zwp_pointer_gesture_pinch_v1, zwp_pointer_gestures_v1, }; @@ -221,6 +224,8 @@ pub struct Globals { Option, pub decoration_manager: Option, pub layer_shell: Option, + pub background_effect_manager: + Option, pub blur_manager: Option, pub text_input_manager: Option, pub gesture_manager: Option, @@ -267,6 +272,7 @@ impl Globals { fractional_scale_manager: globals.bind(&qh, 1..=1, ()).ok(), decoration_manager: globals.bind(&qh, 1..=1, ()).ok(), layer_shell: globals.bind(&qh, 1..=5, ()).ok(), + background_effect_manager: globals.bind(&qh, 1..=1, ()).ok(), blur_manager: globals.bind(&qh, 1..=1, ()).ok(), text_input_manager: globals.bind(&qh, 1..=1, ()).ok(), gesture_manager: globals.bind(&qh, 1..=3, ()).ok(), @@ -3221,3 +3227,6 @@ mod tests { ); } } + +delegate_noop!(WaylandClientStatePtr: ignore ext_background_effect_manager_v1::ExtBackgroundEffectManagerV1); +delegate_noop!(WaylandClientStatePtr: ignore ext_background_effect_surface_v1::ExtBackgroundEffectSurfaceV1); diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 3096e71a9385de..ccae2428d373bb 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -105,12 +105,14 @@ pub struct WaylandWindowState { pub surface: wl_surface::WlSurface, app_id: Option, appearance: WindowAppearance, + background_effect: Option, blur: Option, viewport: Option, outputs: HashMap, display: Option<(ObjectId, Output)>, globals: Globals, renderer: WgpuRenderer, + glass: crate::linux::glass::Glass, bounds: Bounds, scale: f32, input_handler: Option, @@ -581,6 +583,13 @@ impl WaylandWindowState { WgpuRenderer::new(gpu_context, &raw_window, config, compositor_gpu)? }; + let mut glass = crate::linux::glass::Glass::with_wake(globals.frame_ping.clone()); + glass.title = options + .titlebar + .as_ref() + .and_then(|bar| bar.title.as_ref()) + .map(ToString::to_string) + .unwrap_or_default(); if let WaylandSurfaceState::Xdg(ref xdg_state) = surface_state { if let Some(title) = options.titlebar.and_then(|titlebar| titlebar.title) { xdg_state.toplevel.set_title(title.to_string()); @@ -604,6 +613,7 @@ impl WaylandWindowState { children: FxHashMap::default(), surface, app_id: options.app_id, + background_effect: None, blur: None, viewport, globals, @@ -614,6 +624,7 @@ impl WaylandWindowState { scale: 1.0, input_handler: None, decorations: WindowDecorations::Client, + glass, background_appearance: WindowBackgroundAppearance::Opaque, fullscreen: false, maximized: false, @@ -769,6 +780,9 @@ impl Drop for WaylandWindow { state.renderer.destroy(); + if let Some(effect) = state.background_effect.take() { + effect.destroy(); + } // Destroy blur first, this has no dependencies. if let Some(blur) = &state.blur { blur.release(); @@ -1000,6 +1014,9 @@ impl WaylandWindowStatePtr { } pub fn scheduled_frame_fired(&self) { + if self.state.borrow().glass.needs_frame() { + self.request_redraw(); + } if self.frame_loop.get() == FrameLoop::Scheduled { self.frame(); } @@ -1085,6 +1102,8 @@ impl WaylandWindowStatePtr { state.tiling = configure.tiling; let visibility_changed = state.visibility != configure.visibility; state.visibility = configure.visibility; + let active = state.active && state.visibility.is_visible(); + state.glass.set_active(active); // Limit interactive resizes to once per vblank let throttled = configure.resizing && state.resize_throttle; if throttled { @@ -1557,7 +1576,12 @@ impl WaylandWindowStatePtr { } pub fn set_focused(&self, focus: bool) { - self.state.borrow_mut().active = focus; + { + let mut state = self.state.borrow_mut(); + state.active = focus; + let active = focus && state.visibility.is_visible(); + state.glass.set_active(active); + } let callback = self.callbacks.borrow_mut().active_status_change.take(); if let Some(mut fun) = callback { fun(focus); @@ -1818,6 +1842,7 @@ impl PlatformWindow for WaylandWindow { } fn set_title(&mut self, title: &str) { + self.borrow_mut().glass.title = title.to_owned(); if let Some(toplevel) = self.borrow().surface_state.toplevel() { toplevel.set_title(title.to_string()); } @@ -1841,6 +1866,27 @@ impl PlatformWindow for WaylandWindow { self.0.request_redraw(); } + fn set_background_wallpaper(&self, enabled: bool) { + self.borrow_mut().glass.enabled = enabled; + } + fn set_background_wallpaper_image(&self, image: Option) { + self.borrow_mut().glass.image = image; + } + fn set_background_wallpaper_follows_screen(&self, follows: bool) { + self.borrow_mut().glass.follows_screen = follows; + } + fn set_background_wallpaper_cover(&self, cover: Option>) { + self.borrow_mut().glass.cover = cover; + } + fn set_background_live(&self, live: Option) { + self.borrow_mut().glass.live = live; + } + fn set_background_video(&self, video: Option, only_on_power: bool) { + let mut state = self.borrow_mut(); + state.glass.video = video; + state.glass.only_on_power = only_on_power; + } + fn background_appearance(&self) -> WindowBackgroundAppearance { self.borrow().background_appearance } @@ -1965,6 +2011,25 @@ impl PlatformWindow for WaylandWindow { let callback = state.surface.frame(&state.globals.qh, state.surface.id()); state.pending_frame_callback = Some(callback); } + let active = state.active && state.visibility.is_visible(); + let bounds = state.bounds; + let screen = state + .display + .as_ref() + .map(|(_, display)| display.bounds.to_pixels(state.scale)) + .unwrap_or(bounds); + let monitor = state + .display + .as_ref() + .and_then(|(_, display)| display.name.clone()); + let light = matches!( + state.appearance, + WindowAppearance::Light | WindowAppearance::VibrantLight + ); + let frame = state + .glass + .frame(active, bounds, screen, monitor, light, true); + state.renderer.set_glass_frame(frame); if state.renderer.draw(scene) { state.presentation = PresentationState::Presented; self.0.frame_loop.set(FrameLoop::AwaitingCallback); @@ -1973,7 +2038,7 @@ impl PlatformWindow for WaylandWindow { self.0.frame_loop.set(FrameLoop::PresentationFailed); } - if state.renderer.needs_redraw() { + if state.renderer.needs_redraw() || state.glass.needs_frame() { state.redraw_requested = true; } } @@ -2235,12 +2300,22 @@ fn update_window(mut state: RefMut) { state.surface.set_opaque_region(None); } - if let Some(ref blur_manager) = state.globals.blur_manager { + // Prefer the standard protocol; older Plasma compositors expose the KDE protocol. + if let Some(ref manager) = state.globals.background_effect_manager { + if state.background_effect.is_none() { + state.background_effect = + Some(manager.get_background_effect(&state.surface, &state.globals.qh, ())); + } + state.background_effect.as_ref().unwrap().set_blur_region( + (state.background_appearance == WindowBackgroundAppearance::Blurred).then_some(®ion), + ); + } else if let Some(ref blur_manager) = state.globals.blur_manager { if state.background_appearance == WindowBackgroundAppearance::Blurred { if state.blur.is_none() { let blur = blur_manager.create(&state.surface, &state.globals.qh, ()); state.blur = Some(blur); } + state.blur.as_ref().unwrap().set_region(Some(®ion)); state.blur.as_ref().unwrap().commit(); } else { // It probably doesn't hurt to clear the blur for opaque windows diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 489160a4d8f228..a12e03643d68ca 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -73,6 +73,7 @@ x11rb::atom_manager! { _NET_WM_SYNC_REQUEST, _NET_WM_SYNC_REQUEST_COUNTER, _NET_WM_BYPASS_COMPOSITOR, + _KDE_NET_WM_BLUR_BEHIND_REGION, _NET_WM_MOVERESIZE, _NET_WM_WINDOW_TYPE, _NET_WM_WINDOW_TYPE_NOTIFICATION, @@ -281,6 +282,7 @@ pub struct X11WindowState { bounds: Bounds, scale_factor: f32, renderer: WgpuRenderer, + glass: crate::linux::glass::Glass, display: Rc, input_handler: Option, appearance: WindowAppearance, @@ -629,6 +631,13 @@ impl X11WindowState { xcb.configure_window(x_window, &xproto::ConfigureWindowAux::new().x(x).y(y)), )?; } + let mut glass = crate::linux::glass::Glass::default(); + glass.title = params + .titlebar + .as_ref() + .and_then(|bar| bar.title.as_ref()) + .map(ToString::to_string) + .unwrap_or_default(); if let Some(titlebar) = params.titlebar && let Some(title) = titlebar.title { @@ -908,6 +917,7 @@ impl X11WindowState { hidden: false, appearance, handle, + glass, background_appearance: WindowBackgroundAppearance::Opaque, destroyed: false, client_side_decorations_supported, @@ -1245,6 +1255,9 @@ impl X11WindowStatePtr { } } + let active = state.active && state.visibility.is_visible(); + state.glass.set_active(active); + // The urgency hint has no withdrawal signal of its own; ICCCM leaves that to // the client, and focus is the conventional means for the user to zero it. if state.active && !was_active { @@ -1291,6 +1304,7 @@ impl X11WindowStatePtr { } pub fn refresh(&self, mut request_frame_options: RequestFrameOptions) { + request_frame_options.force_render |= self.state.borrow().glass.needs_frame(); let callback = self.callbacks.borrow_mut().request_frame.take(); if let Some(mut fun) = callback { // Expose events can present a frame before the refresh timer runs, @@ -1462,6 +1476,11 @@ impl X11WindowStatePtr { if std::mem::replace(&mut self.state.borrow_mut().visibility, visibility) == visibility { return; } + { + let mut state = self.state.borrow_mut(); + let active = state.active && visibility.is_visible(); + state.glass.set_active(active); + } let callback = self.callbacks.borrow_mut().visibility_change.take(); if let Some(mut fun) = callback { fun(visibility); @@ -1755,6 +1774,7 @@ impl PlatformWindow for X11Window { } fn set_title(&mut self, title: &str) { + self.0.state.borrow_mut().glass.title = title.to_owned(); check_reply( || "X11 ChangeProperty8 on WM_NAME failed.", self.0.xcb.change_property8( @@ -1814,6 +1834,48 @@ impl PlatformWindow for X11Window { state.background_appearance = background_appearance; let transparent = state.is_transparent(); state.renderer.update_transparency(transparent); + // KWin's property is also understood by compositors implementing its blur contract. + // Empty CARDINAL data means the whole window; deleting it removes the request. + if background_appearance == WindowBackgroundAppearance::Blurred { + self.0 + .xcb + .change_property32( + xproto::PropMode::REPLACE, + self.0.x_window, + state.atoms._KDE_NET_WM_BLUR_BEHIND_REGION, + xproto::AtomEnum::CARDINAL, + &[], + ) + .log_err(); + } else { + self.0 + .xcb + .delete_property(self.0.x_window, state.atoms._KDE_NET_WM_BLUR_BEHIND_REGION) + .log_err(); + } + xcb_flush(&self.0.xcb); + state.force_render_after_recovery = true; + } + + fn set_background_wallpaper(&self, enabled: bool) { + self.0.state.borrow_mut().glass.enabled = enabled; + } + fn set_background_wallpaper_image(&self, image: Option) { + self.0.state.borrow_mut().glass.image = image; + } + fn set_background_wallpaper_follows_screen(&self, follows: bool) { + self.0.state.borrow_mut().glass.follows_screen = follows; + } + fn set_background_wallpaper_cover(&self, cover: Option>) { + self.0.state.borrow_mut().glass.cover = cover; + } + fn set_background_live(&self, live: Option) { + self.0.state.borrow_mut().glass.live = live; + } + fn set_background_video(&self, video: Option, only_on_power: bool) { + let mut state = self.0.state.borrow_mut(); + state.glass.video = video; + state.glass.only_on_power = only_on_power; } fn background_appearance(&self) -> WindowBackgroundAppearance { @@ -1955,9 +2017,20 @@ impl PlatformWindow for X11Window { return; } + let active = inner.active && inner.visibility.is_visible(); + let bounds = inner.bounds; + let screen = inner.display.bounds(); + let light = matches!( + inner.appearance, + WindowAppearance::Light | WindowAppearance::VibrantLight + ); + let frame = inner + .glass + .frame(active, bounds, screen, None, light, false); + inner.renderer.set_glass_frame(frame); inner.renderer.draw(scene); - if inner.renderer.needs_redraw() { + if inner.renderer.needs_redraw() || inner.glass.needs_frame() { inner.force_render_after_recovery = true; } } diff --git a/crates/gpui_wgpu/src/glass.rs b/crates/gpui_wgpu/src/glass.rs new file mode 100644 index 00000000000000..c04c8ce31be8bb --- /dev/null +++ b/crates/gpui_wgpu/src/glass.rs @@ -0,0 +1,343 @@ +//! Low-resolution, GPU-rendered glass backdrops shared by the Linux window backends. +//! The scene is composited over this once, preserving independent sidebar/work-area tint. +use gpui::{Bounds, LiveBackground, Pixels}; +use std::sync::Arc; +use wgpu::util::DeviceExt; + +#[derive(Clone)] +pub struct GlassImage { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +#[derive(Clone, Default)] +pub struct GlassSource { + pub image: Option>, + pub live: Option, + pub phase: f32, +} + +#[derive(Clone)] +pub struct GlassFrame { + pub current: GlassSource, + pub fade: f32, + pub transition: u64, + pub view_size: [f32; 2], + pub cover: Bounds, +} + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct LiveUniforms { + resolution: [f32; 2], + view_size: [f32; 2], + cover_origin: [f32; 2], + cover_size: [f32; 2], + phase: f32, + period: f32, + brightness: f32, + style: f32, + colors: [[f32; 4]; 3], +} + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct Uniforms { + current: LiveUniforms, + fade: [f32; 4], +} + +pub(crate) struct GlassRenderer { + pipeline: wgpu::RenderPipeline, + copy_pipeline: wgpu::RenderPipeline, + layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, + uniform: wgpu::Buffer, + picture: Option<(Arc, wgpu::Texture)>, + empty: wgpu::Texture, + canvas: wgpu::Texture, + snapshot: wgpu::Texture, + capture_snapshot: bool, + frame: GlassFrame, +} + +impl GlassRenderer { + pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, frame: GlassFrame) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("glass"), + source: wgpu::ShaderSource::Wgsl(include_str!("glass.wgsl").into()), + }); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("glass"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + texture_binding(2), + texture_binding(3), + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("glass"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let pipeline = |entry, target| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(entry), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("glass_vertex"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some(entry), + targets: &[Some(wgpu::ColorTargetState { + format: target, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }) + }; + Self { + pipeline: pipeline("glass_fragment", wgpu::TextureFormat::Rgba8Unorm), + copy_pipeline: pipeline("glass_copy", format), + layout, + sampler: device.create_sampler(&wgpu::SamplerDescriptor { + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }), + uniform: device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("glass uniforms"), + contents: bytemuck::bytes_of(&::zeroed()), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }), + picture: None, + empty: texture(device, 1, 1), + canvas: texture(device, 320, 200), + snapshot: texture(device, 320, 200), + capture_snapshot: false, + frame, + } + } + + pub fn set_frame(&mut self, frame: GlassFrame) { + self.capture_snapshot |= self.frame.transition != frame.transition && frame.fade < 1.0; + self.frame = frame; + } + + pub fn draw( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + target: &wgpu::TextureView, + size: [u32; 2], + ) { + if self.capture_snapshot { + encoder.copy_texture_to_texture( + self.canvas.as_image_copy(), + self.snapshot.as_image_copy(), + self.canvas.size(), + ); + self.capture_snapshot = false; + } + { + let slot = &mut self.picture; + match &self.frame.current.image { + Some(image) + if !slot + .as_ref() + .is_some_and(|(old, _)| Arc::ptr_eq(old, image)) => + { + let picture = slot + .take() + .filter(|(_, picture)| { + picture.width() == image.width && picture.height() == image.height + }) + .map(|(_, picture)| picture) + .unwrap_or_else(|| texture(device, image.width, image.height)); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &picture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &image.rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(image.width * 4), + rows_per_image: Some(image.height), + }, + picture.size(), + ); + *slot = Some((image.clone(), picture)); + } + None => *slot = None, + _ => {} + } + } + let resolution = [320.0, 200.0]; + let uniforms = Uniforms { + current: uniforms(&self.frame.current, &self.frame, resolution), + fade: [self.frame.fade, size[0] as f32, size[1] as f32, 1.0], + }; + queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniforms)); + let previous = self.snapshot.create_view(&Default::default()); + let current = self + .picture + .as_ref() + .map_or(&self.empty, |(_, t)| t) + .create_view(&Default::default()); + let canvas = self.canvas.create_view(&Default::default()); + let group = self.bind_group(device, &previous, ¤t); + render(encoder, &canvas, &self.pipeline, &group); + let group = self.bind_group(device, &previous, &canvas); + render(encoder, target, &self.copy_pipeline, &group); + } + + fn bind_group( + &self, + device: &wgpu::Device, + previous: &wgpu::TextureView, + current: &wgpu::TextureView, + ) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("glass"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(previous), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(current), + }, + ], + }) + } +} + +fn texture_binding(binding: u32) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + } +} + +fn texture(device: &wgpu::Device, width: u32, height: u32) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("glass"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }) +} + +fn render( + encoder: &mut wgpu::CommandEncoder, + target: &wgpu::TextureView, + pipeline: &wgpu::RenderPipeline, + group: &wgpu::BindGroup, +) { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("glass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + ..Default::default() + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, group, &[]); + pass.draw(0..3, 0..1); +} + +fn uniforms(source: &GlassSource, frame: &GlassFrame, resolution: [f32; 2]) -> LiveUniforms { + let style = source + .live + .as_ref() + .and_then(|live| { + [ + "aurora", "ink", "drift", "nebula", "silk", "bokeh", "waves", "mesh", + ] + .iter() + .position(|id| *id == live.style.as_ref()) + }) + .map_or(if source.image.is_some() { 9.0 } else { 0.0 }, |index| { + (index + 1) as f32 + }); + LiveUniforms { + resolution, + view_size: frame.view_size, + cover_origin: [frame.cover.origin.x.into(), frame.cover.origin.y.into()], + cover_size: [ + frame.cover.size.width.into(), + frame.cover.size.height.into(), + ], + phase: source.phase, + period: 120.0, + brightness: source.live.as_ref().map_or(1.0, |live| live.brightness), + style, + colors: source.live.as_ref().map_or([[0.0; 4]; 3], |live| { + live.colors.map(|c| [c[0], c[1], c[2], 1.0]) + }), + } +} diff --git a/crates/gpui_wgpu/src/glass.wgsl b/crates/gpui_wgpu/src/glass.wgsl new file mode 100644 index 00000000000000..95befa67c04ebb --- /dev/null +++ b/crates/gpui_wgpu/src/glass.wgsl @@ -0,0 +1,272 @@ +// Port of gpui_macos/src/live_backdrop.metal. Keep all eight styles, gains and time loops aligned. +struct LiveUniforms { + resolution: vec2, view_size: vec2, + cover_origin: vec2, cover_size: vec2, + phase: f32, period: f32, brightness: f32, style: f32, + c0: vec4, c1: vec4, c2: vec4, +}; +struct GlassUniforms { current: LiveUniforms, fade: vec4 }; +@group(0) @binding(0) var glass: GlassUniforms; +@group(0) @binding(1) var linear_sampler: sampler; +@group(0) @binding(2) var previous_image: texture_2d; +@group(0) @binding(3) var current_image: texture_2d; +@vertex fn glass_vertex(@builtin(vertex_index) index: u32) -> @builtin(position) vec4 { + let corner = vec2(f32((index << 1u) & 2u), f32(index & 2u)); + return vec4(corner * 2.0 - 1.0, 0.0, 1.0); +} +const LIVE_TAU: f32 = 6.28318530718; + +// Per-style brightness gains (see live_finish), measured with render-posters.swift --measure. +const LIVE_GAIN_AURORA: f32 = 1.155; +const LIVE_GAIN_INK: f32 = 0.533; +const LIVE_GAIN_DRIFT: f32 = 0.599; +const LIVE_GAIN_NEBULA: f32 = 1.860; +const LIVE_GAIN_SILK: f32 = 0.941; +const LIVE_GAIN_BOKEH: f32 = 2.041; +const LIVE_GAIN_WAVES: f32 = 0.706; +const LIVE_GAIN_MESH: f32 = 0.588; + +fn live_angle(u: LiveUniforms) -> f32 { + return LIVE_TAU * u.phase / u.period; +} + +// The point, in cover units: x runs with the cover's aspect, y from -0.5 at the top to 0.5. +fn live_point(frag: vec4, u: LiveUniforms) -> vec2 { + let view_point = frag.xy / u.resolution * u.view_size; + let uv = (view_point - u.cover_origin) / max(u.cover_size, vec2(1.0)); + let aspect = u.cover_size.x / max(u.cover_size.y, 1.0); + return vec2((uv.x - 0.5) * aspect, uv.y - 0.5); +} + +fn live_hash(input: vec3) -> f32 { + var p = input; + p = fract(p * 0.3183099 + vec3(0.71, 0.113, 0.419)); + p *= 17.0; + return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); +} + +fn live_noise(x: vec3) -> f32 { + var i: vec3 = floor(x); + var f: vec3 = fract(x); + f = f * f * (3.0 - 2.0 * f); + var n000: f32 = live_hash(i + vec3(0, 0, 0)); + var n100: f32 = live_hash(i + vec3(1, 0, 0)); + var n010: f32 = live_hash(i + vec3(0, 1, 0)); + var n110: f32 = live_hash(i + vec3(1, 1, 0)); + var n001: f32 = live_hash(i + vec3(0, 0, 1)); + var n101: f32 = live_hash(i + vec3(1, 0, 1)); + var n011: f32 = live_hash(i + vec3(0, 1, 1)); + var n111: f32 = live_hash(i + vec3(1, 1, 1)); + return mix( + mix(mix(n000, n100, f.x), mix(n010, n110, f.x), f.y), + mix(mix(n001, n101, f.x), mix(n011, n111, f.x), f.y), + f.z); +} + +fn live_fbm(input: vec3) -> f32 { + var p = input; + var value: f32 = 0.0; + var amplitude: f32 = 0.5; + for (var octave: i32 = 0; octave < 4; octave += 1) { + value += amplitude * live_noise(p); + p = p * 2.03 + vec3(1.7, 9.2, 3.1); + amplitude *= 0.5; + } + return value; +} + +// A point of the time loop: `turns` whole turns per period on a circle of `radius`, used as a +// noise offset so the field drifts and comes back to where it started. +fn live_orbit(angle: f32, turns: f32, radius: f32, seed: f32) -> vec3 { + var a: f32 = angle * turns + seed; + return vec3(radius * cos(a), radius * sin(a), radius * 0.6 * sin(a + 1.3)); +} + +fn live_palette(input: f32, u: LiveUniforms) -> vec3 { + let t = clamp(input, 0.0, 1.0); + var low: vec3 = mix(u.c0.rgb, u.c1.rgb, smoothstep(0.0, 0.6, t)); + return mix(low, u.c2.rgb, smoothstep(0.45, 1.0, t)); +} + +// Brightness dims a style toward its deepest colour (near black in dark mode, the pale base in +// light mode). `gain` evens the styles out, so every one sits at about the same brightness at the +// same setting. Dithering hides the 8-bit steps a small texture shows once it is scaled up. +fn live_finish(input: vec3, frag: vec4, u: LiveUniforms, gain: f32) -> vec4 { + var color = mix(u.c0.rgb, input, clamp(u.brightness * gain, 0.0, 1.0)); + var dither: f32 = (live_hash(vec3(frag.xy, 3.7)) - 0.5) / 255.0; + return vec4(clamp(color + dither, vec3(0.0), vec3(1.0)), 1.0); +} + +fn live_aurora(frag: vec4, u: LiveUniforms) -> vec4 { + let aspect = u.cover_size.x / max(u.cover_size.y, 1.0); + var p: vec2 = live_point(frag, u); + var angle: f32 = live_angle(u); + var o1: vec3 = live_orbit(angle, 1.0, 1.2, 0.0); + var o2: vec3 = live_orbit(angle, 2.0, 0.8, 2.1); + var ribbon1: f32 = -0.08 + 1.3 * (live_fbm(vec3(p.x * 0.8, 0.0, 0.0) + o1) - 0.5); + var ribbon2: f32 = 0.14 + 1.1 * (live_fbm(vec3(p.x * 0.7, 3.0, 0.0) + o2) - 0.5); + // A curtain hangs upward from its ribbon: it fades slowly above and quickly below. + var d1: f32 = p.y - ribbon1; + var d2: f32 = p.y - ribbon2; + var band1: f32 = exp(-d1 * d1 / select(0.006, 0.045, d1 < 0.0)); + var band2: f32 = exp(-d2 * d2 / select(0.005, 0.035, d2 < 0.0)); + var folds: f32 = 0.7 + 0.3 * live_fbm(vec3(p.x * 2.6, p.y * 0.25, 0.0) + o1 * 0.5); + var color: vec3 = u.c0.rgb; + color = mix(color, u.c1.rgb, clamp(band1 * folds * 1.15, 0.0, 1.0)); + color = mix(color, u.c2.rgb, clamp(band2 * folds, 0.0, 1.0)); + return live_finish(color, frag, u, LIVE_GAIN_AURORA); +} + +fn live_ink(frag: vec4, u: LiveUniforms) -> vec4 { + let aspect = u.cover_size.x / max(u.cover_size.y, 1.0); + var p: vec2 = live_point(frag, u) * 1.6; + var angle: f32 = live_angle(u); + var base: vec3 = vec3(p, 0.0); + var q: vec2 = vec2( + live_fbm(base + live_orbit(angle, 1.0, 0.6, 0.0)), + live_fbm(base + vec3(5.2, 1.3, 0.0) + live_orbit(angle, 1.0, 0.6, 1.7))); + var r: vec2 = vec2( + live_fbm(base + vec3(3.0 * q, 0.0) + vec3(1.7, 9.2, 0.0) + live_orbit(angle, 2.0, 0.35, 0.4)), + live_fbm(base + vec3(3.0 * q, 0.0) + vec3(8.3, 2.8, 0.0) + live_orbit(angle, 2.0, 0.35, 2.9))); + var f: f32 = live_fbm(base + vec3(3.0 * r, 0.0)); + var color: vec3 = live_palette(f * 1.35 - 0.1, u); + color = mix(color, u.c2.rgb, clamp(length(q) * 0.35 - 0.1, 0.0, 0.5) * 0.6); + return live_finish(color, frag, u, LIVE_GAIN_INK); +} + +fn live_drift(frag: vec4, u: LiveUniforms) -> vec4 { + let aspect = u.cover_size.x / max(u.cover_size.y, 1.0); + var p: vec2 = live_point(frag, u); + var angle: f32 = live_angle(u); + var color: vec3 = mix(u.c0.rgb, u.c1.rgb, 0.25); + var mid: vec3 = mix(u.c1.rgb, u.c2.rgb, 0.5); + for (var i: i32 = 0; i < 5; i += 1) { + var seed: f32 = f32(i) * 1.618; + var turns: f32 = select(2.0, 1.0, i % 2 == 0); + var direction: f32 = select(-1.0, 1.0, i < 3); + var center: vec2 = vec2( + 0.38 * aspect * sin(angle * turns * direction + seed * 2.0), + 0.3 * cos(angle * turns + seed)); + var radius: f32 = 0.42 + 0.08 * sin(angle + seed * 3.0); + var weight: f32 = exp(-dot(p - center, p - center) / (radius * radius)); + var blob: vec3 = select(select(select(select(u.c2.rgb * 0.8 + u.c0.rgb * 0.2, u.c1.rgb, i == 3), mid, i == 2), u.c2.rgb, i == 1), u.c1.rgb, i == 0); + color = mix(color, blob, clamp(weight * 0.9, 0.0, 1.0)); + } + return live_finish(color, frag, u, LIVE_GAIN_DRIFT); +} + +fn live_nebula(frag: vec4, u: LiveUniforms) -> vec4 { + let aspect = u.cover_size.x / max(u.cover_size.y, 1.0); + var p: vec2 = live_point(frag, u) * 1.3; + var angle: f32 = live_angle(u); + var large: f32 = live_fbm(vec3(p * 1.2, 0.0) + live_orbit(angle, 1.0, 0.8, 0.3)); + var small: f32 = live_fbm(vec3(p * 3.0, 4.0) + live_orbit(angle, 2.0, 0.5, 1.9)); + var cloud: f32 = smoothstep(0.35, 0.85, large * 0.75 + small * 0.4); + var glow: f32 = smoothstep(0.45, 0.95, small) * cloud; + var color: vec3 = mix(u.c0.rgb, u.c1.rgb, cloud); + color = mix(color, u.c2.rgb, glow * 0.8); + return live_finish(color, frag, u, LIVE_GAIN_NEBULA); +} + +fn live_silk(frag: vec4, u: LiveUniforms) -> vec4 { + let aspect = u.cover_size.x / max(u.cover_size.y, 1.0); + var p: vec2 = live_point(frag, u) * 1.2; + var angle: f32 = live_angle(u); + var warp: f32 = 2.0 * live_fbm(vec3(p, 0.0) + live_orbit(angle, 1.0, 0.55, 0.0)); + var fold: f32 = sin((p.x + p.y * 0.6) * 4.0 + warp * 4.0 + angle * 2.0); + var sheen: f32 = smoothstep(0.35, 1.0, fold); + var shade: f32 = 0.5 + 0.5 * sin((p.x * 0.7 - p.y) * 2.5 + warp * 2.0 - angle); + var color: vec3 = mix(u.c0.rgb, u.c1.rgb, shade * 0.9); + color = mix(color, u.c2.rgb, sheen * 0.75); + return live_finish(color, frag, u, LIVE_GAIN_SILK); +} + +fn live_bokeh(frag: vec4, u: LiveUniforms) -> vec4 { + let aspect = u.cover_size.x / max(u.cover_size.y, 1.0); + var p: vec2 = live_point(frag, u); + var angle: f32 = live_angle(u); + var color: vec3 = u.c0.rgb * 0.9 + u.c1.rgb * 0.1; + for (var i: i32 = 0; i < 10; i += 1) { + var seed: f32 = f32(i) * 2.399; + var turns: f32 = f32(1 + i % 3); + var home: vec2 = vec2( + (fract(sin(seed * 12.9898) * 43758.5453) - 0.5) * aspect, + fract(sin(seed * 78.233) * 43758.5453) - 0.5); + var center: vec2 = home + 0.07 * vec2(cos(angle * turns + seed), sin(angle * turns + seed * 1.7)); + var radius: f32 = 0.07 + 0.09 * fract(seed * 0.37); + var disc: f32 = exp(-dot(p - center, p - center) / (radius * radius)); + var light: vec3 = select(u.c2.rgb, u.c1.rgb, i % 2 == 0); + color = mix(color, light, clamp(disc * 0.75, 0.0, 1.0)); + } + return live_finish(color, frag, u, LIVE_GAIN_BOKEH); +} + +fn live_waves(frag: vec4, u: LiveUniforms) -> vec4 { + let aspect = u.cover_size.x / max(u.cover_size.y, 1.0); + var p: vec2 = live_point(frag, u); + var angle: f32 = live_angle(u); + var color: vec3 = u.c0.rgb; + for (var i: i32 = 0; i < 4; i += 1) { + var layer: f32 = f32(i); + var height: f32 = -0.25 + layer * 0.17; + var crest: f32 = height + + 0.06 * sin(p.x * (2.0 + layer * 0.7) + angle * (1.0 + layer) + layer * 1.3) + + 0.03 * sin(p.x * (3.7 - layer * 0.4) - angle * 2.0 + layer); + var fill: f32 = smoothstep(crest - 0.05, crest + 0.05, p.y); + var tone: vec3 = live_palette(0.3 + layer * 0.22, u); + color = mix(color, tone, fill * 0.85); + } + return live_finish(color, frag, u, LIVE_GAIN_WAVES); +} + +fn live_mesh(frag: vec4, u: LiveUniforms) -> vec4 { + let aspect = u.cover_size.x / max(u.cover_size.y, 1.0); + var p: vec2 = live_point(frag, u); + var angle: f32 = live_angle(u); + var colors = array, 4>(u.c0.rgb, u.c1.rgb, u.c2.rgb, mix(u.c1.rgb, u.c2.rgb, 0.5) * 0.9 + u.c0.rgb * 0.1); + var corners = array, 4>( + vec2(-0.35 * aspect, -0.35), vec2(0.35 * aspect, -0.3), + vec2(0.3 * aspect, 0.35), vec2(-0.3 * aspect, 0.3)); + var color: vec3 = vec3(0.0); + var total: f32 = 0.0; + for (var i: i32 = 0; i < 4; i += 1) { + var seed: f32 = f32(i) * 1.9; + var point: vec2 = corners[i] + 0.18 * vec2(cos(angle * (1.0 + f32(i % 2)) + seed), sin(angle + seed * 1.3)); + var weight: f32 = 1.0 / pow(dot(p - point, p - point) + 0.02, 1.6); + color += colors[i] * weight; + total += weight; + } + return live_finish(color / total, frag, u, LIVE_GAIN_MESH); +} + + +fn sample_image(frag: vec4, u: LiveUniforms, picture: texture_2d) -> vec4 { + let size = vec2(textureDimensions(picture)); + let scale = max(u.cover_size.x / size.x, u.cover_size.y / size.y); + let point = frag.xy / u.resolution * u.view_size - u.cover_origin; + let uv = (point - (u.cover_size - size * scale) * 0.5) / (size * scale); + return textureSampleLevel(picture, linear_sampler, uv, 0.0); +} +fn backdrop(frag: vec4, u: LiveUniforms, picture: texture_2d) -> vec4 { + switch u32(u.style) { + case 1u: { return live_aurora(frag, u); } + case 2u: { return live_ink(frag, u); } + case 3u: { return live_drift(frag, u); } + case 4u: { return live_nebula(frag, u); } + case 5u: { return live_silk(frag, u); } + case 6u: { return live_bokeh(frag, u); } + case 7u: { return live_waves(frag, u); } + case 8u: { return live_mesh(frag, u); } + case 9u: { return sample_image(frag, u, picture); } + default: { return vec4(0.0); } + } +} +@fragment fn glass_fragment(@builtin(position) frag: vec4) -> @location(0) vec4 { + let current = backdrop(frag, glass.current, current_image); + if glass.fade.x >= 1.0 { return current; } + let previous = textureSampleLevel(previous_image, linear_sampler, frag.xy / glass.current.resolution, 0.0); + return mix(previous, current, glass.fade.x); +} +@fragment fn glass_copy(@builtin(position) frag: vec4) -> @location(0) vec4 { + return textureSampleLevel(current_image, linear_sampler, frag.xy / glass.fade.yz, 0.0); +} diff --git a/crates/gpui_wgpu/src/gpui_wgpu.rs b/crates/gpui_wgpu/src/gpui_wgpu.rs index 29a79afc6e36fe..3b4afdf65422cb 100644 --- a/crates/gpui_wgpu/src/gpui_wgpu.rs +++ b/crates/gpui_wgpu/src/gpui_wgpu.rs @@ -13,3 +13,6 @@ pub use wgpu_context::*; ))] pub use wgpu_renderer::WgpuHeadlessRenderer; pub use wgpu_renderer::{GpuContext, WgpuRenderer, WgpuSurfaceConfig}; + +mod glass; +pub use glass::{GlassFrame, GlassImage, GlassSource}; diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index 9cd8f2554a238d..211d3a1ba79e31 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -211,6 +211,8 @@ impl WgpuResources { } struct WgpuRendererCore { + glass: Option, + frame_size: [u32; 2], resources: WgpuResources, atlas: Arc, path_globals_offset: u64, @@ -1120,6 +1122,25 @@ impl WgpuRenderer { self.max_texture_size } + pub fn set_glass_frame(&mut self, frame: Option) { + let Some(core) = self.core_mut() else { + return; + }; + match frame { + None => core.glass = None, + Some(frame) => match core.glass.as_mut() { + Some(glass) => glass.set_frame(frame), + None => { + core.glass = Some(crate::glass::GlassRenderer::new( + &core.resources.device, + core.target_format, + frame, + )) + } + }, + } + } + pub fn draw(&mut self, scene: &Scene) -> bool { #[cfg(target_family = "wasm")] if self.device_lost() { @@ -1338,6 +1359,8 @@ impl WgpuRendererCore { let max_texture_size = device.limits().max_texture_dimension_2d; Self { + glass: None, + frame_size: [1, 1], resources: WgpuResources { device, queue, @@ -1433,6 +1456,7 @@ impl WgpuRendererCore { self.max_texture_size ); + self.frame_size = [size.width.0 as u32, size.height.0 as u32]; self.atlas.before_frame(); self.ensure_intermediate_textures(size); @@ -1505,6 +1529,15 @@ impl WgpuRendererCore { label: Some("main_encoder"), }); + if let Some(glass) = &mut self.glass { + glass.draw( + &self.resources.device, + &self.resources.queue, + &mut encoder, + frame_view, + self.frame_size, + ); + } { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("main_pass"), @@ -1512,7 +1545,11 @@ impl WgpuRendererCore { view: frame_view, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(clear_color), + load: if self.glass.is_some() { + wgpu::LoadOp::Load + } else { + wgpu::LoadOp::Clear(clear_color) + }, store: wgpu::StoreOp::Store, }, depth_slice: None,