diff --git a/src/bubble.rs b/src/bubble.rs index a4154de..6144721 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -133,16 +133,16 @@ pub fn create(config: BubbleConfig) -> HWND { let initial_size_logical = config .size_logical .clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE); + let dpi_for_create = primary_dpi(); + 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(); - let dpi = primary_dpi(); - let width_px = scale_to_dpi(initial_size_logical, dpi); - let height_px = scale_to_dpi(bubble_height_logical(initial_size_logical), dpi); - let (x, y) = config - .position - .unwrap_or_else(|| default_position(width_px, height_px, config.model)); CreateWindowExW( WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_NOACTIVATE, PCWSTR::from_raw(class_w.as_ptr()), @@ -217,6 +217,16 @@ pub fn create(config: BubbleConfig) -> HWND { }, ); + 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); @@ -792,8 +802,26 @@ fn clamp_into_work_area(hwnd: HWND) { 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 ny = r.top.clamp(wa.top, (wa.bottom - h).max(wa.top)); + let mut ny = r.top.clamp(wa.top, (wa.bottom - h).max(wa.top)); + + // 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, TrayIconKind::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); + } + if nx != r.left || ny != r.top { + log::warn!( + "clamp_into_work_area moved bubble from ({}, {}) to ({nx}, {ny})", + r.left, + r.top + ); unsafe { let _ = SetWindowPos( hwnd, diff --git a/src/settings.rs b/src/settings.rs index e76150b..6ac6fe3 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,11 +1,18 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use windows::Win32::Foundation::RECT; +use windows::Win32::Graphics::Gdi::{MonitorFromRect, MONITOR_DEFAULTTONULL}; use crate::bubble::DEFAULT_BUBBLE_SIZE; use crate::usage::ProviderId; type TrayIconKind = ProviderId; +// 140px matches MIN_BUBBLE_SIZE — a saved top-left a few px past the work-area +// edge still passes the validator, but a position fully on a disconnected +// monitor (the bug we're guarding against) fails. +const POSITION_PROBE_PX: i32 = 140; + const APP_DIR_NAME: &str = "ClaudeCodeUsageBubble"; const SETTINGS_FILE: &str = "settings.json"; @@ -67,6 +74,37 @@ impl BubblePositions { self.claude = None; self.codex = None; } + + /// Drop any saved position whose top-left no longer falls on a connected + /// monitor. Guards against `bubble::create` placing the window on a + /// disconnected secondary monitor (where the user can't see or recover it). + pub fn validate(&mut self) { + if let Some((x, y)) = self.claude { + if !position_on_any_monitor(x, y) { + log::warn!("bubble position claude ({x},{y}) outside all monitors; resetting to default"); + self.claude = None; + } + } + if let Some((x, y)) = self.codex { + if !position_on_any_monitor(x, y) { + log::warn!("bubble position codex ({x},{y}) outside all monitors; resetting to default"); + self.codex = None; + } + } + } +} + +fn position_on_any_monitor(x: i32, y: i32) -> bool { + // MONITOR_DEFAULTTONULL returns a null HMONITOR when the rect intersects + // no connected monitor — exactly the signal we want. + let probe = RECT { + left: x, + top: y, + right: x + POSITION_PROBE_PX, + bottom: y + POSITION_PROBE_PX, + }; + let monitor = unsafe { MonitorFromRect(&probe, MONITOR_DEFAULTTONULL) }; + !monitor.is_invalid() } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -132,6 +170,8 @@ pub fn load() -> Settings { settings.bubble_size_logical = settings .bubble_size_logical .clamp(crate::bubble::MIN_BUBBLE_SIZE, crate::bubble::MAX_BUBBLE_SIZE); + // Drop positions on monitors that have since been disconnected. + settings.bubble_positions.validate(); settings }