From e3df426996ab67c188e55fddedf36e4fbcadd9f4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 17:02:09 +0800 Subject: [PATCH] bread-capture: rainbow-gradient isolation background instead of flat color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A solid background can't reveal whether a surface that's supposed to be translucent (breadbox/breadclip/breadsearch's full-screen overlay windows, breadbar's alpha-blended notification/OSD surfaces) is actually compositing as translucent — a flat color showing through a flat color still just looks flat. Generates a diagonal rainbow gradient (full hue sweep, via the `image` crate) sized exactly to the capture canvas and sets it as Sway's output background instead. Needed installing swaybg (the external helper Sway's `output ... bg` config directive shells out to) — without it the bg command silently no-ops and the canvas stays black, which is why the first attempt at this looked identical to the old flat-color version. --- Cargo.lock | 64 +++++++++++++++++++++++++++++++ bread-capture/Cargo.toml | 3 ++ bread-capture/src/isolation.rs | 69 ++++++++++++++++++++++++++++++---- 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e32afd2..b0ebcac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,6 +155,7 @@ dependencies = [ "anyhow", "bread-utils", "clap", + "image", ] [[package]] @@ -222,6 +223,18 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "cairo-rs" version = "0.22.0" @@ -591,6 +604,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -1183,6 +1205,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1362,6 +1397,16 @@ dependencies = [ "syn", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "ndarray" version = "0.17.2" @@ -1519,6 +1564,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.14.0" @@ -1570,6 +1628,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quote" version = "1.0.46" diff --git a/bread-capture/Cargo.toml b/bread-capture/Cargo.toml index b87cbda..763f7fa 100644 --- a/bread-capture/Cargo.toml +++ b/bread-capture/Cargo.toml @@ -16,3 +16,6 @@ path = "src/main.rs" bread-utils = { path = "../bread-utils" } clap = { workspace = true } anyhow = { workspace = true } +# Generates the isolated capture canvas's rainbow-gradient background — see +# isolation.rs. png-only: no decoding, no other format support needed. +image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/bread-capture/src/isolation.rs b/bread-capture/src/isolation.rs index ef43da2..f1506b0 100644 --- a/bread-capture/src/isolation.rs +++ b/bread-capture/src/isolation.rs @@ -39,6 +39,17 @@ //! breadbar already has to tolerate a missing/dead Hyprland connection //! gracefully (it survives Hyprland restarting), so this just exercises that //! same fallback path instead of a real error case. +//! +//! The background is a generated rainbow gradient, not a flat colour — +//! deliberately: a solid fill can't tell you whether a window that's +//! *supposed* to be translucent (breadbox/breadclip/breadsearch's +//! full-screen overlay windows, breadbar's notification/OSD surfaces) is +//! actually compositing as translucent, since a flat colour showing through +//! a flat colour still just looks flat. A continuously-varying gradient +//! makes any real transparency immediately obvious (multiple hues bleed +//! through) and any accidentally-opaque surface just as obvious (it blocks +//! the gradient out entirely, a flat rectangle where there should be colour +//! variation). use anyhow::{bail, Context, Result}; use std::collections::HashSet; @@ -48,15 +59,11 @@ use std::time::{Duration, Instant}; const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); -/// Matches bread-theme's `FIXED_BACKGROUND` (`bread-theme/src/palette.rs`) — -/// so the empty canvas behind a capture reads as "the app's own dark theme", -/// not an arbitrary compositor default. -const BACKGROUND_COLOR: &str = "#0c0c0c"; - pub struct Isolation { child: Child, pub wayland_display: String, config_path: PathBuf, + background_path: PathBuf, runtime_dir: PathBuf, } @@ -70,7 +77,8 @@ impl Isolation { let runtime_dir = PathBuf::from( std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string()), ); - let config_path = write_headless_config(width, height)?; + let background_path = write_rainbow_background(width, height)?; + let config_path = write_headless_config(width, height, &background_path)?; let before_sockets: HashSet = dir_names(&runtime_dir) .into_iter() .filter(|n| is_wayland_socket_name(n)) @@ -91,6 +99,7 @@ impl Isolation { child, wayland_display: String::new(), config_path, + background_path, runtime_dir: runtime_dir.clone(), }; @@ -122,6 +131,7 @@ impl Drop for Isolation { let _ = self.child.kill(); let _ = self.child.wait(); let _ = std::fs::remove_file(&self.config_path); + let _ = std::fs::remove_file(&self.background_path); // Killing sway doesn't unlink the socket it bound — confirmed // empirically, a killed instance leaves both files behind — so // without this, every capture run permanently orphans a @@ -133,16 +143,59 @@ impl Drop for Isolation { } } -fn write_headless_config(width: u32, height: u32) -> Result { +fn write_headless_config(width: u32, height: u32, background_path: &Path) -> Result { let path = std::env::temp_dir().join(format!("bread-capture-sway-{}.conf", std::process::id())); + let bg = background_path.display(); let contents = format!( "output HEADLESS-1 resolution {width}x{height}\n\ - output HEADLESS-1 bg {BACKGROUND_COLOR} solid_color\n" + output HEADLESS-1 bg {bg} stretch\n" ); std::fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?; Ok(path) } +/// Renders a diagonal rainbow (full hue sweep, both x and y contribute) to a +/// PNG at exactly `width`x`height`, for use as the isolated canvas's +/// background — see the module doc for why a gradient instead of a flat +/// colour. Diagonal rather than a simple left-to-right sweep so a capture +/// showing color variation isn't just luck-of-the-x-position: a purely +/// horizontal gradient would still make a tall, narrow surface look like a +/// near-flat single hue. +fn write_rainbow_background(width: u32, height: u32) -> Result { + let path = std::env::temp_dir().join(format!("bread-capture-bg-{}.png", std::process::id())); + let mut img = image::RgbImage::new(width.max(1), height.max(1)); + let denom = (width + height).max(1) as f32; + for y in 0..img.height() { + for x in 0..img.width() { + let hue = ((x + y) as f32 / denom) * 360.0; + img.put_pixel(x, y, image::Rgb(hsv_to_rgb(hue, 0.85, 0.95))); + } + } + img.save(&path).with_context(|| format!("writing {}", path.display()))?; + Ok(path) +} + +/// Standard HSV -> RGB conversion. `h` in degrees [0, 360), `s`/`v` in [0, 1]. +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [u8; 3] { + let c = v * s; + let h_prime = (h / 60.0) % 6.0; + let x = c * (1.0 - (h_prime % 2.0 - 1.0).abs()); + let m = v - c; + let (r1, g1, b1) = match h_prime as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + [ + ((r1 + m) * 255.0).round() as u8, + ((g1 + m) * 255.0).round() as u8, + ((b1 + m) * 255.0).round() as u8, + ] +} + fn dir_names(path: &Path) -> HashSet { std::fs::read_dir(path) .into_iter()