diff --git a/src/bubble.rs b/src/bubble.rs index 051dc17..2b8837f 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -183,30 +183,42 @@ fn screens(app: &AppHandle) -> Vec { /// The primary screen, else the first one, else a 1920x1080 stand-in. fn primary_screen(app: &AppHandle, screens: &[Screen]) -> Screen { + primary_monitor_screen(app, screens).unwrap_or(FALLBACK) +} + +/// The primary screen, else the first one; `None` without any monitor. +fn primary_monitor_screen(app: &AppHandle, screens: &[Screen]) -> Option { match app.primary_monitor() { - Ok(Some(monitor)) => Screen::of(&monitor), - Ok(None) => screens.first().copied().unwrap_or(FALLBACK), + Ok(Some(monitor)) => Some(Screen::of(&monitor)), + Ok(None) => screens.first().copied(), Err(err) => { log::warn!("primary monitor unavailable: {err}"); - screens.first().copied().unwrap_or(FALLBACK) + screens.first().copied() } } } -/// The screen a window is on (Win32 picks the nearest), else the primary. +/// The screen a window is on (Win32 picks the nearest), else the primary, +/// else a 1920x1080 stand-in. fn screen_of(window: &WebviewWindow) -> Screen { + monitor_screen_of(window).unwrap_or(FALLBACK) +} + +/// The screen a window is on, else the primary; `None` without any monitor. +fn monitor_screen_of(window: &WebviewWindow) -> Option { match window.current_monitor() { - Ok(Some(monitor)) => Screen::of(&monitor), - Ok(None) => { - let app = window.app_handle(); - primary_screen(app, &screens(app)) - } - Err(err) => { - log::warn!("monitor of {} unavailable: {err}", window.label()); - let app = window.app_handle(); - primary_screen(app, &screens(app)) - } + Ok(Some(monitor)) => return Some(Screen::of(&monitor)), + Ok(None) => {} + Err(err) => log::warn!("monitor of {} unavailable: {err}", window.label()), } + let app = window.app_handle(); + primary_monitor_screen(app, &screens(app)) +} + +/// Work area of the monitor `window` is on, else of the primary; `None` +/// when no monitor can be found. +pub(crate) fn work_area_of(window: &WebviewWindow) -> Option { + monitor_screen_of(window).map(|s| s.work) } fn outer_rect(window: &WebviewWindow) -> Option { @@ -527,20 +539,9 @@ pub fn on_window_event(window: &Window, event: &WindowEvent) { } }); } - // tao reports "no preference" and an unreachable portal as light, so - // the portal is read again and only a stated preference applies. - #[cfg(target_os = "linux")] - WindowEvent::ThemeChanged(_) => { - let app = window.app_handle().clone(); - defer("bubble-theme", move || { - let Some(is_dark) = platform::linux::portal_prefers_dark() else { - return; - }; - if let Some(runtime) = runtime_of(&app) { - runtime.set_dark(is_dark); - } - }); - } + // On Linux tao sends ThemeChanged with a placeholder window id that + // tauri drops, so it never arrives here; `platform::linux_theme` + // follows the portal's SettingChanged signal instead. _ => {} } } @@ -663,20 +664,30 @@ fn reclamp(app: &AppHandle, provider: ProviderId) { } /// Resize every bubble to `size_logical` around its own centre, at its own -/// DPI. Nothing snaps and no position is saved. -pub fn resize_all(app: &AppHandle, size_logical: i32) { +/// DPI, kept inside the work area of the monitor it was on. Nothing snaps +/// and nothing is saved here; returns where each moved bubble now is, empty +/// where windows cannot be positioned. +pub fn resize_all(app: &AppHandle, size_logical: i32) -> Vec<(ProviderId, Point)> { let placeable = platform::positioning_supported(); - for (_, window) in windows(app) { + let mut moved = Vec::new(); + for (provider, window) in windows(app) { let Some(rect) = outer_rect(&window) else { continue; }; + // The monitor before the resize: a bubble grown past an edge may + // overlap a neighbour more than its own monitor afterwards. + let work = screen_of(&window).work; let (w, h) = geom::bubble_size_px(size_logical, dpi_of(&window)); let (cx, cy) = ((rect.l + rect.r) / 2, (rect.t + rect.b) / 2); set_size(&window, (w, h)); if placeable { - set_position(&window, Point::new(cx - w / 2, cy - h / 2)); + let centred = Rect::from_xywh(cx - w / 2, cy - h / 2, w, h); + let pos = geom::clamp_into_work_area(centred, work, provider); + set_position(&window, pos); + moved.push((provider, pos)); } } + moved } /// Move every bubble to its default spot on the primary screen. The diff --git a/src/core/geom.rs b/src/core/geom.rs index a1cc0d8..e86995e 100644 --- a/src/core/geom.rs +++ b/src/core/geom.rs @@ -324,6 +324,9 @@ fn snap_alongside_taskbar( gap: i32, ) { let Fit { wa, w, h, zone } = *fit; + if !taskbar_borders_work_area(edge, tb, wa) { + return; + } // Only snap on the taskbar's docked edge. The bubble docks against the // inner face of the taskbar with a 4-px gap so it visually leans on it. match edge { @@ -354,6 +357,21 @@ fn snap_alongside_taskbar( } } +/// Whether the taskbar sits on this work area's `edge`: its inner face is +/// that work-area edge and it overlaps the work area along that edge. A +/// taskbar on another monitor (the platform reports only the primary's) +/// fails this, so it never pulls a bubble dropped on a secondary monitor. +fn taskbar_borders_work_area(edge: Edge, tb: &Rect, wa: &Rect) -> bool { + let overlaps_x = tb.l < wa.r && tb.r > wa.l; + let overlaps_y = tb.t < wa.b && tb.b > wa.t; + match edge { + Edge::Bottom => tb.t == wa.b && overlaps_x, + Edge::Top => tb.b == wa.t && overlaps_x, + Edge::Left => tb.r == wa.l && overlaps_y, + Edge::Right => tb.l == wa.r && overlaps_y, + } +} + fn align_with_peer(peers: &[Rect], ny: &mut i32, tolerance: i32) { if let Some(peer) = peers.iter().find(|p| (*ny - p.t).abs() <= tolerance) { *ny = peer.t; @@ -362,7 +380,7 @@ fn align_with_peer(peers: &[Rect], ny: &mut i32, tolerance: i32) { /// Place a `panel_w`×`panel_h` panel below `anchor` (the bubble's outer /// rect), flipping above when it would clip, and keep it inside `bounds` -/// (the virtual-screen union of all monitors, whose origin can be negative). +/// (the work area of the bubble's monitor, whose origin can be negative). pub fn place_near(anchor: Rect, panel_size: (i32, i32), bounds: Rect) -> Point { let (panel_w, panel_h) = panel_size; let mut x = anchor.l; @@ -662,6 +680,29 @@ mod tests { assert_eq!(snap(&i), Point::new(800, 900)); } + #[test] + fn taskbar_of_another_monitor_never_snaps() { + // The platform reports the primary's bottom taskbar; the bubble is + // dropped on a secondary monitor right of the primary, 10 px above + // where that taskbar's snap would put it and far from its own edges. + let primary_taskbar = Rect::new(0, 1040, 1920, 1080); + let rect = Rect::from_xywh(2800, 1040 - 4 - 66 - 10, 200, 66); + for secondary_wa in [ + Rect::new(1920, 0, 3840, 1080), + // Same bottom as the primary's work area, still no overlap. + Rect::new(1920, 0, 3840, 1040), + ] { + let i = SnapInput { + rect, + work_area: secondary_wa, + dpi: 96, + taskbar: Some((Edge::Bottom, primary_taskbar)), + peers: Vec::new(), + }; + assert_eq!(snap(&i), Point::new(rect.l, rect.t), "{secondary_wa:?}"); + } + } + #[test] fn peer_alignment_takes_first_peer_within_tolerance() { let mut i = input(Rect::from_xywh(800, 500, 200, 66)); @@ -741,6 +782,42 @@ mod tests { assert_eq!(place_near(b, (280, 400), tall_bounds), Point::new(0, -190)); } + #[test] + fn place_near_flips_above_on_the_shorter_monitor_of_a_mixed_layout() { + // A 1920x1080 laptop (40 px taskbar) left of a top-aligned + // 2560x1440 monitor. Near the laptop's bottom the panel flips above + // within the laptop's work area, though the union of both monitors + // would leave room below. + let laptop_work = Rect::new(0, 0, 1920, 1040); + let bubble = Rect::from_xywh(1500, 940, 200, 66); + assert_eq!( + place_near(bubble, (280, 120), laptop_work), + Point::new(1500, 940 - 120 - 8) + ); + let union = Rect::new(0, 0, 4480, 1440); + assert_eq!( + place_near(bubble, (280, 120), union), + Point::new(1500, 940 + 66 + 8) + ); + } + + #[test] + fn place_near_flips_against_the_work_area_not_the_monitor() { + // The panel fits below inside the 1080-tall monitor but not inside + // its work area, which ends at the taskbar's top at 1040. + let monitor = Rect::new(0, 0, 1920, 1080); + let work = Rect::new(0, 0, 1920, 1040); + let bubble = Rect::from_xywh(100, 880, 200, 66); + assert_eq!( + place_near(bubble, (280, 120), monitor), + Point::new(100, 954) + ); + assert_eq!( + place_near(bubble, (280, 120), work), + Point::new(100, 880 - 120 - 8) + ); + } + #[test] fn position_probe_against_monitors() { let monitors = [Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 3840, 1080)]; diff --git a/src/panel.rs b/src/panel.rs index d780511..cdd2c57 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -19,6 +19,7 @@ use tauri::{ WebviewWindowBuilder, Window, WindowEvent, }; +use crate::bubble; use crate::core::geom::{self, Point, Rect}; use crate::core::payload::PanelPayload; use crate::platform; @@ -245,7 +246,8 @@ fn show_for(app: &AppHandle, window: &WebviewWindow, provider: ProviderId) { } /// Size the panel for the bubble's DPI and place it next to the bubble, -/// inside the union of all monitors. Without a bubble it stays where it is. +/// inside the work area of the bubble's monitor. Without a bubble it stays +/// where it is. fn place_near_bubble(app: &AppHandle, window: &WebviewWindow, provider: ProviderId) { let Some(bubble) = app.get_webview_window(&runtime::bubble_label(provider)) else { log::warn!("no bubble for {}; panel not moved", provider.slug()); @@ -283,7 +285,12 @@ fn place_near_bubble(app: &AppHandle, window: &WebviewWindow, provider: Provider i32::try_from(size.width).unwrap_or(i32::MAX), i32::try_from(size.height).unwrap_or(i32::MAX), ); - let bounds = virtual_screen(&runtime::monitor_rects(app)).unwrap_or(anchor); + // The bubble's own work area keeps the panel off the taskbar and off + // the gaps of a mixed-size layout; the union of all monitors and then + // the bubble itself stand in only when no monitor is found. + let bounds = bubble::work_area_of(&bubble) + .or_else(|| virtual_screen(&runtime::monitor_rects(app))) + .unwrap_or(anchor); let target = geom::place_near(anchor, panel_size, bounds); // Move first so a DPI change from crossing monitors resizes the window // before the explicit size, then place it again at the final size. diff --git a/src/platform/linux_theme.rs b/src/platform/linux_theme.rs new file mode 100644 index 0000000..8e974f6 --- /dev/null +++ b/src/platform/linux_theme.rs @@ -0,0 +1,157 @@ +//! Live theme switching on Linux. +//! +//! tao reports a desktop theme change with a placeholder window id that +//! tauri drops, so no window ever sees `ThemeChanged`. Instead one +//! background thread listens on the session bus for the desktop portal's +//! `SettingChanged` signal and applies a stated `color-scheme` preference. + +use std::sync::Arc; +use std::time::Duration; + +use dbus::arg::{ArgType, RefArg, Variant}; +use dbus::blocking::Connection; +use dbus::message::MatchRule; +use tauri::{AppHandle, Manager}; + +use crate::platform::linux::color_scheme_is_dark; +use crate::runtime::Runtime; + +const SETTINGS_INTERFACE: &str = "org.freedesktop.portal.Settings"; +const APPEARANCE_NAMESPACE: &str = "org.freedesktop.appearance"; +const COLOR_SCHEME_KEY: &str = "color-scheme"; + +/// How long one wait for bus traffic blocks before it is started again. +const PROCESS_TIMEOUT: Duration = Duration::from_secs(60); + +/// Start the listener thread. It runs for the life of the process; without +/// a session bus it logs once and ends, leaving the startup theme in place. +pub fn spawn(app: &AppHandle) { + let app = app.clone(); + let spawned = std::thread::Builder::new() + .name("theme-listener".into()) + .spawn(move || listen(&app)); + if let Err(err) = spawned { + log::error!("theme listener thread could not start: {err}"); + } +} + +fn listen(app: &AppHandle) { + let conn = match Connection::new_session() { + Ok(conn) => conn, + Err(err) => { + log::warn!("no session bus; the theme will not follow the desktop: {err}"); + return; + } + }; + let rule = MatchRule::new_signal(SETTINGS_INTERFACE, "SettingChanged"); + let handle = app.clone(); + let added = conn.add_match( + rule, + move |(namespace, key, value): (String, String, Variant>), + _: &Connection, + _: &dbus::Message| { + let scheme = color_scheme_of(&namespace, &key, &value); + if let Some(is_dark) = color_scheme_is_dark(scheme) { + apply(&handle, is_dark); + } + true + }, + ); + if let Err(err) = added { + log::warn!( + "portal settings signal not watched; the theme will not follow the desktop: {err}" + ); + return; + } + loop { + if let Err(err) = conn.process(PROCESS_TIMEOUT) { + log::warn!("theme listener stopped: {err}"); + return; + } + } +} + +/// Record the new theme on the main thread, like every other UI change. +fn apply(app: &AppHandle, is_dark: bool) { + let handle = app.clone(); + let queued = app.run_on_main_thread(move || { + if let Some(runtime) = handle.try_state::>() { + runtime.set_dark(is_dark); + } + }); + if let Err(err) = queued { + log::warn!("theme change could not reach the main thread: {err}"); + } +} + +/// The `color-scheme` value carried by one `SettingChanged` signal, or +/// `None` for any other setting or a value that is not a `u32`. Variants +/// are unwrapped however deeply the portal nests them. +fn color_scheme_of(namespace: &str, key: &str, value: &dyn RefArg) -> Option { + if namespace != APPEARANCE_NAMESPACE || key != COLOR_SCHEME_KEY { + return None; + } + unwrap_u32(value) +} + +fn unwrap_u32(value: &dyn RefArg) -> Option { + match value.arg_type() { + ArgType::UInt32 => value.as_u64().and_then(|v| u32::try_from(v).ok()), + ArgType::Variant => value.as_iter()?.next().and_then(unwrap_u32), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn boxed(value: impl RefArg + 'static) -> Variant> { + Variant(Box::new(value)) + } + + #[test] + fn color_scheme_is_read_from_its_own_setting_only() { + let dark = boxed(1u32); + assert_eq!( + color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &dark), + Some(1) + ); + assert_eq!( + color_scheme_of(APPEARANCE_NAMESPACE, "accent-color", &dark), + None + ); + assert_eq!( + color_scheme_of("org.gnome.desktop.interface", COLOR_SCHEME_KEY, &dark), + None + ); + } + + #[test] + fn nested_variants_unwrap_to_the_scheme() { + let nested = boxed(Variant(2u32)); + assert_eq!( + color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &nested), + Some(2) + ); + let bare: Box = Box::new(0u32); + assert_eq!( + color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &bare), + Some(0) + ); + } + + #[test] + fn a_value_of_another_type_is_ignored() { + let text = boxed("prefer-dark".to_string()); + assert_eq!( + color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &text), + None + ); + let signed = boxed(1i32); + assert_eq!( + color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &signed), + None + ); + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index fe0ea1a..ba2aa8a 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -5,6 +5,8 @@ use std::time::{Duration, Instant}; #[cfg(target_os = "linux")] pub mod linux; +#[cfg(target_os = "linux")] +pub mod linux_theme; #[cfg(target_os = "macos")] pub mod macos; #[cfg(windows)] diff --git a/src/runtime.rs b/src/runtime.rs index 09f2b07..f7eef0d 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -515,7 +515,9 @@ impl Runtime { } /// Clamp and store the bubble size, then resize every bubble around its - /// centre (no snap, no position save); a no-op when it does not change. + /// centre, kept inside its work area (no snap); a no-op when it does not + /// change. A bubble with a saved position has it updated to where the + /// resize put it; one still at its default spot stays unsaved. pub fn set_bubble_size(self: &Arc, size_logical: i32) { let size = size_logical.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE); let changed = self.with_state(|s| s.settings.bubble_size_logical != size); @@ -523,7 +525,12 @@ impl Runtime { return; } self.update_settings(|s| s.bubble_size_logical = size); - bubble::resize_all(&self.ui.app, size); + for (provider, pos) in bubble::resize_all(&self.ui.app, size) { + let saved = self.with_state(|s| s.settings.bubble_positions.get(provider).is_some()); + if saved { + self.save_bubble_position(provider, (pos.x, pos.y)); + } + } self.propagate(); } @@ -986,6 +993,10 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box> { scheduler::spawn(config, rx, Arc::clone(&runtime))?; runtime.send(Message::PollNow); + // tao's theme change never reaches a window on Linux; follow the portal. + #[cfg(target_os = "linux")] + platform::linux_theme::spawn(app.handle()); + // Without the thread the bubbles just stay up over fullscreen apps. if let Err(err) = fullscreen::spawn(Arc::clone(&runtime)) { log::error!("fullscreen check thread could not start: {err}");