From 340464de582004c8ed7fc000a9d1871daee9b659 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 21 Oct 2026 14:40:13 +0700 Subject: [PATCH] feat: build the bubble windows with snapping, no-activate show and visibility state Each enabled live provider gets a transparent, undecorated, always-on-top bubble webview, created hidden at its exact physical size and its saved or default position, clamped into the work area. It is shown without taking focus once the page reports ready, or 1.5 s after creation. On Windows a subclass hook reports move ends, setting changes (theme recheck and reclamp) and display changes, answers WM_MOUSEACTIVATE with MA_NOACTIVATE, and bubbles are shown and hidden only through SW_SHOWNOACTIVATE and SW_HIDE. Elsewhere a 250 ms debounce armed by the page's drag start ends the drag; on X11 it waits while Button1 is held. Move end snaps once and saves the position; resize, clamp and reset only move the windows. Native Wayland skips positioning. User show/hide goes through a pure visibility state shared with the coming fullscreen auto-hide, and the saved widget_visible now applies at startup. A theme change reaches open bubbles. The unused os module is removed. --- src/bubble.rs | 2782 ++++++++++++--------------------------- src/commands.rs | 56 +- src/core/mod.rs | 1 + src/core/visibility.rs | 156 +++ src/lib.rs | 1 + src/os/mod.rs | 12 - src/os/string.rs | 10 - src/platform/linux.rs | 92 ++ src/platform/macos.rs | 9 + src/platform/mod.rs | 36 + src/platform/windows.rs | 189 ++- src/runtime.rs | 212 +-- 12 files changed, 1452 insertions(+), 2104 deletions(-) create mode 100644 src/core/visibility.rs delete mode 100644 src/os/mod.rs delete mode 100644 src/os/string.rs create mode 100644 src/platform/macos.rs diff --git a/src/bubble.rs b/src/bubble.rs index e03f1d0..86daa55 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -1,2041 +1,887 @@ -// Floating stadium-shaped bubble window. -// -// Top-level window with WS_POPUP + WS_EX_LAYERED + WS_EX_TOPMOST + WS_EX_NOACTIVATE. -// The shape is a stadium (rounded-rect with corner_radius = height/2). The left -// half is the "head" — usage and remaining-time rings around the 5h percentage -// glyph. The right half is the "tail" — weekly usage and remaining-time bars. -// -// Painting is hybrid: tiny-skia renders the shape (AA fills + AA stroked arc) -// into a Pixmap; the Pixmap is copied byte-for-byte into a 32bpp BI_RGB DIB; -// GDI then overlays ClearType text on top; UpdateLayeredWindow blits the result -// to the screen with per-pixel alpha. WM_NCHITTEST returns HTCAPTION inside the -// stadium so the OS handles drag for free. +//! Bubble windows: one transparent, undecorated, always-on-top webview per +//! enabled live provider, labelled `bubble-`. +//! +//! Rust owns the window: its exact physical size, its position, snapping +//! at the end of a drag, clamping into the work area, and showing it +//! without taking focus. The page (`ui/bubble.js`) only draws the payload +//! and reports clicks, drag starts, right-clicks and Ctrl+wheel. +//! +//! Window event handlers and the Windows hook hand their Tauri work to a +//! short-lived thread ([`defer`]), so it runs after the event that caused +//! it (after tao applied its own DPI resize, for one) and never inside a +//! window procedure. No function here holds the state lock across a Tauri +//! call. use std::collections::HashMap; -use std::ffi::c_void; -use std::sync::{Mutex, MutexGuard, OnceLock}; -use std::time::{Duration, SystemTime}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; +#[cfg(not(windows))] +use std::time::Instant; -use tiny_skia::{FillRule, LineCap, Paint, PathBuilder, Pixmap, Rect, Stroke, Transform}; -use windows::core::PCWSTR; -use windows::Win32::Foundation::*; -use windows::Win32::Graphics::Dwm::{ - DwmGetWindowAttribute, DWMWA_CLOAKED, DWMWA_EXTENDED_FRAME_BOUNDS, +use tauri::{ + AppHandle, Manager, Monitor, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, + WebviewWindowBuilder, Window, WindowEvent, }; -use windows::Win32::Graphics::Gdi::*; -use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW}; -use windows::Win32::UI::HiDpi::*; -use windows::Win32::UI::Shell::{ - ExtractIconExW, SHAppBarMessage, ABE_BOTTOM, ABE_LEFT, ABE_RIGHT, ABE_TOP, ABM_GETTASKBARPOS, - APPBARDATA, -}; -use windows::Win32::UI::WindowsAndMessaging::*; -use crate::os::dpi::scale as scale_to_dpi; -use crate::os::{to_utf16_nul as wide_str, Rgb as Color}; - -const TIMER_FULLSCREEN_CHECK: usize = 5; -const TIMER_PULSE: usize = 6; -const TIMER_TIME_PROGRESS: usize = 7; -const PULSE_INTERVAL_MS: u32 = 80; -const TIME_PROGRESS_INTERVAL_MS: u32 = 60_000; +use crate::core::geom::{self, Point, Rect, SnapInput}; +use crate::core::visibility::Action; +use crate::platform; +use crate::runtime::{self, Runtime, BUBBLE_LABEL_PREFIX}; use crate::usage::ProviderId; -// ---------- Public types & API ---------- +/// How long a new bubble waits for `bubble_ready` before it is shown +/// anyway; a hidden webview may throttle the page that would send it. +const READY_FALLBACK: Duration = Duration::from_millis(1500); -// Width clamps in logical pixels. Height is derived per width (see -// `bubble_height_logical`) — aspect tapers from 3:1 at the small end toward -// 2.6:1 at the large end so the bars look proportionally chunkier as the -// bubble grows. -pub const MIN_BUBBLE_SIZE: i32 = 140; -pub const MAX_BUBBLE_SIZE: i32 = 360; -pub const DEFAULT_BUBBLE_SIZE: i32 = 200; -pub const RESIZE_STEP_LOGICAL: i32 = 20; -const SNAP_ZONE_LOGICAL: i32 = 12; -const CORNER_SNAP_ZONE_LOGICAL: i32 = 32; -const CORNER_INSET_LOGICAL: i32 = 12; -const TASKBAR_GAP_LOGICAL: i32 = 4; -const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8; -const CLASS_NAME: &str = "ClaudeCodeUsageBubble"; -const FULLSCREEN_POLL_MS: u32 = 1500; -const FULLSCREEN_EDGE_TOLERANCE_PX: i32 = 2; -const FIVE_HOURS_SECS: u64 = 5 * 60 * 60; -const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60; +/// Quiet time after the last `Moved` event that counts as the end of a +/// drag where the OS reports no move-loop end. +#[cfg(not(windows))] +const MOVE_END_DEBOUNCE: Duration = Duration::from_millis(250); -/// (num, den) such that bubble_height = (width * den) / num. 3:1 below 200, -/// 2.8:1 below 280, 2.6:1 above — the bubble gets a touch taller as it -/// grows so the wider bars don't look anaemic. -fn aspect_at_width(w_logical: i32) -> (i32, i32) { - if w_logical <= 200 { - (3, 1) - } else if w_logical <= 280 { - (14, 5) // 2.8 : 1 - } else { - (13, 5) // 2.6 : 1 - } -} +const TITLE: &str = "Claude Code Usage Bubble"; -pub struct BubbleConfig { - pub model: ProviderId, - pub size_logical: i32, - pub position: Option<(i32, i32)>, - pub session_pct: Option, - pub session_text: String, - pub session_resets_at: Option, - pub weekly_pct: Option, - pub weekly_text: String, - pub weekly_resets_at: Option, - pub is_dark: bool, -} +/// Work area used when no monitor can be enumerated. +const FALLBACK_SCREEN: Rect = Rect::new(0, 0, 1920, 1080); -fn bubble_height_logical(width_logical: i32) -> i32 { - let (num, den) = aspect_at_width(width_logical); - ((width_logical * den) / num).max(20) -} - -#[derive(Clone, Copy)] -enum UsageWindowKind { - Primary, - Secondary, -} - -fn window_duration_secs(model: ProviderId, window: UsageWindowKind) -> u64 { - // Claude exposes 5h/7d directly. Codex exposes primary/secondary fields; - // the product maps those to the same short/long windows in the compact UI. - match (model, window) { - (ProviderId::Claude, UsageWindowKind::Primary) => FIVE_HOURS_SECS, - (ProviderId::Claude, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS, - (ProviderId::ChatGpt, UsageWindowKind::Primary) => FIVE_HOURS_SECS, - (ProviderId::ChatGpt, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS, - (ProviderId::OpenCodeGo, UsageWindowKind::Primary) => SEVEN_DAYS_SECS, - (ProviderId::OpenCodeGo, UsageWindowKind::Secondary) => 30 * 24 * 60 * 60, - } -} - -fn remaining_fraction(resets_at: Option, duration_secs: u64) -> Option { - let reset = resets_at?; - let remaining = reset - .duration_since(SystemTime::now()) - .unwrap_or_else(|_| Duration::from_secs(0)); - Some((remaining.as_secs_f64() / duration_secs as f64).clamp(0.0, 1.0) as f32) -} - -/// Owner-supplied event callbacks. The bubble window proc is a leaf — it -/// doesn't know about `app`. The owner installs these once at startup so the -/// proc can dispatch UI events back without an upward `crate::app::` reach. -pub struct Callbacks { - pub on_click: fn(HWND, ProviderId), - pub on_right_click: fn(HWND, ProviderId, POINT), - pub on_moved: fn(ProviderId, (i32, i32)), - pub on_resized: fn(ProviderId, i32), - pub on_menu_command: fn(u32, HWND), - pub on_settings_changed: fn(), -} - -static CALLBACKS: OnceLock = OnceLock::new(); - -/// Install the owner's callbacks. Called once by `app::run` before any -/// bubble is created. Subsequent calls are silently ignored. -pub fn install_callbacks(cb: Callbacks) { - let _ = CALLBACKS.set(cb); -} - -fn dispatch(f: F) { - if let Some(cb) = CALLBACKS.get() { - f(cb); - } else { - log::warn!("bubble event dispatched before install_callbacks; event dropped"); - } -} - -/// Register the bubble window class. Idempotent; safe to call before the first -/// `create()` from the UI thread. -pub fn register_class() { - static REGISTERED: OnceLock<()> = OnceLock::new(); - REGISTERED.get_or_init(|| unsafe { - let class_w = wide_str(CLASS_NAME); - let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default(); - let wc = WNDCLASSEXW { - cbSize: std::mem::size_of::() as u32, - style: CS_HREDRAW | CS_VREDRAW, - lpfnWndProc: Some(wnd_proc), - hInstance: HINSTANCE(hinstance.0), - hCursor: LoadCursorW(HINSTANCE::default(), IDC_SIZEALL).unwrap_or_default(), - hbrBackground: HBRUSH(std::ptr::null_mut()), - lpszClassName: PCWSTR::from_raw(class_w.as_ptr()), - ..Default::default() - }; - if RegisterClassExW(&wc) == 0 { - log::error!("bubble RegisterClassExW returned 0"); - } - }); -} - -/// Create a bubble window. Returns the HWND. The caller (app::run) owns the -/// message-loop dispatch. -pub fn create(config: BubbleConfig) -> HWND { - register_class(); - let initial_size_logical = config.size_logical.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE); - let dpi_for_create = crate::os::dpi::for_system(); - let width_px = scale_to_dpi(initial_size_logical, dpi_for_create); - let height_px = scale_to_dpi(bubble_height_logical(initial_size_logical), dpi_for_create); - let (x, y) = config - .position - .unwrap_or_else(|| default_position(width_px, height_px, config.model)); - let hwnd = unsafe { - let class_w = wide_str(CLASS_NAME); - let title_w = wide_str("Claude Code Usage Bubble"); - let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default(); - CreateWindowExW( - WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE, - PCWSTR::from_raw(class_w.as_ptr()), - PCWSTR::from_raw(title_w.as_ptr()), - WS_POPUP, - x, - y, - width_px, - height_px, - HWND::default(), - HMENU::default(), - hinstance, - None, - ) - .unwrap_or_default() - }; - - if hwnd == HWND::default() { - log::error!("bubble CreateWindowExW failed"); - return hwnd; - } - - // Embed app icon in window non-client (mostly cosmetic; toolwindows - // don't show captions but the icon helps in dev tooling). The HICONs - // are extracted once at process startup and reused across every bubble - // create() so we don't leak a pair per toggle cycle. - let (large_icon, small_icon) = app_icons(); - unsafe { - if !large_icon.is_invalid() { - let _ = SendMessageW( - hwnd, - WM_SETICON, - WPARAM(ICON_BIG as usize), - LPARAM(large_icon.0 as isize), - ); - } - if !small_icon.is_invalid() { - let _ = SendMessageW( - hwnd, - WM_SETICON, - WPARAM(ICON_SMALL as usize), - LPARAM(small_icon.0 as isize), - ); - } - } - - let dpi = unsafe { GetDpiForWindow(hwnd).max(96) }; - lock_bubbles().insert( - hwnd.0 as isize, - BubbleState { - model: config.model, - size_logical: initial_size_logical, - dpi, - session_pct: config.session_pct, - session_text: config.session_text, - session_resets_at: config.session_resets_at, - weekly_pct: config.weekly_pct, - weekly_text: config.weekly_text, - weekly_resets_at: config.weekly_resets_at, - is_dark: config.is_dark, - drag_start_pos: None, - hidden_by_fullscreen: false, - user_hidden: false, - pulse_phase: 0, - pulse_timer_armed: false, - time_progress_timer_armed: false, - }, - ); - - log::info!( - "bubble create model={:?} pos=({x},{y}) size={width_px}x{height_px} dpi={dpi}", - config.model - ); - - // Defense in depth: settings::load already validates positions against - // currently-connected monitors, but a monitor unplug between load and - // create (or a partially-off-screen saved position) is still possible. - clamp_into_work_area(hwnd); - - render(hwnd); - unsafe { - let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); - // Periodic fullscreen-foreground check. - SetTimer(hwnd, TIMER_FULLSCREEN_CHECK, FULLSCREEN_POLL_MS, None); - } - - hwnd -} - -pub fn destroy(hwnd: HWND) { - unsafe { - let _ = KillTimer(hwnd, TIMER_FULLSCREEN_CHECK); - let _ = KillTimer(hwnd, TIMER_PULSE); - let _ = KillTimer(hwnd, TIMER_TIME_PROGRESS); - let _ = DestroyWindow(hwnd); - } -} - -/// Extract the EXE's own icon pair once per process. Stored as raw pointer -/// values because `HICON` is `!Send`/`!Sync`; reconstituted for each caller. -/// The pair is intentionally never destroyed — Windows tears them down on -/// process exit, and one pair per process is bounded leak rather than the -/// O(bubble-toggles) leak we'd get from extracting per `create()`. -fn app_icons() -> (HICON, HICON) { - static ICONS: OnceLock<(isize, isize)> = OnceLock::new(); - let (big, small) = *ICONS.get_or_init(|| unsafe { - let mut large = HICON::default(); - let mut small = HICON::default(); - let mut exe = [0u16; 260]; - GetModuleFileNameW(HMODULE::default(), &mut exe); - let _ = ExtractIconExW( - PCWSTR::from_raw(exe.as_ptr()), - 0, - Some(&mut large), - Some(&mut small), - 1, - ); - if large.is_invalid() && small.is_invalid() { - log::warn!("ExtractIconExW yielded null handles; bubbles will be iconless"); - } - (large.0 as isize, small.0 as isize) - }); - (HICON(big as *mut _), HICON(small as *mut _)) -} - -pub fn update_data( - hwnd: HWND, - session_pct: Option, - session_text: String, - session_resets_at: Option, - weekly_pct: Option, - weekly_text: String, - weekly_resets_at: Option, -) { - { - let mut bubbles = lock_bubbles(); - let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else { - return; - }; - b.session_pct = session_pct; - b.session_text = session_text; - b.session_resets_at = session_resets_at; - b.weekly_pct = weekly_pct; - b.weekly_text = weekly_text; - b.weekly_resets_at = weekly_resets_at; - } - sync_pulse_timer(hwnd); - sync_time_progress_timer(hwnd); - render(hwnd); -} - -fn any_pct_in_alarm(b: &BubbleState) -> bool { - b.session_pct.is_some_and(|p| p >= 95.0) || b.weekly_pct.is_some_and(|p| p >= 95.0) -} - -fn sync_pulse_timer(hwnd: HWND) { - let (should_be_armed, currently_armed) = { - let bubbles = lock_bubbles(); - let Some(b) = bubbles.get(&(hwnd.0 as isize)) else { - return; - }; - (any_pct_in_alarm(b), b.pulse_timer_armed) - }; - if should_be_armed == currently_armed { - return; - } - unsafe { - if should_be_armed { - SetTimer(hwnd, TIMER_PULSE, PULSE_INTERVAL_MS, None); - } else { - let _ = KillTimer(hwnd, TIMER_PULSE); - } - } - if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) { - b.pulse_timer_armed = should_be_armed; - if !should_be_armed { - b.pulse_phase = 0; - } - } -} - -fn sync_time_progress_timer(hwnd: HWND) { - let (should_be_armed, currently_armed) = { - let bubbles = lock_bubbles(); - let Some(b) = bubbles.get(&(hwnd.0 as isize)) else { - return; - }; - ( - b.session_resets_at.is_some() || b.weekly_resets_at.is_some(), - b.time_progress_timer_armed, - ) - }; - if should_be_armed == currently_armed { - return; - } - unsafe { - if should_be_armed { - SetTimer(hwnd, TIMER_TIME_PROGRESS, TIME_PROGRESS_INTERVAL_MS, None); - } else { - let _ = KillTimer(hwnd, TIMER_TIME_PROGRESS); - } - } - if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) { - b.time_progress_timer_armed = should_be_armed; - } -} - -pub fn update_dark_mode(hwnd: HWND, is_dark: bool) { - { - let mut bubbles = lock_bubbles(); - let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else { - return; - }; - b.is_dark = is_dark; - } - render(hwnd); -} - -pub fn set_user_visible(hwnd: HWND, visible: bool) { - { - let mut bubbles = lock_bubbles(); - let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else { - return; - }; - b.user_hidden = !visible; - } - unsafe { - let cmd = if visible { SW_SHOWNOACTIVATE } else { SW_HIDE }; - let _ = ShowWindow(hwnd, cmd); - } - // A layered window's composited surface is dropped while hidden, so - // ShowWindow(SW_SHOWNOACTIVATE) on its own renders blank until the next - // UpdateLayeredWindow. The cached BubbleState (pcts + texts) hasn't gone - // anywhere, so just re-paint from it so the bubble pops back with the - // last good data instead of empty placeholders. - if visible { - render(hwnd); - } -} - -pub fn position(hwnd: HWND) -> Option<(i32, i32)> { - let mut r = RECT::default(); - unsafe { - if GetWindowRect(hwnd, &mut r).is_err() { - return None; - } - } - Some((r.left, r.top)) -} - -pub fn model(hwnd: HWND) -> Option { - lock_bubbles().get(&(hwnd.0 as isize)).map(|b| b.model) -} - -pub fn size_logical(hwnd: HWND) -> Option { - lock_bubbles() - .get(&(hwnd.0 as isize)) - .map(|b| b.size_logical) -} - -// ---------- State ---------- - -struct BubbleState { - model: ProviderId, - size_logical: i32, +/// A monitor in physical px. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Screen { + bounds: Rect, + work: Rect, dpi: u32, - session_pct: Option, - session_text: String, - session_resets_at: Option, - weekly_pct: Option, - weekly_text: String, - weekly_resets_at: Option, - is_dark: bool, - drag_start_pos: Option<(i32, i32)>, - hidden_by_fullscreen: bool, - user_hidden: bool, - /// Frame counter for the ≥95% pulse animation. Increments on each - /// TIMER_PULSE tick when at least one bar is in the alarm band. - pulse_phase: u32, - /// Whether TIMER_PULSE is currently armed for this bubble. - pulse_timer_armed: bool, - /// Whether TIMER_TIME_PROGRESS is armed to keep reset-time visuals current. - time_progress_timer_armed: bool, } -fn bubbles() -> &'static Mutex> { - static BUBBLES: OnceLock>> = OnceLock::new(); - BUBBLES.get_or_init(|| Mutex::new(HashMap::new())) -} - -fn lock_bubbles() -> MutexGuard<'static, HashMap> { - bubbles().lock().expect("bubble state mutex poisoned") -} - -// ---------- Window proc ---------- - -unsafe extern "system" fn wnd_proc( - hwnd: HWND, - msg: u32, - wparam: WPARAM, - lparam: LPARAM, -) -> LRESULT { - match msg { - WM_NCHITTEST => hit_test(hwnd, lparam), - WM_ENTERSIZEMOVE => { - let mut r = RECT::default(); - let _ = GetWindowRect(hwnd, &mut r); - if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) { - b.drag_start_pos = Some((r.left, r.top)); - } - LRESULT(0) +impl Screen { + fn of(monitor: &Monitor) -> Self { + let pos = monitor.position(); + let size = monitor.size(); + let work = monitor.work_area(); + Self { + bounds: Rect::from_xywh(pos.x, pos.y, clamp_u32(size.width), clamp_u32(size.height)), + work: Rect::from_xywh( + work.position.x, + work.position.y, + clamp_u32(work.size.width), + clamp_u32(work.size.height), + ), + dpi: runtime::dpi_for_scale(monitor.scale_factor()), } - WM_EXITSIZEMOVE => { - // WM_NCLBUTTONUP isn't reliably delivered for HTCAPTION drags; instead - // we infer click-vs-drag from whether the window actually moved. - let start = { - let mut bubbles = lock_bubbles(); - let start = bubbles - .get(&(hwnd.0 as isize)) - .and_then(|b| b.drag_start_pos); - if let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) { - b.drag_start_pos = None; - } - start - }; - let mut current = RECT::default(); - let _ = GetWindowRect(hwnd, &mut current); - let moved = match start { - Some((sx, sy)) => (current.left - sx).abs() >= 3 || (current.top - sy).abs() >= 3, - None => false, - }; - if moved { - snap_to_edge(hwnd); - if let Some(model) = model(hwnd) { - if let Some(pos) = position(hwnd) { - dispatch(|cb| (cb.on_moved)(model, pos)); - } - } - } else if let Some(model) = model(hwnd) { - dispatch(|cb| (cb.on_click)(hwnd, model)); - } - LRESULT(0) - } - WM_NCRBUTTONUP => { - if let Some(model) = model(hwnd) { - let pt = lparam_to_point(lparam); - dispatch(|cb| (cb.on_right_click)(hwnd, model, pt)); - } - LRESULT(0) - } - WM_MOUSEWHEEL => { - let modifiers = (wparam.0 & 0xFFFF) as u32; - const MK_CONTROL: u32 = 0x0008; - if modifiers & MK_CONTROL != 0 { - let delta = ((wparam.0 >> 16) & 0xFFFF) as i16; - let step = if delta > 0 { - RESIZE_STEP_LOGICAL - } else { - -RESIZE_STEP_LOGICAL - }; - resize_step(hwnd, step); - LRESULT(0) - } else { - DefWindowProcW(hwnd, msg, wparam, lparam) - } - } - WM_DPICHANGED => { - let new_dpi = ((wparam.0 >> 16) & 0xFFFF) as u32; - if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) { - b.dpi = new_dpi; - } - let rect_ptr = lparam.0 as *const RECT; - if !rect_ptr.is_null() { - let r = *rect_ptr; - let _ = SetWindowPos( - hwnd, - HWND::default(), - r.left, - r.top, - r.right - r.left, - r.bottom - r.top, - SWP_NOZORDER | SWP_NOACTIVATE, - ); - } - render(hwnd); - LRESULT(0) - } - WM_TIMER => { - match wparam.0 { - w if w == TIMER_FULLSCREEN_CHECK => check_fullscreen(hwnd), - w if w == TIMER_PULSE => { - if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) { - b.pulse_phase = b.pulse_phase.wrapping_add(1); - } - render(hwnd); - } - w if w == TIMER_TIME_PROGRESS => render(hwnd), - _ => {} - } - LRESULT(0) - } - WM_COMMAND => { - dispatch(|cb| (cb.on_menu_command)(wparam.0 as u32, hwnd)); - LRESULT(0) - } - WM_SETTINGCHANGE => { - // Taskbar move / auto-hide toggle / DPI change / theme toggle - // all post this. Re-clamp into the new work area (bubble must - // not end up hidden behind the new taskbar position) and ask - // the app to re-read the light/dark setting — Windows fires - // this message when the user flips the OS theme in Settings. - clamp_into_work_area(hwnd); - dispatch(|cb| (cb.on_settings_changed)()); - LRESULT(0) - } - WM_DESTROY => { - lock_bubbles().remove(&(hwnd.0 as isize)); - LRESULT(0) - } - _ => DefWindowProcW(hwnd, msg, wparam, lparam), } } -fn hit_test(hwnd: HWND, lparam: LPARAM) -> LRESULT { - let pt = lparam_to_point(lparam); - let mut r = RECT::default(); - unsafe { - if GetWindowRect(hwnd, &mut r).is_err() { - return LRESULT(HTNOWHERE as isize); - } - } - let w = r.right - r.left; - let h = r.bottom - r.top; - let radius = corner_radius_px(h); - // Local coordinates relative to top-left of the bubble. - let lx = pt.x - r.left; - let ly = pt.y - r.top; - if point_in_rounded_rect(lx, ly, w, h, radius) { - LRESULT(HTCAPTION as isize) - } else { - LRESULT(HTTRANSPARENT as isize) - } +const FALLBACK: Screen = Screen { + bounds: FALLBACK_SCREEN, + work: FALLBACK_SCREEN, + dpi: geom::BASE_DPI, +}; + +fn clamp_u32(v: u32) -> i32 { + i32::try_from(v).unwrap_or(i32::MAX) } -fn corner_radius_px(height_px: i32) -> i32 { - height_px / 2 +/// Where a new bubble goes: its top-left, its physical size and the DPI it +/// was sized for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Placement { + pos: Point, + size: (i32, i32), + dpi: u32, } -fn point_in_rounded_rect(x: i32, y: i32, w: i32, h: i32, r: i32) -> bool { - if x < 0 || y < 0 || x >= w || y >= h { - return false; - } - // The straight horizontal and vertical strips are always inside; only the - // four corner squares need the circular falloff check. - let in_x_strip = x >= r && x < w - r; - let in_y_strip = y >= r && y < h - r; - if in_x_strip || in_y_strip { - return true; - } - let cx = if x < r { r } else { w - 1 - r }; - let cy = if y < r { r } else { h - 1 - r }; - let dx = x - cx; - let dy = y - cy; - dx * dx + dy * dy <= r * r -} - -fn lparam_to_point(lparam: LPARAM) -> POINT { - let lo = (lparam.0 & 0xFFFF) as i16 as i32; - let hi = ((lparam.0 >> 16) & 0xFFFF) as i16 as i32; - POINT { x: lo, y: hi } -} - -// ---------- Resize / snap ---------- - -fn resize_step(hwnd: HWND, delta: i32) { - let Some(current) = size_logical(hwnd) else { - return; +/// Index of the screen that holds most of `rect`; when none overlaps it, +/// the screen whose bounds are nearest to its centre. +fn screen_for(rect: Rect, screens: &[Screen]) -> Option { + let overlap = |s: &Screen| -> i64 { + let w = (rect.r.min(s.bounds.r) - rect.l.max(s.bounds.l)).max(0); + let h = (rect.b.min(s.bounds.b) - rect.t.max(s.bounds.t)).max(0); + i64::from(w) * i64::from(h) }; - set_size_logical(hwnd, current + delta); -} - -pub fn set_size_logical(hwnd: HWND, size_logical: i32) { - let (new_logical, dpi) = { - let mut bubbles = lock_bubbles(); - let Some(b) = bubbles.get_mut(&(hwnd.0 as isize)) else { - return; - }; - let new_logical = size_logical.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE); - if new_logical == b.size_logical { - return; - } - b.size_logical = new_logical; - (new_logical, b.dpi) + let (cx, cy) = ( + i64::from(rect.l) + i64::from(rect.w()) / 2, + i64::from(rect.t) + i64::from(rect.h()) / 2, + ); + let distance = |s: &Screen| -> i64 { + let dx = (i64::from(s.bounds.l) - cx) + .max(cx - i64::from(s.bounds.r)) + .max(0); + let dy = (i64::from(s.bounds.t) - cy) + .max(cy - i64::from(s.bounds.b)) + .max(0); + dx * dx + dy * dy }; - let width_px = scale_to_dpi(new_logical, dpi); - let height_px = scale_to_dpi(bubble_height_logical(new_logical), dpi); - let mut r = RECT::default(); - unsafe { - let _ = GetWindowRect(hwnd, &mut r); - // Resize centered on existing center. - let cx = (r.left + r.right) / 2; - let cy = (r.top + r.bottom) / 2; - let new_x = cx - width_px / 2; - let new_y = cy - height_px / 2; - let _ = SetWindowPos( - hwnd, - HWND::default(), - new_x, - new_y, - width_px, - height_px, - SWP_NOZORDER | SWP_NOACTIVATE, - ); - } - render(hwnd); - if let Some(m) = model(hwnd) { - dispatch(|cb| (cb.on_resized)(m, new_logical)); - } -} - -fn snap_to_edge(hwnd: HWND) { - let dpi = lock_bubbles() - .get(&(hwnd.0 as isize)) - .map(|b| b.dpi) - .unwrap_or(96); - let edge_zone = scale_to_dpi(SNAP_ZONE_LOGICAL, dpi); - let corner_zone = scale_to_dpi(CORNER_SNAP_ZONE_LOGICAL, dpi); - let corner_inset = scale_to_dpi(CORNER_INSET_LOGICAL, dpi); - let taskbar_gap = scale_to_dpi(TASKBAR_GAP_LOGICAL, dpi); - let peer_tolerance = scale_to_dpi(PEER_ALIGN_TOLERANCE_LOGICAL, dpi); - - let mut r = RECT::default(); - let monitor; - unsafe { - if GetWindowRect(hwnd, &mut r).is_err() { - return; - } - monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); - } - if monitor.is_invalid() { - return; - } - let mut info = MONITORINFO { - cbSize: std::mem::size_of::() as u32, - ..Default::default() - }; - unsafe { - if !GetMonitorInfoW(monitor, &mut info).as_bool() { - return; - } - } - let wa = info.rcWork; - let w = r.right - r.left; - let h = r.bottom - r.top; - let mut nx = r.left; - let mut ny = r.top; - - // 1. Corner snap — if the bubble's nearest-corner distance is under the - // 32-px corner zone, slam it into the corner with the 12-px inset. - let snapped_to_corner = try_corner_snap(&mut nx, &mut ny, &wa, w, h, corner_zone, corner_inset); - - if !snapped_to_corner { - // 2. Edge snap (existing behavior) — also handles taskbar-adjacency - // when the taskbar steals from the work area on the same edge. - let taskbar = read_taskbar(); - snap_to_work_area_edges(&mut nx, &mut ny, &wa, w, h, edge_zone); - if let Some(tb) = taskbar { - snap_alongside_taskbar(&mut nx, &mut ny, &tb, &wa, w, h, edge_zone, taskbar_gap); - } - - // 3. Peer vertical alignment — when the other bubble is within ±8 px - // on Y, snap the dragged bubble to share its baseline. - align_with_peer(hwnd, &mut ny, peer_tolerance); - } - - // Clamp into the work area in any case (so the bubble can't be lost off-screen). - nx = nx.clamp(wa.left, (wa.right - w).max(wa.left)); - ny = ny.clamp(wa.top, (wa.bottom - h).max(wa.top)); - - if nx != r.left || ny != r.top { - unsafe { - let _ = SetWindowPos( - hwnd, - HWND::default(), - nx, - ny, - 0, - 0, - SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE, - ); - } - } -} - -fn try_corner_snap( - nx: &mut i32, - ny: &mut i32, - wa: &RECT, - w: i32, - h: i32, - zone: i32, - inset: i32, -) -> bool { - // Distance from each work-area corner to the bubble's nearest corner. - let tl = (*nx - wa.left).abs() + (*ny - wa.top).abs(); - let tr = (wa.right - (*nx + w)).abs() + (*ny - wa.top).abs(); - let bl = (*nx - wa.left).abs() + (wa.bottom - (*ny + h)).abs(); - let br = (wa.right - (*nx + w)).abs() + (wa.bottom - (*ny + h)).abs(); - let min = tl.min(tr).min(bl).min(br); - if min > zone * 2 { - return false; - } - if min == tl { - *nx = wa.left + inset; - *ny = wa.top + inset; - } else if min == tr { - *nx = wa.right - inset - w; - *ny = wa.top + inset; - } else if min == bl { - *nx = wa.left + inset; - *ny = wa.bottom - inset - h; - } else { - *nx = wa.right - inset - w; - *ny = wa.bottom - inset - h; - } - true -} - -fn snap_to_work_area_edges(nx: &mut i32, ny: &mut i32, wa: &RECT, w: i32, h: i32, zone: i32) { - if (*nx - wa.left).abs() < zone { - *nx = wa.left; - } else if (wa.right - (*nx + w)).abs() < zone { - *nx = wa.right - w; - } - if (*ny - wa.top).abs() < zone { - *ny = wa.top; - } else if (wa.bottom - (*ny + h)).abs() < zone { - *ny = wa.bottom - h; - } -} - -struct Taskbar { - rect: RECT, - edge: u32, -} - -fn read_taskbar() -> Option { - let mut abd = APPBARDATA { - cbSize: std::mem::size_of::() as u32, - ..Default::default() - }; - let res = unsafe { SHAppBarMessage(ABM_GETTASKBARPOS, &mut abd) }; - if res == 0 { - return None; - } - Some(Taskbar { - rect: abd.rc, - edge: abd.uEdge, + let best_overlap = screens + .iter() + .enumerate() + .map(|(i, s)| (i, overlap(s))) + .filter(|(_, area)| *area > 0) + .max_by_key(|(i, area)| (*area, std::cmp::Reverse(*i))) + .map(|(i, _)| i); + best_overlap.or_else(|| { + screens + .iter() + .enumerate() + .min_by_key(|(_, s)| distance(s)) + .map(|(i, _)| i) }) } -fn snap_alongside_taskbar( - nx: &mut i32, - ny: &mut i32, - tb: &Taskbar, - wa: &RECT, - w: i32, - h: i32, - zone: i32, - gap: i32, -) { - // 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 tb.edge { - e if e == ABE_BOTTOM => { - let target = tb.rect.top - gap - h; - if (*ny - target).abs() < zone { - *ny = target.max(wa.top); +/// Place a new bubble: at its saved top-left on the screen it overlaps +/// most, otherwise at the default spot on the primary screen; sized for +/// that screen's DPI and clamped into its work area. +fn initial_placement( + provider: ProviderId, + size_logical: i32, + saved: Option<(i32, i32)>, + screens: &[Screen], + primary: Screen, +) -> Placement { + let (screen, pos) = match saved { + Some((x, y)) => { + let (w, h) = geom::bubble_size_px(size_logical, primary.dpi); + let probe = Rect::from_xywh(x, y, w, h); + let screen = screen_for(probe, screens).map_or(primary, |i| screens[i]); + (screen, Point::new(x, y)) + } + None => { + let (w, h) = geom::bubble_size_px(size_logical, primary.dpi); + ( + primary, + geom::default_position(primary.work, w, h, provider), + ) + } + }; + let size = geom::bubble_size_px(size_logical, screen.dpi); + let rect = Rect::from_xywh(pos.x, pos.y, size.0, size.1); + Placement { + pos: geom::clamp_into_work_area(rect, screen.work, provider), + size, + dpi: screen.dpi, + } +} + +fn runtime_of(app: &AppHandle) -> Option> { + app.try_state::>().map(|r| Arc::clone(&r)) +} + +/// Run `f` on a short-lived thread (see the module docs). +fn defer(name: &str, f: impl FnOnce() + Send + 'static) { + if let Err(err) = std::thread::Builder::new().name(name.into()).spawn(f) { + log::error!("{name} thread could not start: {err}"); + } +} + +fn screens(app: &AppHandle) -> Vec { + match app.available_monitors() { + Ok(monitors) => monitors.iter().map(Screen::of).collect(), + Err(err) => { + log::warn!("monitors could not be enumerated: {err}"); + Vec::new() + } + } +} + +/// The primary screen, else the first one, else a 1920x1080 stand-in. +fn primary_screen(app: &AppHandle, screens: &[Screen]) -> Screen { + match app.primary_monitor() { + Ok(Some(monitor)) => Screen::of(&monitor), + Ok(None) => screens.first().copied().unwrap_or(FALLBACK), + Err(err) => { + log::warn!("primary monitor unavailable: {err}"); + screens.first().copied().unwrap_or(FALLBACK) + } + } +} + +/// The screen a window is on (Win32 picks the nearest), else the primary. +fn screen_of(window: &WebviewWindow) -> Screen { + 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)) + } + } +} + +fn outer_rect(window: &WebviewWindow) -> Option { + let pos = window.outer_position(); + let size = window.outer_size(); + match (pos, size) { + (Ok(pos), Ok(size)) => Some(Rect::from_xywh( + pos.x, + pos.y, + clamp_u32(size.width), + clamp_u32(size.height), + )), + (Err(err), _) | (_, Err(err)) => { + log::warn!("bounds of {} unavailable: {err}", window.label()); + None + } + } +} + +fn dpi_of(window: &WebviewWindow) -> u32 { + match window.scale_factor() { + Ok(scale) => runtime::dpi_for_scale(scale), + Err(err) => { + log::warn!("scale factor of {} unavailable: {err}", window.label()); + geom::BASE_DPI + } + } +} + +fn set_position(window: &WebviewWindow, pos: Point) { + if let Err(err) = window.set_position(PhysicalPosition::new(pos.x, pos.y)) { + log::warn!("{} not moved: {err}", window.label()); + } +} + +fn set_size(window: &WebviewWindow, (w, h): (i32, i32)) { + let size = PhysicalSize::new(w.max(1).unsigned_abs(), h.max(1).unsigned_abs()); + if let Err(err) = window.set_size(size) { + log::warn!("{} not resized: {err}", window.label()); + } +} + +/// Bubble windows that exist, with their provider, in display order. +fn windows(app: &AppHandle) -> Vec<(ProviderId, WebviewWindow)> { + ProviderId::LIVE_USAGE + .into_iter() + .filter_map(|p| { + app.get_webview_window(&runtime::bubble_label(p)) + .map(|w| (p, w)) + }) + .collect() +} + +// ---------- Creation, ready and show ---------- + +/// Creation number of each bubble still waiting for `bubble_ready`. The +/// number keeps a fallback timer of a destroyed window from showing a new +/// window with the same label. +static PENDING_READY: Mutex>> = Mutex::new(None); +static CREATIONS: AtomicU64 = AtomicU64::new(0); + +fn with_pending(f: impl FnOnce(&mut HashMap) -> T) -> T { + let mut guard = PENDING_READY.lock().unwrap_or_else(PoisonError::into_inner); + f(guard.get_or_insert_with(HashMap::new)) +} + +/// Create a bubble from a short-lived thread. Building a webview window +/// inside a menu or window event handler deadlocks on Windows (WebView2), +/// while off the main thread the build is posted to the event loop. The +/// lock makes a toggle and a `Destroyed` recreate for the same provider +/// check and build one at a time, so only one of them creates the window. +/// The main thread never takes it. +pub fn spawn_create(app: &AppHandle, provider: ProviderId) { + static CREATE: Mutex<()> = Mutex::new(()); + let app = app.clone(); + defer("bubble-create", move || { + let _serial = CREATE.lock().unwrap_or_else(PoisonError::into_inner); + if let Err(err) = create(&app, provider) { + log::error!("bubble for {} could not be created: {err}", provider.slug()); + } + }); +} + +/// Create `provider`'s bubble, hidden, at its exact physical size and +/// position. Does nothing while a window with the label still exists; a +/// destroyed one is recreated from its `Destroyed` event. The page shows it +/// through `bubble_ready`, or [`READY_FALLBACK`] after creation. +pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> { + let label = runtime::bubble_label(provider); + if app.get_webview_window(&label).is_some() { + log::debug!("{label} still exists; not creating it again"); + return Ok(()); + } + let Some(runtime) = runtime_of(app) else { + log::warn!("{label} not created: the runtime is not ready"); + return Ok(()); + }; + let (size_logical, saved) = runtime.with_state(|s| { + ( + s.settings.bubble_size_logical, + s.settings.bubble_positions.get(provider), + ) + }); + let all = screens(app); + let primary = primary_screen(app, &all); + let placement = initial_placement(provider, size_logical, saved, &all, primary); + let scale = f64::from(placement.dpi) / f64::from(geom::BASE_DPI); + + let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::App("bubble.html".into())) + .title(TITLE) + .transparent(true) + .decorations(false) + .shadow(false) + .always_on_top(true) + .skip_taskbar(true) + .resizable(false) + .maximizable(false) + .minimizable(false) + .focused(false) + .focusable(false) + .visible(false) + // Close to the final size; the exact physical size is set below. + .inner_size( + f64::from(placement.size.0) / scale, + f64::from(placement.size.1) / scale, + ) + .build()?; + if platform::positioning_supported() { + set_position(&window, placement.pos); + } + set_size(&window, placement.size); + log::info!( + "{label} created at ({}, {}) size {}x{} dpi {}", + placement.pos.x, + placement.pos.y, + placement.size.0, + placement.size.1, + placement.dpi + ); + + prepare_native(app, &window, provider); + #[cfg(not(windows))] + match window.theme() { + Ok(theme) => runtime.set_dark(theme != tauri::Theme::Light), + Err(err) => log::debug!("theme of {label} unavailable: {err}"), + } + + let creation = CREATIONS.fetch_add(1, Ordering::Relaxed); + with_pending(|p| p.insert(label.clone(), creation)); + let app = app.clone(); + defer("bubble-ready-fallback", move || { + std::thread::sleep(READY_FALLBACK); + let still_pending = with_pending(|p| { + if p.get(&label) == Some(&creation) { + p.remove(&label); + true + } else { + false + } + }); + if still_pending { + log::warn!("{label} sent no bubble_ready; showing it anyway"); + show_if_visible(&app, &label); + } + }); + Ok(()) +} + +/// Windows: make the bubble a non-activating tool window and hook its +/// move-end, setting-change and display-change messages. Both must run on +/// the window's own (main) thread before the first show. +#[cfg(windows)] +fn prepare_native(app: &AppHandle, window: &WebviewWindow, provider: ProviderId) { + let window = window.clone(); + let hook_app = app.clone(); + let queued = app.run_on_main_thread(move || { + if let Err(err) = platform::windows::apply_no_activate(&window) { + log::warn!("{} may take focus: {err}", window.label()); + } + let hooked = platform::windows::install_bubble_hook(&window, move |event| { + on_hook_event(&hook_app, provider, event); + }); + if let Err(err) = hooked { + log::warn!("{} has no move-end hook: {err}", window.label()); + } + }); + if let Err(err) = queued { + log::warn!("bubble set-up could not reach the main thread: {err}"); + } +} + +#[cfg(not(windows))] +fn prepare_native(_app: &AppHandle, _window: &WebviewWindow, _provider: ProviderId) {} + +#[cfg(windows)] +fn on_hook_event(app: &AppHandle, provider: ProviderId, event: platform::windows::BubbleHookEvent) { + use platform::windows::BubbleHookEvent; + let app = app.clone(); + defer("bubble-hook", move || match event { + BubbleHookEvent::MoveEnd => move_end(&app, provider), + BubbleHookEvent::SettingChange => { + if let Some(runtime) = runtime_of(&app) { + runtime.set_dark(platform::windows::is_dark()); + } + reclamp(&app, provider); + } + BubbleHookEvent::DisplayChange => reclamp(&app, provider), + }); +} + +/// The page applied its first state: show the bubble unless it is hidden. +pub fn mark_ready(app: &AppHandle, label: &str) -> Result<(), String> { + let provider = label + .strip_prefix(BUBBLE_LABEL_PREFIX) + .and_then(ProviderId::from_slug) + .filter(|p| p.metadata().live_usage) + .ok_or_else(|| "not a bubble window".to_string())?; + if app.get_webview_window(label).is_none() { + return Err("no such bubble window".into()); + } + with_pending(|p| p.remove(label)); + log::debug!("{} ready", provider.slug()); + show_if_visible(app, label); + Ok(()) +} + +/// Show one bubble when the visibility state allows it. +fn show_if_visible(app: &AppHandle, label: &str) { + let Some(runtime) = runtime_of(app) else { + return; + }; + if !runtime.with_state(|s| s.visibility.shows()) { + return; + } + let app = app.clone(); + let label = label.to_string(); + let queued = app.clone().run_on_main_thread(move || { + if let Some(window) = app.get_webview_window(&label) { + show(&window); + } + }); + if let Err(err) = queued { + log::warn!("bubble show could not reach the main thread: {err}"); + } +} + +/// Show or hide every bubble. On Windows this uses the raw no-activate +/// show and plain hide, never Tauri's, for both directions. +pub fn apply(app: &AppHandle, action: Action) { + let handle = app.clone(); + let queued = app.run_on_main_thread(move || { + for (_, window) in windows(&handle) { + match action { + Action::Show => show(&window), + Action::Hide => hide(&window), } } - e if e == ABE_TOP => { - let target = tb.rect.bottom + gap; - if (*ny - target).abs() < zone { - *ny = target.min(wa.bottom - h); - } + }); + if let Err(err) = queued { + log::warn!("bubble visibility could not reach the main thread: {err}"); + } +} + +fn show(window: &WebviewWindow) { + #[cfg(windows)] + let result = platform::windows::show_no_activate(window).map_err(|e| e.to_string()); + #[cfg(not(windows))] + let result = window.show().map_err(|e| e.to_string()); + if let Err(err) = result { + log::warn!("{} not shown: {err}", window.label()); + } +} + +fn hide(window: &WebviewWindow) { + #[cfg(windows)] + let result = platform::windows::hide(window).map_err(|e| e.to_string()); + #[cfg(not(windows))] + let result = window.hide().map_err(|e| e.to_string()); + if let Err(err) = result { + log::warn!("{} not hidden: {err}", window.label()); + } +} + +// ---------- Window events ---------- + +/// Handle a bubble's window events; other windows are ignored. +pub fn on_window_event(window: &Window, event: &WindowEvent) { + let label = window.label(); + let Some(provider) = label + .strip_prefix(BUBBLE_LABEL_PREFIX) + .and_then(ProviderId::from_slug) + else { + return; + }; + match event { + // Bubbles ignore close requests (Alt+F4); they are only destroyed. + WindowEvent::CloseRequested { api, .. } => api.prevent_close(), + WindowEvent::Destroyed => on_destroyed(window, provider), + WindowEvent::ScaleFactorChanged { .. } => { + let app = window.app_handle().clone(); + defer("bubble-dpi", move || fit_to_dpi(&app, provider)); } - e if e == ABE_LEFT => { - let target = tb.rect.right + gap; - if (*nx - target).abs() < zone { - *nx = target.min(wa.right - w); - } - } - e if e == ABE_RIGHT => { - let target = tb.rect.left - gap - w; - if (*nx - target).abs() < zone { - *nx = target.max(wa.left); - } + #[cfg(not(windows))] + 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))] + WindowEvent::ThemeChanged(theme) => { + let is_dark = *theme != tauri::Theme::Light; + let app = window.app_handle().clone(); + defer("bubble-theme", move || { + if let Some(runtime) = runtime_of(&app) { + runtime.set_dark(is_dark); + } + }); } _ => {} } } -fn clamp_into_work_area(hwnd: HWND) { - let mut r = RECT::default(); - let monitor; - unsafe { - if GetWindowRect(hwnd, &mut r).is_err() { - return; - } - monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); - } - if monitor.is_invalid() { +/// A bubble destroyed while its provider is enabled again (a fast off/on +/// toggle) is recreated once the old window is gone. +fn on_destroyed(window: &Window, provider: ProviderId) { + with_pending(|p| p.remove(window.label())); + #[cfg(not(windows))] + drag::disarm(window.label()); + let Some(runtime) = runtime_of(window.app_handle()) else { + return; + }; + if runtime.is_exiting() { return; } - let mut info = MONITORINFO { - cbSize: std::mem::size_of::() as u32, - ..Default::default() + // The old window is already out of the manager here, so the existence + // check in `create` sees it gone. + if runtime.with_state(|s| s.settings.is_provider_enabled(provider)) { + spawn_create(window.app_handle(), provider); + } +} + +/// Size a bubble for its current DPI and send it a fresh payload. +fn fit_to_dpi(app: &AppHandle, provider: ProviderId) { + let Some(window) = app.get_webview_window(&runtime::bubble_label(provider)) else { + return; }; - unsafe { - if !GetMonitorInfoW(monitor, &mut info).as_bool() { - return; - } + let Some(runtime) = runtime_of(app) else { + return; + }; + let size_logical = runtime.with_state(|s| s.settings.bubble_size_logical); + let wanted = geom::bubble_size_px(size_logical, dpi_of(&window)); + if outer_rect(&window).is_some_and(|r| (r.w(), r.h()) != wanted) { + set_size(&window, wanted); } - let wa = info.rcWork; - let w = r.right - r.left; - let h = r.bottom - r.top; - let nx = r.left.clamp(wa.left, (wa.right - w).max(wa.left)); - let mut ny = r.top.clamp(wa.top, (wa.bottom - h).max(wa.top)); + runtime.push_bubbles(); +} - // When both bubbles get clamped to the same bottom-right corner (e.g., - // saved positions were on a disconnected monitor and the validator missed - // them), keep the Codex-above-Claude stagger that `default_position` uses - // so they don't visually stack. - let is_codex = lock_bubbles() - .get(&(hwnd.0 as isize)) - .is_some_and(|b| matches!(b.model, ProviderId::ChatGpt)); - if is_codex && nx == wa.right - w && ny == wa.bottom - h { - const STAGGER_GAP: i32 = 24; - ny = (ny - h - STAGGER_GAP).max(wa.top); +// ---------- Drag, snap and clamp ---------- + +/// A drag started on `provider`'s bubble. On Windows the move-loop hook +/// reports its end, so nothing is armed. +pub fn drag_start(app: &AppHandle, provider: ProviderId) { + #[cfg(windows)] + let _ = (app, provider); + #[cfg(not(windows))] + drag::arm(app, provider); +} + +/// The user dropped `provider`'s bubble: snap it, then save where it ended. +/// Programmatic moves never get here, so they neither snap nor save. +fn move_end(app: &AppHandle, provider: ProviderId) { + if !platform::positioning_supported() { + return; } + let Some(window) = app.get_webview_window(&runtime::bubble_label(provider)) else { + return; + }; + let Some(rect) = outer_rect(&window) else { + return; + }; + let peers = windows(app) + .into_iter() + .filter(|(p, _)| *p != provider) + .filter_map(|(_, w)| outer_rect(&w)) + .collect(); + #[cfg(windows)] + let taskbar = platform::windows::taskbar(); + #[cfg(not(windows))] + let taskbar = None; + let snapped = geom::snap(&SnapInput { + rect, + work_area: screen_of(&window).work, + dpi: dpi_of(&window), + taskbar, + peers, + }); + if snapped != Point::new(rect.l, rect.t) { + set_position(&window, snapped); + } + log::debug!( + "{} dropped at ({}, {}), snapped to ({}, {})", + provider.slug(), + rect.l, + rect.t, + snapped.x, + snapped.y + ); + if let Some(runtime) = runtime_of(app) { + runtime.save_bubble_position(provider, (snapped.x, snapped.y)); + } +} - if nx != r.left || ny != r.top { - log::warn!( - "clamp_into_work_area moved bubble from ({}, {}) to ({nx}, {ny})", - r.left, - r.top +/// Pull a bubble back into its monitor's work area, without saving. Runs +/// on the Windows setting and display change messages. +#[cfg(windows)] +fn reclamp(app: &AppHandle, provider: ProviderId) { + if !platform::positioning_supported() { + return; + } + let Some(window) = app.get_webview_window(&runtime::bubble_label(provider)) else { + return; + }; + let Some(rect) = outer_rect(&window) else { + return; + }; + let clamped = geom::clamp_into_work_area(rect, screen_of(&window).work, provider); + if clamped != Point::new(rect.l, rect.t) { + log::info!( + "{} clamped from ({}, {}) to ({}, {})", + provider.slug(), + rect.l, + rect.t, + clamped.x, + clamped.y ); - unsafe { - let _ = SetWindowPos( - hwnd, - HWND::default(), - nx, - ny, - 0, - 0, - SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE, - ); + set_position(&window, clamped); + } +} + +/// 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) { + let placeable = platform::positioning_supported(); + for (_, window) in windows(app) { + let Some(rect) = outer_rect(&window) else { + continue; + }; + 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)); } } } -fn align_with_peer(this_hwnd: HWND, ny: &mut i32, tolerance: i32) { - let bubbles = lock_bubbles(); - for (id, _) in bubbles.iter() { - if *id == this_hwnd.0 as isize { - continue; +/// Move every bubble to its default spot on the primary screen. The +/// windows are moved, not recreated. +pub fn move_all_to_defaults(app: &AppHandle) { + if !platform::positioning_supported() { + return; + } + let primary = primary_screen(app, &screens(app)); + let Some(runtime) = runtime_of(app) else { + return; + }; + let size_logical = runtime.with_state(|s| s.settings.bubble_size_logical); + for (provider, window) in windows(app) { + let (w, h) = geom::bubble_size_px(size_logical, dpi_of(&window)); + set_position( + &window, + geom::default_position(primary.work, w, h, provider), + ); + } +} + +/// Move-end detection where the OS reports no end of a window drag: a +/// [`MOVE_END_DEBOUNCE`] quiet period after the last `Moved` event, armed +/// only by a drag the page started. On X11 the period restarts while +/// Button1 is still held, so pausing mid-drag does not snap. +#[cfg(not(windows))] +mod drag { + use super::*; + + /// Deadline of each armed bubble, by label. + static ARMED: Mutex>> = Mutex::new(None); + + fn with_armed(f: impl FnOnce(&mut HashMap) -> T) -> T { + let mut guard = ARMED.lock().unwrap_or_else(PoisonError::into_inner); + f(guard.get_or_insert_with(HashMap::new)) + } + + /// Arm the debounce and start its watcher, unless one already runs. + pub fn arm(app: &AppHandle, provider: ProviderId) { + let label = runtime::bubble_label(provider); + let fresh = with_armed(|armed| { + armed + .insert(label.clone(), Instant::now() + MOVE_END_DEBOUNCE) + .is_none() + }); + if !fresh { + return; } - let peer_hwnd = HWND(*id as *mut c_void); - let mut pr = RECT::default(); - unsafe { - if GetWindowRect(peer_hwnd, &mut pr).is_err() { + let app = app.clone(); + defer("bubble-drag", move || { + if wait_for_end(&label) { + move_end(&app, provider); + } + }); + } + + /// A move pushes the deadline out, while armed. + pub fn moved(label: &str) { + with_armed(|armed| { + if let Some(deadline) = armed.get_mut(label) { + *deadline = Instant::now() + MOVE_END_DEBOUNCE; + } + }); + } + + pub fn disarm(label: &str) { + with_armed(|armed| armed.remove(label)); + } + + /// Sleep until the deadline stops moving and the button is up, then + /// disarm. False when the bubble was disarmed meanwhile (destroyed). + fn wait_for_end(label: &str) -> bool { + loop { + let Some(deadline) = with_armed(|armed| armed.get(label).copied()) else { + return false; + }; + let now = Instant::now(); + if now < deadline { + std::thread::sleep(deadline - now); continue; } + if platform::primary_button_down() { + with_armed(|armed| armed.insert(label.to_string(), now + MOVE_END_DEBOUNCE)); + continue; + } + // Disarm before the snap moves the window, so its own `Moved` + // cannot re-arm. + with_armed(|armed| armed.remove(label)); + return true; } - if (*ny - pr.top).abs() <= tolerance { - *ny = pr.top; - return; - } - } -} - -// ---------- Fullscreen detection ---------- - -fn check_fullscreen(bubble_hwnd: HWND) { - let fg = unsafe { GetForegroundWindow() }; - let evaluation = evaluate_foreground_fullscreen(fg, bubble_hwnd); - - let (was_hidden_by_fs, user_hidden) = { - let bubbles = lock_bubbles(); - let Some(b) = bubbles.get(&(bubble_hwnd.0 as isize)) else { - return; - }; - (b.hidden_by_fullscreen, b.user_hidden) - }; - - if evaluation.is_fullscreen && !was_hidden_by_fs { - unsafe { - let _ = ShowWindow(bubble_hwnd, SW_HIDE); - } - if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) { - b.hidden_by_fullscreen = true; - } - log_fullscreen_decision("hide", &evaluation, false); - } else if !evaluation.is_fullscreen && was_hidden_by_fs { - show_after_fullscreen(bubble_hwnd, user_hidden); - log_fullscreen_decision("show", &evaluation, user_hidden); - } -} - -struct FullscreenEvaluation { - is_fullscreen: bool, - hwnd: HWND, - class_name: String, - bounds: Option, - bounds_source: &'static str, - monitor: Option, - reason: &'static str, -} - -fn evaluate_foreground_fullscreen(fg: HWND, bubble_hwnd: HWND) -> FullscreenEvaluation { - let mut evaluation = FullscreenEvaluation { - is_fullscreen: false, - hwnd: fg, - class_name: String::new(), - bounds: None, - bounds_source: "none", - monitor: None, - reason: "not evaluated", - }; - - if fg == HWND::default() { - evaluation.reason = "no foreground window"; - return evaluation; - } - - evaluation.class_name = window_class_name(fg); - if fg == bubble_hwnd || is_ignored_fullscreen_class(&evaluation.class_name) { - evaluation.reason = "ignored foreground class"; - return evaluation; - } - if unsafe { !IsWindowVisible(fg).as_bool() || IsIconic(fg).as_bool() } { - evaluation.reason = "foreground invisible or minimized"; - return evaluation; - } - if is_dwm_cloaked(fg) { - evaluation.reason = "foreground cloaked"; - return evaluation; - } - - let Some((bounds, source)) = visible_window_bounds(fg) else { - evaluation.reason = "foreground bounds unavailable"; - return evaluation; - }; - evaluation.bounds = Some(bounds); - evaluation.bounds_source = source; - - let monitor = unsafe { MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) }; - if monitor.is_invalid() { - evaluation.reason = "foreground monitor unavailable"; - return evaluation; - } - let mut info = MONITORINFO { - cbSize: std::mem::size_of::() as u32, - ..Default::default() - }; - if unsafe { !GetMonitorInfoW(monitor, &mut info).as_bool() } { - evaluation.reason = "foreground monitor info unavailable"; - return evaluation; - } - evaluation.monitor = Some(info.rcMonitor); - - if !rect_covers_monitor(&bounds, &info.rcMonitor) { - evaluation.reason = "visible bounds do not cover monitor"; - return evaluation; - } - - if !window_style_allows_fullscreen(fg) { - evaluation.reason = "standard framed window"; - return evaluation; - } - - evaluation.is_fullscreen = true; - evaluation.reason = "visible bounds cover monitor"; - evaluation -} - -fn show_after_fullscreen(bubble_hwnd: HWND, user_hidden: bool) { - if !user_hidden { - unsafe { - let _ = ShowWindow(bubble_hwnd, SW_SHOWNOACTIVATE); - } - // Re-paint so the layered surface has the cached data again - // (see comment in `set_user_visible`). - render(bubble_hwnd); - } - if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) { - b.hidden_by_fullscreen = false; - } -} - -fn visible_window_bounds(hwnd: HWND) -> Option<(RECT, &'static str)> { - let mut rect = RECT::default(); - let dwm_ok = unsafe { - DwmGetWindowAttribute( - hwnd, - DWMWA_EXTENDED_FRAME_BOUNDS, - &mut rect as *mut _ as *mut c_void, - std::mem::size_of::() as u32, - ) - .is_ok() - }; - if dwm_ok && !rect_is_empty(&rect) { - return Some((rect, "dwm-extended-frame")); - } - - unsafe { - if GetWindowRect(hwnd, &mut rect).is_err() { - return None; - } - } - if rect_is_empty(&rect) { - None - } else { - Some((rect, "window-rect")) - } -} - -fn is_dwm_cloaked(hwnd: HWND) -> bool { - let mut cloaked = 0u32; - unsafe { - DwmGetWindowAttribute( - hwnd, - DWMWA_CLOAKED, - &mut cloaked as *mut _ as *mut c_void, - std::mem::size_of::() as u32, - ) - .is_ok() - && cloaked != 0 - } -} - -fn window_class_name(hwnd: HWND) -> String { - let mut buf = [0u16; 256]; - let len = unsafe { GetClassNameW(hwnd, &mut buf) }; - if len <= 0 { - String::new() - } else { - String::from_utf16_lossy(&buf[..len as usize]) - } -} - -fn is_ignored_fullscreen_class(class_name: &str) -> bool { - ["Progman", "WorkerW", "Shell_TrayWnd", CLASS_NAME] - .iter() - .any(|ignored| class_name.eq_ignore_ascii_case(ignored)) -} - -fn window_style_allows_fullscreen(hwnd: HWND) -> bool { - unsafe { - SetLastError(WIN32_ERROR(0)); - } - let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) }; - if style == 0 && unsafe { GetLastError() } != WIN32_ERROR(0) { - return false; - } - window_style_bits_allow_fullscreen(style as u32) -} - -fn window_style_bits_allow_fullscreen(style: u32) -> bool { - let has_child = style & WS_CHILD.0 != 0; - let has_popup = style & WS_POPUP.0 != 0; - let has_caption = style & WS_CAPTION.0 != 0; - let has_resize_frame = style & WS_THICKFRAME.0 != 0; - - !has_child && (has_popup || (!has_caption && !has_resize_frame)) -} - -fn rect_covers_monitor(rect: &RECT, bounds: &RECT) -> bool { - rect.left <= bounds.left + FULLSCREEN_EDGE_TOLERANCE_PX - && rect.top <= bounds.top + FULLSCREEN_EDGE_TOLERANCE_PX - && rect.right >= bounds.right - FULLSCREEN_EDGE_TOLERANCE_PX - && rect.bottom >= bounds.bottom - FULLSCREEN_EDGE_TOLERANCE_PX -} - -fn rect_is_empty(rect: &RECT) -> bool { - rect.right <= rect.left || rect.bottom <= rect.top -} - -fn log_fullscreen_decision(action: &str, evaluation: &FullscreenEvaluation, user_hidden: bool) { - log::info!( - "bubble fullscreen {action} fg=0x{:X} class={} reason={} bounds_source={} bounds={} monitor={} user_hidden={}", - evaluation.hwnd.0 as usize, - if evaluation.class_name.is_empty() { - "" - } else { - evaluation.class_name.as_str() - }, - evaluation.reason, - evaluation.bounds_source, - format_rect(evaluation.bounds.as_ref()), - format_rect(evaluation.monitor.as_ref()), - user_hidden - ); -} - -fn format_rect(rect: Option<&RECT>) -> String { - match rect { - Some(r) => format!( - "({},{} {}x{})", - r.left, - r.top, - r.right - r.left, - r.bottom - r.top - ), - None => String::from(""), } } #[cfg(test)] -mod fullscreen_tests { +mod tests { use super::*; + fn screen(bounds: Rect, work: Rect, dpi: u32) -> Screen { + Screen { bounds, work, dpi } + } + + const PRIMARY: Screen = Screen { + bounds: Rect::new(0, 0, 1920, 1080), + work: Rect::new(0, 0, 1920, 1040), + dpi: 96, + }; + #[test] - fn style_bits_allow_popup_or_borderless_windows_only() { - assert!(window_style_bits_allow_fullscreen(WS_POPUP.0)); - assert!(window_style_bits_allow_fullscreen(0)); - assert!(!window_style_bits_allow_fullscreen( - WS_CAPTION.0 | WS_THICKFRAME.0 - )); - assert!(!window_style_bits_allow_fullscreen(WS_CHILD.0 | WS_POPUP.0)); + fn screen_for_prefers_the_largest_overlap() { + let right = screen( + Rect::new(1920, 0, 3840, 1080), + Rect::new(1920, 0, 3840, 1040), + 144, + ); + let screens = [PRIMARY, right]; + // 80 px on the primary, 120 px on the right one. + assert_eq!( + screen_for(Rect::from_xywh(1840, 100, 200, 66), &screens), + Some(1) + ); + assert_eq!( + screen_for(Rect::from_xywh(1800, 100, 200, 66), &screens), + Some(0) + ); + // A tie keeps the earlier screen. + assert_eq!( + screen_for(Rect::from_xywh(1820, 100, 200, 66), &screens), + Some(0) + ); } #[test] - fn rect_cover_check_allows_small_edge_differences() { - let monitor = RECT { - left: 0, - top: 0, - right: 1920, - bottom: 1080, - }; - let almost_exact = RECT { - left: 1, - top: 2, - right: 1919, - bottom: 1078, - }; - let inset = RECT { - left: 8, - top: 0, - right: 1920, - bottom: 1080, - }; - - assert!(rect_covers_monitor(&almost_exact, &monitor)); - assert!(!rect_covers_monitor(&inset, &monitor)); - } -} - -// ---------- Painting ---------- - -// Sized for the widest English countdown text the bubble renders. -const COUNTDOWN_TEMPLATE: &str = "999d"; - -/// Geometry for the bubble's "circle head + pill tail" shape, in DPI-scaled pixels. -/// -/// The outline is a stadium (rounded rect with `corner_radius = canvas_h / 2`). -/// The left `head_diameter × canvas_h` square holds the 5h progress ring + big -/// percent glyph. The rest is the tail: weekly usage lane + reset-time lane. -struct BubbleLayout { - canvas_w: i32, - canvas_h: i32, - corner_radius: i32, - head_diameter: i32, - ring_cx: f32, - ring_cy: f32, - ring_radius: f32, - ring_stroke_w: f32, - time_ring_radius: f32, - time_ring_stroke_w: f32, - head_label_rect: RECT, - head_pct_rect: RECT, - tail_usage_pct_rect: RECT, - tail_usage_bar_rect: RECT, - tail_time_text_rect: RECT, - tail_time_bar_rect: RECT, - big_font_px: i32, - small_font_px: i32, - main_font_px: i32, -} - -fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayout { - let width_px = scale_to_dpi(size_logical, dpi); - let height_px = scale_to_dpi(bubble_height_logical(size_logical), dpi); - let head_diameter = height_px; - - let head_pad = scale_to_dpi(5, dpi); - let ring_stroke_w = scale_to_dpi(3, dpi).clamp(2, 4) as f32; - let ring_cx = (head_diameter as f32) / 2.0; - let ring_cy = (height_px as f32) / 2.0; - // Ring centerline: midway between outer and inner edge, then keep stroke - // inside the head padding. ring_radius is the centerline radius. - let ring_outer = (head_diameter as f32) / 2.0 - (head_pad as f32); - let ring_radius = ring_outer - ring_stroke_w / 2.0; - // Inner ring renders the remaining-time arc. Floor stroke at 2 logical so - // it stays visible at smaller bubble sizes (clamp 1 produced a hairline - // that disappeared into the track on dark themes). - let time_ring_stroke_w = scale_to_dpi(2, dpi).clamp(2, 3) as f32; - let time_ring_radius = - (ring_radius - ring_stroke_w - scale_to_dpi(4, dpi) as f32).max(time_ring_stroke_w); - - let big_font_px = (head_diameter * 24 / 100).max(scale_to_dpi(11, dpi)); - let small_font_px = ((big_font_px * 55) / 100).max(scale_to_dpi(9, dpi)); - let main_font_px = small_font_px; - - let head_label_h = small_font_px + scale_to_dpi(2, dpi); - let head_pct_h = big_font_px + scale_to_dpi(2, dpi); - let label_pct_gap = (big_font_px * 15 / 100).max(scale_to_dpi(2, dpi)); - let head_total_h = head_label_h + label_pct_gap + head_pct_h; - let head_text_top = (height_px - head_total_h) / 2; - let head_label_rect = RECT { - left: scale_to_dpi(4, dpi), - top: head_text_top, - right: head_diameter - scale_to_dpi(4, dpi), - bottom: head_text_top + head_label_h, - }; - let head_pct_rect = RECT { - left: scale_to_dpi(4, dpi), - top: head_text_top + head_label_h + label_pct_gap, - right: head_diameter - scale_to_dpi(4, dpi), - bottom: head_text_top + head_total_h, - }; - - let tail_left = head_diameter; - let tail_right = width_px - scale_to_dpi(14, dpi); - let pad = scale_to_dpi(6, dpi); - // Breathing room between bar end and the right-aligned text. 6 logical - // had the bar visually colliding with "100%" / countdown glyphs. - let bar_text_gap = scale_to_dpi(8, dpi); - - let countdown_w = measure_text_w(mem_dc, COUNTDOWN_TEMPLATE, main_font_px); - let pct_reserve_w = measure_text_w(mem_dc, "100%", small_font_px) + scale_to_dpi(2, dpi); - - // Usage bar carries the primary signal; make it ~2.5x the mass of the - // time bar so the two lanes don't read as two competing quotas. - let usage_bar_h = (height_px * 10 / 100).clamp(scale_to_dpi(6, dpi), scale_to_dpi(12, dpi)); - let time_bar_h = (height_px * 4 / 100).clamp(scale_to_dpi(3, dpi), scale_to_dpi(6, dpi)); - let lane_gap = scale_to_dpi(6, dpi); - let lanes_h = usage_bar_h + lane_gap + time_bar_h; - let usage_bar_top = (height_px - lanes_h) / 2; - let time_bar_top = usage_bar_top + usage_bar_h + lane_gap; - let time_text_h = main_font_px + scale_to_dpi(2, dpi); - let usage_pct_h = small_font_px + scale_to_dpi(2, dpi); - - let content_left = tail_left + pad; - let content_right = tail_right; - let content_w = (content_right - content_left).max(0); - let bar_min = scale_to_dpi(8, dpi); - let desired_text_w = countdown_w.max(pct_reserve_w); - let text_w = if content_w >= desired_text_w + bar_text_gap + bar_min { - desired_text_w - } else { - (content_w - bar_text_gap - bar_min).max(0) - }; - let text_left = content_right - text_w; - let bar_left = content_left; - let bar_right = (text_left - bar_text_gap).max(bar_left + bar_min); - - BubbleLayout { - canvas_w: width_px, - canvas_h: height_px, - corner_radius: height_px / 2, - head_diameter, - ring_cx, - ring_cy, - ring_radius, - ring_stroke_w, - time_ring_radius, - time_ring_stroke_w, - head_label_rect, - head_pct_rect, - tail_usage_pct_rect: RECT { - left: text_left, - top: usage_bar_top + (usage_bar_h - usage_pct_h) / 2, - right: content_right, - bottom: usage_bar_top + (usage_bar_h - usage_pct_h) / 2 + usage_pct_h, - }, - tail_usage_bar_rect: RECT { - left: bar_left, - top: usage_bar_top, - right: bar_right, - bottom: usage_bar_top + usage_bar_h, - }, - tail_time_text_rect: RECT { - left: text_left, - top: time_bar_top + (time_bar_h - time_text_h) / 2, - right: content_right, - bottom: time_bar_top + (time_bar_h - time_text_h) / 2 + time_text_h, - }, - tail_time_bar_rect: RECT { - left: bar_left, - top: time_bar_top, - right: bar_right, - bottom: time_bar_top + time_bar_h, - }, - big_font_px, - small_font_px, - main_font_px, - } -} - -/// Render the bubble's shape into a fresh tiny-skia `Pixmap`. The Pixmap is -/// premultiplied RGBA at one byte per channel — the caller copies it into the -/// GDI DIB section, then GDI text is overlaid on top. -fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option { - let mut pixmap = Pixmap::new(layout.canvas_w as u32, layout.canvas_h as u32)?; - pixmap.fill(tiny_skia::Color::TRANSPARENT); - - let bg = if inputs.is_dark { - Color::from_hex("#1F1F1F") - } else { - Color::from_hex("#F3F3F3") - }; - let track = if inputs.is_dark { - Color::from_hex("#2C2C2C") - } else { - Color::from_hex("#E2E2E2") - }; - // Inner-ring / time-bar neutral track. Lifted off the background to - // clear WCAG 1.4.11 3:1 on dark themes (#303030 on #1F1F1F was ~1.13:1). - let time_track = if inputs.is_dark { - Color::from_hex("#404040") - } else { - Color::from_hex("#E0E0E0") - }; - let time_fill = if inputs.is_dark { - Color::from_hex("#B0B0B0") - } else { - Color::from_hex("#666666") - }; - - // ---- Stadium background ---- - { - let mut paint = Paint::default(); - paint.set_color(rgb_to_skia(bg)); - paint.anti_alias = true; - let r = (layout.canvas_h as f32) / 2.0; - let w = layout.canvas_w as f32; - let h = layout.canvas_h as f32; - - // Two end-cap circles + middle rect. Overlap is fine — same color. - let mut pb = PathBuilder::new(); - pb.push_circle(r, r, r); - pb.push_circle(w - r, r, r); - if let Some(p) = pb.finish() { - pixmap.fill_path(&p, &paint, FillRule::Winding, Transform::identity(), None); - } - if let Some(rect) = Rect::from_xywh(r, 0.0, (w - 2.0 * r).max(0.0), h) { - pixmap.fill_rect(rect, &paint, Transform::identity(), None); - } - } - - // ---- Ring (5h) ---- - { - // Track: full circle in muted color. - let mut paint = Paint::default(); - paint.set_color(rgb_to_skia(track)); - paint.anti_alias = true; - let mut stroke = Stroke::default(); - stroke.width = layout.ring_stroke_w; - let mut pb = PathBuilder::new(); - pb.push_circle(layout.ring_cx, layout.ring_cy, layout.ring_radius); - if let Some(p) = pb.finish() { - pixmap.stroke_path(&p, &paint, &stroke, Transform::identity(), None); - } - - // Active sweep arc. - if let Some(pct) = inputs.session_pct { - let sweep = (pct.clamp(0.0, 100.0) / 100.0) as f32; - if sweep > 0.0 { - let mut color = - crate::usage_color::bar_fill_color(inputs.model, inputs.is_dark, pct); - if pct >= 95.0 { - let t = pulse_triangle(inputs.pulse_phase); - color = brighten(color, t); - } - let mut paint = Paint::default(); - paint.set_color(rgb_to_skia(color)); - paint.anti_alias = true; - let mut stroke = Stroke::default(); - stroke.width = layout.ring_stroke_w; - stroke.line_cap = LineCap::Round; - if let Some(path) = - build_arc(layout.ring_cx, layout.ring_cy, layout.ring_radius, sweep) - { - pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); - } - } - } - - // Inner ring: true remaining time for the 5h/primary window. This - // stays neutral so it reads as time, not another quota alarm. - let mut paint = Paint::default(); - paint.set_color(rgb_to_skia(time_track)); - paint.anti_alias = true; - let mut stroke = Stroke::default(); - stroke.width = layout.time_ring_stroke_w; - let mut pb = PathBuilder::new(); - pb.push_circle(layout.ring_cx, layout.ring_cy, layout.time_ring_radius); - if let Some(p) = pb.finish() { - pixmap.stroke_path(&p, &paint, &stroke, Transform::identity(), None); - } - if let Some(frac) = remaining_fraction( - inputs.session_resets_at, - window_duration_secs(inputs.model, UsageWindowKind::Primary), - ) { - if frac > 0.0 { - let mut paint = Paint::default(); - paint.set_color(rgb_to_skia(time_fill)); - paint.anti_alias = true; - let mut stroke = Stroke::default(); - stroke.width = layout.time_ring_stroke_w; - stroke.line_cap = LineCap::Round; - if let Some(path) = build_remaining_arc( - layout.ring_cx, - layout.ring_cy, - layout.time_ring_radius, - frac, - ) { - pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); - } - } - } - } - - // ---- Tail usage bar + reset-time bar ---- - { - let bar_x = layout.tail_usage_bar_rect.left as f32; - let bar_y = layout.tail_usage_bar_rect.top as f32; - let bar_w = (layout.tail_usage_bar_rect.right - layout.tail_usage_bar_rect.left) as f32; - let bar_h = (layout.tail_usage_bar_rect.bottom - layout.tail_usage_bar_rect.top) as f32; - let cap = bar_h * 0.5; - if bar_w > 0.0 && bar_h > 0.0 { - paint_pill(&mut pixmap, bar_x, bar_y, bar_w, bar_h, cap, track); - if let Some(pct) = inputs.weekly_pct { - let frac = (pct.clamp(0.0, 100.0) / 100.0) as f32; - let fill_w = bar_w * frac; - if fill_w > 0.0 { - let mut color = - crate::usage_color::bar_fill_color(inputs.model, inputs.is_dark, pct); - if pct >= 95.0 { - let t = pulse_triangle(inputs.pulse_phase); - color = brighten(color, t); - } - // Floor at one cap-diameter so a 1% reading still renders - // as a recognizable dot rather than a sub-pixel sliver. - let mut clipped_w = fill_w.min(bar_w); - if clipped_w < bar_h { - clipped_w = bar_h.min(bar_w); - } - paint_pill(&mut pixmap, bar_x, bar_y, clipped_w, bar_h, cap, color); - } - } - } - - let bar_x = layout.tail_time_bar_rect.left as f32; - let bar_y = layout.tail_time_bar_rect.top as f32; - let bar_w = (layout.tail_time_bar_rect.right - layout.tail_time_bar_rect.left) as f32; - let bar_h = (layout.tail_time_bar_rect.bottom - layout.tail_time_bar_rect.top) as f32; - let cap = bar_h * 0.5; - if bar_w > 0.0 && bar_h > 0.0 { - paint_pill(&mut pixmap, bar_x, bar_y, bar_w, bar_h, cap, time_track); - if let Some(frac) = remaining_fraction( - inputs.weekly_resets_at, - window_duration_secs(inputs.model, UsageWindowKind::Secondary), - ) { - let fill_w = (bar_w * frac).min(bar_w); - if fill_w > 0.0 { - // Anchor on the right edge so the bar shrinks toward the right as time passes. - let fill_x = bar_x + bar_w - fill_w; - paint_pill(&mut pixmap, fill_x, bar_y, fill_w, bar_h, cap, time_fill); - } - } - } - } - - Some(pixmap) -} - -/// Fill a horizontal pill at `(x, y, w, h)` with circular end caps of radius -/// `cap`. Used for both track and fill segments in the tail bars. -fn paint_pill(pixmap: &mut Pixmap, x: f32, y: f32, w: f32, h: f32, cap: f32, color: Color) { - let mut paint = Paint::default(); - paint.set_color(rgb_to_skia(color)); - paint.anti_alias = true; - let mut pb = PathBuilder::new(); - pb.push_circle(x + cap, y + h * 0.5, cap); - pb.push_circle(x + w - cap, y + h * 0.5, cap); - if let Some(p) = pb.finish() { - pixmap.fill_path(&p, &paint, FillRule::Winding, Transform::identity(), None); - } - if let Some(rect) = Rect::from_xywh(x + cap, y, (w - 2.0 * cap).max(0.0), h) { - pixmap.fill_rect(rect, &paint, Transform::identity(), None); - } -} - -fn rgb_to_skia(c: Color) -> tiny_skia::Color { - tiny_skia::Color::from_rgba8(c.r, c.g, c.b, 0xFF) -} - -/// Build a clockwise arc path starting at 12 o'clock, sweeping `sweep_fraction` -/// of a full turn. Sampled — tiny-skia 0.11 lacks a direct arc primitive. -fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option { - let segments = ((sweep_fraction * 64.0).ceil() as usize).max(1); - let mut pb = PathBuilder::new(); - let start_angle: f32 = -std::f32::consts::FRAC_PI_2; - let total = sweep_fraction * std::f32::consts::TAU; - for i in 0..=segments { - let t = i as f32 / segments as f32; - let a = start_angle + t * total; - let x = cx + a.cos() * radius; - let y = cy + a.sin() * radius; - if i == 0 { - pb.move_to(x, y); - } else { - pb.line_to(x, y); - } - } - pb.finish() -} - -/// Clockwise arc that ENDS at 12 o'clock; the consumed wedge grows clockwise -/// from 12 as `remaining_fraction` shrinks, mirroring a clock-hand countdown. -fn build_remaining_arc( - cx: f32, - cy: f32, - radius: f32, - remaining_fraction: f32, -) -> Option { - let frac = remaining_fraction.clamp(0.0, 1.0); - let segments = ((frac * 64.0).ceil() as usize).max(1); - let mut pb = PathBuilder::new(); - let twelve: f32 = -std::f32::consts::FRAC_PI_2; - let total = frac * std::f32::consts::TAU; - let start_angle = twelve + (std::f32::consts::TAU - total); - for i in 0..=segments { - let t = i as f32 / segments as f32; - let a = start_angle + t * total; - let x = cx + a.cos() * radius; - let y = cy + a.sin() * radius; - if i == 0 { - pb.move_to(x, y); - } else { - pb.line_to(x, y); - } - } - pb.finish() -} - -/// Copy a premultiplied-RGBA `Pixmap` into the 32bpp BI_RGB DIB the bubble -/// uses for `UpdateLayeredWindow`. The DIB stores BGRA bytes (little-endian -/// `0xAARRGGBB` when read as u32); tiny-skia's premultiplied alpha is exactly -/// the format `AC_SRC_ALPHA` expects. -fn copy_pixmap_to_dib(pixmap: &Pixmap, dst: &mut [u32]) { - let src = pixmap.data(); - let pixel_count = (pixmap.width() * pixmap.height()) as usize; - for i in 0..pixel_count { - let r = src[i * 4]; - let g = src[i * 4 + 1]; - let b = src[i * 4 + 2]; - let a = src[i * 4 + 3]; - dst[i] = ((a as u32) << 24) | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32); - } -} - -/// Re-stamp the alpha byte of every DIB pixel from the source `Pixmap`. Used -/// after GDI text rendering, which writes RGB but leaves the BI_RGB DIB's -/// "reserved" alpha byte at zero — making glyph pixels appear transparent -/// when `UpdateLayeredWindow` composites with `AC_SRC_ALPHA`. -fn restore_alpha_from_pixmap(pixmap: &Pixmap, dst: &mut [u32]) { - let src = pixmap.data(); - let pixel_count = (pixmap.width() * pixmap.height()) as usize; - for i in 0..pixel_count { - let a = src[i * 4 + 3] as u32; - dst[i] = (dst[i] & 0x00FF_FFFF) | (a << 24); - } -} - -fn measure_text_w(hdc: HDC, text: &str, font_height_px: i32) -> i32 { - use windows::Win32::Foundation::SIZE; - let font_name = wide_str("Segoe UI"); - let mut w: Vec = text.encode_utf16().collect(); - unsafe { - let font = CreateFontW( - -font_height_px, - 0, - 0, - 0, - FW_NORMAL.0 as i32, - 0, - 0, - 0, - DEFAULT_CHARSET.0 as u32, - OUT_DEFAULT_PRECIS.0 as u32, - CLIP_DEFAULT_PRECIS.0 as u32, - CLEARTYPE_QUALITY.0 as u32, - (FF_SWISS.0 | DEFAULT_PITCH.0) as u32, - PCWSTR::from_raw(font_name.as_ptr()), + fn screen_for_falls_back_to_the_nearest_screen() { + let right = screen( + Rect::new(1920, 0, 3840, 1080), + Rect::new(1920, 0, 3840, 1040), + 96, ); - let old = SelectObject(hdc, font); - let mut size = SIZE::default(); - let _ = GetTextExtentPoint32W(hdc, &mut w, &mut size); - SelectObject(hdc, old); - let _ = DeleteObject(font); - size.cx - } -} - -struct PaintInputs { - model: ProviderId, - session_pct: Option, - session_text: String, - session_resets_at: Option, - weekly_pct: Option, - weekly_text: String, - weekly_resets_at: Option, - is_dark: bool, - pulse_phase: u32, -} - -fn render(hwnd: HWND) { - let (size_logical, dpi, inputs) = { - let bubbles = lock_bubbles(); - let Some(b) = bubbles.get(&(hwnd.0 as isize)) else { - return; - }; - ( - b.size_logical, - b.dpi, - PaintInputs { - model: b.model, - session_pct: b.session_pct, - session_text: b.session_text.clone(), - session_resets_at: b.session_resets_at, - weekly_pct: b.weekly_pct, - weekly_text: b.weekly_text.clone(), - weekly_resets_at: b.weekly_resets_at, - is_dark: b.is_dark, - pulse_phase: b.pulse_phase, - }, - ) - }; - - unsafe { - let screen_dc = GetDC(hwnd); - if screen_dc.is_invalid() { - return; - } - let mem_dc = CreateCompatibleDC(screen_dc); - if mem_dc.is_invalid() { - ReleaseDC(hwnd, screen_dc); - return; - } - let layout = compute_bubble_layout(size_logical, dpi, mem_dc); - - let bmi = BITMAPINFO { - bmiHeader: BITMAPINFOHEADER { - biSize: std::mem::size_of::() as u32, - biWidth: layout.canvas_w, - biHeight: -layout.canvas_h, - biPlanes: 1, - biBitCount: 32, - biCompression: 0, - ..Default::default() - }, - ..Default::default() - }; - let mut bits: *mut c_void = std::ptr::null_mut(); - let dib = - CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0).unwrap_or_default(); - if dib.is_invalid() || bits.is_null() { - let _ = DeleteDC(mem_dc); - ReleaseDC(hwnd, screen_dc); - return; - } - let old_bmp = SelectObject(mem_dc, dib); - - let pixel_count = (layout.canvas_w * layout.canvas_h) as usize; - let pixels = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count); - - // Paint shape via tiny-skia (AA), then copy into the DIB. GDI text - // overlays on top of the resulting bitmap. - let pixmap_opt = paint_bubble_pixmap(&layout, &inputs); - if let Some(ref pixmap) = pixmap_opt { - copy_pixmap_to_dib(pixmap, pixels); - } else { - pixels.fill(0); - } - paint_bubble_text(mem_dc, &layout, &inputs); - // GDI text writes RGB into the 32bpp BI_RGB DIB but does not preserve - // the alpha byte (per the BITMAPINFOHEADER contract: byte 3 is - // "reserved/0" for BI_RGB). UpdateLayeredWindow with AC_SRC_ALPHA then - // reads those zeroed alpha bytes and paints the glyph pixels as fully - // transparent — desktop bleeds through. Fix: re-stamp the alpha - // channel from the tiny-skia Pixmap we still have in scope. This - // preserves the AA alpha on the stadium's curved perimeter and forces - // glyph pixels back to the opacity tiny-skia computed for that - // location (255 in the interior, 0 outside). - if let Some(ref pixmap) = pixmap_opt { - restore_alpha_from_pixmap(pixmap, pixels); - } - - let mut wr = RECT::default(); - let _ = GetWindowRect(hwnd, &mut wr); - let pt_dst = POINT { - x: wr.left, - y: wr.top, - }; - let pt_src = POINT { x: 0, y: 0 }; - let sz = SIZE { - cx: layout.canvas_w, - cy: layout.canvas_h, - }; - let blend = BLENDFUNCTION { - BlendOp: 0, - BlendFlags: 0, - SourceConstantAlpha: 255, - AlphaFormat: 1, // AC_SRC_ALPHA - }; - let _ = UpdateLayeredWindow( - hwnd, - screen_dc, - Some(&pt_dst), - Some(&sz), - mem_dc, - Some(&pt_src), - COLORREF(0), - Some(&blend), - ULW_ALPHA, + let screens = [PRIMARY, right]; + assert_eq!( + screen_for(Rect::from_xywh(5000, 100, 200, 66), &screens), + Some(1) ); - - SelectObject(mem_dc, old_bmp); - let _ = DeleteObject(dib); - let _ = DeleteDC(mem_dc); - ReleaseDC(hwnd, screen_dc); + assert_eq!( + screen_for(Rect::from_xywh(-900, 2000, 200, 66), &screens), + Some(0) + ); + assert_eq!(screen_for(Rect::from_xywh(0, 0, 10, 10), &[]), None); } -} -/// Triangle wave in [0, 1] with period 24 ticks. 0 at phase=0,12; 1 at phase=6,18. -fn pulse_triangle(phase: u32) -> f64 { - let p = (phase % 24) as i32; - let dist = (p - 12).abs(); // 0..12 - 1.0 - (dist as f64 / 12.0) -} - -/// Linearly brighten `c` toward white by `t` in [0, 1]. -fn brighten(c: Color, t: f64) -> Color { - // Map t to a smaller brightness delta — the pulse should be a subtle nudge. - let t = t.clamp(0.0, 1.0) * 0.30; - Color::new( - ((c.r as f64) + (255.0 - c.r as f64) * t).round() as u8, - ((c.g as f64) + (255.0 - c.g as f64) * t).round() as u8, - ((c.b as f64) + (255.0 - c.b as f64) * t).round() as u8, - ) -} - -/// Paint the bubble's text overlay via GDI: primary countdown + big "%" -/// glyph in the head, weekly percent + weekly countdown on the tail. -fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) { - let text_color = if inputs.is_dark { - Color::from_hex("#EAEAEA") - } else { - Color::from_hex("#1F1F1F") - }; - let muted_color = if inputs.is_dark { - Color::from_hex("#A8A8A8") - } else { - Color::from_hex("#5E5E5E") - }; - - let font_name = wide_str("Segoe UI"); - unsafe { - // Big head percent uses FW_BOLD to anchor the eye against the ring. - // small_font is semibold because it carries both the "5H" window tag - // and the tail weekly-percent — both want a touch more weight than - // the countdown text rendered with main_font. - let big_font = create_font(layout.big_font_px, &font_name, FW_BOLD.0 as i32); - let small_font = create_font(layout.small_font_px, &font_name, FW_SEMIBOLD.0 as i32); - let main_font = create_font(layout.main_font_px, &font_name, FW_NORMAL.0 as i32); - SetBkMode(hdc, TRANSPARENT); - - let prev_font = SelectObject(hdc, small_font); - - // Head: 5h countdown text if available, otherwise the static "5h" tag. - // The ring already signals "this is the 5h window", so the countdown - // is the more useful glanceable info when we have it. Fall back to - // "5h" when the countdown would overflow the rect at the - // 140-logical minimum width — - // DT_NOCLIP would otherwise leak the glyphs onto the ring stroke. - SetTextColor(hdc, COLORREF(muted_color.into_colorref())); - let head_label_rect_w = layout.head_label_rect.right - layout.head_label_rect.left; - let head_label_text: &str = if inputs.session_text.is_empty() { - "5H" - } else if measure_text_w(hdc, &inputs.session_text, layout.small_font_px) - <= head_label_rect_w - { - inputs.session_text.as_str() - } else { - "5H" - }; - draw_text_in_rect(hdc, &layout.head_label_rect, head_label_text, DT_CENTER); - - // Head: big "X%" glyph centered. - SelectObject(hdc, big_font); - SetTextColor(hdc, COLORREF(text_color.into_colorref())); - let pct_text = match inputs.session_pct { - Some(p) => format!("{:.0}%", p), - None => String::from("—"), - }; - draw_text_in_rect(hdc, &layout.head_pct_rect, &pct_text, DT_CENTER); - - // Tail: weekly percent (foreground color, right of its usage bar). Skipped - // when the layout collapsed the rect at small widths. Foreground — - // not the accent color the bar uses — because Codex teal #10A37F on - // the light theme background only hits ~3.2:1 contrast, below WCAG - // AA for small text. Adjacency to the bar carries the visual - // grouping; we don't need hue to do it too. - SelectObject(hdc, small_font); - if let Some(pct) = inputs.weekly_pct { - if layout.tail_usage_pct_rect.right > layout.tail_usage_pct_rect.left { - let mut color = text_color; - if pct >= 95.0 { - let t = pulse_triangle(inputs.pulse_phase); - color = brighten(color, t); - } - SetTextColor(hdc, COLORREF(color.into_colorref())); - let weekly_pct_text = format!("{:.0}%", pct); - draw_tail_text_in_rect( - hdc, - &layout.tail_usage_pct_rect, - &weekly_pct_text, - DT_RIGHT, - ); + #[test] + fn default_placement_is_bottom_right_of_the_primary_work_area() { + let p = initial_placement(ProviderId::Claude, 200, None, &[PRIMARY], PRIMARY); + assert_eq!( + p, + Placement { + pos: Point::new(1920 - 200 - 24, 1040 - 66 - 24), + size: (200, 66), + dpi: 96, } - } - - // Tail: weekly countdown aligned with its true remaining-time bar. - // Muted color — the percent above is the headline; the countdown is - // supporting context and should not compete for visual weight. - SelectObject(hdc, main_font); - SetTextColor(hdc, COLORREF(muted_color.into_colorref())); - if !inputs.weekly_text.is_empty() { - draw_tail_text_in_rect( - hdc, - &layout.tail_time_text_rect, - &inputs.weekly_text, - DT_RIGHT, - ); - } - - SelectObject(hdc, prev_font); - let _ = DeleteObject(big_font); - let _ = DeleteObject(small_font); - let _ = DeleteObject(main_font); - } -} - -/// Draw `text` into `rect` with the given horizontal alignment flag, vertically -/// centered. The DT_NOCLIP flag preserves ascenders/descenders that would -/// otherwise be clipped by tight rects. -fn draw_text_in_rect(hdc: HDC, rect: &RECT, text: &str, halign: DRAW_TEXT_FORMAT) { - let mut buf = wide_str(text); - let len_no_nul = buf.len().saturating_sub(1); - let mut r = *rect; - unsafe { - let _ = DrawTextW( - hdc, - &mut buf[..len_no_nul], - &mut r, - halign | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP, ); + let codex = initial_placement(ProviderId::ChatGpt, 200, None, &[PRIMARY], PRIMARY); + assert_eq!(codex.pos, Point::new(1696, 1040 - 66 - 24 - 90)); } -} -fn draw_tail_text_in_rect(hdc: HDC, rect: &RECT, text: &str, halign: DRAW_TEXT_FORMAT) { - if rect.right <= rect.left { - return; - } - let mut buf = wide_str(text); - let len_no_nul = buf.len().saturating_sub(1); - let mut r = *rect; - unsafe { - let _ = DrawTextW( - hdc, - &mut buf[..len_no_nul], - &mut r, - halign | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS, + #[test] + fn saved_placement_takes_the_dpi_of_its_screen_and_is_clamped() { + let right = screen( + Rect::new(1920, 0, 3840, 1080), + Rect::new(1920, 0, 3840, 1040), + 144, ); - } -} - -fn create_font(height_px: i32, name_w: &[u16], weight: i32) -> HFONT { - unsafe { - CreateFontW( - -height_px, - 0, - 0, - 0, - weight, - 0, - 0, - 0, - DEFAULT_CHARSET.0 as u32, - OUT_DEFAULT_PRECIS.0 as u32, - CLIP_DEFAULT_PRECIS.0 as u32, - CLEARTYPE_QUALITY.0 as u32, - (FF_SWISS.0 | DEFAULT_PITCH.0) as u32, - PCWSTR::from_raw(name_w.as_ptr()), - ) - } -} - -// ---------- Helpers ---------- - -fn default_position(width_px: i32, height_px: i32, model: ProviderId) -> (i32, i32) { - // Bottom-right of primary work area, with a 24-pixel gap from the edges. - // Stagger the Codex bubble above the Claude one if both are enabled. - unsafe { - let monitor = MonitorFromPoint(POINT { x: 0, y: 0 }, MONITOR_DEFAULTTOPRIMARY); - let mut info = MONITORINFO { - cbSize: std::mem::size_of::() as u32, - ..Default::default() - }; - let wa = if GetMonitorInfoW(monitor, &mut info).as_bool() { - info.rcWork - } else { - RECT { - left: 0, - top: 0, - right: 1920, - bottom: 1080, + let screens = [PRIMARY, right]; + let p = initial_placement( + ProviderId::Claude, + 200, + Some((2500, 400)), + &screens, + PRIMARY, + ); + assert_eq!( + p, + Placement { + pos: Point::new(2500, 400), + size: (300, 99), + dpi: 144, } - }; - let gap = 24; - let stagger = match model { - ProviderId::Claude => 0, - ProviderId::ChatGpt => height_px + gap, - ProviderId::OpenCodeGo => 2 * (height_px + gap), - }; - let x = wa.right - width_px - gap; - let y = wa.bottom - height_px - gap - stagger; - (x, y) + ); + // Partly below the work area: pulled back in. + let low = initial_placement( + ProviderId::Claude, + 200, + Some((100, 1030)), + &screens, + PRIMARY, + ); + assert_eq!(low.pos, Point::new(100, 1040 - 66)); + } + + #[test] + fn saved_placement_on_no_screen_lands_on_the_nearest() { + let p = initial_placement( + ProviderId::Claude, + 200, + Some((5000, 5000)), + &[PRIMARY], + PRIMARY, + ); + assert_eq!(p.pos, Point::new(1720, 974)); + let codex = initial_placement( + ProviderId::ChatGpt, + 200, + Some((5000, 5000)), + &[PRIMARY], + PRIMARY, + ); + assert_eq!(codex.pos, Point::new(1720, 974 - 66 - 24)); + } + + #[test] + fn fallback_screen_is_a_plain_1080p_monitor() { + assert_eq!(FALLBACK.work, Rect::new(0, 0, 1920, 1080)); + assert_eq!(FALLBACK.dpi, 96); } } diff --git a/src/commands.rs b/src/commands.rs index a19c649..5bb3c04 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -3,11 +3,13 @@ use std::sync::Arc; -use tauri::{AppHandle, Manager, State, WebviewWindow}; +use tauri::{AppHandle, State, WebviewWindow}; +use crate::bubble; +use crate::core::geom::RESIZE_STEP_LOGICAL; use crate::core::i18n::LocaleStrings; use crate::core::payload::BubblePayload; -use crate::runtime::{Runtime, BUBBLE_LABEL_PREFIX}; +use crate::runtime::Runtime; use crate::usage::ProviderId; /// Longest JS log message kept, in characters. @@ -45,16 +47,43 @@ pub fn log_js(window: WebviewWindow, level: String, message: String) { } } -/// The bubble rendered its first state and can be shown. +/// The bubble rendered its first state and can be shown (unless hidden). #[tauri::command] pub fn bubble_ready(app: AppHandle, label: String) -> Result<(), String> { - if !label.starts_with(BUBBLE_LABEL_PREFIX) { - return Err("not a bubble window".into()); + bubble::mark_ready(&app, &label) +} + +/// A left click (a press released before the drag threshold) on a bubble. +#[tauri::command] +pub fn bubble_click(runtime: State<'_, Arc>, slug: String) -> Result<(), String> { + let provider = live_provider(&slug)?; + runtime.bubble_clicked(provider); + Ok(()) +} + +/// The page is about to start an OS drag of a bubble. +#[tauri::command] +pub fn bubble_drag_start(app: AppHandle, slug: String) -> Result<(), String> { + let provider = live_provider(&slug)?; + bubble::drag_start(&app, provider); + Ok(()) +} + +/// Ctrl+wheel on a bubble: `dir` 1 grows every bubble one step, -1 shrinks. +#[tauri::command] +pub fn bubble_resize_step(runtime: State<'_, Arc>, dir: i32) -> Result<(), String> { + let delta = resize_delta(dir)?; + runtime.resize_bubbles(delta); + Ok(()) +} + +/// The size change for a resize direction; only 1 and -1 are accepted. +fn resize_delta(dir: i32) -> Result { + match dir { + 1 => Ok(RESIZE_STEP_LOGICAL), + -1 => Ok(-RESIZE_STEP_LOGICAL), + _ => Err("resize direction must be 1 or -1".into()), } - let window = app - .get_webview_window(&label) - .ok_or_else(|| "no such bubble window".to_string())?; - window.show().map_err(|err| err.to_string()) } fn live_provider(slug: &str) -> Result { @@ -86,6 +115,15 @@ mod tests { assert!(live_provider("CLAUDE").is_err()); } + #[test] + fn resize_steps_are_one_step_either_way() { + assert_eq!(resize_delta(1), Ok(20)); + assert_eq!(resize_delta(-1), Ok(-20)); + assert!(resize_delta(0).is_err()); + assert!(resize_delta(2).is_err()); + assert!(resize_delta(i32::MIN).is_err()); + } + #[test] fn log_messages_stay_on_one_capped_line() { assert_eq!(sanitize_log_message("a\nb\r\tc"), "a b c"); diff --git a/src/core/mod.rs b/src/core/mod.rs index 7b716f9..1adb50e 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -9,3 +9,4 @@ pub mod proxy; pub mod settings; pub mod state; pub mod usage_color; +pub mod visibility; diff --git a/src/core/visibility.rs b/src/core/visibility.rs new file mode 100644 index 0000000..8244c86 --- /dev/null +++ b/src/core/visibility.rs @@ -0,0 +1,156 @@ +//! Bubble visibility: the user's show/hide choice and the fullscreen +//! auto-hide, combined into show and hide actions. +//! +//! A user show wins over fullscreen and shows at once; leaving fullscreen +//! shows the bubbles again only when the user has not hidden them. + +/// The two reasons a bubble can be hidden. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Visibility { + pub user_hidden: bool, + pub hidden_by_fullscreen: bool, +} + +impl Visibility { + /// The state at start-up, from the saved `widget_visible` setting. + pub fn from_widget_visible(widget_visible: bool) -> Self { + Self { + user_hidden: !widget_visible, + hidden_by_fullscreen: false, + } + } + + /// Whether a bubble created now should be shown. + pub fn shows(&self) -> bool { + !self.user_hidden && !self.hidden_by_fullscreen + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Event { + UserShow, + UserHide, + FullscreenOn, + FullscreenOff, +} + +/// What to do with every bubble after an event. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + Show, + Hide, +} + +/// Apply `event` to `state`. Returns the new state and the action for the +/// bubbles, `None` when they stay as they are. +pub fn next(state: Visibility, event: Event) -> (Visibility, Option) { + match event { + Event::UserShow => ( + Visibility { + user_hidden: false, + ..state + }, + Some(Action::Show), + ), + Event::UserHide => ( + Visibility { + user_hidden: true, + ..state + }, + Some(Action::Hide), + ), + Event::FullscreenOn if state.hidden_by_fullscreen => (state, None), + Event::FullscreenOn => ( + Visibility { + hidden_by_fullscreen: true, + ..state + }, + Some(Action::Hide), + ), + Event::FullscreenOff if !state.hidden_by_fullscreen => (state, None), + Event::FullscreenOff => ( + Visibility { + hidden_by_fullscreen: false, + ..state + }, + (!state.user_hidden).then_some(Action::Show), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHOWN: Visibility = Visibility { + user_hidden: false, + hidden_by_fullscreen: false, + }; + + fn state(user_hidden: bool, hidden_by_fullscreen: bool) -> Visibility { + Visibility { + user_hidden, + hidden_by_fullscreen, + } + } + + #[test] + fn user_show_shows_at_once_even_during_fullscreen() { + assert_eq!( + next(state(true, false), Event::UserShow), + (SHOWN, Some(Action::Show)) + ); + assert_eq!( + next(state(true, true), Event::UserShow), + (state(false, true), Some(Action::Show)) + ); + } + + #[test] + fn user_hide_hides() { + assert_eq!( + next(SHOWN, Event::UserHide), + (state(true, false), Some(Action::Hide)) + ); + assert_eq!( + next(state(false, true), Event::UserHide), + (state(true, true), Some(Action::Hide)) + ); + } + + #[test] + fn fullscreen_on_hides_once() { + assert_eq!( + next(SHOWN, Event::FullscreenOn), + (state(false, true), Some(Action::Hide)) + ); + assert_eq!( + next(state(true, false), Event::FullscreenOn), + (state(true, true), Some(Action::Hide)) + ); + assert_eq!( + next(state(false, true), Event::FullscreenOn), + (state(false, true), None) + ); + } + + #[test] + fn fullscreen_off_shows_only_when_the_user_has_not_hidden() { + assert_eq!( + next(state(false, true), Event::FullscreenOff), + (SHOWN, Some(Action::Show)) + ); + assert_eq!( + next(state(true, true), Event::FullscreenOff), + (state(true, false), None) + ); + assert_eq!(next(SHOWN, Event::FullscreenOff), (SHOWN, None)); + } + + #[test] + fn start_up_state_follows_the_saved_setting() { + assert!(Visibility::from_widget_visible(true).shows()); + assert!(!Visibility::from_widget_visible(false).shows()); + assert!(!state(false, true).shows()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3cf94d4..a57c4da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ //! that items wired up incrementally do not trip `dead_code`. pub mod badge; +pub mod bubble; pub mod cli; pub mod commands; pub mod core; diff --git a/src/os/mod.rs b/src/os/mod.rs deleted file mode 100644 index 81f16b5..0000000 --- a/src/os/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -// `os` namespace: thin, typed wrappers over the slice of Win32 we use. -// -// Each submodule covers one concern (color conversion, UTF-16 strings, -// DPI math). Nothing in here knows about -// the bubble UI or the polling loop — it's pure platform glue. - -pub mod color; -pub mod dpi; -pub mod string; - -pub use color::Rgb; -pub use string::to_utf16_nul; diff --git a/src/os/string.rs b/src/os/string.rs deleted file mode 100644 index e0d4982..0000000 --- a/src/os/string.rs +++ /dev/null @@ -1,10 +0,0 @@ -// UTF-16 conversion helpers. - -/// Encode a Rust `&str` as a NUL-terminated UTF-16 vector suitable for -/// passing to Win32 `PCWSTR`-typed parameters. -/// -/// The result lives as long as the returned `Vec`; callers must keep -/// the vector alive across the FFI call. -pub fn to_utf16_nul(s: &str) -> Vec { - s.encode_utf16().chain(std::iter::once(0)).collect() -} diff --git a/src/platform/linux.rs b/src/platform/linux.rs index db377dd..b82fb97 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,7 +1,12 @@ //! Linux-only helpers. +use std::ffi::OsString; use std::sync::OnceLock; +use x11rb::connection::Connection; +use x11rb::protocol::xproto::{ConnectionExt, KeyButMask, Window}; +use x11rb::rust_connection::RustConnection; + /// The AppIndicator libraries the tray backend loads, in its own order. The /// unversioned names come from the backend's `backcompat` feature (on by /// default) and are sometimes the only ones present in an AppImage. @@ -37,3 +42,90 @@ pub fn appindicator_available() -> bool { }) .is_some() } + +/// Whether GTK runs on X11, where windows can be placed: `GDK_BACKEND` +/// names x11 first, or it is unset outside a Wayland session. The runtime +/// sets `GDK_BACKEND=x11` under Wayland when Xwayland is available. +pub fn positioning_supported( + wayland_display: Option, + gdk_backend: Option, +) -> bool { + let set = |v: &Option| v.as_ref().is_some_and(|v| !v.is_empty()); + if set(&gdk_backend) { + let backend = gdk_backend.unwrap_or_default(); + return backend + .to_string_lossy() + .split(',') + .next() + .is_some_and(|first| first.trim() == "x11"); + } + !set(&wayland_display) +} + +/// One X connection for the process and its default root window. +struct X11 { + conn: RustConnection, + root: Window, +} + +/// The shared X connection, opened on first use; `None` without an X +/// server (then it is not retried). +fn x11() -> Option<&'static X11> { + static X11_CONNECTION: OnceLock> = OnceLock::new(); + X11_CONNECTION + .get_or_init(|| match x11rb::connect(None) { + Ok((conn, screen)) => { + let root = conn.setup().roots.get(screen)?.root; + Some(X11 { conn, root }) + } + Err(err) => { + log::info!("no X11 connection: {err}"); + None + } + }) + .as_ref() +} + +/// Whether mouse Button1 is held, from an X `QueryPointer`. False without +/// X11 or when the query fails. +pub fn button1_down() -> bool { + let Some(x11) = x11() else { + return false; + }; + let reply = x11 + .conn + .query_pointer(x11.root) + .map_err(|err| err.to_string()) + .and_then(|cookie| cookie.reply().map_err(|err| err.to_string())); + match reply { + Ok(reply) => reply.mask.contains(KeyButMask::BUTTON1), + Err(err) => { + log::debug!("X QueryPointer failed: {err}"); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn os(v: &str) -> Option { + Some(OsString::from(v)) + } + + #[test] + fn positioning_follows_the_gdk_backend() { + // Plain X11 session. + assert!(positioning_supported(None, None)); + assert!(positioning_supported(os(""), None)); + // Wayland with the x11 override (Xwayland). + assert!(positioning_supported(os("wayland-0"), os("x11"))); + assert!(positioning_supported(os("wayland-0"), os("x11,wayland"))); + // Native Wayland. + assert!(!positioning_supported(os("wayland-0"), None)); + assert!(!positioning_supported(os("wayland-0"), os("wayland"))); + assert!(!positioning_supported(os("wayland-0"), os("wayland,x11"))); + assert!(!positioning_supported(None, os("broadway"))); + } +} diff --git a/src/platform/macos.rs b/src/platform/macos.rs new file mode 100644 index 0000000..b2fafce --- /dev/null +++ b/src/platform/macos.rs @@ -0,0 +1,9 @@ +//! macOS-only helpers. The macOS build is a compile target for now; these +//! stubs keep the portable callers working until the platform is finished. + +/// Whether the primary mouse button is held. Always false for now, so a +/// drag ends 250 ms after the last move; `NSEvent.pressedMouseButtons` +/// would let a paused drag keep the debounce armed, as on X11. +pub fn primary_button_down() -> bool { + false +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index a557632..de5184a 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 = "macos")] +pub mod macos; #[cfg(windows)] pub mod windows; @@ -76,6 +78,40 @@ pub fn system_is_dark() -> bool { } } +/// Whether the app can place its windows. False only under a native +/// Wayland GTK backend (no Xwayland), where moves are ignored and positions +/// read back as `(0, 0)`, so snapping, clamping and saving are skipped. +pub fn positioning_supported() -> bool { + #[cfg(target_os = "linux")] + { + linux::positioning_supported( + std::env::var_os("WAYLAND_DISPLAY"), + std::env::var_os("GDK_BACKEND"), + ) + } + #[cfg(not(target_os = "linux"))] + { + true + } +} + +/// Whether the primary mouse button is held right now, where the platform +/// can tell; false otherwise. +pub fn primary_button_down() -> bool { + #[cfg(target_os = "linux")] + { + linux::button1_down() + } + #[cfg(target_os = "macos")] + { + macos::primary_button_down() + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + false + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/platform/windows.rs b/src/platform/windows.rs index be4abd9..9d9dfc7 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -6,13 +6,24 @@ use std::time::Duration; use webview2_com::Microsoft::Web::WebView2::Win32::GetAvailableCoreWebView2BrowserVersionString; use windows::core::{HSTRING, PCWSTR, PWSTR}; -use windows::Win32::Foundation::{CloseHandle, WAIT_OBJECT_0}; +use windows::Win32::Foundation::{ + CloseHandle, SetLastError, HWND, LPARAM, LRESULT, WAIT_OBJECT_0, WIN32_ERROR, WPARAM, +}; use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE}; -use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; +use windows::Win32::UI::Shell::{ + DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage, + SetCurrentProcessExplicitAppUserModelID, SetWindowSubclass, ABE_BOTTOM, ABE_LEFT, ABE_RIGHT, + ABE_TOP, ABM_GETTASKBARPOS, APPBARDATA, +}; use windows::Win32::UI::WindowsAndMessaging::{ - MessageBoxW, SetForegroundWindow, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, + GetWindowLongPtrW, MessageBoxW, SetForegroundWindow, SetWindowLongPtrW, SetWindowPos, + ShowWindow, GWL_EXSTYLE, MA_NOACTIVATE, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, + SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SW_HIDE, + SW_SHOWNOACTIVATE, WM_DISPLAYCHANGE, WM_EXITSIZEMOVE, WM_MOUSEACTIVATE, WM_NCDESTROY, + WM_SETTINGCHANGE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, }; +use crate::core::geom::{Edge, Rect}; use crate::core::proxy::{self, SystemProxy}; const INTERNET_SETTINGS_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"; @@ -159,6 +170,178 @@ fn quoted_path(path: &Path) -> String { format!("\"{}\"", path.display()) } +/// The window handle of `window`. +fn hwnd_of(window: &tauri::WebviewWindow) -> io::Result { + window + .hwnd() + .map_err(|err| io::Error::other(format!("no HWND for {}: {err}", window.label()))) +} + +/// Make a bubble a tool window that never activates: no Alt-Tab entry and +/// no focus taken on a click or a show. Run it before the first show. +pub fn apply_no_activate(window: &tauri::WebviewWindow) -> io::Result<()> { + let hwnd = hwnd_of(window)?; + let wanted = (WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW).0 as isize; + // SAFETY: `hwnd` is a live top-level window of this process. + let current = unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) }; + if current & wanted == wanted { + return Ok(()); + } + // SAFETY: as above. A zero return is only an error when the last error + // is set, so it is cleared first. + unsafe { SetLastError(WIN32_ERROR(0)) }; + let previous = unsafe { SetWindowLongPtrW(hwnd, GWL_EXSTYLE, current | wanted) }; + if previous == 0 { + let err = io::Error::last_os_error(); + if err.raw_os_error().unwrap_or(0) != 0 { + return Err(err); + } + } + // Extended style changes take effect after a frame change. + // SAFETY: as above; nothing moves, resizes or activates. + unsafe { + SetWindowPos( + hwnd, + None, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED, + ) + } + .map_err(io::Error::from) +} + +/// Show a bubble without activating it. Bubbles are shown and hidden only +/// through this and [`hide`], never through Tauri's `show`/`hide`. +pub fn show_no_activate(window: &tauri::WebviewWindow) -> io::Result<()> { + let hwnd = hwnd_of(window)?; + // SAFETY: `hwnd` is a live window of this process. The return value is + // the previous visibility, not an error. + let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) }; + Ok(()) +} + +/// Hide a bubble (see [`show_no_activate`]). +pub fn hide(window: &tauri::WebviewWindow) -> io::Result<()> { + let hwnd = hwnd_of(window)?; + // SAFETY: as in `show_no_activate`. + let _ = unsafe { ShowWindow(hwnd, SW_HIDE) }; + Ok(()) +} + +/// A bubble window message the app reacts to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BubbleHookEvent { + /// An OS move loop ended (`WM_EXITSIZEMOVE`): the user dropped the bubble. + MoveEnd, + /// A system setting changed (`WM_SETTINGCHANGE`): theme, taskbar, work area. + SettingChange, + /// The display layout or resolution changed (`WM_DISPLAYCHANGE`). + DisplayChange, +} + +type BubbleHookCallback = Box; + +/// Subclass id of the bubble hook ("BUBB"). +const BUBBLE_SUBCLASS_ID: usize = 0x4255_4242; + +/// Subclass a bubble window so `callback` hears about move ends, setting +/// changes and display changes. The callback runs inside the window +/// procedure on the main thread, after the default handling; it must not +/// block, and should hand any Tauri work to another thread. The hook also +/// answers `WM_MOUSEACTIVATE` with `MA_NOACTIVATE`, so a click never +/// activates the bubble. Must be called on the main thread (the window's +/// thread); the hook removes itself when the window is destroyed. +pub fn install_bubble_hook( + window: &tauri::WebviewWindow, + callback: impl Fn(BubbleHookEvent) + Send + 'static, +) -> io::Result<()> { + let hwnd = hwnd_of(window)?; + let boxed: Box = Box::new(Box::new(callback)); + let data = Box::into_raw(boxed); + // SAFETY: `hwnd` belongs to this thread; `data` stays valid until the + // hook frees it on WM_NCDESTROY. + let installed = unsafe { + SetWindowSubclass( + hwnd, + Some(bubble_subclass_proc), + BUBBLE_SUBCLASS_ID, + data as usize, + ) + }; + if installed.as_bool() { + Ok(()) + } else { + // SAFETY: the hook was not installed, so nothing else owns `data`. + drop(unsafe { Box::from_raw(data) }); + Err(io::Error::other(format!( + "SetWindowSubclass failed for {}", + window.label() + ))) + } +} + +unsafe extern "system" fn bubble_subclass_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + _id: usize, + data: usize, +) -> LRESULT { + match msg { + WM_MOUSEACTIVATE => return LRESULT(MA_NOACTIVATE as isize), + WM_NCDESTROY => { + // SAFETY: the hook was installed on this window with this proc + // and id; `data` is the box `install_bubble_hook` leaked, freed + // exactly once here after the hook is gone. + unsafe { + let _ = RemoveWindowSubclass(hwnd, Some(bubble_subclass_proc), BUBBLE_SUBCLASS_ID); + drop(Box::from_raw(data as *mut BubbleHookCallback)); + return DefSubclassProc(hwnd, msg, wparam, lparam); + } + } + _ => {} + } + // SAFETY: forwarding the message down the subclass chain. + let result = unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) }; + let event = match msg { + WM_EXITSIZEMOVE => Some(BubbleHookEvent::MoveEnd), + WM_SETTINGCHANGE => Some(BubbleHookEvent::SettingChange), + WM_DISPLAYCHANGE => Some(BubbleHookEvent::DisplayChange), + _ => None, + }; + if let Some(event) = event { + // SAFETY: `data` stays valid until WM_NCDESTROY, handled above. + let callback = unsafe { &*(data as *const BubbleHookCallback) }; + callback(event); + } + result +} + +/// The taskbar's docked edge and rect, when the shell reports one. +pub fn taskbar() -> Option<(Edge, Rect)> { + let mut data = APPBARDATA { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + // SAFETY: `data` is a correctly sized APPBARDATA. + if unsafe { SHAppBarMessage(ABM_GETTASKBARPOS, &mut data) } == 0 { + return None; + } + let edge = match data.uEdge { + ABE_LEFT => Edge::Left, + ABE_TOP => Edge::Top, + ABE_RIGHT => Edge::Right, + ABE_BOTTOM => Edge::Bottom, + _ => return None, + }; + let rc = data.rc; + Some((edge, Rect::new(rc.left, rc.top, rc.right, rc.bottom))) +} + /// Bring `window` to the foreground. A popup menu needs its owner in the /// foreground, or it does not dismiss on a click elsewhere. pub fn set_foreground(window: &tauri::WebviewWindow) { diff --git a/src/runtime.rs b/src/runtime.rs index d8d9225..35cf9de 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -15,9 +15,8 @@ use std::sync::mpsc::{self, Sender}; use std::sync::{Arc, Mutex, PoisonError}; use std::time::{Duration, Instant, SystemTime}; -use tauri::{ - AppHandle, Emitter, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, Window, WindowEvent, -}; +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager, RunEvent}; use tauri_plugin_notification::NotificationExt; use crate::cli::{self, CliArgs}; @@ -27,15 +26,19 @@ use crate::core::menu_model::MenuCommand; use crate::core::payload::{self, BubblePayload, Canvas}; use crate::core::settings::{self, Settings}; use crate::core::state::{Alert, PollOutcome, UpdateStatus, UsageState}; +use crate::core::visibility::{self, Visibility}; use crate::menu::{self, MenuCache, MenuSnapshot}; use crate::scheduler::{self, Action, Message, SchedulerHost}; use crate::tray::{self, Trays}; use crate::usage::{self, refresh, ProviderId, Registry}; -use crate::{diag, handoff, net, platform}; +use crate::{bubble, diag, handoff, net, platform}; /// Event carrying a [`BubblePayload`] to a bubble webview. pub const BUBBLE_UPDATE_EVENT: &str = "bubble:update"; +/// Event telling every webview the system theme changed. +pub const THEME_EVENT: &str = "theme"; + /// Label prefix of every bubble window. pub const BUBBLE_LABEL_PREFIX: &str = "bubble-"; @@ -64,6 +67,8 @@ pub struct AppState { pub settings: Settings, pub strings: LocaleStrings, pub is_dark: bool, + /// Why the bubbles are hidden, if they are. + pub visibility: Visibility, pub usage: UsageState, pub update_status: UpdateStatus, /// The "update applied" alert from `--updated-to`, shown once the @@ -111,6 +116,13 @@ impl UiBridge { } } +/// Payload of [`THEME_EVENT`]. +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ThemePayload { + is_dark: bool, +} + /// An owned copy of what the bubble payloads need. struct BubbleSnapshot { providers: Vec, @@ -199,6 +211,26 @@ impl Runtime { self.send(Message::RecomputeCountdown(countdown)); } + /// Send every bubble its payload, sized for its current window. + pub fn push_bubbles(&self) { + let snapshot = self.with_state(|s| BubbleSnapshot::of(s)); + self.ui.emit_bubble_updates(snapshot); + } + + /// Record the system theme. A change reaches every open webview at + /// once and is followed by fresh payloads, whose fills depend on it. + pub fn set_dark(&self, is_dark: bool) { + let changed = self.with_state(|s| std::mem::replace(&mut s.is_dark, is_dark) != is_dark); + if !changed { + return; + } + log::info!("theme is now {}", if is_dark { "dark" } else { "light" }); + if let Err(err) = self.ui.app.emit(THEME_EVENT, ThemePayload { is_dark }) { + log::warn!("emit {THEME_EVENT} failed: {err}"); + } + self.propagate(); + } + /// Bring the tray icons in line with state and rebuild their menus when /// the menu tree changed, or always with `force_menu` (after a menu /// click, whose native check mark may now be wrong). Runs on the main @@ -395,7 +427,7 @@ impl Runtime { } let enabled = self.update_settings(|s| s.toggle_live_provider(provider)); if enabled { - spawn_bubble_window(&self.ui.app, provider); + bubble::spawn_create(&self.ui.app, provider); } else if let Some(window) = self.ui.app.get_webview_window(&bubble_label(provider)) { if let Err(err) = window.destroy() { log::warn!( @@ -410,48 +442,80 @@ impl Runtime { /// Show or hide every bubble and remember the choice. pub fn toggle_widget(&self) { - let (visible, providers) = self.update_settings(|s| { - s.widget_visible = !s.widget_visible; - (s.widget_visible, enabled_live_providers(s)) + let event = if self.with_state(|s| s.visibility.user_hidden) { + visibility::Event::UserShow + } else { + visibility::Event::UserHide + }; + self.apply_visibility(event); + } + + /// Apply a visibility event to every bubble. A user show or hide is + /// also saved as `widget_visible`. + pub fn apply_visibility(&self, event: visibility::Event) { + let (action, saved) = self.with_state(|s| { + let (next, action) = visibility::next(s.visibility, event); + s.visibility = next; + let user_event = matches!( + event, + visibility::Event::UserShow | visibility::Event::UserHide + ); + let saved = user_event.then(|| { + s.settings.widget_visible = !next.user_hidden; + s.settings.clone() + }); + (action, saved) }); - for provider in providers { - let Some(window) = self.ui.app.get_webview_window(&bubble_label(provider)) else { - continue; - }; - let result = if visible { - window.show() - } else { - window.hide() - }; - if let Err(err) = result { - log::warn!("bubble for {} not toggled: {err}", provider.slug()); + if let Some(snapshot) = saved { + if let Err(err) = settings::save(&snapshot) { + log::warn!("settings save failed: {err}"); } } + if let Some(action) = action { + log::debug!("bubbles {action:?} after {event:?}"); + bubble::apply(&self.ui.app, action); + } } - /// Forget the saved positions, push the cached state and poll. + /// Forget the saved positions, move the bubbles to their default spots, + /// push the cached state and poll. fn reset_positions(self: &Arc) { self.update_settings(|s| s.bubble_positions.reset_all()); + bubble::move_all_to_defaults(&self.ui.app); self.propagate(); self.spawn_poll(); } - fn resize_bubbles(self: &Arc, delta: i32) { + /// Save where the user dropped a bubble. + pub fn save_bubble_position(&self, provider: ProviderId, pos: (i32, i32)) { + self.update_settings(|s| s.bubble_positions.set(provider, pos)); + } + + /// Grow or shrink the bubbles by `delta` logical px. + pub fn resize_bubbles(self: &Arc, delta: i32) { let current = self.with_state(|s| s.settings.bubble_size_logical); self.set_bubble_size(current.saturating_add(delta)); } - /// Clamp and store the bubble size; a no-op when it does not change. - fn set_bubble_size(self: &Arc, size_logical: i32) { + /// 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. + 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); if !changed { return; } self.update_settings(|s| s.bubble_size_logical = size); + bubble::resize_all(&self.ui.app, size); self.propagate(); } + /// A left click on a bubble. The details panel does not exist yet, so + /// the click is only logged. + pub fn bubble_clicked(&self, provider: ProviderId) { + log::debug!("bubble click on {}; no details panel yet", provider.slug()); + } + /// Re-format the countdown texts after time passed and push them out. fn refresh_countdowns(&self) { self.with_state(|s| s.usage.refresh_texts(&s.strings)); @@ -484,6 +548,11 @@ impl Runtime { } } + /// Whether the process is shutting down. + pub fn is_exiting(&self) -> bool { + self.exiting.load(Ordering::Acquire) + } + /// End the process. The scheduler stops from the `Exit` run event. pub fn exit(&self) { self.exiting.store(true, Ordering::Release); @@ -540,13 +609,18 @@ pub fn enabled_live_providers(settings: &Settings) -> Vec { .collect() } -/// The bubble canvas in physical px for a window scale factor. -pub fn bubble_canvas(size_logical: i32, scale_factor: f64) -> Canvas { - let dpi = if scale_factor.is_finite() && scale_factor > 0.0 { +/// The DPI for a window or monitor scale factor; 96 for a bad factor. +pub fn dpi_for_scale(scale_factor: f64) -> u32 { + if scale_factor.is_finite() && scale_factor > 0.0 { (scale_factor * f64::from(geom::BASE_DPI)).round() as u32 } else { geom::BASE_DPI - }; + } +} + +/// The bubble canvas in physical px for a window scale factor. +pub fn bubble_canvas(size_logical: i32, scale_factor: f64) -> Canvas { + let dpi = dpi_for_scale(scale_factor); let (w, h) = geom::bubble_size_px(size_logical, dpi); Canvas { w: u32::try_from(w).unwrap_or(1), @@ -630,9 +704,12 @@ pub fn run() { crate::commands::get_strings, crate::commands::log_js, crate::commands::bubble_ready, + crate::commands::bubble_click, + crate::commands::bubble_drag_start, + crate::commands::bubble_resize_step, crate::menu::show_context_menu, ]) - .on_window_event(on_window_event) + .on_window_event(bubble::on_window_event) .on_menu_event(menu::on_menu_event) .on_tray_icon_event(tray::on_tray_icon_event) .setup(move |app| setup(app, &cli)) @@ -667,35 +744,6 @@ pub fn run() { }); } -/// Bubbles ignore close requests (Alt+F4); they are only ever destroyed. -/// A bubble destroyed while its provider is enabled again (a fast off/on -/// toggle) is recreated once the old window is gone. -fn on_window_event(window: &Window, event: &WindowEvent) { - let Some(slug) = window.label().strip_prefix(BUBBLE_LABEL_PREFIX) else { - return; - }; - match event { - WindowEvent::CloseRequested { api, .. } => api.prevent_close(), - WindowEvent::Destroyed => { - let Some(provider) = ProviderId::from_slug(slug) else { - return; - }; - let Some(runtime) = window.try_state::>() else { - return; - }; - if runtime.exiting.load(Ordering::Acquire) { - return; - } - // The old window is already out of the manager here, so the - // existence check in `create_bubble_window` sees it gone. - if runtime.with_state(|s| s.settings.is_provider_enabled(provider)) { - spawn_bubble_window(window.app_handle(), provider); - } - } - _ => {} - } -} - fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box> { let mut settings = settings::load(); settings::drop_offscreen_positions(&mut settings, &monitor_rects(app.handle())); @@ -705,6 +753,7 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box> { let state = AppState { strings: I18n::load().strings().clone(), is_dark, + visibility: Visibility::from_widget_visible(settings.widget_visible), usage: UsageState::default(), update_status: UpdateStatus::Idle, pending_update_alert: cli @@ -750,7 +799,7 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box> { runtime.send(Message::PollNow); for provider in providers { - create_bubble_window(app.handle(), provider)?; + bubble::create(app.handle(), provider)?; } runtime.refresh_shell(true); runtime.deliver_pending_update_alert(); @@ -803,50 +852,6 @@ fn monitor_rects(app: &AppHandle) -> Vec { } } -/// A plain window for one provider that renders its payload as JSON, -/// standing in for the real bubble window. Does nothing while a window -/// with the label still exists; a destroyed one is recreated from its -/// `Destroyed` event. -/// Create a bubble from a short-lived thread. Building a webview window -/// inside a menu or window event handler deadlocks on Windows (WebView2), -/// while off the main thread the build is posted to the event loop. The -/// lock makes a toggle and a `Destroyed` recreate for the same provider -/// check and build one at a time, so only one of them creates the window. -/// The main thread never takes it. -fn spawn_bubble_window(app: &AppHandle, provider: ProviderId) { - static CREATE: Mutex<()> = Mutex::new(()); - let app = app.clone(); - let spawned = std::thread::Builder::new() - .name("bubble-create".into()) - .spawn(move || { - let _serial = CREATE.lock().unwrap_or_else(PoisonError::into_inner); - if let Err(err) = create_bubble_window(&app, provider) { - log::error!("bubble for {} could not be created: {err}", provider.slug()); - } - }); - if let Err(err) = spawned { - log::error!( - "bubble for {} not created, no thread: {err}", - provider.slug() - ); - } -} - -fn create_bubble_window(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> { - let label = bubble_label(provider); - if app.get_webview_window(&label).is_some() { - log::debug!("{label} still exists; not creating it again"); - return Ok(()); - } - WebviewWindowBuilder::new(app, label, WebviewUrl::App("bubble.html".into())) - .title("Claude Code Usage Bubble") - .inner_size(460.0, 420.0) - .always_on_top(true) - .skip_taskbar(true) - .build()?; - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -887,6 +892,8 @@ mod tests { assert_eq!(bubble_canvas(200, 1.25).dpi, 120); assert_eq!(bubble_canvas(200, f64::NAN).dpi, 96); assert_eq!(bubble_canvas(200, 0.0).dpi, 96); + assert_eq!(dpi_for_scale(1.75), 168); + assert_eq!(dpi_for_scale(-1.0), 96); } #[test] @@ -931,6 +938,7 @@ mod tests { settings: Settings::default(), strings: I18n::load().strings().clone(), is_dark: true, + visibility: Visibility::default(), usage: UsageState::default(), update_status: UpdateStatus::Idle, pending_update_alert: None,