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.

fix: cover both monitors on Wayland GNOME

- replace display_info with native wl_output enumeration; detects
connected outputs more reliably
- create one slint window per monitor
- use winit_window().await + fullscreen::borderless()monitorhandle) to
call xdg_toplevel.setfullscreen(wl_output)
- fix dead-code warnings

+256 -92
+1
Cargo.lock
··· 5266 5266 dependencies = [ 5267 5267 "const-field-offset", 5268 5268 "i-slint-backend-selector", 5269 + "i-slint-backend-winit", 5269 5270 "i-slint-common", 5270 5271 "i-slint-core", 5271 5272 "i-slint-core-macros",
+1 -1
Cargo.toml
··· 6 6 7 7 [dependencies] 8 8 # UI 9 - slint = "1" 9 + slint = { version = "1", features = ["unstable-winit-030"] } 10 10 11 11 # System tray + menu 12 12 tray-icon = "0.19"
+2
src/idle/mod.rs
··· 27 27 } 28 28 29 29 /// Fallback for unsupported platforms. 30 + #[cfg(not(any(target_os = "linux", target_os = "windows")))] 30 31 pub struct NullIdleDetector; 32 + #[cfg(not(any(target_os = "linux", target_os = "windows")))] 31 33 impl IdleDetector for NullIdleDetector { 32 34 fn idle_duration(&mut self) -> Result<Option<Duration>> { 33 35 Ok(None)
+1 -56
src/overlay/mod.rs
··· 50 50 None 51 51 }; 52 52 53 - let slint_monitors = slint_monitor_slice(session, &all_monitors); 54 - 55 53 let backend = Box::new(multi_slint::MultiSlintBackend::new( 56 - is_dark, slint_monitors, cmd_tx, cfg_arc, 54 + is_dark, session, &all_monitors, cmd_tx, cfg_arc, 57 55 )?); 58 56 59 57 Ok(Self { ··· 100 98 } 101 99 } 102 100 103 - /// Returns the subset of monitors that the Slint backend should cover. 104 - /// On Wayland, Slint only covers the primary monitor; secondary monitors 105 - /// are handled by `LayerShellBarrier`. On X11/Windows all monitors are used. 106 - fn slint_monitor_slice<'a>( 107 - session: SessionType, 108 - monitors: &'a [monitors::MonitorInfo], 109 - ) -> &'a [monitors::MonitorInfo] { 110 - match session { 111 - SessionType::Wayland => { 112 - log::info!("Wayland session: Slint overlay on primary monitor only"); 113 - &monitors[..1.min(monitors.len())] 114 - } 115 - SessionType::X11 | SessionType::Windows => monitors, 116 - } 117 - } 118 - 119 101 #[cfg(test)] 120 102 mod tests { 121 103 use super::*; 122 - use crate::overlay::monitors::MonitorInfo; 123 - 124 - fn make_monitors(count: usize) -> Vec<MonitorInfo> { 125 - (0..count) 126 - .map(|i| MonitorInfo { 127 - x: (i as i32) * 1920, 128 - y: 0, 129 - width: 1920, 130 - height: 1080, 131 - scale_factor: 1.0, 132 - is_primary: i == 0, 133 - name: format!("monitor-{i}"), 134 - }) 135 - .collect() 136 - } 137 - 138 - #[test] 139 - fn x11_uses_all_monitors() { 140 - let monitors = make_monitors(3); 141 - let slice = slint_monitor_slice(SessionType::X11, &monitors); 142 - assert_eq!(slice.len(), 3); 143 - } 144 - 145 - #[test] 146 - fn wayland_uses_primary_only() { 147 - let monitors = make_monitors(3); 148 - let slice = slint_monitor_slice(SessionType::Wayland, &monitors); 149 - assert_eq!(slice.len(), 1); 150 - assert!(slice[0].is_primary); 151 - } 152 - 153 - #[test] 154 - fn single_monitor_same_for_all_sessions() { 155 - let monitors = make_monitors(1); 156 - assert_eq!(slint_monitor_slice(SessionType::X11, &monitors).len(), 1); 157 - assert_eq!(slint_monitor_slice(SessionType::Wayland, &monitors).len(), 1); 158 - } 159 104 160 105 #[test] 161 106 fn fmt_dur_under_60s() {
+127 -1
src/overlay/monitors.rs
··· 16 16 match enumerate_impl() { 17 17 Ok(v) if !v.is_empty() => v, 18 18 Ok(_) => { 19 - log::warn!("display-info returned no monitors; using single-monitor fallback"); 19 + log::warn!("monitor enumeration returned no monitors; using single-monitor fallback"); 20 20 fallback() 21 21 } 22 22 Err(e) => { ··· 27 27 } 28 28 29 29 fn enumerate_impl() -> anyhow::Result<Vec<MonitorInfo>> { 30 + // On Wayland, use native wl_output enumeration — display_info only does one 31 + // roundtrip and hardcodes is_primary=false for all outputs. 32 + #[cfg(target_os = "linux")] 33 + if std::env::var("WAYLAND_DISPLAY").map(|v| !v.is_empty()).unwrap_or(false) { 34 + return enumerate_wayland(); 35 + } 36 + 30 37 let displays = display_info::DisplayInfo::all() 31 38 .map_err(|e| anyhow::anyhow!("DisplayInfo::all failed: {e:?}"))?; 32 39 Ok(displays ··· 41 48 name: d.name, 42 49 }) 43 50 .collect()) 51 + } 52 + 53 + /// Enumerate Wayland outputs using wl_output events directly. 54 + /// Two roundtrips ensure all Geometry and Mode events are received. 55 + #[cfg(target_os = "linux")] 56 + fn enumerate_wayland() -> anyhow::Result<Vec<MonitorInfo>> { 57 + use wayland_client::{ 58 + protocol::{wl_output, wl_registry}, 59 + Connection, Dispatch, QueueHandle, 60 + }; 61 + 62 + struct RawOutput { 63 + x: i32, 64 + y: i32, 65 + width: i32, 66 + height: i32, 67 + } 68 + 69 + struct EnumState { 70 + outputs: Vec<RawOutput>, 71 + } 72 + 73 + impl Dispatch<wl_registry::WlRegistry, ()> for EnumState { 74 + fn event( 75 + state: &mut Self, 76 + registry: &wl_registry::WlRegistry, 77 + event: wl_registry::Event, 78 + _: &(), 79 + _: &Connection, 80 + qh: &QueueHandle<Self>, 81 + ) { 82 + if let wl_registry::Event::Global { name, interface, .. } = event { 83 + if interface == "wl_output" { 84 + let idx = state.outputs.len(); 85 + registry.bind::<wl_output::WlOutput, _, _>(name, 2, qh, idx); 86 + state.outputs.push(RawOutput { x: 0, y: 0, width: 0, height: 0 }); 87 + } 88 + } 89 + } 90 + } 91 + 92 + impl Dispatch<wl_output::WlOutput, usize> for EnumState { 93 + fn event( 94 + state: &mut Self, 95 + _: &wl_output::WlOutput, 96 + event: wl_output::Event, 97 + data: &usize, 98 + _: &Connection, 99 + _: &QueueHandle<Self>, 100 + ) { 101 + let Some(out) = state.outputs.get_mut(*data) else { return }; 102 + match event { 103 + wl_output::Event::Geometry { x, y, .. } => { 104 + out.x = x; 105 + out.y = y; 106 + } 107 + wl_output::Event::Mode { flags, width, height, .. } => { 108 + // Accept if Current flag set, or if we haven't seen a mode yet. 109 + let is_current = flags 110 + .into_result() 111 + .map(|f| (f.bits() & 1) != 0) 112 + .unwrap_or(true); 113 + if is_current || out.width == 0 { 114 + out.width = width; 115 + out.height = height; 116 + } 117 + } 118 + _ => {} 119 + } 120 + } 121 + } 122 + 123 + let conn = Connection::connect_to_env()?; 124 + let mut eq = conn.new_event_queue::<EnumState>(); 125 + let qh = eq.handle(); 126 + conn.display().get_registry(&qh, ()); 127 + 128 + let mut state = EnumState { outputs: Vec::new() }; 129 + // First roundtrip: bind globals and wl_output objects. 130 + eq.roundtrip(&mut state)?; 131 + // Second roundtrip: receive Geometry and Mode events for all outputs. 132 + eq.roundtrip(&mut state)?; 133 + 134 + log::info!("Wayland wl_output enumeration found {} output(s)", state.outputs.len()); 135 + 136 + let mut monitors: Vec<MonitorInfo> = state 137 + .outputs 138 + .iter() 139 + .enumerate() 140 + .map(|(i, o)| MonitorInfo { 141 + x: o.x, 142 + y: o.y, 143 + width: o.width.max(0) as u32, 144 + height: o.height.max(0) as u32, 145 + scale_factor: 1.0, 146 + is_primary: o.x == 0 && o.y == 0, 147 + name: format!("wayland-output-{i}"), 148 + }) 149 + .collect(); 150 + 151 + // If no output sits at (0,0), mark the one at the smallest (x+y) as primary. 152 + if !monitors.is_empty() && !monitors.iter().any(|m| m.is_primary) { 153 + let best = monitors 154 + .iter() 155 + .enumerate() 156 + .min_by_key(|(_, m)| m.x + m.y) 157 + .map(|(i, _)| i) 158 + .unwrap_or(0); 159 + monitors[best].is_primary = true; 160 + } 161 + 162 + for m in &monitors { 163 + log::info!( 164 + " wl_output: {}x{} at ({},{}), primary={}", 165 + m.width, m.height, m.x, m.y, m.is_primary 166 + ); 167 + } 168 + 169 + Ok(monitors) 44 170 } 45 171 46 172 fn fallback() -> Vec<MonitorInfo> {
+120 -31
src/overlay/multi_slint.rs
··· 2 2 use std::time::Duration; 3 3 4 4 use slint::ComponentHandle; 5 + #[cfg(target_os = "linux")] 6 + use slint::winit_030::{WinitWindowAccessor, winit}; 5 7 6 8 use crate::app::active_profile; 7 9 use crate::config::AppConfig; ··· 9 11 use crate::timer::{BreakMode, ScheduledBreak, TimerCommand}; 10 12 use crate::overlay::fmt_dur; 11 13 use crate::overlay::monitors::MonitorInfo; 14 + use crate::overlay::session::SessionType; 12 15 use crate::overlay::OverlayBackend; 13 16 17 + struct WindowEntry { 18 + window: OverlayWindow, 19 + pos: slint::LogicalPosition, 20 + size: slint::LogicalSize, 21 + } 22 + 14 23 pub struct MultiSlintBackend { 15 - windows: Vec<OverlayWindow>, 24 + entries: Vec<WindowEntry>, 25 + session: SessionType, 16 26 } 17 27 18 28 impl MultiSlintBackend { 19 29 pub fn new( 20 30 is_dark: bool, 31 + session: SessionType, 21 32 monitors: &[MonitorInfo], 22 33 cmd_tx: tokio::sync::mpsc::UnboundedSender<TimerCommand>, 23 34 cfg_arc: Arc<Mutex<AppConfig>>, 24 35 ) -> anyhow::Result<Self> { 25 - let mut windows = Vec::with_capacity(monitors.len()); 26 - 27 - for monitor in monitors { 28 - let w = OverlayWindow::new()?; 29 - w.set_is_dark(is_dark); 30 - 31 - // Position on the correct monitor before fullscreen so the WM places it there. 32 - w.window().set_position(slint::WindowPosition::Physical( 33 - slint::PhysicalPosition { x: monitor.x, y: monitor.y }, 34 - )); 36 + let mut entries = Vec::new(); 35 37 36 - wire_callbacks(&w, cmd_tx.clone(), cfg_arc.clone()); 37 - w.hide().unwrap_or_default(); 38 - windows.push(w); 38 + if session == SessionType::Wayland { 39 + // On Wayland, create one window per monitor. We set position before 40 + // show() as a hint to nudge the compositor toward the right output, 41 + // then call set_fullscreen(true) so it fills that output. 42 + log::info!( 43 + "MultiSlintBackend: Wayland — {} compositor-placed overlay window(s)", 44 + monitors.len() 45 + ); 46 + for monitor in monitors { 47 + log::info!( 48 + " monitor: {}x{} at ({},{}), primary={}", 49 + monitor.width, monitor.height, monitor.x, monitor.y, monitor.is_primary 50 + ); 51 + let pos = slint::LogicalPosition { x: monitor.x as f32, y: monitor.y as f32 }; 52 + let size = slint::LogicalSize { 53 + width: monitor.width as f32, 54 + height: monitor.height as f32, 55 + }; 56 + let w = OverlayWindow::new()?; 57 + w.set_is_dark(is_dark); 58 + wire_callbacks(&w, cmd_tx.clone(), cfg_arc.clone()); 59 + w.hide().unwrap_or_default(); 60 + entries.push(WindowEntry { window: w, pos, size }); 61 + } 62 + } else { 63 + // X11 / Windows: one window per monitor, positioned and sized explicitly. 64 + log::info!("MultiSlintBackend: creating overlays for {} monitor(s)", monitors.len()); 65 + for monitor in monitors { 66 + log::info!( 67 + " monitor: {}x{} at ({},{}), primary={}", 68 + monitor.width, monitor.height, monitor.x, monitor.y, monitor.is_primary 69 + ); 70 + let pos = slint::LogicalPosition { x: monitor.x as f32, y: monitor.y as f32 }; 71 + let size = slint::LogicalSize { 72 + width: monitor.width as f32, 73 + height: monitor.height as f32, 74 + }; 75 + let w = OverlayWindow::new()?; 76 + w.set_is_dark(is_dark); 77 + wire_callbacks(&w, cmd_tx.clone(), cfg_arc.clone()); 78 + w.hide().unwrap_or_default(); 79 + entries.push(WindowEntry { window: w, pos, size }); 80 + } 39 81 } 40 82 41 - Ok(Self { windows }) 83 + Ok(Self { entries, session }) 42 84 } 43 85 } 44 86 45 87 impl OverlayBackend for MultiSlintBackend { 46 88 fn show_break(&self, sched: &ScheduledBreak, is_enforced: bool, snooze_used: bool) { 47 - for w in &self.windows { 48 - w.set_break_label(sched.label.as_str().into()); 49 - w.set_snooze_visible(!is_enforced && !snooze_used); 50 - w.set_countdown_text(fmt_dur(sched.break_duration).as_str().into()); 51 - w.set_progress(0.0); 52 - w.show().unwrap_or_default(); 53 - w.window().set_fullscreen(true); 89 + for e in &self.entries { 90 + e.window.set_break_label(sched.label.as_str().into()); 91 + e.window.set_snooze_visible(!is_enforced && !snooze_used); 92 + e.window.set_countdown_text(fmt_dur(sched.break_duration).as_str().into()); 93 + e.window.set_progress(0.0); 94 + if self.session == SessionType::Wayland { 95 + #[cfg(target_os = "linux")] 96 + { 97 + // show() queues window creation but the winit Window isn't yet 98 + // available synchronously. spawn_local runs after the event loop 99 + // processes show(), at which point winit_window().await resolves 100 + // and we can call set_fullscreen with the specific wl_output — 101 + // the only reliable way to target a specific output on GNOME Wayland. 102 + let target_pos = e.pos; 103 + let win_weak = e.window.as_weak(); 104 + e.window.show().unwrap_or_default(); 105 + slint::spawn_local(async move { 106 + let Some(overlay) = win_weak.upgrade() else { return }; 107 + let winit_win = match overlay.window().winit_window().await { 108 + Ok(w) => w, 109 + Err(_) => { 110 + log::warn!("winit_window() unavailable for output at ({},{})", target_pos.x, target_pos.y); 111 + return; 112 + } 113 + }; 114 + let handle = winit_win.available_monitors().find(|m| { 115 + let sf = m.scale_factor(); 116 + let phys = m.position(); 117 + let lx = (phys.x as f64 / sf) as f32; 118 + let ly = (phys.y as f64 / sf) as f32; 119 + (lx - target_pos.x).abs() < 2.0 && (ly - target_pos.y).abs() < 2.0 120 + }); 121 + if handle.is_some() { 122 + log::info!("Wayland: fullscreen on output at ({},{})", target_pos.x, target_pos.y); 123 + } else { 124 + log::warn!("Wayland: no output matched ({},{}), compositor will choose", target_pos.x, target_pos.y); 125 + } 126 + winit_win.set_fullscreen(Some(winit::window::Fullscreen::Borderless(handle))); 127 + }).ok(); 128 + } 129 + #[cfg(not(target_os = "linux"))] 130 + { 131 + e.window.show().unwrap_or_default(); 132 + e.window.window().set_fullscreen(true); 133 + } 134 + } else { 135 + // On X11/Windows, explicit logical position+size is reliable and 136 + // avoids WM-dependent fullscreen-monitor selection behaviour. 137 + e.window.window().set_position(slint::WindowPosition::Logical(e.pos)); 138 + e.window.window().set_size(slint::WindowSize::Logical(e.size)); 139 + e.window.show().unwrap_or_default(); 140 + } 54 141 } 55 142 } 56 143 57 144 fn hide(&self) { 58 - for w in &self.windows { 59 - w.window().set_fullscreen(false); 60 - w.hide().unwrap_or_default(); 145 + for e in &self.entries { 146 + if self.session == SessionType::Wayland { 147 + e.window.window().set_fullscreen(false); 148 + } 149 + e.window.hide().unwrap_or_default(); 61 150 } 62 151 } 63 152 64 153 fn update_countdown(&self, elapsed: Duration, total: Duration) { 65 154 let remaining = total.saturating_sub(elapsed); 66 155 let progress = elapsed.as_secs_f32() / total.as_secs_f32().max(1.0); 67 - for w in &self.windows { 68 - w.set_countdown_text(fmt_dur(remaining).as_str().into()); 69 - w.set_progress(progress); 156 + for e in &self.entries { 157 + e.window.set_countdown_text(fmt_dur(remaining).as_str().into()); 158 + e.window.set_progress(progress); 70 159 } 71 160 } 72 161 73 162 fn reset_unlock_state(&self) { 74 - for w in &self.windows { 75 - w.set_unlock_input_visible(false); 76 - w.set_unlock_error("".into()); 163 + for e in &self.entries { 164 + e.window.set_unlock_input_visible(false); 165 + e.window.set_unlock_error("".into()); 77 166 } 78 167 } 79 168 }
+4 -3
src/overlay/session.rs
··· 1 1 #[derive(Debug, Clone, Copy, PartialEq)] 2 2 pub enum SessionType { 3 - /// X11 (Linux) — set_position works; one Slint window per monitor. 3 + /// X11 (Linux) — explicit position+size works; one Slint window per monitor. 4 4 X11, 5 - /// Wayland — set_position is ignored; requires wlr-layer-shell for multi-monitor. 5 + /// Wayland — position is compositor-managed; wlr-layer-shell for secondary monitors. 6 6 Wayland, 7 - /// Windows — set_position works; one Slint window per monitor. 7 + /// Windows — explicit position+size works; one Slint window per monitor. 8 + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] 8 9 Windows, 9 10 } 10 11