Files
claude-code-usage-bubble/src/bubble.rs
T
tiennm99 6afd603d5e refactor: rename the project to usage-monitor
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.
2026-10-22 16:59:32 +07:00

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);
}
}