Compare commits

..
12 Commits
Author SHA1 Message Date
tiennm99 15f1120132 chore: bump version to 0.8.1 2026-10-27 15:57:33 +07:00
tiennm99 5462df1e7e feat: raise covered bubbles the moment another app's window appears
A WinEvent hook on window shows and z-order changes re-raises a bubble that another process's always-on-top window covers, instead of waiting for the 1.5 s check, which stays as a backstop. Raises are capped at 20 a second so an app that also fights for the top cannot keep both busy.
2026-10-27 15:56:32 +07:00
tiennm99 dfff4ebe9f style: make the bubble background 80% opaque 2026-10-27 15:56:32 +07:00
tiennm99 4e2ecbf588 fix: keep the bubble visible when click-through is toggled
On Windows, tao's set_ignore_cursor_events re-applies all its window flags. Bubbles are shown through ShowWindow, not tao, so tao believes them hidden: it hid the bubble and rewrote the extended style, dropping its no-activate and tool-window bits. The transparent and layered bits are now set directly on the main thread.
2026-10-27 15:56:32 +07:00
tiennm99 fd5c2ba910 chore: bump version to 0.8.0 2026-10-27 15:16:57 +07:00
tiennm99 cf058a4a38 feat: install found updates without waiting for a click
Where the app can replace itself (the portable exe and the AppImage), a check that finds a release now installs it and relaunches straight away. The .deb still only reports the update.
2026-10-27 15:15:27 +07:00
tiennm99 3aaf963841 fix: keep the see-through bubble readable on light backgrounds
Solid dark tracks looked like black holes and the muted text faded out when the 60% background sat over a light window. Tracks are now tints over the background, a faint edge traces the outline, and text gets a soft halo.
2026-10-27 15:15:27 +07:00
tiennm99 c7af9ed0ac fix: keep bubbles above other apps' always-on-top windows
Telegram toasts and similar topmost windows could sit over a bubble until the next minute's bring-back. On Windows, every fullscreen check now re-raises a shown bubble that another process's visible topmost window overlaps. The app's own panel and menus are ignored, and bubbles hidden for a fullscreen app stay hidden.
2026-10-27 15:15:27 +07:00
tiennm99 677feb2e42 chore: bump version to 0.7.0 2026-10-27 13:09:01 +07:00
tiennm99 5362e46be5 fix: bring hidden or covered bubbles back every minute
Another always-on-top window, or a shell action that hid ours, could
leave a bubble covered or hidden until restart. The fullscreen check
thread now shows every ready bubble again and puts it back at the top of
the always-on-top band once a minute, unless the user or a fullscreen
app has them hidden. Windows re-asserts HWND_TOPMOST without activating;
other platforms drop and re-set always-on-top.
2026-10-27 12:11:24 +07:00
tiennm99 97100cfcc0 feat: keyhole-shaped see-through bubble with a click-through option
The bubble is now a full-height round head with a slimmer, centred tail
whose end has small rounded corners, clipped in the page so only the
shape takes input. Its background is 60% opaque; text, rings and bars
stay solid. A new Click-through menu item makes the bubbles ignore the
mouse so clicks reach the windows below; it is saved as click_through
and turned off again from the tray menu.
2026-10-27 12:11:24 +07:00
tiennm99 4299c8e602 feat: drop the OpenCode Go provider placeholder
OpenCode Go never had a usage source, so the provider list, its colours,
label, window lengths, default position and settings fields go. With it
goes the live/non-live split it introduced: every provider is now live,
and toggling still keeps at least one enabled. Saved settings that carry
the old OpenCode Go keys still load; the keys are dropped on next save.
2026-10-27 12:02:44 +07:00
24 changed files with 561 additions and 256 deletions

No files matched your search

