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,