mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-08-14 12:25:44 +00:00
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:
+35
-7
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user