Generated
+1 -1
View File
@@ -4693,7 +4693,7 @@ dependencies = [
[[package]]
name = "usage-monitor"
version = "0.6.1"
version = "0.8.1"
dependencies = [
"auto-launch",
"dbus",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "usage-monitor"
version = "0.6.1"
version = "0.8.1"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage"
@@ -56,6 +56,7 @@ windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_Registry",
"Win32_System_Threading",
"Win32_UI_Accessibility",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell",
"Win32_Graphics_Dwm",
+18 -9
View File
@@ -27,8 +27,9 @@ self-updater are all written from scratch against the same public APIs
## What you get
- A circular floating bubble showing your current 5-hour Claude Code or
Codex usage as a percentage and a colored progress ring
- A see-through floating bubble: a round head showing your current 5-hour
Claude Code or Codex usage as a percentage and a colored progress ring,
with a slimmer tail holding the 7-day usage and reset bars
- Drag anywhere — the bubble snaps to monitor work-area edges when
released
- Resize with `Ctrl + MouseWheel` on the bubble, or use **Controls** →
@@ -37,12 +38,17 @@ self-updater are all written from scratch against the same public APIs
- Left-click the bubble for an expanded panel with both **5h** and **7d**
bars plus reset countdowns
- Right-click for refresh, displayed providers, update frequency, startup,
**Reset position**, updates, **Show widget**, **Restart** and **Exit**
**Reset position**, updates, **Show widget**, **Click-through**,
**Restart** and **Exit**
- A system tray icon per enabled provider, with a usage badge
- Desktop notifications when usage crosses a threshold, when a sign-in
expires and after an update was applied
- Auto-hide when a fullscreen app is in the foreground (games, video,
presentations) — reappears when you leave fullscreen
- Bubbles stay above other always-on-top windows, such as chat
notifications: on Windows a bubble goes back on top the moment another
app shows such a window over it, and on every platform shown bubbles are
put back on top every minute, so nothing can keep them covered or hidden
## Who this is for
@@ -96,7 +102,8 @@ Grab `usage-monitor.exe` from the
Put it in a folder of its own that you can write to (e.g.
`%LOCALAPPDATA%\UsageMonitor\`) and run it. The app is portable: it keeps
`settings.json` next to the exe, so moving the folder keeps your settings. It
self-updates from the same Releases feed.
updates itself from the same Releases feed: when a check finds a new
release, it installs it and restarts on its own, with no click needed.
**First-run note:** the binary is unsigned, so SmartScreen will show
"Windows protected your PC". Click **More info** → **Run anyway**. Code
@@ -111,7 +118,7 @@ pick one:
- `usage-monitor_X.Y.Z_amd64.AppImage`: make it executable
(`chmod +x`) and run it. Like the Windows exe it is portable and keeps
`settings.json` next to the AppImage file, so give it a folder you can
write to. The AppImage self-updates in place. If you turned on "Start
write to. The AppImage updates itself in place, the same way. If you turned on "Start
at login" in v0.6.0 or earlier and the AppImage path contains a space,
turn it off and on again once: those releases wrote the path unquoted,
so the app never started at login.
@@ -166,7 +173,8 @@ to the nearest edge if you let go close to one.
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
- **Right-click** for **Refresh**, **Update frequency**, **Providers**,
**Settings**, **Controls**, **Show widget**, **Restart** and **Exit**.
**Settings**, **Controls**, **Show widget**, **Click-through**,
**Restart** and **Exit**.
**Settings** holds "Start with Windows" ("Start at login" on Linux),
**Reset position**, a manual **Check for updates** and the
**Auto-update check** interval (Disabled / Hourly / Daily / Weekly)
@@ -175,6 +183,9 @@ to the nearest edge if you let go close to one.
menu, to resize it
- **Tray icon:** left-click toggles the bubble visibility, right-click opens
the same menu (on Linux, any click opens the menu)
- **Click-through:** when checked, the bubbles ignore the mouse, so clicks
reach the windows underneath. You can't drag, resize or right-click a
bubble then; uncheck it from the tray icon's menu
### Providers
@@ -182,9 +193,6 @@ Use the right-click **Providers** menu to choose what is shown:
- **Claude Code** is enabled by default
- **Codex** can be enabled alongside Claude Code or shown by itself
- **OpenCode Go** is listed for future support; usage bars remain disabled
until OpenCode exposes a stable quota source or console integration is
explicitly enabled
When multiple providers are shown, each gets its own bubble that you can position
independently.
@@ -271,6 +279,7 @@ What the app stores locally:
- Bubble position(s) per model
- Bubble size
- Whether the bubbles are shown (**Show widget**)
- Whether the bubbles let clicks through (**Click-through**)
- Polling frequency
- Auto-update check interval
- Last update check time
+1 -3
View File
@@ -126,8 +126,6 @@ fn base_color(kind: ProviderId) -> [u8; 3] {
ProviderId::Claude => [0x2a, 0x1f, 0x1c],
// Cool dark slate for Codex.
ProviderId::ChatGpt => [0x1a, 0x1f, 0x26],
// Deep green for OpenCode Go.
ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20],
}
}
@@ -178,7 +176,7 @@ mod tests {
#[test]
fn arc_starts_at_twelve_o_clock_for_any_percent() {
for kind in ProviderId::LIVE_USAGE {
for kind in ProviderId::ALL {
for pct in [0.5, 1.0, 25.0, 59.9, 60.0, 80.0, 95.0, 100.0, 150.0] {
let pixmap = render_pixmap(kind, Some(pct));
assert_eq!(
+164 -9
View File
@@ -15,9 +15,7 @@
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 std::time::{Duration, Instant};
use tauri::{
AppHandle, Manager, Monitor, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow,
@@ -263,7 +261,7 @@ fn set_size(window: &WebviewWindow, (w, h): (i32, i32)) {
/// Bubble windows that exist, with their provider, in display order.
fn windows(app: &AppHandle) -> Vec<(ProviderId, WebviewWindow)> {
ProviderId::LIVE_USAGE
ProviderId::ALL
.into_iter()
.filter_map(|p| {
app.get_webview_window(&runtime::bubble_label(p))
@@ -316,10 +314,11 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
log::warn!("{label} not created: the runtime is not ready");
return Ok(());
};
let (size_logical, saved) = runtime.with_state(|s| {
let (size_logical, saved, click_through) = runtime.with_state(|s| {
(
s.settings.bubble_size_logical,
s.settings.bubble_positions.get(provider),
s.settings.click_through,
)
});
let all = screens(app);
@@ -350,6 +349,11 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
set_position(&window, placement.pos);
}
set_size(&window, placement.size);
// Windows applies it on the main thread, in `prepare_native`.
#[cfg(not(windows))]
if click_through {
set_ignore_cursor(&window, true);
}
log::info!(
"{label} created at ({}, {}) size {}x{} dpi {}",
placement.pos.x,
@@ -359,7 +363,7 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
placement.dpi
);
prepare_native(app, &window, provider);
prepare_native(app, &window, provider, click_through);
#[cfg(target_os = "linux")]
if let Some(is_dark) = platform::linux::portal_prefers_dark() {
runtime.set_dark(is_dark);
@@ -395,13 +399,22 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
/// 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) {
fn prepare_native(
app: &AppHandle,
window: &WebviewWindow,
provider: ProviderId,
click_through: bool,
) {
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());
}
if click_through {
set_ignore_cursor(&window, true);
}
watch_z_order(&hook_app);
let hooked = platform::windows::install_bubble_hook(&window, move |event| {
on_hook_event(&hook_app, provider, event);
});
@@ -415,7 +428,13 @@ fn prepare_native(app: &AppHandle, window: &WebviewWindow, provider: ProviderId)
}
#[cfg(not(windows))]
fn prepare_native(_app: &AppHandle, _window: &WebviewWindow, _provider: ProviderId) {}
fn prepare_native(
_app: &AppHandle,
_window: &WebviewWindow,
_provider: ProviderId,
_click_through: bool,
) {
}
#[cfg(windows)]
fn on_hook_event(app: &AppHandle, provider: ProviderId, event: platform::windows::BubbleHookEvent) {
@@ -438,7 +457,6 @@ 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());
@@ -486,6 +504,143 @@ pub fn apply(app: &AppHandle, action: Action) {
}
}
/// Show every ready bubble again and put it back on top, unless the
/// bubbles are meant to be hidden. Another always-on-top window, or a shell
/// action that hid ours, can otherwise leave a bubble covered or hidden for
/// good. A bubble still waiting for its page is left to the ready handshake.
/// Visibility is read on the main thread, after any hide queued before.
pub fn bring_back(app: &AppHandle) {
let handle = app.clone();
let queued = app.run_on_main_thread(move || {
let shows = runtime_of(&handle).is_some_and(|r| r.with_state(|s| s.visibility.shows()));
if !shows {
return;
}
for (_, window) in windows(&handle) {
if with_pending(|p| p.contains_key(window.label())) {
continue;
}
show(&window);
raise(&window);
}
});
if let Err(err) = queued {
log::warn!("bubble bring-back could not reach the main thread: {err}");
}
}
/// Put back on top every shown bubble that another app's always-on-top
/// window, such as a chat toast, now covers. Cheap enough to run on every
/// fullscreen check; bubbles hidden for a fullscreen app are left alone.
#[cfg(windows)]
pub fn keep_on_top(app: &AppHandle) {
let handle = app.clone();
let queued = app.run_on_main_thread(move || raise_covered(&handle));
if let Err(err) = queued {
log::warn!("bubble on-top check could not reach the main thread: {err}");
}
}
/// Most raises in [`RAISE_WINDOW`] before raising pauses until it ends, so
/// another app that also fights for the top cannot keep both busy; the
/// fullscreen check carries on at its own pace.
#[cfg(windows)]
const MAX_RAISES: u32 = 20;
#[cfg(windows)]
const RAISE_WINDOW: Duration = Duration::from_secs(1);
/// Body of [`keep_on_top`]; runs on the main thread.
#[cfg(windows)]
fn raise_covered(app: &AppHandle) {
static RAISES: Mutex<Option<(Instant, u32)>> = Mutex::new(None);
let shows = runtime_of(app).is_some_and(|r| r.with_state(|s| s.visibility.shows()));
if !shows {
return;
}
for (_, window) in windows(app) {
match platform::windows::covered_by_other_topmost(&window) {
Ok(true) => {}
Ok(false) => continue,
Err(err) => {
log::warn!("{} on-top check failed: {err}", window.label());
continue;
}
}
let allowed = {
let mut raises = RAISES.lock().unwrap_or_else(PoisonError::into_inner);
let now = Instant::now();
let (start, count) = raises.get_or_insert((now, 0));
if now.duration_since(*start) >= RAISE_WINDOW {
(*start, *count) = (now, 0);
}
*count += 1;
if *count == MAX_RAISES + 1 {
log::warn!("bubble raises paused: another window keeps taking the top");
}
*count <= MAX_RAISES
};
if allowed {
raise(&window);
}
}
}
/// Raise covered bubbles the moment another app shows a window or changes
/// the z-order. Installed once, on the main thread.
#[cfg(windows)]
fn watch_z_order(app: &AppHandle) {
static WATCHING: std::sync::Once = std::sync::Once::new();
WATCHING.call_once(|| {
let handle = app.clone();
if let Err(err) = platform::windows::watch_z_order(move || raise_covered(&handle)) {
log::warn!("z-order watch unavailable, relying on the periodic check: {err}");
}
});
}
fn raise(window: &WebviewWindow) {
#[cfg(windows)]
let result = platform::windows::raise_topmost(window).map_err(|e| e.to_string());
// Re-setting the same level is a no-op, so drop it and set it again.
#[cfg(not(windows))]
let result = window
.set_always_on_top(false)
.and_then(|()| window.set_always_on_top(true))
.map_err(|e| e.to_string());
if let Err(err) = result {
log::warn!("{} not raised: {err}", window.label());
}
}
/// Make every bubble ignore the mouse, or take it again.
pub fn set_click_through(app: &AppHandle, ignore: bool) {
let handle = app.clone();
let queued = app.run_on_main_thread(move || {
for (_, window) in windows(&handle) {
set_ignore_cursor(&window, ignore);
}
});
if let Err(err) = queued {
log::warn!("bubble click-through could not reach the main thread: {err}");
}
}
/// On Windows the style bits are set directly: tao's
/// `set_ignore_cursor_events` re-applies all its window flags, and since
/// bubbles are shown through [`show`], not tao, it believes them hidden and
/// hides them, also dropping their no-activate and tool-window styles.
fn set_ignore_cursor(window: &WebviewWindow, ignore: bool) {
#[cfg(windows)]
let result = platform::windows::set_click_through(window, ignore).map_err(|e| e.to_string());
#[cfg(not(windows))]
let result = window
.set_ignore_cursor_events(ignore)
.map_err(|e| e.to_string());
if let Err(err) = result {
log::warn!("{} click-through not set: {err}", window.label());
}
}
fn show(window: &WebviewWindow) {
#[cfg(windows)]
let result = platform::windows::show_no_activate(window).map_err(|e| e.to_string());
+11 -13
View File
@@ -24,7 +24,7 @@ pub fn get_bubble_state(
runtime: State<'_, Arc<Runtime>>,
provider: String,
) -> Result<BubblePayload, String> {
let provider = live_provider(&provider)?;
let provider = parse_provider(&provider)?;
let scale = window.scale_factor().map_err(|err| err.to_string())?;
Ok(runtime.bubble_payload(provider, scale))
}
@@ -66,7 +66,7 @@ pub fn bubble_ready(app: AppHandle, label: String) -> Result<(), String> {
/// running inside the webview's IPC callback.
#[tauri::command]
pub async fn bubble_click(runtime: State<'_, Arc<Runtime>>, slug: String) -> Result<(), String> {
let provider = live_provider(&slug)?;
let provider = parse_provider(&slug)?;
runtime.bubble_clicked(provider);
Ok(())
}
@@ -74,7 +74,7 @@ pub async fn bubble_click(runtime: State<'_, Arc<Runtime>>, slug: String) -> Res
/// 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)?;
let provider = parse_provider(&slug)?;
bubble::drag_start(&app, provider);
Ok(())
}
@@ -96,10 +96,8 @@ fn resize_delta(dir: i32) -> Result<i32, String> {
}
}
fn live_provider(slug: &str) -> Result<ProviderId, String> {
ProviderId::from_slug(slug)
.filter(|p| p.metadata().live_usage)
.ok_or_else(|| "unknown provider".to_string())
fn parse_provider(slug: &str) -> Result<ProviderId, String> {
ProviderId::from_slug(slug).ok_or_else(|| "unknown provider".to_string())
}
/// One log line: control characters become spaces, so a message cannot
@@ -117,12 +115,12 @@ mod tests {
use super::*;
#[test]
fn only_live_providers_are_accepted() {
assert_eq!(live_provider("claude"), Ok(ProviderId::Claude));
assert_eq!(live_provider("chatgpt"), Ok(ProviderId::ChatGpt));
assert!(live_provider("opencode-go").is_err());
assert!(live_provider("").is_err());
assert!(live_provider("CLAUDE").is_err());
fn only_known_providers_are_accepted() {
assert_eq!(parse_provider("claude"), Ok(ProviderId::Claude));
assert_eq!(parse_provider("chatgpt"), Ok(ProviderId::ChatGpt));
assert!(parse_provider("codex").is_err());
assert!(parse_provider("").is_err());
assert!(parse_provider("CLAUDE").is_err());
}
#[test]
+1 -6
View File
@@ -163,7 +163,6 @@ pub fn default_position(
let stagger = match model {
ProviderId::Claude => 0,
ProviderId::ChatGpt => height_px + STAGGER_GAP,
ProviderId::OpenCodeGo => 2 * (height_px + STAGGER_GAP),
};
let x = work_area.r - width_px - STAGGER_GAP;
let y = work_area.b - height_px - STAGGER_GAP - stagger;
@@ -210,7 +209,7 @@ pub struct SnapInput {
pub dpi: u32,
/// Docked taskbar edge and rect, when the platform reports one.
pub taskbar: Option<(Edge, Rect)>,
/// Outer rects of the other bubbles, in `ProviderId::LIVE_USAGE` order.
/// Outer rects of the other bubbles, in `ProviderId::ALL` order.
pub peers: Vec<Rect>,
}
@@ -499,10 +498,6 @@ mod tests {
default_position(WA, w, h, ProviderId::ChatGpt),
Point::new(1696, 1040 - 66 - 24 - 90)
);
assert_eq!(
default_position(WA, w, h, ProviderId::OpenCodeGo),
Point::new(1696, 1040 - 66 - 24 - 180)
);
}
#[test]
+2 -3
View File
@@ -24,7 +24,6 @@ pub struct LocaleStrings {
pub providers: String,
pub claude_label: String,
pub chatgpt_label: String,
pub opencode_go_label: String,
pub settings: String,
pub start_with_windows: String,
pub start_at_login: String,
@@ -57,6 +56,7 @@ pub struct LocaleStrings {
pub exit: String,
pub restart: String,
pub show_widget: String,
pub click_through: String,
pub session_window: String,
pub weekly_window: String,
pub now: String,
@@ -124,7 +124,6 @@ impl LocaleStrings {
match provider {
ProviderId::Claude => &self.claude_label,
ProviderId::ChatGpt => &self.chatgpt_label,
ProviderId::OpenCodeGo => &self.opencode_go_label,
}
}
}
@@ -141,7 +140,6 @@ fn english_strings() -> LocaleStrings {
providers: "Providers".into(),
claude_label: "Claude Code".into(),
chatgpt_label: "Codex".into(),
opencode_go_label: "OpenCode Go".into(),
settings: "Settings".into(),
start_with_windows: "Start with Windows".into(),
start_at_login: "Start at login".into(),
@@ -172,6 +170,7 @@ fn english_strings() -> LocaleStrings {
exit: "Exit".into(),
restart: "Restart".into(),
show_widget: "Show widget".into(),
click_through: "Click-through".into(),
session_window: "5h".into(),
weekly_window: "7d".into(),
now: "now".into(),
+16 -8
View File
@@ -23,6 +23,7 @@ pub enum MenuCommand {
SizeLarger,
SizeReset,
ToggleWidget,
ToggleClickThrough,
Restart,
Exit,
}
@@ -31,7 +32,7 @@ impl MenuCommand {
/// The item id: `refresh`, `freq:<ms>`, `provider:<slug>`, `autostart`,
/// `reset-position`, `version`, `update-interval:<secs|off>`,
/// `size-smaller`, `size-larger`, `size-reset`, `toggle-widget`,
/// `restart` or `exit`.
/// `click-through`, `restart` or `exit`.
pub fn id(self) -> String {
match self {
MenuCommand::Refresh => "refresh".into(),
@@ -46,6 +47,7 @@ impl MenuCommand {
MenuCommand::SizeLarger => "size-larger".into(),
MenuCommand::SizeReset => "size-reset".into(),
MenuCommand::ToggleWidget => "toggle-widget".into(),
MenuCommand::ToggleClickThrough => "click-through".into(),
MenuCommand::Restart => "restart".into(),
MenuCommand::Exit => "exit".into(),
}
@@ -64,6 +66,7 @@ impl MenuCommand {
"size-larger" => Some(MenuCommand::SizeLarger),
"size-reset" => Some(MenuCommand::SizeReset),
"toggle-widget" => Some(MenuCommand::ToggleWidget),
"click-through" => Some(MenuCommand::ToggleClickThrough),
"restart" => Some(MenuCommand::Restart),
"exit" => Some(MenuCommand::Exit),
_ => None,
@@ -205,7 +208,7 @@ pub fn build(input: MenuInput<'_>) -> Vec<MenuEntry> {
MenuCommand::ToggleProvider(p),
s.provider_label(p),
settings.is_provider_enabled(p),
p.metadata().live_usage,
true,
)
})
.collect();
@@ -282,6 +285,12 @@ pub fn build(input: MenuInput<'_>) -> Vec<MenuEntry> {
settings.widget_visible,
true,
),
check(
MenuCommand::ToggleClickThrough,
&s.click_through,
settings.click_through,
true,
),
MenuEntry::Separator,
action(MenuCommand::Restart, &s.restart, true),
action(MenuCommand::Exit, &s.exit, true),
@@ -360,8 +369,8 @@ mod tests {
poll_interval_ms: POLL_15_MIN,
update_check_interval_secs: Some(UPDATE_CHECK_DAILY_SECS),
show_codex: true,
show_opencode_go: true,
widget_visible: false,
click_through: true,
..Settings::default()
};
let m = menu(&settings, UpdateStatus::Idle);
@@ -371,11 +380,9 @@ mod tests {
assert!(!checked(&m, "update-interval:off"));
assert!(checked(&m, "provider:claude"));
assert!(checked(&m, "provider:chatgpt"));
// OpenCode Go mirrors its flag but stays greyed.
assert!(checked(&m, "provider:opencode-go"));
assert!(!enabled(&m, "provider:opencode-go"));
assert!(checked(&m, "autostart"));
assert!(!checked(&m, "toggle-widget"));
assert!(checked(&m, "click-through"));
}
#[test]
@@ -440,6 +447,7 @@ mod tests {
"Settings",
"Controls",
"Show widget",
"Click-through",
"-",
"Restart",
"Exit"
@@ -524,7 +532,6 @@ mod tests {
"Providers >",
" Claude Code [provider:claude] on",
" Codex [provider:chatgpt] off",
" OpenCode Go [provider:opencode-go] off grey",
"Settings >",
&autostart,
" Reset position [reset-position]",
@@ -543,12 +550,13 @@ mod tests {
let got: Vec<&str> = lines.iter().map(String::as_str).collect();
assert_eq!(got[..expected.len()], expected);
let tail: Vec<&str> = got[expected.len()..].to_vec();
assert_eq!(tail.len(), 9);
assert_eq!(tail.len(), 10);
assert!(tail[..5].iter().all(|l| l.ends_with(" hint")), "{tail:?}");
assert_eq!(
tail[5..],
[
"Show widget [toggle-widget] on",
"Click-through [click-through] off",
"-",
"Restart [restart]",
"Exit [exit]"
-10
View File
@@ -14,7 +14,6 @@ use crate::usage::ProviderId;
const FIVE_HOURS_SECS: u64 = 5 * 60 * 60;
const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60;
const THIRTY_DAYS_SECS: u64 = 30 * 24 * 60 * 60;
/// Placeholder for the head label and weekly countdown before the first poll.
const PENDING: &str = "…";
@@ -25,7 +24,6 @@ const PENDING: &str = "…";
pub fn window_durations_secs(provider: ProviderId) -> (u64, u64) {
match provider {
ProviderId::Claude | ProviderId::ChatGpt => (FIVE_HOURS_SECS, SEVEN_DAYS_SECS),
ProviderId::OpenCodeGo => (SEVEN_DAYS_SECS, THIRTY_DAYS_SECS),
}
}
@@ -339,12 +337,4 @@ mod tests {
assert!(panel.get(key).is_some(), "missing {key}");
}
}
#[test]
fn opencode_go_uses_weekly_and_monthly_windows() {
assert_eq!(
window_durations_secs(ProviderId::OpenCodeGo),
(604_800, 2_592_000)
);
}
}
+39 -68
View File
@@ -40,9 +40,6 @@ fn default_show_claude() -> bool {
fn default_show_codex() -> bool {
false
}
fn default_show_opencode_go() -> bool {
false
}
fn default_widget_visible() -> bool {
true
}
@@ -60,7 +57,6 @@ fn default_update_check_interval_secs() -> Option<u64> {
pub struct BubblePositions {
pub claude: Option<(i32, i32)>,
pub codex: Option<(i32, i32)>,
pub opencode_go: Option<(i32, i32)>,
}
impl BubblePositions {
@@ -68,27 +64,23 @@ impl BubblePositions {
match provider {
ProviderId::Claude => self.claude,
ProviderId::ChatGpt => self.codex,
ProviderId::OpenCodeGo => self.opencode_go,
}
}
pub fn set(&mut self, provider: ProviderId, pos: (i32, i32)) {
match provider {
ProviderId::Claude => self.claude = Some(pos),
ProviderId::ChatGpt => self.codex = Some(pos),
ProviderId::OpenCodeGo => self.opencode_go = Some(pos),
}
}
pub fn reset(&mut self, provider: ProviderId) {
match provider {
ProviderId::Claude => self.claude = None,
ProviderId::ChatGpt => self.codex = None,
ProviderId::OpenCodeGo => self.opencode_go = None,
}
}
pub fn reset_all(&mut self) {
self.claude = None;
self.codex = None;
self.opencode_go = None;
}
/// Drop any saved position whose top-left no longer falls on one of
@@ -115,8 +107,6 @@ pub struct Settings {
pub show_claude_code: bool,
#[serde(default = "default_show_codex")]
pub show_codex: bool,
#[serde(default = "default_show_opencode_go")]
pub show_opencode_go: bool,
#[serde(default)]
pub bubble_positions: BubblePositions,
#[serde(default = "default_bubble_size")]
@@ -129,6 +119,9 @@ pub struct Settings {
pub update_check_interval_secs: Option<u64>,
#[serde(default = "default_widget_visible")]
pub widget_visible: bool,
/// The bubbles ignore the mouse, so clicks reach the windows below.
#[serde(default)]
pub click_through: bool,
}
impl Default for Settings {
@@ -136,13 +129,13 @@ impl Default for Settings {
Self {
show_claude_code: default_show_claude(),
show_codex: default_show_codex(),
show_opencode_go: default_show_opencode_go(),
bubble_positions: BubblePositions::default(),
bubble_size_logical: default_bubble_size(),
poll_interval_ms: default_poll_interval_ms(),
last_update_check_unix: None,
update_check_interval_secs: default_update_check_interval_secs(),
widget_visible: default_widget_visible(),
click_through: false,
}
}
}
@@ -152,7 +145,6 @@ impl Settings {
match provider {
ProviderId::Claude => self.show_claude_code,
ProviderId::ChatGpt => self.show_codex,
ProviderId::OpenCodeGo => self.show_opencode_go,
}
}
@@ -160,30 +152,23 @@ impl Settings {
match provider {
ProviderId::Claude => self.show_claude_code = enabled,
ProviderId::ChatGpt => self.show_codex = enabled,
ProviderId::OpenCodeGo => self.show_opencode_go = enabled,
}
}
pub fn toggle_provider(&mut self, provider: ProviderId) {
/// Menu toggle for a provider. Unchecking the last enabled provider is
/// undone so the app always shows something. Returns the provider's new
/// state.
pub fn toggle_provider(&mut self, provider: ProviderId) -> bool {
let enabled = !self.is_provider_enabled(provider);
self.set_provider_enabled(provider, enabled);
}
/// Menu toggle for a provider. Providers without live usage cannot be
/// toggled, and unchecking the last enabled live provider is undone so
/// the app always shows something. Returns the provider's new state.
pub fn toggle_live_provider(&mut self, provider: ProviderId) -> bool {
if provider.metadata().live_usage {
self.toggle_provider(provider);
if !self.has_enabled_live_provider() {
self.set_provider_enabled(provider, true);
}
if !self.has_enabled_provider() {
self.set_provider_enabled(provider, true);
}
self.is_provider_enabled(provider)
}
pub fn has_enabled_live_provider(&self) -> bool {
ProviderId::LIVE_USAGE
pub fn has_enabled_provider(&self) -> bool {
ProviderId::ALL
.iter()
.any(|provider| self.is_provider_enabled(*provider))
}
@@ -310,8 +295,8 @@ fn parse(content: &str) -> Option<Settings> {
/// Enforce the invariants a hand-edited file may break.
fn normalise(mut settings: Settings) -> Settings {
// At least one live provider must be visible. Otherwise the app has nothing to show.
if !settings.has_enabled_live_provider() {
// At least one provider must be visible. Otherwise the app has nothing to show.
if !settings.has_enabled_provider() {
settings.show_claude_code = true;
}
// Clamp bubble size to safe range in case settings.json was hand-edited.
@@ -408,7 +393,6 @@ mod tests {
assert!(!settings.show_claude_code);
assert!(settings.show_codex);
assert!(!settings.show_opencode_go);
assert_eq!(
settings.bubble_positions.get(ProviderId::Claude),
Some((10, 20))
@@ -417,30 +401,32 @@ mod tests {
settings.bubble_positions.get(ProviderId::ChatGpt),
Some((30, 40))
);
assert_eq!(settings.bubble_positions.get(ProviderId::OpenCodeGo), None);
}
#[test]
fn three_provider_settings_json_loads() {
fn retired_opencode_go_keys_are_ignored() {
let json = r#"{
"show_claude_code": true,
"show_codex": false,
"show_claude_code": false,
"show_codex": true,
"show_opencode_go": true,
"bubble_positions": {
"codex": [30, 40],
"opencode_go": [50, 60]
}
}"#;
let settings: Settings =
serde_json::from_str(json).expect("new settings json should parse");
serde_json::from_str(json).expect("settings with retired keys should parse");
assert!(settings.show_claude_code);
assert!(!settings.show_codex);
assert!(settings.show_opencode_go);
assert!(!settings.show_claude_code);
assert!(settings.show_codex);
assert_eq!(
settings.bubble_positions.get(ProviderId::OpenCodeGo),
Some((50, 60))
settings.bubble_positions.get(ProviderId::ChatGpt),
Some((30, 40))
);
let keys = serde_json::to_value(&settings).expect("serialise");
assert!(keys.get("show_opencode_go").is_none());
assert!(keys["bubble_positions"].get("opencode_go").is_none());
}
#[test]
@@ -518,10 +504,9 @@ mod tests {
}
#[test]
fn normalise_restores_a_live_provider_and_clamps_size() {
fn normalise_restores_a_provider_and_clamps_size() {
let settings = parse_and_normalise(
r#"{"show_claude_code": false, "show_codex": false, "show_opencode_go": true,
"bubble_size_logical": 9000}"#,
r#"{"show_claude_code": false, "show_codex": false, "bubble_size_logical": 9000}"#,
);
assert!(settings.show_claude_code);
assert_eq!(settings.bubble_size_logical, MAX_BUBBLE_SIZE);
@@ -539,11 +524,11 @@ mod tests {
[
"bubble_positions",
"bubble_size_logical",
"click_through",
"last_update_check_unix",
"poll_interval_ms",
"show_claude_code",
"show_codex",
"show_opencode_go",
"update_check_interval_secs",
"widget_visible",
]
@@ -557,15 +542,13 @@ mod tests {
#[test]
fn offscreen_positions_are_dropped_against_monitors() {
let mut settings = parse_and_normalise(
r#"{"bubble_positions": {"claude": [100, 100], "codex": [-3000, 100],
"opencode_go": [2000, 500]}}"#,
r#"{"bubble_positions": {"claude": [100, 100], "codex": [-3000, 100]}}"#,
);
let monitors = [Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 3840, 1080)];
drop_offscreen_positions(&mut settings, &monitors);
let p = &settings.bubble_positions;
assert_eq!(p.get(ProviderId::Claude), Some((100, 100)));
assert_eq!(p.get(ProviderId::ChatGpt), None);
assert_eq!(p.get(ProviderId::OpenCodeGo), Some((2000, 500)));
}
#[test]
@@ -617,27 +600,15 @@ mod tests {
}
#[test]
fn toggle_keeps_provider_flags_independent() {
fn toggle_never_disables_the_last_provider() {
let mut settings = Settings::default();
settings.toggle_provider(ProviderId::ChatGpt);
assert!(settings.is_provider_enabled(ProviderId::ChatGpt));
settings.toggle_provider(ProviderId::Claude);
assert!(!settings.is_provider_enabled(ProviderId::Claude));
assert!(settings.has_enabled_live_provider());
settings.toggle_provider(ProviderId::ChatGpt);
assert!(!settings.has_enabled_live_provider());
}
#[test]
fn live_toggle_never_disables_the_last_live_provider() {
let mut settings = Settings::default();
assert!(settings.toggle_live_provider(ProviderId::Claude));
assert!(settings.show_claude_code);
assert!(settings.toggle_live_provider(ProviderId::ChatGpt));
assert!(!settings.toggle_live_provider(ProviderId::Claude));
assert!(settings.toggle_live_provider(ProviderId::ChatGpt));
// OpenCode Go has no live usage, so its flag never changes.
assert!(!settings.toggle_live_provider(ProviderId::OpenCodeGo));
assert!(!settings.show_opencode_go);
assert!(settings.toggle_provider(ProviderId::ChatGpt));
assert!(!settings.toggle_provider(ProviderId::Claude));
assert!(settings.has_enabled_provider());
// ChatGPT is now the only enabled provider, so it stays on.
assert!(settings.toggle_provider(ProviderId::ChatGpt));
assert!(settings.show_codex);
assert!(settings.toggle_provider(ProviderId::Claude));
assert!(!settings.toggle_provider(ProviderId::ChatGpt));
}
}
-4
View File
@@ -79,10 +79,6 @@ impl Alert {
strings.chatgpt_token_expired_title.clone(),
strings.chatgpt_token_expired_body.clone(),
),
ProviderId::OpenCodeGo => (
strings.opencode_go_label.clone(),
strings.update_failed.clone(),
),
},
Alert::UpdateApplied { version } => (
strings.update_applied_title.clone(),
-2
View File
@@ -19,7 +19,6 @@ pub fn accent_color_for(model: ProviderId, is_dark: bool) -> Color {
Color::from_hex("#1A1A1A")
}
}
ProviderId::OpenCodeGo => Color::from_hex("#3BAE75"),
}
}
@@ -74,7 +73,6 @@ mod tests {
fn safe_band_uses_theme_aware_accent() {
assert_eq!(hex(ProviderId::ChatGpt, true, 10.0), "#E5E5E5");
assert_eq!(hex(ProviderId::ChatGpt, false, 10.0), "#1A1A1A");
assert_eq!(hex(ProviderId::OpenCodeGo, false, 0.0), "#3BAE75");
assert_eq!(
accent_color_for(ProviderId::Claude, false),
accent_color_for(ProviderId::Claude, true)
+17 -1
View File
@@ -2,10 +2,16 @@
//! fullscreen window of another process is in the foreground, and hides the
//! bubbles while one is. They come back without activation when it leaves,
//! unless the user hid them in the meantime.
//!
//! The same thread brings the bubbles back every minute while they should
//! show, in case another window covered or hid them. On Windows it also
//! re-raises, on every check, a bubble that another app's always-on-top
//! window (a chat toast, say) covers, as a backstop for the z-order watch
//! in `bubble.rs`.
use std::io;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use crate::core::visibility::Event;
use crate::platform::{self, FullscreenCheck};
@@ -14,13 +20,23 @@ use crate::runtime::Runtime;
/// How often the foreground window is checked.
pub const CHECK_INTERVAL: Duration = Duration::from_millis(1500);
/// How often shown bubbles are brought back on top.
pub const BRING_BACK_INTERVAL: Duration = Duration::from_secs(60);
/// Start the check thread. It runs until the process starts exiting.
pub fn spawn(runtime: Arc<Runtime>) -> io::Result<()> {
std::thread::Builder::new()
.name("fullscreen".into())
.spawn(move || {
let mut last_bring_back = Instant::now();
while !runtime.is_exiting() {
tick(&runtime);
#[cfg(windows)]
runtime.keep_bubbles_on_top();
if last_bring_back.elapsed() >= BRING_BACK_INTERVAL {
last_bring_back = Instant::now();
runtime.bring_back_bubbles();
}
std::thread::sleep(CHECK_INTERVAL);
}
})
+153 -9
View File
@@ -1,5 +1,6 @@
//! Windows-only helpers. This is the only module that talks to Win32 APIs.
use std::cell::RefCell;
use std::io;
use std::path::Path;
use std::time::Duration;
@@ -20,18 +21,22 @@ use windows::Win32::Graphics::Gdi::{
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
};
use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE};
use windows::Win32::UI::Accessibility::{SetWinEventHook, HWINEVENTHOOK};
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::{
GetClassNameW, GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId,
IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow, SetWindowLongPtrW, SetWindowPos,
ShowWindow, GWL_EXSTYLE, GWL_STYLE, MA_NOACTIVATE, MB_ICONERROR, MB_OK, MB_SETFOREGROUND,
SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SW_HIDE,
SW_SHOWNOACTIVATE, WM_DISPLAYCHANGE, WM_EXITSIZEMOVE, WM_MOUSEACTIVATE, WM_NCDESTROY,
WM_SETTINGCHANGE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW,
GetClassNameW, GetForegroundWindow, GetWindow, GetWindowLongPtrW, GetWindowRect,
GetWindowThreadProcessId, IsIconic, IsWindowVisible, MessageBoxW, SetForegroundWindow,
SetWindowLongPtrW, SetWindowPos, ShowWindow, CHILDID_SELF, EVENT_OBJECT_REORDER,
EVENT_OBJECT_SHOW, GWL_EXSTYLE, GWL_STYLE, GW_HWNDPREV, HWND_TOPMOST, MA_NOACTIVATE,
MB_ICONERROR, MB_OK, MB_SETFOREGROUND, OBJID_WINDOW, SWP_FRAMECHANGED, SWP_NOACTIVATE,
SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SW_HIDE, SW_SHOWNOACTIVATE, WINEVENT_OUTOFCONTEXT,
WINEVENT_SKIPOWNPROCESS, WM_DISPLAYCHANGE, WM_EXITSIZEMOVE, WM_MOUSEACTIVATE, WM_NCDESTROY,
WM_SETTINGCHANGE, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST,
WS_EX_TRANSPARENT,
};
use crate::core::geom::{self, Edge, Rect};
@@ -202,19 +207,34 @@ pub fn apply_toolwindow(window: &tauri::WebviewWindow) -> io::Result<()> {
add_ex_style(window, WS_EX_TOOLWINDOW.0)
}
/// Let mouse input pass through a bubble to the windows below, or take it
/// again. These are the bits tao's `set_ignore_cursor_events` sets, without
/// its re-applying every window flag, which hides a bubble shown through
/// [`show_no_activate`]. Run it on the window's own (main) thread.
pub fn set_click_through(window: &tauri::WebviewWindow, ignore: bool) -> io::Result<()> {
set_ex_style(window, (WS_EX_TRANSPARENT | WS_EX_LAYERED).0, ignore)
}
/// OR `bits` into a window's extended style and apply the frame change.
fn add_ex_style(window: &tauri::WebviewWindow, bits: u32) -> io::Result<()> {
set_ex_style(window, bits, true)
}
/// Set or clear `bits` in a window's extended style and apply the frame
/// change.
fn set_ex_style(window: &tauri::WebviewWindow, bits: u32, on: bool) -> io::Result<()> {
let hwnd = hwnd_of(window)?;
let wanted = bits as isize;
let bits = bits as isize;
// SAFETY: `hwnd` is a live top-level window of this process.
let current = unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) };
if current & wanted == wanted {
let wanted = if on { current | bits } else { current & !bits };
if wanted == current {
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) };
let previous = unsafe { SetWindowLongPtrW(hwnd, GWL_EXSTYLE, wanted) };
if previous == 0 {
let err = io::Error::last_os_error();
if err.raw_os_error().unwrap_or(0) != 0 {
@@ -293,6 +313,130 @@ pub fn show_no_activate(window: &tauri::WebviewWindow) -> io::Result<()> {
Ok(())
}
/// Put a bubble back at the top of the always-on-top band, above any
/// topmost window that came after it, without activating it.
pub fn raise_topmost(window: &tauri::WebviewWindow) -> io::Result<()> {
let hwnd = hwnd_of(window)?;
// SAFETY: `hwnd` is a live window of this process; nothing moves,
// resizes or activates.
unsafe {
SetWindowPos(
hwnd,
Some(HWND_TOPMOST),
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
)
}
.map_err(io::Error::from)
}
/// Whether a visible always-on-top window of another process sits above
/// `window` in the z-order and overlaps it, such as a chat app's toast.
/// Our own windows (the panel, a context menu) never count, so raising a
/// bubble over them is never asked for.
pub fn covered_by_other_topmost(window: &tauri::WebviewWindow) -> io::Result<bool> {
let hwnd = hwnd_of(window)?;
let Some((ours, _)) = visible_window_bounds(hwnd) else {
return Ok(false);
};
let own_pid = std::process::id();
// SAFETY: `hwnd` is a live window of this process; each step only reads
// the z-order, and a window closing mid-walk ends it with an error.
let mut above = unsafe { GetWindow(hwnd, GW_HWNDPREV) };
// Topmost windows sit at the head of the z-order, so the walk is short;
// the cap guards against a z-order that keeps changing under it.
for _ in 0..MAX_Z_ORDER_WALK {
let Ok(other) = above else {
return Ok(false);
};
if covers(other, &ours, own_pid) {
return Ok(true);
}
// SAFETY: as above.
above = unsafe { GetWindow(other, GW_HWNDPREV) };
}
Ok(false)
}
const MAX_Z_ORDER_WALK: usize = 1024;
fn covers(other: HWND, ours: &Rect, own_pid: u32) -> bool {
// SAFETY: read-only queries; a stale handle yields 0 / false.
let ex_style = unsafe { GetWindowLongPtrW(other, GWL_EXSTYLE) } as u32;
if ex_style & WS_EX_TOPMOST.0 == 0
|| unsafe { !IsWindowVisible(other).as_bool() }
|| is_dwm_cloaked(other)
|| window_process_id(other).is_none_or(|pid| pid == own_pid)
{
return false;
}
visible_window_bounds(other).is_some_and(|(theirs, _)| theirs.intersects(ours))
}
thread_local! {
/// The callback of [`watch_z_order`], on the thread that installed it.
static Z_ORDER_CALLBACK: RefCell<Option<Box<dyn Fn()>>> = const { RefCell::new(None) };
}
/// Call `callback` as soon as a window of another process is shown or the
/// z-order changes, so a bubble can go back on top of a new always-on-top
/// window (a chat toast, say) at once instead of at the next poll. The
/// events arrive through this thread's message loop, so install it on the
/// main thread; the hook lives as long as the process.
pub fn watch_z_order(callback: impl Fn() + 'static) -> io::Result<()> {
Z_ORDER_CALLBACK.with(|slot| *slot.borrow_mut() = Some(Box::new(callback)));
// SAFETY: an out-of-context hook needs no module handle; the procedure
// is a plain function that lives for the whole process.
let hook = unsafe {
SetWinEventHook(
EVENT_OBJECT_SHOW,
EVENT_OBJECT_REORDER,
None,
Some(z_order_event_proc),
0,
0,
WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS,
)
};
if hook.is_invalid() {
Z_ORDER_CALLBACK.with(|slot| slot.borrow_mut().take());
return Err(io::Error::other("SetWinEventHook failed"));
}
Ok(())
}
unsafe extern "system" fn z_order_event_proc(
_hook: HWINEVENTHOOK,
event: u32,
_hwnd: HWND,
id_object: i32,
id_child: i32,
_thread: u32,
_time: u32,
) {
// Only whole windows count: a shown caret or cursor is an object too,
// and a hidden window cannot cover anything.
let relevant = match event {
EVENT_OBJECT_SHOW => id_object == OBJID_WINDOW.0 && id_child == CHILDID_SELF as i32,
EVENT_OBJECT_REORDER => true,
_ => false,
};
if relevant {
// `try_borrow` skips an event raised from inside the callback.
Z_ORDER_CALLBACK.with(|slot| {
let Ok(callback) = slot.try_borrow() else {
return;
};
if let Some(callback) = callback.as_ref() {
callback();
}
});
}
}
/// Hide a bubble (see [`show_no_activate`]).
pub fn hide(window: &tauri::WebviewWindow) -> io::Result<()> {
let hwnd = hwnd_of(window)?;
+43 -17
View File
@@ -139,7 +139,7 @@ struct BubbleSnapshot {
impl BubbleSnapshot {
fn of(state: &AppState) -> Self {
Self {
providers: enabled_live_providers(&state.settings),
providers: enabled_providers(&state.settings),
usage: state.usage.clone(),
strings: state.strings.clone(),
is_dark: state.is_dark,
@@ -264,7 +264,7 @@ impl Runtime {
};
let (views, menu_snapshot) = runtime.with_state(|s| {
(
tray::views(&enabled_live_providers(&s.settings), &s.usage, &s.strings),
tray::views(&enabled_providers(&s.settings), &s.usage, &s.strings),
MenuSnapshot::of(s),
)
});
@@ -433,6 +433,7 @@ impl Runtime {
MenuCommand::SizeLarger => self.resize_bubbles(geom::RESIZE_STEP_LOGICAL),
MenuCommand::SizeReset => self.set_bubble_size(DEFAULT_BUBBLE_SIZE),
MenuCommand::ToggleWidget => self.toggle_widget(),
MenuCommand::ToggleClickThrough => self.toggle_click_through(),
MenuCommand::Restart => self.restart(),
MenuCommand::Exit => self.exit(),
}
@@ -468,13 +469,10 @@ impl Runtime {
});
}
/// Toggle a live provider, keeping at least one enabled, then create or
/// Toggle a provider, keeping at least one enabled, then create or
/// destroy its bubble, update the tray and poll.
fn toggle_provider(self: &Arc<Self>, provider: ProviderId) {
if !provider.metadata().live_usage {
return;
}
let enabled = self.update_settings(|s| s.toggle_live_provider(provider));
let enabled = self.update_settings(|s| s.toggle_provider(provider));
if enabled {
bubble::spawn_create(&self.ui.app, provider);
} else if let Some(window) = self.ui.app.get_webview_window(&bubble_label(provider)) {
@@ -499,6 +497,27 @@ impl Runtime {
self.apply_visibility(event);
}
/// Flip whether the bubbles let the mouse through, and remember it.
fn toggle_click_through(&self) {
let ignore = self.update_settings(|s| {
s.click_through = !s.click_through;
s.click_through
});
bubble::set_click_through(&self.ui.app, ignore);
}
/// Show the bubbles again and put them back on top, unless they are
/// meant to be hidden.
pub fn bring_back_bubbles(&self) {
bubble::bring_back(&self.ui.app);
}
/// Raise shown bubbles that another app's always-on-top window covers.
#[cfg(windows)]
pub fn keep_bubbles_on_top(&self) {
bubble::keep_on_top(&self.ui.app);
}
/// Apply a visibility event to every bubble. A user show or hide is
/// also saved as `widget_visible`.
pub fn apply_visibility(&self, event: visibility::Event) {
@@ -638,8 +657,9 @@ impl Runtime {
/// Blocking body of [`Self::check_for_updates`]. Only a check that got
/// an answer is stamped as the last check; a failed one leaves the
/// stamp alone and has the scheduler retry sooner.
fn run_update_check(&self) {
/// stamp alone and has the scheduler retry sooner. A release found where
/// the app can replace itself is installed straight away.
fn run_update_check(self: &Arc<Self>) {
let proxy = update::proxy_url(net::env_proxy().as_ref(), self.http.system_proxy());
let outcome = update::check(&self.ui.app, proxy);
let succeeded = outcome.is_ok();
@@ -681,6 +701,13 @@ impl Runtime {
succeeded,
});
self.refresh_shell(false);
let auto_apply = self.with_state(|s| {
s.update_status == UpdateStatus::Available && s.update_channel.can_apply()
});
if auto_apply {
log::info!("update found; installing it without waiting for a click");
self.apply_update();
}
}
/// Download and install the release the last check found on a worker
@@ -835,9 +862,9 @@ pub fn bubble_label(provider: ProviderId) -> String {
format!("{BUBBLE_LABEL_PREFIX}{}", provider.slug())
}
/// Live providers the user has enabled, in display order.
pub fn enabled_live_providers(settings: &Settings) -> Vec<ProviderId> {
ProviderId::LIVE_USAGE
/// Providers the user has enabled, in display order.
pub fn enabled_providers(settings: &Settings) -> Vec<ProviderId> {
ProviderId::ALL
.into_iter()
.filter(|p| settings.is_provider_enabled(*p))
.collect()
@@ -1008,7 +1035,7 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
update_interval_secs: state.settings.update_check_interval_secs,
last_update_check_unix: state.settings.last_update_check_unix,
};
let providers = enabled_live_providers(&state.settings);
let providers = enabled_providers(&state.settings);
let (tx, rx) = mpsc::channel();
let runtime = Arc::new(Runtime {
@@ -1150,18 +1177,17 @@ mod tests {
}
#[test]
fn enabled_providers_keep_display_order_and_skip_non_live_ones() {
fn enabled_providers_keep_display_order() {
let mut settings = Settings {
show_codex: true,
show_opencode_go: true,
..Settings::default()
};
assert_eq!(
enabled_live_providers(&settings),
enabled_providers(&settings),
[ProviderId::Claude, ProviderId::ChatGpt]
);
settings.show_claude_code = false;
assert_eq!(enabled_live_providers(&settings), [ProviderId::ChatGpt]);
assert_eq!(enabled_providers(&settings), [ProviderId::ChatGpt]);
}
#[test]
+5 -11
View File
@@ -37,7 +37,6 @@ pub struct TrayView {
pub fn views(enabled: &[ProviderId], usage: &UsageState, strings: &LocaleStrings) -> Vec<TrayView> {
enabled
.iter()
.filter(|p| p.metadata().live_usage)
.map(|&provider| {
let entry = usage.snapshots.get(&provider);
TrayView {
@@ -106,7 +105,7 @@ struct Plan {
fn plan(shown: &HashMap<ProviderId, Shown>, views: &[TrayView]) -> Plan {
let mut plan = Plan {
remove: ProviderId::LIVE_USAGE
remove: ProviderId::ALL
.into_iter()
.filter(|p| shown.contains_key(p) && !views.iter().any(|v| v.provider == *p))
.collect(),
@@ -153,7 +152,7 @@ impl Trays {
/// The providers that currently have an icon.
pub fn providers(&self) -> Vec<ProviderId> {
let shown = self.shown();
ProviderId::LIVE_USAGE
ProviderId::ALL
.into_iter()
.filter(|p| shown.contains_key(p))
.collect()
@@ -319,13 +318,8 @@ mod tests {
#[test]
fn views_follow_enabled_providers_and_last_poll() {
let s = strings();
let all = [
ProviderId::Claude,
ProviderId::ChatGpt,
ProviderId::OpenCodeGo,
];
let v = views(&all, &usage(42.0, true), &s);
assert_eq!(v.len(), 2, "OpenCode Go never gets an icon");
let v = views(&ProviderId::ALL, &usage(42.0, true), &s);
assert_eq!(v.len(), 2);
assert_eq!(v[0].provider, ProviderId::Claude);
assert_eq!(v[0].percent, Some(42.0));
assert_eq!(
@@ -335,7 +329,7 @@ mod tests {
assert_eq!(v[1].provider, ProviderId::ChatGpt);
assert_eq!(v[1].percent, None);
// A failed last poll greys every badge to the track.
let v = views(&all, &usage(42.0, false), &s);
let v = views(&ProviderId::ALL, &usage(42.0, false), &s);
assert_eq!(v[0].percent, None);
assert!(views(&[ProviderId::ChatGpt], &usage(1.0, true), &s)
.iter()
+2 -18
View File
@@ -31,16 +31,13 @@ impl Registry {
settings: &Settings,
) -> Vec<(ProviderId, Result<UsageWindows, Error>)> {
let mut out = Vec::new();
for provider in ProviderId::LIVE_USAGE {
for provider in ProviderId::ALL {
if !settings.is_provider_enabled(provider) {
continue;
}
let result = match provider {
ProviderId::Claude => self.claude.poll(http),
ProviderId::ChatGpt => self.chatgpt.poll(http),
ProviderId::OpenCodeGo => {
unreachable!("OpenCode Go has no live usage provider yet")
}
};
out.push((provider, result));
}
@@ -56,7 +53,6 @@ impl Registry {
let locator = match id {
ProviderId::Claude => self.claude.locator(),
ProviderId::ChatGpt => self.chatgpt.locator(),
ProviderId::OpenCodeGo => return refresh::Outcome::CliMissing,
};
match locator.first_available() {
Some(src) => orchestrator.refresh(src),
@@ -80,18 +76,6 @@ mod tests {
use super::*;
use crate::net::USER_AGENT;
#[test]
fn live_usage_excludes_opencode_go() {
// `poll_enabled` relies on this for its `unreachable!` arm.
assert!(!ProviderId::LIVE_USAGE.contains(&ProviderId::OpenCodeGo));
for provider in ProviderId::ALL {
assert_eq!(
ProviderId::LIVE_USAGE.contains(&provider),
provider.metadata().live_usage
);
}
}
/// Polls the real providers with the credentials on this machine. Run by
/// hand: `cargo test -- --ignored live_poll --nocapture`.
#[test]
@@ -104,7 +88,7 @@ mod tests {
}
let mut registry = Registry::with_defaults();
let results = registry.poll_enabled(&http, &settings);
assert_eq!(results.len(), ProviderId::LIVE_USAGE.len());
assert_eq!(results.len(), ProviderId::ALL.len());
for (provider, result) in results {
match result {
Ok(w) => println!(
+1 -51
View File
@@ -12,19 +12,15 @@ pub enum ProviderId {
Claude,
#[serde(rename = "chatgpt")]
ChatGpt,
#[serde(rename = "opencode-go")]
OpenCodeGo,
}
impl ProviderId {
pub const ALL: [Self; 3] = [Self::Claude, Self::ChatGpt, Self::OpenCodeGo];
pub const LIVE_USAGE: [Self; 2] = [Self::Claude, Self::ChatGpt];
pub const ALL: [Self; 2] = [Self::Claude, Self::ChatGpt];
pub fn slug(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::ChatGpt => "chatgpt",
Self::OpenCodeGo => "opencode-go",
}
}
@@ -32,51 +28,6 @@ impl ProviderId {
pub fn from_slug(slug: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.slug() == slug)
}
pub fn metadata(self) -> ProviderMetadata {
match self {
Self::Claude => ProviderMetadata {
id: self,
slug: "claude",
default_enabled: true,
tray_icon_id: 1,
display_mode: ProviderDisplayMode::TwoWindow,
live_usage: true,
},
Self::ChatGpt => ProviderMetadata {
id: self,
slug: "chatgpt",
default_enabled: false,
tray_icon_id: 2,
display_mode: ProviderDisplayMode::TwoWindow,
live_usage: true,
},
Self::OpenCodeGo => ProviderMetadata {
id: self,
slug: "opencode-go",
default_enabled: false,
tray_icon_id: 3,
display_mode: ProviderDisplayMode::WeeklyMonthlyFourBar,
live_usage: false,
},
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProviderDisplayMode {
TwoWindow,
WeeklyMonthlyFourBar,
}
#[derive(Clone, Copy, Debug)]
pub struct ProviderMetadata {
pub id: ProviderId,
pub slug: &'static str,
pub default_enabled: bool,
pub tray_icon_id: u32,
pub display_mode: ProviderDisplayMode,
pub live_usage: bool,
}
/// One usage window: how much you've consumed (0–100), and when it resets.
@@ -110,7 +61,6 @@ mod tests {
let json = serde_json::to_string(&provider).expect("serialise");
assert_eq!(json, format!("\"{}\"", provider.slug()));
assert_eq!(ProviderId::from_slug(provider.slug()), Some(provider));
assert_eq!(provider.metadata().slug, provider.slug());
}
assert_eq!(ProviderId::from_slug("codex"), None);
}
+13
View File
@@ -10,6 +10,7 @@ import {
cssPx,
headLabelText,
headMetrics,
outlinePath,
pulseActive,
pulseT,
remainingFraction,
@@ -156,6 +157,18 @@ describe('tailLayout', () => {
});
});
describe('outlinePath', () => {
it('joins a full-height head to a centred tail with rounded end corners', () => {
// h 100: tail 60 tall from y 20 to 80, end corners of radius 12, and the
// tail edges meet the head circle at x = 50 + sqrt(50² - 30²) = 90.
assert.equal(
outlinePath(300, 100),
'M 90 20 L 288 20 A 12 12 0 0 1 300 32 L 300 68 A 12 12 0 0 1 288 80 ' +
'L 90 80 A 50 50 0 1 1 90 20 Z',
);
});
});
describe('scale and cssPx', () => {
it('truncates like the Rust scale', () => {
assert.equal(scale(200, 96), 200);
+26 -12
View File
@@ -1,20 +1,27 @@
/* Bubble palette. Accent and fill colours come from the payload. */
:root[data-theme='dark'] {
--bg: #1f1f1f;
--track: #2c2c2c;
--time-track: #404040;
/* 80% opaque, so the desktop shows through. Tracks and the edge are
tints over it, so they keep the same contrast step on a light or dark
desktop; the text halo keeps text readable on either. */
--bg: rgb(31 31 31 / 80%);
--track: rgb(0 0 0 / 28%);
--time-track: rgb(255 255 255 / 14%);
--time-fill: #b0b0b0;
--edge: rgb(255 255 255 / 22%);
--text: #eaeaea;
--muted: #a8a8a8;
--muted: #c4c4c4;
--halo: 0 0 2px rgb(0 0 0 / 75%);
}
:root[data-theme='light'] {
--bg: #f3f3f3;
--track: #e2e2e2;
--time-track: #e0e0e0;
--bg: rgb(243 243 243 / 80%);
--track: rgb(0 0 0 / 10%);
--time-track: rgb(0 0 0 / 8%);
--time-fill: #666666;
--edge: rgb(0 0 0 / 18%);
--text: #1f1f1f;
--muted: #5e5e5e;
--muted: #4a4a4a;
--halo: 0 0 2px rgb(255 255 255 / 85%);
}
html,
@@ -26,7 +33,7 @@ body {
background: transparent;
user-select: none;
-webkit-user-select: none;
/* Only the pill takes input: the transparent stadium corners are dead. */
/* Only the pill takes input: the area outside its outline is dead. */
pointer-events: none;
}
@@ -34,7 +41,8 @@ body {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 9999px;
/* bubble.js clips this to the head-and-tail outline, which also bounds
the hit area. */
background: var(--bg);
font-family: 'Segoe UI', system-ui, sans-serif;
cursor: move;
@@ -42,7 +50,7 @@ body {
}
/* Every pointer event targets the pill itself, whose hit area follows its
rounded outline. */
clipped outline. */
.pill * {
pointer-events: none;
}
@@ -54,10 +62,15 @@ body {
height: 100%;
}
.rings circle {
.rings circle,
.edge {
fill: none;
}
.edge {
stroke: var(--edge);
}
.ring-track {
stroke: var(--track);
}
@@ -80,6 +93,7 @@ body {
margin: 0;
white-space: nowrap;
color: var(--text);
text-shadow: var(--halo);
}
.head-label {
+1
View File
@@ -9,6 +9,7 @@
<body>
<div class="pill" id="pill">
<svg class="rings" id="rings" aria-hidden="true">
<path class="edge" id="edge"></path>
<circle class="ring-track" id="ring-track"></circle>
<circle class="ring-arc" id="ring-arc"></circle>
<circle class="time-ring-track" id="time-ring-track"></circle>
+10
View File
@@ -8,6 +8,7 @@ import {
cssPx,
headLabelText,
headMetrics,
outlinePath,
pulseActive,
pulseT,
remainingFraction,
@@ -58,6 +59,8 @@ const FONT_STACK = '"Segoe UI", system-ui, sans-serif';
/** Weight text is measured at when the caller names none (regular). */
const MEASURE_WEIGHT = 400;
const HEAD_LABEL_WEIGHT = 600;
/** Outline stroke in CSS px; the clip keeps the inner half. */
const EDGE_STROKE = 2;
const slug = windowLabel.replace(/^bubble-/, '');
@@ -79,6 +82,8 @@ function byId(id) {
const pill = byId('pill');
/** @type {SVGSVGElement} */
const rings = byId('rings');
/** @type {SVGPathElement} */
const edge = byId('edge');
/** @type {SVGCircleElement} */
const ringTrack = byId('ring-track');
/** @type {SVGCircleElement} */
@@ -217,7 +222,12 @@ function relayout(p) {
tail = tailLayout(canvas, (text, px, weight) => measure(text, px, weight));
layoutKey = key;
const outline = outlinePath(cssPx(p.canvasW, p.dpi), cssPx(p.canvasH, p.dpi));
pill.style.clipPath = `path('${outline}')`;
rings.setAttribute('viewBox', `0 0 ${p.canvasW} ${p.canvasH}`);
// Half the stroke falls outside the clip, leaving a 1 px edge.
edge.setAttribute('d', outlinePath(p.canvasW, p.canvasH));
edge.setAttribute('stroke-width', String((EDGE_STROKE * p.dpi) / BASE_DPI));
placeCircle(ringTrack, head.ringCx, head.ringCy, head.ringRadius, head.ringStroke);
placeCircle(ringArc, head.ringCx, head.ringCy, head.ringRadius, head.ringStroke);
placeCircle(timeRingTrack, head.ringCx, head.ringCy, head.timeRingRadius, head.timeRingStroke);
+35
View File
@@ -30,6 +30,12 @@ const WHEEL_MODE_PX = [1, 16, 800];
/** Usage at or above this percent pulses. */
export const PULSE_THRESHOLD_PCT = 95;
/** Tail height as a share of the head diameter. */
export const TAIL_HEIGHT_RATIO = 0.6;
/** Tail end corner radius as a share of the tail height. */
const TAIL_CORNER_RATIO = 0.2;
/** Pulse period in ticks and the share of the way to white at the peak. */
const PULSE_PERIOD = 24;
const PULSE_STRENGTH = 0.3;
@@ -195,6 +201,35 @@ export function headMetrics(canvas) {
};
}
/**
* Outline of the bubble: a round head the full height of the window, with a
* narrower, vertically centred tail running to the right edge. The tail's
* end has small rounded corners. Units are whatever `w` and `h` are in.
* @param {number} w Window width.
* @param {number} h Window height, which is also the head diameter.
* @returns {string} SVG path data for CSS `clip-path: path(...)`.
*/
export function outlinePath(w, h) {
const r = h / 2;
const tailH = h * TAIL_HEIGHT_RATIO;
const top = (h - tailH) / 2;
const bottom = top + tailH;
const corner = tailH * TAIL_CORNER_RATIO;
// Where the tail's top and bottom edges meet the head circle.
const joinX = r + Math.sqrt(r * r - (r - top) ** 2);
const n = (/** @type {number} */ v) => Number(v.toFixed(2));
return [
`M ${n(joinX)} ${n(top)}`,
`L ${n(w - corner)} ${n(top)}`,
`A ${n(corner)} ${n(corner)} 0 0 1 ${n(w)} ${n(top + corner)}`,
`L ${n(w)} ${n(bottom - corner)}`,
`A ${n(corner)} ${n(corner)} 0 0 1 ${n(w - corner)} ${n(bottom)}`,
`L ${n(joinX)} ${n(bottom)}`,
`A ${n(r)} ${n(r)} 0 1 1 ${n(joinX)} ${n(top)}`,
'Z',
].join(' ');
}
/**
* Geometry of the tail: the weekly usage bar and remaining-time bar with
* their right-aligned texts. When the tail is too narrow, the text column