fix(bubble): recover off-screen position from disconnected monitor

Saved bubble_positions could land on a secondary monitor that was later
disconnected, leaving the bubble created off-screen with no visual feedback
on toggle-show.

- settings::load now drops any position whose 140px probe rect intersects
  no connected monitor (MonitorFromRect + MONITOR_DEFAULTTONULL).
- bubble::create calls clamp_into_work_area before the first render as a
  defense-in-depth catch for partial overflows or load/create monitor races.
- clamp_into_work_area preserves the Codex-above-Claude stagger from
  default_position when both bubbles get clamped to the same corner.
- Added info/warn log lines on create + clamp paths so future visibility
  bugs are diagnosable via --diagnose.
This commit is contained in:
2026-05-18 09:43:27 +07:00
parent eca430ccc6
commit 3c0878f6cc
2 changed files with 75 additions and 7 deletions
+35 -7
View File
@@ -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,
+40
View File
@@ -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
}