mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-22 14:16:31 +00:00
feat: build the bubble windows with snapping, no-activate show and visibility state
Each enabled live provider gets a transparent, undecorated, always-on-top bubble webview, created hidden at its exact physical size and its saved or default position, clamped into the work area. It is shown without taking focus once the page reports ready, or 1.5 s after creation. On Windows a subclass hook reports move ends, setting changes (theme recheck and reclamp) and display changes, answers WM_MOUSEACTIVATE with MA_NOACTIVATE, and bubbles are shown and hidden only through SW_SHOWNOACTIVATE and SW_HIDE. Elsewhere a 250 ms debounce armed by the page's drag start ends the drag; on X11 it waits while Button1 is held. Move end snaps once and saves the position; resize, clamp and reset only move the windows. Native Wayland skips positioning. User show/hide goes through a pure visibility state shared with the coming fullscreen auto-hide, and the saved widget_visible now applies at startup. A theme change reaches open bubbles. The unused os module is removed.
This commit is contained in:
1 parent
4da57949ee
commit
340464de58
12 files changed
+1452
-2104
No files matched your search
+814
-1968
File diff suppressed because it is too large.
Load diff
+47
-9
@@ -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<Runtime>>, 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<Runtime>>, 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<i32, String> {
|
||||
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<ProviderId, String> {
|
||||
@@ -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");
|
||||
|
||||
@@ -9,3 +9,4 @@ pub mod proxy;
|
||||
pub mod settings;
|
||||
pub mod state;
|
||||
pub mod usage_color;
|
||||
pub mod visibility;
|
||||
@@ -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<Action>) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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<u16>`; callers must keep
|
||||
/// the vector alive across the FFI call.
|
||||
pub fn to_utf16_nul(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
@@ -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<OsString>,
|
||||
gdk_backend: Option<OsString>,
|
||||
) -> bool {
|
||||
let set = |v: &Option<OsString>| 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<Option<X11>> = 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<OsString> {
|
||||
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")));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
+186
-3
@@ -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<HWND> {
|
||||
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<dyn Fn(BubbleHookEvent) + Send + 'static>;
|
||||
|
||||
/// 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<BubbleHookCallback> = 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::<APPBARDATA>() 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) {
|
||||
|
||||
+110
-102
@@ -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<ProviderId>,
|
||||
@@ -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>) {
|
||||
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<Self>, 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<Self>, 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<Self>, 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<Self>, 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<ProviderId> {
|
||||
.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::<Arc<Runtime>>() 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<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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<Rect> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
Reference in new issue
Block a user