a lightweight, interval-based utility to combat digital strain through "Ma" (intentional pauses) for the eyes and body.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: create overlay on non-primary monitor in wayland

"wlr-layer-shell barrier for secondary Wayland monitors

Adds `src/overlay/layer_shell.rs` with `LayerShellBarrier`: connects to
Wayland, probes for `zwlr_layer_shell_v1`, and on Show creates opaque
layer surfaces (Overlay layer, all-anchored, exclusive-zone -1) on every
non-primary output using a dedicated background thread. Primary output
is intentionally skipped because the Slint window covers it.
`OverlayManager` now owns an optional `LayerShellBarrier` on
Linux/Wayland and calls `show`/`hide` alongside the existing Slint
backend."

+509 -17
+36 -9
Cargo.lock
··· 2989 2989 "tokio", 2990 2990 "toml 0.8.2", 2991 2991 "tray-icon", 2992 + "wayland-client", 2993 + "wayland-protocols-wlr 0.2.0", 2992 2994 "windows 0.58.0", 2993 2995 "zbus 4.4.0", 2994 2996 ] ··· 5336 5338 "wayland-client", 5337 5339 "wayland-csd-frame", 5338 5340 "wayland-cursor", 5339 - "wayland-protocols", 5340 - "wayland-protocols-wlr", 5341 + "wayland-protocols 0.32.12", 5342 + "wayland-protocols-wlr 0.3.12", 5341 5343 "wayland-scanner", 5342 5344 "xkeysym", 5343 5345 ] ··· 5361 5363 "wayland-client", 5362 5364 "wayland-csd-frame", 5363 5365 "wayland-cursor", 5364 - "wayland-protocols", 5366 + "wayland-protocols 0.32.12", 5365 5367 "wayland-protocols-experimental", 5366 5368 "wayland-protocols-misc", 5367 - "wayland-protocols-wlr", 5369 + "wayland-protocols-wlr 0.3.12", 5368 5370 "wayland-scanner", 5369 5371 "xkeysym", 5370 5372 ] ··· 6383 6385 6384 6386 [[package]] 6385 6387 name = "wayland-protocols" 6388 + version = "0.31.2" 6389 + source = "registry+https://github.com/rust-lang/crates.io-index" 6390 + checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" 6391 + dependencies = [ 6392 + "bitflags 2.11.1", 6393 + "wayland-backend", 6394 + "wayland-client", 6395 + "wayland-scanner", 6396 + ] 6397 + 6398 + [[package]] 6399 + name = "wayland-protocols" 6386 6400 version = "0.32.12" 6387 6401 source = "registry+https://github.com/rust-lang/crates.io-index" 6388 6402 checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" ··· 6402 6416 "bitflags 2.11.1", 6403 6417 "wayland-backend", 6404 6418 "wayland-client", 6405 - "wayland-protocols", 6419 + "wayland-protocols 0.32.12", 6406 6420 "wayland-scanner", 6407 6421 ] 6408 6422 ··· 6415 6429 "bitflags 2.11.1", 6416 6430 "wayland-backend", 6417 6431 "wayland-client", 6418 - "wayland-protocols", 6432 + "wayland-protocols 0.32.12", 6419 6433 "wayland-scanner", 6420 6434 ] 6421 6435 ··· 6428 6442 "bitflags 2.11.1", 6429 6443 "wayland-backend", 6430 6444 "wayland-client", 6431 - "wayland-protocols", 6445 + "wayland-protocols 0.32.12", 6446 + "wayland-scanner", 6447 + ] 6448 + 6449 + [[package]] 6450 + name = "wayland-protocols-wlr" 6451 + version = "0.2.0" 6452 + source = "registry+https://github.com/rust-lang/crates.io-index" 6453 + checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" 6454 + dependencies = [ 6455 + "bitflags 2.11.1", 6456 + "wayland-backend", 6457 + "wayland-client", 6458 + "wayland-protocols 0.31.2", 6432 6459 "wayland-scanner", 6433 6460 ] 6434 6461 ··· 6441 6468 "bitflags 2.11.1", 6442 6469 "wayland-backend", 6443 6470 "wayland-client", 6444 - "wayland-protocols", 6471 + "wayland-protocols 0.32.12", 6445 6472 "wayland-scanner", 6446 6473 ] 6447 6474 ··· 7178 7205 "wasm-bindgen-futures", 7179 7206 "wayland-backend", 7180 7207 "wayland-client", 7181 - "wayland-protocols", 7208 + "wayland-protocols 0.32.12", 7182 7209 "wayland-protocols-plasma", 7183 7210 "web-sys", 7184 7211 "web-time",
+2
Cargo.toml
··· 46 46 [target.'cfg(target_os = "linux")'.dependencies] 47 47 zbus = { version = "4", default-features = false, features = ["tokio"] } 48 48 gtk = "0.18" 49 + wayland-client = "0.31" 50 + wayland-protocols-wlr = { version = "0.2", features = ["client"] } 49 51 50 52 [target.'cfg(target_os = "windows")'.dependencies] 51 53 windows = { version = "0.58", features = [
+436
src/overlay/layer_shell.rs
··· 1 + use std::fs::File; 2 + use std::io::Write; 3 + use std::os::unix::io::AsFd; 4 + use std::sync::mpsc; 5 + use std::thread::JoinHandle; 6 + use std::time::Duration; 7 + 8 + use wayland_client::{ 9 + delegate_noop, 10 + protocol::{ 11 + wl_buffer, wl_compositor, wl_output, wl_registry, wl_shm, wl_shm_pool, wl_surface, 12 + }, 13 + Connection, Dispatch, EventQueue, QueueHandle, 14 + }; 15 + use wayland_protocols_wlr::layer_shell::v1::client::{ 16 + zwlr_layer_shell_v1::{self, ZwlrLayerShellV1}, 17 + zwlr_layer_surface_v1::{self, Anchor, ZwlrLayerSurfaceV1}, 18 + }; 19 + 20 + // ── Public API ─────────────────────────────────────────────────────────────── 21 + 22 + /// Covers all non-primary Wayland outputs with an opaque barrier surface using 23 + /// wlr-layer-shell. Returns `None` if the compositor doesn't support the protocol. 24 + pub struct LayerShellBarrier { 25 + cmd_tx: mpsc::SyncSender<Cmd>, 26 + _thread: JoinHandle<()>, 27 + } 28 + 29 + impl LayerShellBarrier { 30 + /// Try to create a barrier. `primary_x` / `primary_y` are the compositor 31 + /// coordinates of the primary output so we can skip it (the Slint overlay 32 + /// covers it instead). 33 + pub fn try_new(primary_x: i32, primary_y: i32) -> Option<Self> { 34 + match Self::init(primary_x, primary_y) { 35 + Ok(Some(b)) => Some(b), 36 + Ok(None) => { 37 + log::info!("wlr-layer-shell not available; secondary monitors uncovered on Wayland"); 38 + None 39 + } 40 + Err(e) => { 41 + log::warn!("LayerShellBarrier init failed: {e}"); 42 + None 43 + } 44 + } 45 + } 46 + 47 + pub fn show(&self, is_dark: bool) { 48 + let _ = self.cmd_tx.try_send(Cmd::Show { is_dark }); 49 + } 50 + 51 + pub fn hide(&self) { 52 + let _ = self.cmd_tx.try_send(Cmd::Hide); 53 + } 54 + } 55 + 56 + impl Drop for LayerShellBarrier { 57 + fn drop(&mut self) { 58 + let _ = self.cmd_tx.try_send(Cmd::Quit); 59 + } 60 + } 61 + 62 + // ── Internals ──────────────────────────────────────────────────────────────── 63 + 64 + #[derive(Debug)] 65 + enum Cmd { 66 + Show { is_dark: bool }, 67 + Hide, 68 + Quit, 69 + } 70 + 71 + struct OutputInfo { 72 + wl_output: wl_output::WlOutput, 73 + x: i32, 74 + y: i32, 75 + } 76 + 77 + struct SurfaceEntry { 78 + surface: wl_surface::WlSurface, 79 + layer_surface: ZwlrLayerSurfaceV1, 80 + buffer: Option<wl_buffer::WlBuffer>, 81 + output_idx: usize, 82 + width: u32, 83 + height: u32, 84 + needs_render: bool, 85 + } 86 + 87 + struct BarrierState { 88 + compositor: Option<wl_compositor::WlCompositor>, 89 + shm: Option<wl_shm::WlShm>, 90 + layer_shell: Option<ZwlrLayerShellV1>, 91 + outputs: Vec<OutputInfo>, 92 + surfaces: Vec<SurfaceEntry>, 93 + primary_x: i32, 94 + primary_y: i32, 95 + is_dark: bool, 96 + } 97 + 98 + impl BarrierState { 99 + fn new(primary_x: i32, primary_y: i32) -> Self { 100 + Self { 101 + compositor: None, 102 + shm: None, 103 + layer_shell: None, 104 + outputs: Vec::new(), 105 + surfaces: Vec::new(), 106 + primary_x, 107 + primary_y, 108 + is_dark: true, 109 + } 110 + } 111 + 112 + fn create_surfaces(&mut self, is_dark: bool, qh: &QueueHandle<Self>) { 113 + self.is_dark = is_dark; 114 + let compositor = match &self.compositor { 115 + Some(c) => c.clone(), 116 + None => return, 117 + }; 118 + let layer_shell = match &self.layer_shell { 119 + Some(ls) => ls.clone(), 120 + None => return, 121 + }; 122 + 123 + for (idx, output) in self.outputs.iter().enumerate() { 124 + if output.x == self.primary_x && output.y == self.primary_y { 125 + continue; 126 + } 127 + let surface = compositor.create_surface(qh, ()); 128 + let layer_surface = layer_shell.get_layer_surface( 129 + &surface, 130 + Some(&output.wl_output), 131 + zwlr_layer_shell_v1::Layer::Overlay, 132 + "ioma-barrier".to_string(), 133 + qh, 134 + idx, 135 + ); 136 + // 0×0 + all-anchors → compositor fills the entire output 137 + layer_surface.set_size(0, 0); 138 + layer_surface.set_anchor(Anchor::from_bits_truncate(0b1111)); // top|bottom|left|right 139 + layer_surface.set_exclusive_zone(-1); 140 + surface.commit(); 141 + self.surfaces.push(SurfaceEntry { 142 + surface, 143 + layer_surface, 144 + buffer: None, 145 + output_idx: idx, 146 + width: 0, 147 + height: 0, 148 + needs_render: false, 149 + }); 150 + } 151 + } 152 + 153 + fn render_pending(&mut self, qh: &QueueHandle<Self>) { 154 + let is_dark = self.is_dark; 155 + let shm = match &self.shm { 156 + Some(s) => s.clone(), 157 + None => return, 158 + }; 159 + for entry in &mut self.surfaces { 160 + if !entry.needs_render || entry.width == 0 || entry.height == 0 { 161 + continue; 162 + } 163 + match create_shm_buffer(&shm, entry.width, entry.height, is_dark, qh) { 164 + Ok(buf) => { 165 + entry.surface.attach(Some(&buf), 0, 0); 166 + entry.surface.damage_buffer( 167 + 0, 168 + 0, 169 + entry.width as i32, 170 + entry.height as i32, 171 + ); 172 + entry.surface.commit(); 173 + entry.buffer = Some(buf); 174 + entry.needs_render = false; 175 + } 176 + Err(e) => log::warn!("Failed to create shm buffer: {e}"), 177 + } 178 + } 179 + } 180 + 181 + fn destroy_surfaces(&mut self) { 182 + for entry in self.surfaces.drain(..) { 183 + entry.layer_surface.destroy(); 184 + entry.surface.destroy(); 185 + // buffer drops automatically 186 + } 187 + } 188 + } 189 + 190 + // ── Dispatch impls ─────────────────────────────────────────────────────────── 191 + 192 + impl Dispatch<wl_registry::WlRegistry, ()> for BarrierState { 193 + fn event( 194 + state: &mut Self, 195 + registry: &wl_registry::WlRegistry, 196 + event: wl_registry::Event, 197 + _: &(), 198 + _: &Connection, 199 + qh: &QueueHandle<Self>, 200 + ) { 201 + if let wl_registry::Event::Global { name, interface, .. } = event { 202 + match &interface[..] { 203 + "wl_compositor" => { 204 + state.compositor = 205 + Some(registry.bind::<wl_compositor::WlCompositor, _, _>(name, 4, qh, ())); 206 + } 207 + "wl_shm" => { 208 + state.shm = 209 + Some(registry.bind::<wl_shm::WlShm, _, _>(name, 1, qh, ())); 210 + } 211 + "zwlr_layer_shell_v1" => { 212 + state.layer_shell = 213 + Some(registry.bind::<ZwlrLayerShellV1, _, _>(name, 1, qh, ())); 214 + } 215 + "wl_output" => { 216 + let idx = state.outputs.len(); 217 + let output = 218 + registry.bind::<wl_output::WlOutput, _, _>(name, 2, qh, idx); 219 + state.outputs.push(OutputInfo { wl_output: output, x: 0, y: 0 }); 220 + } 221 + _ => {} 222 + } 223 + } 224 + } 225 + } 226 + 227 + impl Dispatch<wl_output::WlOutput, usize> for BarrierState { 228 + fn event( 229 + state: &mut Self, 230 + _: &wl_output::WlOutput, 231 + event: wl_output::Event, 232 + data: &usize, 233 + _: &Connection, 234 + _: &QueueHandle<Self>, 235 + ) { 236 + if let wl_output::Event::Geometry { x, y, .. } = event { 237 + if let Some(output) = state.outputs.get_mut(*data) { 238 + output.x = x; 239 + output.y = y; 240 + } 241 + } 242 + } 243 + } 244 + 245 + impl Dispatch<ZwlrLayerShellV1, ()> for BarrierState { 246 + fn event( 247 + _: &mut Self, 248 + _: &ZwlrLayerShellV1, 249 + _: zwlr_layer_shell_v1::Event, 250 + _: &(), 251 + _: &Connection, 252 + _: &QueueHandle<Self>, 253 + ) { 254 + // zwlr_layer_shell_v1 has no events 255 + } 256 + } 257 + 258 + impl Dispatch<ZwlrLayerSurfaceV1, usize> for BarrierState { 259 + fn event( 260 + state: &mut Self, 261 + layer_surface: &ZwlrLayerSurfaceV1, 262 + event: zwlr_layer_surface_v1::Event, 263 + data: &usize, 264 + _: &Connection, 265 + _: &QueueHandle<Self>, 266 + ) { 267 + match event { 268 + zwlr_layer_surface_v1::Event::Configure { serial, width, height } => { 269 + layer_surface.ack_configure(serial); 270 + if let Some(entry) = 271 + state.surfaces.iter_mut().find(|e| e.output_idx == *data) 272 + { 273 + entry.width = width; 274 + entry.height = height; 275 + entry.needs_render = true; 276 + } 277 + } 278 + zwlr_layer_surface_v1::Event::Closed => { 279 + state.surfaces.retain(|e| e.output_idx != *data); 280 + } 281 + _ => {} 282 + } 283 + } 284 + } 285 + 286 + delegate_noop!(BarrierState: ignore wl_compositor::WlCompositor); 287 + delegate_noop!(BarrierState: ignore wl_surface::WlSurface); 288 + delegate_noop!(BarrierState: ignore wl_shm::WlShm); 289 + delegate_noop!(BarrierState: ignore wl_shm_pool::WlShmPool); 290 + delegate_noop!(BarrierState: ignore wl_buffer::WlBuffer); 291 + 292 + // ── Thread logic ───────────────────────────────────────────────────────────── 293 + 294 + fn run_thread( 295 + mut state: BarrierState, 296 + mut event_queue: EventQueue<BarrierState>, 297 + cmd_rx: mpsc::Receiver<Cmd>, 298 + ) { 299 + let qh = event_queue.handle(); 300 + 301 + loop { 302 + match cmd_rx.try_recv() { 303 + Ok(Cmd::Show { is_dark }) => { 304 + state.create_surfaces(is_dark, &qh); 305 + // Roundtrip so the compositor sends configure events for the new surfaces. 306 + if event_queue.roundtrip(&mut state).is_err() { 307 + return; 308 + } 309 + state.render_pending(&qh); 310 + let _ = event_queue.flush(); 311 + } 312 + Ok(Cmd::Hide) => { 313 + state.destroy_surfaces(); 314 + let _ = event_queue.flush(); 315 + } 316 + Ok(Cmd::Quit) | Err(mpsc::TryRecvError::Disconnected) => return, 317 + Err(mpsc::TryRecvError::Empty) => {} 318 + } 319 + 320 + if event_queue.dispatch_pending(&mut state).is_err() { 321 + return; 322 + } 323 + let _ = event_queue.flush(); 324 + std::thread::sleep(Duration::from_millis(50)); 325 + } 326 + } 327 + 328 + // ── Init ───────────────────────────────────────────────────────────────────── 329 + 330 + impl LayerShellBarrier { 331 + fn init(primary_x: i32, primary_y: i32) -> anyhow::Result<Option<Self>> { 332 + let conn = Connection::connect_to_env()?; 333 + let mut event_queue = conn.new_event_queue::<BarrierState>(); 334 + let qh = event_queue.handle(); 335 + 336 + let display = conn.display(); 337 + display.get_registry(&qh, ()); 338 + 339 + let mut state = BarrierState::new(primary_x, primary_y); 340 + 341 + // First roundtrip: populate globals and bind wl_outputs. 342 + event_queue.roundtrip(&mut state)?; 343 + // Second roundtrip: receive wl_output geometry events. 344 + event_queue.roundtrip(&mut state)?; 345 + 346 + if state.layer_shell.is_none() { 347 + return Ok(None); 348 + } 349 + 350 + let (cmd_tx, cmd_rx) = mpsc::sync_channel::<Cmd>(4); 351 + 352 + let thread = std::thread::Builder::new() 353 + .name("ioma-layer-shell".into()) 354 + .spawn(move || run_thread(state, event_queue, cmd_rx)) 355 + .map_err(|e| anyhow::anyhow!("Failed to spawn layer-shell thread: {e}"))?; 356 + 357 + Ok(Some(Self { cmd_tx, _thread: thread })) 358 + } 359 + } 360 + 361 + // ── SHM buffer helper ──────────────────────────────────────────────────────── 362 + 363 + fn create_shm_buffer( 364 + shm: &wl_shm::WlShm, 365 + width: u32, 366 + height: u32, 367 + is_dark: bool, 368 + qh: &QueueHandle<BarrierState>, 369 + ) -> anyhow::Result<wl_buffer::WlBuffer> { 370 + // ARGB8888: dark = almost-black navy, light = light grey 371 + let color: u32 = if is_dark { 0xFF_1A_1A_2E } else { 0xFF_E8_E8_E8 }; 372 + let color_bytes = color.to_ne_bytes(); 373 + let stride = width * 4; 374 + let size = (stride * height) as usize; 375 + 376 + let mut file = make_tmpfile()?; 377 + let mut buf = std::io::BufWriter::new(&mut file); 378 + for _ in 0..height { 379 + for _ in 0..width { 380 + buf.write_all(&color_bytes)?; 381 + } 382 + } 383 + buf.flush()?; 384 + drop(buf); 385 + 386 + let pool = shm.create_pool(file.as_fd(), size as i32, qh, ()); 387 + let buffer = pool.create_buffer( 388 + 0, 389 + width as i32, 390 + height as i32, 391 + stride as i32, 392 + wl_shm::Format::Argb8888, 393 + qh, 394 + (), 395 + ); 396 + pool.destroy(); 397 + Ok(buffer) 398 + } 399 + 400 + fn make_tmpfile() -> anyhow::Result<File> { 401 + use std::os::unix::fs::OpenOptionsExt; 402 + let path = std::env::temp_dir() 403 + .join(format!("ioma-shm-{}.raw", std::process::id())); 404 + let file = std::fs::OpenOptions::new() 405 + .read(true) 406 + .write(true) 407 + .create(true) 408 + .mode(0o600) 409 + .open(&path)?; 410 + let _ = std::fs::remove_file(&path); 411 + Ok(file) 412 + } 413 + 414 + #[cfg(test)] 415 + mod tests { 416 + use super::*; 417 + 418 + #[test] 419 + fn cmd_enum_is_debug() { 420 + let c = Cmd::Show { is_dark: true }; 421 + assert!(format!("{c:?}").contains("Show")); 422 + let c = Cmd::Hide; 423 + assert!(format!("{c:?}").contains("Hide")); 424 + let c = Cmd::Quit; 425 + assert!(format!("{c:?}").contains("Quit")); 426 + } 427 + 428 + #[test] 429 + fn barrier_state_skips_primary_output() { 430 + let state = BarrierState::new(0, 0); 431 + assert_eq!(state.primary_x, 0); 432 + assert_eq!(state.primary_y, 0); 433 + assert!(state.surfaces.is_empty()); 434 + assert!(state.outputs.is_empty()); 435 + } 436 + }
+35 -8
src/overlay/mod.rs
··· 1 + pub mod layer_shell; 1 2 pub mod monitors; 2 3 pub mod multi_slint; 3 4 pub mod password; ··· 10 11 use crate::timer::{ScheduledBreak, TimerCommand}; 11 12 use crate::overlay::session::SessionType; 12 13 14 + #[cfg(target_os = "linux")] 15 + use layer_shell::LayerShellBarrier; 16 + 13 17 // ── Backend trait ──────────────────────────────────────────────────────────── 14 18 15 19 pub trait OverlayBackend { ··· 23 27 24 28 pub struct OverlayManager { 25 29 backend: Box<dyn OverlayBackend>, 30 + is_dark: bool, 31 + #[cfg(target_os = "linux")] 32 + layer_barrier: Option<LayerShellBarrier>, 26 33 } 27 34 28 35 impl OverlayManager { ··· 34 41 let session = session::detect(); 35 42 let all_monitors = monitors::enumerate(); 36 43 37 - let monitors: &[monitors::MonitorInfo] = match session { 44 + #[cfg(target_os = "linux")] 45 + let layer_barrier: Option<LayerShellBarrier> = if session == SessionType::Wayland { 46 + let primary = all_monitors.iter().find(|m| m.is_primary); 47 + let (px, py) = primary.map(|m| (m.x, m.y)).unwrap_or((0, 0)); 48 + LayerShellBarrier::try_new(px, py) 49 + } else { 50 + None 51 + }; 52 + 53 + let slint_monitors: &[monitors::MonitorInfo] = match session { 38 54 SessionType::Wayland => { 39 - // wlr-layer-shell not yet implemented; cover primary monitor only. 40 - log::info!( 41 - "Wayland session detected — multi-monitor overlay requires wlr-layer-shell \ 42 - (Phase 6D). Using primary monitor only." 43 - ); 55 + // On Wayland, Slint handles the primary monitor only; LayerShellBarrier 56 + // covers secondary monitors via wlr-layer-shell. 57 + log::info!("Wayland session: Slint overlay on primary monitor only"); 44 58 &all_monitors[..1] 45 59 } 46 60 SessionType::X11 | SessionType::Windows => &all_monitors, 47 61 }; 48 62 49 63 let backend = Box::new(multi_slint::MultiSlintBackend::new( 50 - is_dark, monitors, cmd_tx, cfg_arc, 64 + is_dark, slint_monitors, cmd_tx, cfg_arc, 51 65 )?); 52 66 53 - Ok(Self { backend }) 67 + Ok(Self { 68 + backend, 69 + is_dark, 70 + #[cfg(target_os = "linux")] 71 + layer_barrier, 72 + }) 54 73 } 55 74 56 75 pub fn show_break(&self, sched: &ScheduledBreak, is_enforced: bool, snooze_used: bool) { 57 76 self.backend.show_break(sched, is_enforced, snooze_used); 77 + #[cfg(target_os = "linux")] 78 + if let Some(ref b) = self.layer_barrier { 79 + b.show(self.is_dark); 80 + } 58 81 } 59 82 60 83 pub fn hide(&self) { 61 84 self.backend.hide(); 85 + #[cfg(target_os = "linux")] 86 + if let Some(ref b) = self.layer_barrier { 87 + b.hide(); 88 + } 62 89 } 63 90 64 91 pub fn update_countdown(&self, elapsed: Duration, total: Duration) {