mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-22 20:15:09 +00:00
Rename the crate, binary, product name, identifier, settings folder, log file, autostart entry, user agent, updater endpoint and release assets. Legacy Win32 clients still update: they fall back to the first .exe asset, and the legacy rollback workflow reads the package name from the ref it builds. Upgraded installs start with default settings.
941 lines
31 KiB
Rust
941 lines
31 KiB
Rust
//! Bubble windows: one transparent, undecorated, always-on-top webview per
|
|
//! enabled live provider, labelled `bubble-<slug>`.
|
|
//!
|
|
//! Rust owns the window: its exact physical size, its position, snapping
|
|
//! at the end of a drag, clamping into the work area, and showing it
|
|
//! without taking focus. The page (`ui/bubble.js`) only draws the payload
|
|
//! and reports clicks, drag starts, right-clicks and Ctrl+wheel.
|
|
//!
|
|
//! Window event handlers and the Windows hook hand their Tauri work to a
|
|
//! short-lived thread ([`defer`]), so it runs after the event that caused
|
|
//! it (after tao applied its own DPI resize, for one) and never inside a
|
|
//! window procedure. No function here holds the state lock across a Tauri
|
|
//! call.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex, PoisonError};
|
|
use std::time::Duration;
|
|
#[cfg(not(windows))]
|
|
use std::time::Instant;
|
|
|
|
use tauri::{
|
|
AppHandle, Manager, Monitor, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow,
|
|
WebviewWindowBuilder, Window, WindowEvent,
|
|
};
|
|
|
|
use crate::core::geom::{self, Point, Rect, SnapInput};
|
|
use crate::core::visibility::Action;
|
|
use crate::platform;
|
|
use crate::runtime::{self, Runtime, BUBBLE_LABEL_PREFIX};
|
|
use crate::usage::ProviderId;
|
|
|
|
/// How long a new bubble waits for `bubble_ready` before it is shown
|
|
/// anyway; a hidden webview may throttle the page that would send it.
|
|
const READY_FALLBACK: Duration = Duration::from_millis(1500);
|
|
|
|
/// Quiet time after the last `Moved` event that counts as the end of a
|
|
/// drag where the OS reports no move-loop end.
|
|
#[cfg(not(windows))]
|
|
const MOVE_END_DEBOUNCE: Duration = Duration::from_millis(250);
|
|
|
|
const TITLE: &str = "Usage Monitor";
|
|
|
|
/// Work area used when no monitor can be enumerated.
|
|
const FALLBACK_SCREEN: Rect = Rect::new(0, 0, 1920, 1080);
|
|
|
|
/// A monitor in physical px.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
struct Screen {
|
|
bounds: Rect,
|
|
work: Rect,
|
|
dpi: u32,
|
|
}
|
|
|
|
impl Screen {
|
|
fn of(monitor: &Monitor) -> Self {
|
|
let pos = monitor.position();
|
|
let size = monitor.size();
|
|
let work = monitor.work_area();
|
|
Self {
|
|
bounds: Rect::from_xywh(pos.x, pos.y, clamp_u32(size.width), clamp_u32(size.height)),
|
|
work: Rect::from_xywh(
|
|
work.position.x,
|
|
work.position.y,
|
|
clamp_u32(work.size.width),
|
|
clamp_u32(work.size.height),
|
|
),
|
|
dpi: runtime::dpi_for_scale(monitor.scale_factor()),
|
|
}
|
|
}
|
|
}
|
|
|
|
const FALLBACK: Screen = Screen {
|
|
bounds: FALLBACK_SCREEN,
|
|
work: FALLBACK_SCREEN,
|
|
dpi: geom::BASE_DPI,
|
|
};
|
|
|
|
fn clamp_u32(v: u32) -> i32 {
|
|
i32::try_from(v).unwrap_or(i32::MAX)
|
|
}
|
|
|
|
/// Where a new bubble goes: its top-left, its physical size and the DPI it
|
|
/// was sized for.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
struct Placement {
|
|
pos: Point,
|
|
size: (i32, i32),
|
|
dpi: u32,
|
|
}
|
|
|
|
/// Index of the screen that holds most of `rect`; when none overlaps it,
|
|
/// the screen whose bounds are nearest to its centre.
|
|
fn screen_for(rect: Rect, screens: &[Screen]) -> Option<usize> {
|
|
let overlap = |s: &Screen| -> i64 {
|
|
let w = (rect.r.min(s.bounds.r) - rect.l.max(s.bounds.l)).max(0);
|
|
let h = (rect.b.min(s.bounds.b) - rect.t.max(s.bounds.t)).max(0);
|
|
i64::from(w) * i64::from(h)
|
|
};
|
|
let (cx, cy) = (
|
|
i64::from(rect.l) + i64::from(rect.w()) / 2,
|
|
i64::from(rect.t) + i64::from(rect.h()) / 2,
|
|
);
|
|
let distance = |s: &Screen| -> i64 {
|
|
let dx = (i64::from(s.bounds.l) - cx)
|
|
.max(cx - i64::from(s.bounds.r))
|
|
.max(0);
|
|
let dy = (i64::from(s.bounds.t) - cy)
|
|
.max(cy - i64::from(s.bounds.b))
|
|
.max(0);
|
|
dx * dx + dy * dy
|
|
};
|
|
let best_overlap = screens
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, s)| (i, overlap(s)))
|
|
.filter(|(_, area)| *area > 0)
|
|
.max_by_key(|(i, area)| (*area, std::cmp::Reverse(*i)))
|
|
.map(|(i, _)| i);
|
|
best_overlap.or_else(|| {
|
|
screens
|
|
.iter()
|
|
.enumerate()
|
|
.min_by_key(|(_, s)| distance(s))
|
|
.map(|(i, _)| i)
|
|
})
|
|
}
|
|
|
|
/// Place a new bubble: at its saved top-left on the screen it overlaps
|
|
/// most, otherwise at the default spot on the primary screen; sized for
|
|
/// that screen's DPI and clamped into its work area.
|
|
fn initial_placement(
|
|
provider: ProviderId,
|
|
size_logical: i32,
|
|
saved: Option<(i32, i32)>,
|
|
screens: &[Screen],
|
|
primary: Screen,
|
|
) -> Placement {
|
|
let (screen, pos) = match saved {
|
|
Some((x, y)) => {
|
|
let (w, h) = geom::bubble_size_px(size_logical, primary.dpi);
|
|
let probe = Rect::from_xywh(x, y, w, h);
|
|
let screen = screen_for(probe, screens).map_or(primary, |i| screens[i]);
|
|
(screen, Point::new(x, y))
|
|
}
|
|
None => {
|
|
let (w, h) = geom::bubble_size_px(size_logical, primary.dpi);
|
|
(
|
|
primary,
|
|
geom::default_position(primary.work, w, h, provider),
|
|
)
|
|
}
|
|
};
|
|
let size = geom::bubble_size_px(size_logical, screen.dpi);
|
|
let rect = Rect::from_xywh(pos.x, pos.y, size.0, size.1);
|
|
Placement {
|
|
pos: geom::clamp_into_work_area(rect, screen.work, provider),
|
|
size,
|
|
dpi: screen.dpi,
|
|
}
|
|
}
|
|
|
|
fn runtime_of(app: &AppHandle) -> Option<Arc<Runtime>> {
|
|
app.try_state::<Arc<Runtime>>().map(|r| Arc::clone(&r))
|
|
}
|
|
|
|
/// Run `f` on a short-lived thread (see the module docs).
|
|
fn defer(name: &str, f: impl FnOnce() + Send + 'static) {
|
|
if let Err(err) = std::thread::Builder::new().name(name.into()).spawn(f) {
|
|
log::error!("{name} thread could not start: {err}");
|
|
}
|
|
}
|
|
|
|
fn screens(app: &AppHandle) -> Vec<Screen> {
|
|
match app.available_monitors() {
|
|
Ok(monitors) => monitors.iter().map(Screen::of).collect(),
|
|
Err(err) => {
|
|
log::warn!("monitors could not be enumerated: {err}");
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The primary screen, else the first one, else a 1920x1080 stand-in.
|
|
fn primary_screen(app: &AppHandle, screens: &[Screen]) -> Screen {
|
|
match app.primary_monitor() {
|
|
Ok(Some(monitor)) => Screen::of(&monitor),
|
|
Ok(None) => screens.first().copied().unwrap_or(FALLBACK),
|
|
Err(err) => {
|
|
log::warn!("primary monitor unavailable: {err}");
|
|
screens.first().copied().unwrap_or(FALLBACK)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The screen a window is on (Win32 picks the nearest), else the primary.
|
|
fn screen_of(window: &WebviewWindow) -> Screen {
|
|
match window.current_monitor() {
|
|
Ok(Some(monitor)) => Screen::of(&monitor),
|
|
Ok(None) => {
|
|
let app = window.app_handle();
|
|
primary_screen(app, &screens(app))
|
|
}
|
|
Err(err) => {
|
|
log::warn!("monitor of {} unavailable: {err}", window.label());
|
|
let app = window.app_handle();
|
|
primary_screen(app, &screens(app))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn outer_rect(window: &WebviewWindow) -> Option<Rect> {
|
|
let pos = window.outer_position();
|
|
let size = window.outer_size();
|
|
match (pos, size) {
|
|
(Ok(pos), Ok(size)) => Some(Rect::from_xywh(
|
|
pos.x,
|
|
pos.y,
|
|
clamp_u32(size.width),
|
|
clamp_u32(size.height),
|
|
)),
|
|
(Err(err), _) | (_, Err(err)) => {
|
|
log::warn!("bounds of {} unavailable: {err}", window.label());
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn dpi_of(window: &WebviewWindow) -> u32 {
|
|
match window.scale_factor() {
|
|
Ok(scale) => runtime::dpi_for_scale(scale),
|
|
Err(err) => {
|
|
log::warn!("scale factor of {} unavailable: {err}", window.label());
|
|
geom::BASE_DPI
|
|
}
|
|
}
|
|
}
|
|
|
|
fn set_position(window: &WebviewWindow, pos: Point) {
|
|
if let Err(err) = window.set_position(PhysicalPosition::new(pos.x, pos.y)) {
|
|
log::warn!("{} not moved: {err}", window.label());
|
|
}
|
|
}
|
|
|
|
fn set_size(window: &WebviewWindow, (w, h): (i32, i32)) {
|
|
let size = PhysicalSize::new(w.max(1).unsigned_abs(), h.max(1).unsigned_abs());
|
|
if let Err(err) = window.set_size(size) {
|
|
log::warn!("{} not resized: {err}", window.label());
|
|
}
|
|
}
|
|
|
|
/// Bubble windows that exist, with their provider, in display order.
|
|
fn windows(app: &AppHandle) -> Vec<(ProviderId, WebviewWindow)> {
|
|
ProviderId::LIVE_USAGE
|
|
.into_iter()
|
|
.filter_map(|p| {
|
|
app.get_webview_window(&runtime::bubble_label(p))
|
|
.map(|w| (p, w))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// ---------- Creation, ready and show ----------
|
|
|
|
/// Creation number of each bubble still waiting for `bubble_ready`. The
|
|
/// number keeps a fallback timer of a destroyed window from showing a new
|
|
/// window with the same label.
|
|
static PENDING_READY: Mutex<Option<HashMap<String, u64>>> = Mutex::new(None);
|
|
static CREATIONS: AtomicU64 = AtomicU64::new(0);
|
|
|
|
fn with_pending<T>(f: impl FnOnce(&mut HashMap<String, u64>) -> T) -> T {
|
|
let mut guard = PENDING_READY.lock().unwrap_or_else(PoisonError::into_inner);
|
|
f(guard.get_or_insert_with(HashMap::new))
|
|
}
|
|
|
|
/// 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.
|
|
pub fn spawn_create(app: &AppHandle, provider: ProviderId) {
|
|
static CREATE: Mutex<()> = Mutex::new(());
|
|
let app = app.clone();
|
|
defer("bubble-create", move || {
|
|
let _serial = CREATE.lock().unwrap_or_else(PoisonError::into_inner);
|
|
if let Err(err) = create(&app, provider) {
|
|
log::error!("bubble for {} could not be created: {err}", provider.slug());
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Create `provider`'s bubble, hidden, at its exact physical size and
|
|
/// position. Does nothing while a window with the label still exists; a
|
|
/// destroyed one is recreated from its `Destroyed` event. The page shows it
|
|
/// through `bubble_ready`, or [`READY_FALLBACK`] after creation.
|
|
pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
|
|
let label = runtime::bubble_label(provider);
|
|
if app.get_webview_window(&label).is_some() {
|
|
log::debug!("{label} still exists; not creating it again");
|
|
return Ok(());
|
|
}
|
|
let Some(runtime) = runtime_of(app) else {
|
|
log::warn!("{label} not created: the runtime is not ready");
|
|
return Ok(());
|
|
};
|
|
let (size_logical, saved) = runtime.with_state(|s| {
|
|
(
|
|
s.settings.bubble_size_logical,
|
|
s.settings.bubble_positions.get(provider),
|
|
)
|
|
});
|
|
let all = screens(app);
|
|
let primary = primary_screen(app, &all);
|
|
let placement = initial_placement(provider, size_logical, saved, &all, primary);
|
|
let scale = f64::from(placement.dpi) / f64::from(geom::BASE_DPI);
|
|
|
|
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::App("bubble.html".into()))
|
|
.title(TITLE)
|
|
.transparent(true)
|
|
.decorations(false)
|
|
.shadow(false)
|
|
.always_on_top(true)
|
|
.skip_taskbar(true)
|
|
.resizable(false)
|
|
.maximizable(false)
|
|
.minimizable(false)
|
|
.focused(false)
|
|
.focusable(false)
|
|
.visible(false)
|
|
// Close to the final size; the exact physical size is set below.
|
|
.inner_size(
|
|
f64::from(placement.size.0) / scale,
|
|
f64::from(placement.size.1) / scale,
|
|
)
|
|
.build()?;
|
|
if platform::positioning_supported() {
|
|
set_position(&window, placement.pos);
|
|
}
|
|
set_size(&window, placement.size);
|
|
log::info!(
|
|
"{label} created at ({}, {}) size {}x{} dpi {}",
|
|
placement.pos.x,
|
|
placement.pos.y,
|
|
placement.size.0,
|
|
placement.size.1,
|
|
placement.dpi
|
|
);
|
|
|
|
prepare_native(app, &window, provider);
|
|
#[cfg(target_os = "linux")]
|
|
if let Some(is_dark) = platform::linux::portal_prefers_dark() {
|
|
runtime.set_dark(is_dark);
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
match window.theme() {
|
|
Ok(theme) => runtime.set_dark(theme != tauri::Theme::Light),
|
|
Err(err) => log::debug!("theme of {label} unavailable: {err}"),
|
|
}
|
|
|
|
let creation = CREATIONS.fetch_add(1, Ordering::Relaxed);
|
|
with_pending(|p| p.insert(label.clone(), creation));
|
|
let app = app.clone();
|
|
defer("bubble-ready-fallback", move || {
|
|
std::thread::sleep(READY_FALLBACK);
|
|
let still_pending = with_pending(|p| {
|
|
if p.get(&label) == Some(&creation) {
|
|
p.remove(&label);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
});
|
|
if still_pending {
|
|
log::warn!("{label} sent no bubble_ready; showing it anyway");
|
|
show_if_visible(&app, &label);
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// Windows: make the bubble a non-activating tool window and hook its
|
|
/// move-end, setting-change and display-change messages. Both must run on
|
|
/// the window's own (main) thread before the first show.
|
|
#[cfg(windows)]
|
|
fn prepare_native(app: &AppHandle, window: &WebviewWindow, provider: ProviderId) {
|
|
let window = window.clone();
|
|
let hook_app = app.clone();
|
|
let queued = app.run_on_main_thread(move || {
|
|
if let Err(err) = platform::windows::apply_no_activate(&window) {
|
|
log::warn!("{} may take focus: {err}", window.label());
|
|
}
|
|
let hooked = platform::windows::install_bubble_hook(&window, move |event| {
|
|
on_hook_event(&hook_app, provider, event);
|
|
});
|
|
if let Err(err) = hooked {
|
|
log::warn!("{} has no move-end hook: {err}", window.label());
|
|
}
|
|
});
|
|
if let Err(err) = queued {
|
|
log::warn!("bubble set-up could not reach the main thread: {err}");
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn prepare_native(_app: &AppHandle, _window: &WebviewWindow, _provider: ProviderId) {}
|
|
|
|
#[cfg(windows)]
|
|
fn on_hook_event(app: &AppHandle, provider: ProviderId, event: platform::windows::BubbleHookEvent) {
|
|
use platform::windows::BubbleHookEvent;
|
|
let app = app.clone();
|
|
defer("bubble-hook", move || match event {
|
|
BubbleHookEvent::MoveEnd => move_end(&app, provider),
|
|
BubbleHookEvent::SettingChange => {
|
|
if let Some(runtime) = runtime_of(&app) {
|
|
runtime.set_dark(platform::windows::is_dark());
|
|
}
|
|
reclamp(&app, provider);
|
|
}
|
|
BubbleHookEvent::DisplayChange => reclamp(&app, provider),
|
|
});
|
|
}
|
|
|
|
/// The page applied its first state: show the bubble unless it is hidden.
|
|
pub fn mark_ready(app: &AppHandle, label: &str) -> Result<(), String> {
|
|
let provider = label
|
|
.strip_prefix(BUBBLE_LABEL_PREFIX)
|
|
.and_then(ProviderId::from_slug)
|
|
.filter(|p| p.metadata().live_usage)
|
|
.ok_or_else(|| "not a bubble window".to_string())?;
|
|
if app.get_webview_window(label).is_none() {
|
|
return Err("no such bubble window".into());
|
|
}
|
|
with_pending(|p| p.remove(label));
|
|
log::debug!("{} ready", provider.slug());
|
|
show_if_visible(app, label);
|
|
Ok(())
|
|
}
|
|
|
|
/// Show one bubble when the visibility state allows it.
|
|
fn show_if_visible(app: &AppHandle, label: &str) {
|
|
let Some(runtime) = runtime_of(app) else {
|
|
return;
|
|
};
|
|
if !runtime.with_state(|s| s.visibility.shows()) {
|
|
return;
|
|
}
|
|
let app = app.clone();
|
|
let label = label.to_string();
|
|
let queued = app.clone().run_on_main_thread(move || {
|
|
if let Some(window) = app.get_webview_window(&label) {
|
|
show(&window);
|
|
}
|
|
});
|
|
if let Err(err) = queued {
|
|
log::warn!("bubble show could not reach the main thread: {err}");
|
|
}
|
|
}
|
|
|
|
/// Show or hide every bubble. On Windows this uses the raw no-activate
|
|
/// show and plain hide, never Tauri's, for both directions.
|
|
pub fn apply(app: &AppHandle, action: Action) {
|
|
let handle = app.clone();
|
|
let queued = app.run_on_main_thread(move || {
|
|
for (_, window) in windows(&handle) {
|
|
match action {
|
|
Action::Show => show(&window),
|
|
Action::Hide => hide(&window),
|
|
}
|
|
}
|
|
});
|
|
if let Err(err) = queued {
|
|
log::warn!("bubble visibility could not reach the main thread: {err}");
|
|
}
|
|
}
|
|
|
|
fn show(window: &WebviewWindow) {
|
|
#[cfg(windows)]
|
|
let result = platform::windows::show_no_activate(window).map_err(|e| e.to_string());
|
|
#[cfg(not(windows))]
|
|
let result = window.show().map_err(|e| e.to_string());
|
|
if let Err(err) = result {
|
|
log::warn!("{} not shown: {err}", window.label());
|
|
}
|
|
}
|
|
|
|
fn hide(window: &WebviewWindow) {
|
|
#[cfg(windows)]
|
|
let result = platform::windows::hide(window).map_err(|e| e.to_string());
|
|
#[cfg(not(windows))]
|
|
let result = window.hide().map_err(|e| e.to_string());
|
|
if let Err(err) = result {
|
|
log::warn!("{} not hidden: {err}", window.label());
|
|
}
|
|
}
|
|
|
|
// ---------- Window events ----------
|
|
|
|
/// Handle a bubble's window events; other windows are ignored.
|
|
pub fn on_window_event(window: &Window, event: &WindowEvent) {
|
|
let label = window.label();
|
|
let Some(provider) = label
|
|
.strip_prefix(BUBBLE_LABEL_PREFIX)
|
|
.and_then(ProviderId::from_slug)
|
|
else {
|
|
return;
|
|
};
|
|
match event {
|
|
// Bubbles ignore close requests (Alt+F4); they are only destroyed.
|
|
WindowEvent::CloseRequested { api, .. } => api.prevent_close(),
|
|
WindowEvent::Destroyed => on_destroyed(window, provider),
|
|
WindowEvent::ScaleFactorChanged { .. } => {
|
|
let app = window.app_handle().clone();
|
|
defer("bubble-dpi", move || fit_to_dpi(&app, provider));
|
|
}
|
|
#[cfg(not(windows))]
|
|
WindowEvent::Moved(_) => drag::moved(label),
|
|
// Windows reads the theme from the registry on WM_SETTINGCHANGE;
|
|
// tao's theme follows the apps setting, not the system one.
|
|
#[cfg(target_os = "macos")]
|
|
WindowEvent::ThemeChanged(theme) => {
|
|
let is_dark = *theme != tauri::Theme::Light;
|
|
let app = window.app_handle().clone();
|
|
defer("bubble-theme", move || {
|
|
if let Some(runtime) = runtime_of(&app) {
|
|
runtime.set_dark(is_dark);
|
|
}
|
|
});
|
|
}
|
|
// tao reports "no preference" and an unreachable portal as light, so
|
|
// the portal is read again and only a stated preference applies.
|
|
#[cfg(target_os = "linux")]
|
|
WindowEvent::ThemeChanged(_) => {
|
|
let app = window.app_handle().clone();
|
|
defer("bubble-theme", move || {
|
|
let Some(is_dark) = platform::linux::portal_prefers_dark() else {
|
|
return;
|
|
};
|
|
if let Some(runtime) = runtime_of(&app) {
|
|
runtime.set_dark(is_dark);
|
|
}
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// A bubble destroyed while its provider is enabled again (a fast off/on
|
|
/// toggle) is recreated once the old window is gone.
|
|
fn on_destroyed(window: &Window, provider: ProviderId) {
|
|
with_pending(|p| p.remove(window.label()));
|
|
#[cfg(not(windows))]
|
|
drag::disarm(window.label());
|
|
let Some(runtime) = runtime_of(window.app_handle()) else {
|
|
return;
|
|
};
|
|
if runtime.is_exiting() {
|
|
return;
|
|
}
|
|
// The old window is already out of the manager here, so the existence
|
|
// check in `create` sees it gone.
|
|
if runtime.with_state(|s| s.settings.is_provider_enabled(provider)) {
|
|
spawn_create(window.app_handle(), provider);
|
|
}
|
|
}
|
|
|
|
/// Size a bubble for its current DPI and send it a fresh payload.
|
|
fn fit_to_dpi(app: &AppHandle, provider: ProviderId) {
|
|
let Some(window) = app.get_webview_window(&runtime::bubble_label(provider)) else {
|
|
return;
|
|
};
|
|
let Some(runtime) = runtime_of(app) else {
|
|
return;
|
|
};
|
|
let size_logical = runtime.with_state(|s| s.settings.bubble_size_logical);
|
|
let wanted = geom::bubble_size_px(size_logical, dpi_of(&window));
|
|
if outer_rect(&window).is_some_and(|r| (r.w(), r.h()) != wanted) {
|
|
set_size(&window, wanted);
|
|
}
|
|
runtime.push_bubbles();
|
|
}
|
|
|
|
// ---------- Drag, snap and clamp ----------
|
|
|
|
/// A drag started on `provider`'s bubble. On Windows the move-loop hook
|
|
/// reports its end, so nothing is armed.
|
|
pub fn drag_start(app: &AppHandle, provider: ProviderId) {
|
|
#[cfg(windows)]
|
|
let _ = (app, provider);
|
|
#[cfg(not(windows))]
|
|
drag::arm(app, provider);
|
|
}
|
|
|
|
/// The user dropped `provider`'s bubble: snap it, then save where it ended.
|
|
/// Programmatic moves never get here, so they neither snap nor save.
|
|
fn move_end(app: &AppHandle, provider: ProviderId) {
|
|
if !platform::positioning_supported() {
|
|
return;
|
|
}
|
|
let Some(window) = app.get_webview_window(&runtime::bubble_label(provider)) else {
|
|
return;
|
|
};
|
|
let Some(rect) = outer_rect(&window) else {
|
|
return;
|
|
};
|
|
let peers = windows(app)
|
|
.into_iter()
|
|
.filter(|(p, _)| *p != provider)
|
|
.filter_map(|(_, w)| outer_rect(&w))
|
|
.collect();
|
|
#[cfg(windows)]
|
|
let taskbar = platform::windows::taskbar();
|
|
#[cfg(not(windows))]
|
|
let taskbar = None;
|
|
let snapped = geom::snap(&SnapInput {
|
|
rect,
|
|
work_area: screen_of(&window).work,
|
|
dpi: dpi_of(&window),
|
|
taskbar,
|
|
peers,
|
|
});
|
|
if snapped != Point::new(rect.l, rect.t) {
|
|
set_position(&window, snapped);
|
|
}
|
|
log::debug!(
|
|
"{} dropped at ({}, {}), snapped to ({}, {})",
|
|
provider.slug(),
|
|
rect.l,
|
|
rect.t,
|
|
snapped.x,
|
|
snapped.y
|
|
);
|
|
if let Some(runtime) = runtime_of(app) {
|
|
runtime.save_bubble_position(provider, (snapped.x, snapped.y));
|
|
}
|
|
}
|
|
|
|
/// Pull a bubble back into its monitor's work area, without saving. Runs
|
|
/// on the Windows setting and display change messages.
|
|
#[cfg(windows)]
|
|
fn reclamp(app: &AppHandle, provider: ProviderId) {
|
|
if !platform::positioning_supported() {
|
|
return;
|
|
}
|
|
let Some(window) = app.get_webview_window(&runtime::bubble_label(provider)) else {
|
|
return;
|
|
};
|
|
let Some(rect) = outer_rect(&window) else {
|
|
return;
|
|
};
|
|
let clamped = geom::clamp_into_work_area(rect, screen_of(&window).work, provider);
|
|
if clamped != Point::new(rect.l, rect.t) {
|
|
log::info!(
|
|
"{} clamped from ({}, {}) to ({}, {})",
|
|
provider.slug(),
|
|
rect.l,
|
|
rect.t,
|
|
clamped.x,
|
|
clamped.y
|
|
);
|
|
set_position(&window, clamped);
|
|
}
|
|
}
|
|
|
|
/// Resize every bubble to `size_logical` around its own centre, at its own
|
|
/// DPI. Nothing snaps and no position is saved.
|
|
pub fn resize_all(app: &AppHandle, size_logical: i32) {
|
|
let placeable = platform::positioning_supported();
|
|
for (_, window) in windows(app) {
|
|
let Some(rect) = outer_rect(&window) else {
|
|
continue;
|
|
};
|
|
let (w, h) = geom::bubble_size_px(size_logical, dpi_of(&window));
|
|
let (cx, cy) = ((rect.l + rect.r) / 2, (rect.t + rect.b) / 2);
|
|
set_size(&window, (w, h));
|
|
if placeable {
|
|
set_position(&window, Point::new(cx - w / 2, cy - h / 2));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Move every bubble to its default spot on the primary screen. The
|
|
/// windows are moved, not recreated.
|
|
pub fn move_all_to_defaults(app: &AppHandle) {
|
|
if !platform::positioning_supported() {
|
|
return;
|
|
}
|
|
let primary = primary_screen(app, &screens(app));
|
|
let Some(runtime) = runtime_of(app) else {
|
|
return;
|
|
};
|
|
let size_logical = runtime.with_state(|s| s.settings.bubble_size_logical);
|
|
for (provider, window) in windows(app) {
|
|
let placement = reset_placement(provider, size_logical, primary);
|
|
// Move first so a DPI-change resize from crossing monitors happens
|
|
// before the explicit size, then re-place at the final size.
|
|
set_position(&window, placement.pos);
|
|
set_size(&window, placement.size);
|
|
set_position(&window, placement.pos);
|
|
}
|
|
}
|
|
|
|
/// Where Reset position puts a bubble: the default spot on the primary
|
|
/// screen, sized for the primary's DPI whatever screen the bubble is on now.
|
|
fn reset_placement(provider: ProviderId, size_logical: i32, primary: Screen) -> Placement {
|
|
initial_placement(provider, size_logical, None, &[primary], primary)
|
|
}
|
|
|
|
/// Move-end detection where the OS reports no end of a window drag: a
|
|
/// [`MOVE_END_DEBOUNCE`] quiet period after the last `Moved` event, armed
|
|
/// only by a drag the page started. On X11 the period restarts while
|
|
/// Button1 is still held, so pausing mid-drag does not snap.
|
|
#[cfg(not(windows))]
|
|
mod drag {
|
|
use super::*;
|
|
|
|
/// Deadline of each armed bubble, by label.
|
|
static ARMED: Mutex<Option<HashMap<String, Instant>>> = Mutex::new(None);
|
|
|
|
fn with_armed<T>(f: impl FnOnce(&mut HashMap<String, Instant>) -> T) -> T {
|
|
let mut guard = ARMED.lock().unwrap_or_else(PoisonError::into_inner);
|
|
f(guard.get_or_insert_with(HashMap::new))
|
|
}
|
|
|
|
/// Arm the debounce and start its watcher, unless one already runs.
|
|
pub fn arm(app: &AppHandle, provider: ProviderId) {
|
|
let label = runtime::bubble_label(provider);
|
|
let fresh = with_armed(|armed| {
|
|
armed
|
|
.insert(label.clone(), Instant::now() + MOVE_END_DEBOUNCE)
|
|
.is_none()
|
|
});
|
|
if !fresh {
|
|
return;
|
|
}
|
|
let app = app.clone();
|
|
defer("bubble-drag", move || {
|
|
if wait_for_end(&label) {
|
|
move_end(&app, provider);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// A move pushes the deadline out, while armed.
|
|
pub fn moved(label: &str) {
|
|
with_armed(|armed| {
|
|
if let Some(deadline) = armed.get_mut(label) {
|
|
*deadline = Instant::now() + MOVE_END_DEBOUNCE;
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn disarm(label: &str) {
|
|
with_armed(|armed| armed.remove(label));
|
|
}
|
|
|
|
/// Sleep until the deadline stops moving and the button is up, then
|
|
/// disarm. False when the bubble was disarmed meanwhile (destroyed).
|
|
fn wait_for_end(label: &str) -> bool {
|
|
loop {
|
|
let Some(deadline) = with_armed(|armed| armed.get(label).copied()) else {
|
|
return false;
|
|
};
|
|
let now = Instant::now();
|
|
if now < deadline {
|
|
std::thread::sleep(deadline - now);
|
|
continue;
|
|
}
|
|
if platform::primary_button_down() {
|
|
with_armed(|armed| armed.insert(label.to_string(), now + MOVE_END_DEBOUNCE));
|
|
continue;
|
|
}
|
|
// Disarm before the snap moves the window, so its own `Moved`
|
|
// cannot re-arm.
|
|
with_armed(|armed| armed.remove(label));
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn screen(bounds: Rect, work: Rect, dpi: u32) -> Screen {
|
|
Screen { bounds, work, dpi }
|
|
}
|
|
|
|
const PRIMARY: Screen = Screen {
|
|
bounds: Rect::new(0, 0, 1920, 1080),
|
|
work: Rect::new(0, 0, 1920, 1040),
|
|
dpi: 96,
|
|
};
|
|
|
|
#[test]
|
|
fn screen_for_prefers_the_largest_overlap() {
|
|
let right = screen(
|
|
Rect::new(1920, 0, 3840, 1080),
|
|
Rect::new(1920, 0, 3840, 1040),
|
|
144,
|
|
);
|
|
let screens = [PRIMARY, right];
|
|
// 80 px on the primary, 120 px on the right one.
|
|
assert_eq!(
|
|
screen_for(Rect::from_xywh(1840, 100, 200, 66), &screens),
|
|
Some(1)
|
|
);
|
|
assert_eq!(
|
|
screen_for(Rect::from_xywh(1800, 100, 200, 66), &screens),
|
|
Some(0)
|
|
);
|
|
// A tie keeps the earlier screen.
|
|
assert_eq!(
|
|
screen_for(Rect::from_xywh(1820, 100, 200, 66), &screens),
|
|
Some(0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn screen_for_falls_back_to_the_nearest_screen() {
|
|
let right = screen(
|
|
Rect::new(1920, 0, 3840, 1080),
|
|
Rect::new(1920, 0, 3840, 1040),
|
|
96,
|
|
);
|
|
let screens = [PRIMARY, right];
|
|
assert_eq!(
|
|
screen_for(Rect::from_xywh(5000, 100, 200, 66), &screens),
|
|
Some(1)
|
|
);
|
|
assert_eq!(
|
|
screen_for(Rect::from_xywh(-900, 2000, 200, 66), &screens),
|
|
Some(0)
|
|
);
|
|
assert_eq!(screen_for(Rect::from_xywh(0, 0, 10, 10), &[]), None);
|
|
}
|
|
|
|
#[test]
|
|
fn default_placement_is_bottom_right_of_the_primary_work_area() {
|
|
let p = initial_placement(ProviderId::Claude, 200, None, &[PRIMARY], PRIMARY);
|
|
assert_eq!(
|
|
p,
|
|
Placement {
|
|
pos: Point::new(1920 - 200 - 24, 1040 - 66 - 24),
|
|
size: (200, 66),
|
|
dpi: 96,
|
|
}
|
|
);
|
|
let codex = initial_placement(ProviderId::ChatGpt, 200, None, &[PRIMARY], PRIMARY);
|
|
assert_eq!(codex.pos, Point::new(1696, 1040 - 66 - 24 - 90));
|
|
}
|
|
|
|
#[test]
|
|
fn saved_placement_takes_the_dpi_of_its_screen_and_is_clamped() {
|
|
let right = screen(
|
|
Rect::new(1920, 0, 3840, 1080),
|
|
Rect::new(1920, 0, 3840, 1040),
|
|
144,
|
|
);
|
|
let screens = [PRIMARY, right];
|
|
let p = initial_placement(
|
|
ProviderId::Claude,
|
|
200,
|
|
Some((2500, 400)),
|
|
&screens,
|
|
PRIMARY,
|
|
);
|
|
assert_eq!(
|
|
p,
|
|
Placement {
|
|
pos: Point::new(2500, 400),
|
|
size: (300, 99),
|
|
dpi: 144,
|
|
}
|
|
);
|
|
// Partly below the work area: pulled back in.
|
|
let low = initial_placement(
|
|
ProviderId::Claude,
|
|
200,
|
|
Some((100, 1030)),
|
|
&screens,
|
|
PRIMARY,
|
|
);
|
|
assert_eq!(low.pos, Point::new(100, 1040 - 66));
|
|
}
|
|
|
|
#[test]
|
|
fn saved_placement_on_no_screen_lands_on_the_nearest() {
|
|
let p = initial_placement(
|
|
ProviderId::Claude,
|
|
200,
|
|
Some((5000, 5000)),
|
|
&[PRIMARY],
|
|
PRIMARY,
|
|
);
|
|
assert_eq!(p.pos, Point::new(1720, 974));
|
|
let codex = initial_placement(
|
|
ProviderId::ChatGpt,
|
|
200,
|
|
Some((5000, 5000)),
|
|
&[PRIMARY],
|
|
PRIMARY,
|
|
);
|
|
assert_eq!(codex.pos, Point::new(1720, 974 - 66 - 24));
|
|
}
|
|
|
|
#[test]
|
|
fn reset_placement_is_sized_for_the_primary_dpi() {
|
|
let hidpi_primary = screen(
|
|
Rect::new(0, 0, 2880, 1620),
|
|
Rect::new(0, 0, 2880, 1560),
|
|
144,
|
|
);
|
|
let claude = reset_placement(ProviderId::Claude, 200, hidpi_primary);
|
|
assert_eq!(
|
|
claude,
|
|
Placement {
|
|
pos: Point::new(2880 - 300 - 24, 1560 - 99 - 24),
|
|
size: (300, 99),
|
|
dpi: 144,
|
|
}
|
|
);
|
|
let codex = reset_placement(ProviderId::ChatGpt, 200, hidpi_primary);
|
|
assert_eq!(codex.size, (300, 99));
|
|
assert_eq!(codex.pos, Point::new(2556, 1560 - 99 - 24 - 99 - 24));
|
|
// Fully inside the work area and clear of the Claude bubble.
|
|
assert!(claude.pos.x + claude.size.0 <= 2880);
|
|
assert!(codex.pos.y + codex.size.1 < claude.pos.y);
|
|
|
|
let low_dpi = reset_placement(ProviderId::Claude, 200, PRIMARY);
|
|
assert_eq!(low_dpi.size, (200, 66));
|
|
assert_eq!(low_dpi.pos, Point::new(1920 - 200 - 24, 1040 - 66 - 24));
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_screen_is_a_plain_1080p_monitor() {
|
|
assert_eq!(FALLBACK.work, Rect::new(0, 0, 1920, 1080));
|
|
assert_eq!(FALLBACK.dpi, 96);
|
|
}
|
|
}
|