diff --git a/Cargo.lock b/Cargo.lock index 0826a05..8ada3a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -509,6 +509,7 @@ name = "claude-code-usage-bubble" version = "0.5.0" dependencies = [ "auto-launch", + "dbus", "dirs 6.0.0", "flate2", "libc", diff --git a/Cargo.toml b/Cargo.toml index 074c94e..c265884 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,8 @@ libc = "0.2" [target.'cfg(target_os = "linux")'.dependencies] x11rb = "0.14" libloading = "0.9" +# Same major as the copy tao pulls; reads the portal colour scheme. +dbus = "0.9" [target.'cfg(windows)'.dependencies] # Keep the major in step with the `windows` crate tauri pulls diff --git a/plans/261021-1028-tauri-v2-port/phase-06-bubble-window-ui.md b/plans/261021-1028-tauri-v2-port/phase-06-bubble-window-ui.md index 86f313d..dcf835f 100644 --- a/plans/261021-1028-tauri-v2-port/phase-06-bubble-window-ui.md +++ b/plans/261021-1028-tauri-v2-port/phase-06-bubble-window-ui.md @@ -34,7 +34,7 @@ corners for the hit area (D4). - `taskbar_rect()` uses `SHAppBarMessage(ABM_GETTASKBARPOS)`. - **Linux and macOS** (D3): - Move end is a 250 ms debounce on `Moved`, armed only by `bubble_drag_start`. On X11, when the debounce fires, `platform::linux::button1_down()` (x11rb `QueryPointer`) re-arms it while Button1 is still held. - - Theme comes from `window.theme()`, defaulting to dark, and is rechecked on `ThemeChanged`. + - Theme defaults to dark and is rechecked on bubble creation and `ThemeChanged`. macOS uses `window.theme()`. Linux reads the portal `org.freedesktop.appearance color-scheme` itself (1 dark, 2 light) and keeps the current theme on "no preference" or an unreachable portal, because tao maps both to light. - There is no taskbar rect. On Wayland without Xwayland, skip snap, clamp and saving positions. - **Move end** (Rust): disarm, run `core::geom::snap` with the peers' `outer_position`/`outer_size` in `LIVE_USAGE` order, apply `set_position(Physical)` only if the position changed, then persist `bubble_positions[slug]`. - **Commands:** @@ -105,13 +105,13 @@ corners for the hit area (D4). - [x] `ui/bubble.html`, `ui/bubble.css` (palette per `[data-theme]`, dead corners), `ui/bubble.js` (layout, texts from the payload only, pulse, 60 s time progress, `visibilitychange`, pointer drag/click, `contextmenu`, Ctrl+wheel), `ui/tauri-bridge.js` - [x] `src/bubble.rs` rewritten: builder options as listed, exact physical size and saved/default position clamped into the work area, refit on `ScaleFactorChanged`, `destroy()` only with recreate after `Destroyed`, show on `bubble_ready` or the 1.5 s fallback, `widget_visible` honoured at creation - [x] `src/platform/windows.rs`: `apply_no_activate`, `show_no_activate`, `hide`, `install_bubble_hook` (`WM_EXITSIZEMOVE`, `WM_SETTINGCHANGE`, `WM_DISPLAYCHANGE`, plus `MA_NOACTIVATE`), `taskbar` -- [x] Linux/macOS move end: 250 ms debounce armed by `bubble_drag_start`, re-armed while X11 Button1 is held (`platform::linux::button1_down`); theme from `window.theme()` and `ThemeChanged`; native Wayland skips snap, clamp and saving +- [x] Linux/macOS move end: 250 ms debounce armed by `bubble_drag_start`, re-armed while X11 Button1 is held (`platform::linux::button1_down`); theme from `window.theme()` (macOS) or the portal color-scheme (Linux, only a stated preference applies) on creation and `ThemeChanged`; native Wayland skips snap, clamp and saving - [x] Move end snaps with peers in `LIVE_USAGE` order, moves only on a change, saves the position - [x] Commands `bubble_click` (logs until the panel exists), `bubble_drag_start`, `bubble_resize_step`, `bubble_ready`; resize around each centre without snap or save - [x] Reset position moves the existing bubbles to their defaults - [x] `src/core/visibility.rs` with tests for every row of the transition table; user show/hide goes through it - [x] Deleted `src/os/mod.rs` and `src/os/string.rs` (`src/os/theme.rs` was already gone) -- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (181 passed, 1 ignored), `npm run lint`, `npm test` (32 passed), `npm run tauri build -- --no-bundle`; CI green on `windows-latest`, `ubuntu-22.04` and `macos-latest` +- [x] Host: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` (183 passed, 1 ignored), `npm run lint`, `npm test` (32 passed), `npm run tauri build -- --no-bundle`; CI green on `windows-latest`, `ubuntu-22.04` and `macos-latest` - [x] Headless Chromium render of the page with a stubbed `__TAURI__` at 100%, 150% and 200% (layout, both themes, dead corners) and synthetic pointer checks (click, drag past 3 px, no drag on a later hover, Ctrl+wheel, right-click on the pill only) - [ ] Visual comparison against a v0.5.0 screenshot at 100% and 150% (none in the repo; Windows manual check) - [ ] Linux desktop (X11) and Windows manual checks below diff --git a/src/bubble.rs b/src/bubble.rs index 7f712cd..324a5fd 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -348,7 +348,11 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> { ); prepare_native(app, &window, provider); - #[cfg(not(windows))] + #[cfg(target_os = "linux")] + if let Some(is_dark) = platform::linux::portal_prefers_dark() { + runtime.set_dark(is_dark); + } + #[cfg(target_os = "macos")] match window.theme() { Ok(theme) => runtime.set_dark(theme != tauri::Theme::Light), Err(err) => log::debug!("theme of {label} unavailable: {err}"), @@ -513,7 +517,7 @@ pub fn on_window_event(window: &Window, event: &WindowEvent) { WindowEvent::Moved(_) => drag::moved(label), // Windows reads the theme from the registry on WM_SETTINGCHANGE; // tao's theme follows the apps setting, not the system one. - #[cfg(not(windows))] + #[cfg(target_os = "macos")] WindowEvent::ThemeChanged(theme) => { let is_dark = *theme != tauri::Theme::Light; let app = window.app_handle().clone(); @@ -523,6 +527,20 @@ 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); + } + }); + } _ => {} } } diff --git a/src/platform/linux.rs b/src/platform/linux.rs index b82fb97..5ae06dc 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -106,6 +106,54 @@ pub fn button1_down() -> bool { } } +/// How long the portal settings read may block. +const PORTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// Maps the portal's `org.freedesktop.appearance color-scheme` value to a +/// dark flag: 1 prefers dark, 2 prefers light. "No preference" (0), any +/// other value and an unreachable portal give `None`, so the caller keeps +/// its current theme. tao's `Window::theme()` maps all of those to light. +pub fn color_scheme_is_dark(scheme: Option) -> Option { + match scheme { + Some(1) => Some(true), + Some(2) => Some(false), + _ => None, + } +} + +/// Whether the desktop portal states a dark or light preference; `None` +/// when it states none or cannot be reached. Blocks for up to +/// `PORTAL_TIMEOUT`. +pub fn portal_prefers_dark() -> Option { + color_scheme_is_dark(read_color_scheme()) +} + +fn read_color_scheme() -> Option { + use dbus::arg::Variant; + use dbus::blocking::Connection; + + let result = Connection::new_session().and_then(|conn| { + let proxy = conn.with_proxy( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + PORTAL_TIMEOUT, + ); + let (value,): (Variant>,) = proxy.method_call( + "org.freedesktop.portal.Settings", + "Read", + ("org.freedesktop.appearance", "color-scheme"), + )?; + Ok(value.0 .0) + }); + match result { + Ok(scheme) => Some(scheme), + Err(err) => { + log::debug!("portal color-scheme unavailable: {err}"); + None + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -128,4 +176,15 @@ mod tests { assert!(!positioning_supported(os("wayland-0"), os("wayland,x11"))); assert!(!positioning_supported(None, os("broadway"))); } + + #[test] + fn only_a_stated_portal_preference_sets_the_theme() { + assert_eq!(color_scheme_is_dark(Some(1)), Some(true)); + assert_eq!(color_scheme_is_dark(Some(2)), Some(false)); + // No preference, unknown values and an unreachable portal keep the + // current theme (dark by default). + assert_eq!(color_scheme_is_dark(Some(0)), None); + assert_eq!(color_scheme_is_dark(Some(7)), None); + assert_eq!(color_scheme_is_dark(None), None); + } }