mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-27 16:15:59 +00:00
fix: keep bubbles above other apps' always-on-top windows
Telegram toasts and similar topmost windows could sit over a bubble until the next minute's bring-back. On Windows, every fullscreen check now re-raises a shown bubble that another process's visible topmost window overlaps. The app's own panel and menus are ignored, and bubbles hidden for a fullscreen app stay hidden.
This commit is contained in:
1 parent
677feb2e42
commit
c7af9ed0ac
5 files changed
+89
-9
No files matched your search
@@ -45,8 +45,10 @@ self-updater are all written from scratch against the same public APIs
|
||||
expires and after an update was applied
|
||||
- Auto-hide when a fullscreen app is in the foreground (games, video,
|
||||
presentations) — reappears when you leave fullscreen
|
||||
- Every minute, shown bubbles are put back on top, so another
|
||||
always-on-top window can't keep them covered or hidden
|
||||
- Bubbles stay above other always-on-top windows, such as chat
|
||||
notifications: on Windows a covered bubble is raised again within about
|
||||
1.5 s, and on every platform shown bubbles are put back on top every
|
||||
minute, so nothing can keep them covered or hidden
|
||||
|
||||
## Who this is for
|
||||
|
||||
|
||||
@@ -514,6 +514,30 @@ pub fn bring_back(app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put back on top every shown bubble that another app's always-on-top
|
||||
/// window, such as a chat toast, now covers. Cheap enough to run on every
|
||||
/// fullscreen check; bubbles hidden for a fullscreen app are left alone.
|
||||
#[cfg(windows)]
|
||||
pub fn keep_on_top(app: &AppHandle) {
|
||||
let handle = app.clone();
|
||||
let queued = app.run_on_main_thread(move || {
|
||||
let shows = runtime_of(&handle).is_some_and(|r| r.with_state(|s| s.visibility.shows()));
|
||||
if !shows {
|
||||
return;
|
||||
}
|
||||
for (_, window) in windows(&handle) {
|
||||
match platform::windows::covered_by_other_topmost(&window) {
|
||||
Ok(true) => raise(&window),
|
||||
Ok(false) => {}
|
||||
Err(err) => log::warn!("{} on-top check failed: {err}", window.label()),
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(err) = queued {
|
||||
log::warn!("bubble on-top check could not reach the main thread: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn raise(window: &WebviewWindow) {
|
||||
#[cfg(windows)]
|
||||
let result = platform::windows::raise_topmost(window).map_err(|e| e.to_string());
|
||||
|
||||
+5
-1
@@ -4,7 +4,9 @@
|
||||
//! unless the user hid them in the meantime.
|
||||
//!
|
||||
//! The same thread brings the bubbles back every minute while they should
|
||||
//! show, in case another window covered or hid them.
|
||||
//! show, in case another window covered or hid them. On Windows it also
|
||||
//! re-raises, on every check, a bubble that another app's always-on-top
|
||||
//! window (a chat toast, say) has just covered.
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
@@ -28,6 +30,8 @@ pub fn spawn(runtime: Arc<Runtime>) -> io::Result<()> {
|
||||
let mut last_bring_back = Instant::now();
|
||||
while !runtime.is_exiting() {
|
||||
tick(&runtime);
|
||||
#[cfg(windows)]
|
||||
runtime.keep_bubbles_on_top();
|
||||
if last_bring_back.elapsed() >= BRING_BACK_INTERVAL {
|
||||
last_bring_back = Instant::now();
|
||||
runtime.bring_back_bubbles();
|
||||
|
||||
+50
-6
@@ -26,12 +26,13 @@ use windows::Win32::UI::Shell::{
|
||||
ABE_TOP, ABM_GETTASKBARPOS, APPBARDATA,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetClassNameW, GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId,
|
||||
IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow, SetWindowLongPtrW, SetWindowPos,
|
||||
ShowWindow, GWL_EXSTYLE, GWL_STYLE, HWND_TOPMOST, 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,
|
||||
GetClassNameW, GetForegroundWindow, GetWindow, GetWindowLongPtrW, GetWindowRect,
|
||||
GetWindowThreadProcessId, IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow,
|
||||
SetWindowLongPtrW, SetWindowPos, ShowWindow, GWL_EXSTYLE, GWL_STYLE, GW_HWNDPREV, HWND_TOPMOST,
|
||||
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, WS_EX_TOPMOST,
|
||||
};
|
||||
|
||||
use crate::core::geom::{self, Edge, Rect};
|
||||
@@ -313,6 +314,49 @@ pub fn raise_topmost(window: &tauri::WebviewWindow) -> io::Result<()> {
|
||||
.map_err(io::Error::from)
|
||||
}
|
||||
|
||||
/// Whether a visible always-on-top window of another process sits above
|
||||
/// `window` in the z-order and overlaps it, such as a chat app's toast.
|
||||
/// Our own windows (the panel, a context menu) never count, so raising a
|
||||
/// bubble over them is never asked for.
|
||||
pub fn covered_by_other_topmost(window: &tauri::WebviewWindow) -> io::Result<bool> {
|
||||
let hwnd = hwnd_of(window)?;
|
||||
let Some((ours, _)) = visible_window_bounds(hwnd) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let own_pid = std::process::id();
|
||||
// SAFETY: `hwnd` is a live window of this process; each step only reads
|
||||
// the z-order, and a window closing mid-walk ends it with an error.
|
||||
let mut above = unsafe { GetWindow(hwnd, GW_HWNDPREV) };
|
||||
// Topmost windows sit at the head of the z-order, so the walk is short;
|
||||
// the cap guards against a z-order that keeps changing under it.
|
||||
for _ in 0..MAX_Z_ORDER_WALK {
|
||||
let Ok(other) = above else {
|
||||
return Ok(false);
|
||||
};
|
||||
if covers(other, &ours, own_pid) {
|
||||
return Ok(true);
|
||||
}
|
||||
// SAFETY: as above.
|
||||
above = unsafe { GetWindow(other, GW_HWNDPREV) };
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
const MAX_Z_ORDER_WALK: usize = 1024;
|
||||
|
||||
fn covers(other: HWND, ours: &Rect, own_pid: u32) -> bool {
|
||||
// SAFETY: read-only queries; a stale handle yields 0 / false.
|
||||
let ex_style = unsafe { GetWindowLongPtrW(other, GWL_EXSTYLE) } as u32;
|
||||
if ex_style & WS_EX_TOPMOST.0 == 0
|
||||
|| unsafe { !IsWindowVisible(other).as_bool() }
|
||||
|| is_dwm_cloaked(other)
|
||||
|| window_process_id(other).is_none_or(|pid| pid == own_pid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
visible_window_bounds(other).is_some_and(|(theirs, _)| theirs.intersects(ours))
|
||||
}
|
||||
|
||||
/// Hide a bubble (see [`show_no_activate`]).
|
||||
pub fn hide(window: &tauri::WebviewWindow) -> io::Result<()> {
|
||||
let hwnd = hwnd_of(window)?;
|
||||
|
||||
@@ -512,6 +512,12 @@ impl Runtime {
|
||||
bubble::bring_back(&self.ui.app);
|
||||
}
|
||||
|
||||
/// Raise shown bubbles that another app's always-on-top window covers.
|
||||
#[cfg(windows)]
|
||||
pub fn keep_bubbles_on_top(&self) {
|
||||
bubble::keep_on_top(&self.ui.app);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
||||
Reference in new issue
Block a user