mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-27 12:16:46 +00:00
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.
This commit is contained in:
1 parent
4299c8e602
commit
97100cfcc0
10 files changed
+126
-13
No files matched your search
@@ -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,7 +38,8 @@ 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
|
||||
@@ -166,7 +168,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 +178,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
|
||||
|
||||
@@ -268,6 +274,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
|
||||
|
||||
+24
-1
@@ -316,10 +316,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 +351,9 @@ pub fn create(app: &AppHandle, provider: ProviderId) -> tauri::Result<()> {
|
||||
set_position(&window, placement.pos);
|
||||
}
|
||||
set_size(&window, placement.size);
|
||||
if click_through {
|
||||
set_ignore_cursor(&window, true);
|
||||
}
|
||||
log::info!(
|
||||
"{label} created at ({}, {}) size {}x{} dpi {}",
|
||||
placement.pos.x,
|
||||
@@ -485,6 +489,25 @@ pub fn apply(app: &AppHandle, action: Action) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
}
|
||||
}
|
||||
|
||||
fn set_ignore_cursor(window: &WebviewWindow, ignore: bool) {
|
||||
if let Err(err) = window.set_ignore_cursor_events(ignore) {
|
||||
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());
|
||||
|
||||
@@ -56,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,
|
||||
@@ -169,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
-3
@@ -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,
|
||||
@@ -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),
|
||||
@@ -361,6 +370,7 @@ mod tests {
|
||||
update_check_interval_secs: Some(UPDATE_CHECK_DAILY_SECS),
|
||||
show_codex: true,
|
||||
widget_visible: false,
|
||||
click_through: true,
|
||||
..Settings::default()
|
||||
};
|
||||
let m = menu(&settings, UpdateStatus::Idle);
|
||||
@@ -372,6 +382,7 @@ mod tests {
|
||||
assert!(checked(&m, "provider:chatgpt"));
|
||||
assert!(checked(&m, "autostart"));
|
||||
assert!(!checked(&m, "toggle-widget"));
|
||||
assert!(checked(&m, "click-through"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -436,6 +447,7 @@ mod tests {
|
||||
"Settings",
|
||||
"Controls",
|
||||
"Show widget",
|
||||
"Click-through",
|
||||
"-",
|
||||
"Restart",
|
||||
"Exit"
|
||||
@@ -538,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]"
|
||||
@@ -567,7 +580,7 @@ mod tests {
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
walk(&m, &mut commands);
|
||||
assert_eq!(commands.len(), 20);
|
||||
assert_eq!(commands.len(), 21);
|
||||
for c in commands {
|
||||
assert_eq!(MenuCommand::parse(&c.id()), Some(c), "{}", c.id());
|
||||
}
|
||||
|
||||
@@ -119,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 {
|
||||
@@ -132,6 +135,7 @@ impl Default for Settings {
|
||||
last_update_check_unix: None,
|
||||
update_check_interval_secs: default_update_check_interval_secs(),
|
||||
widget_visible: default_widget_visible(),
|
||||
click_through: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -520,6 +524,7 @@ mod tests {
|
||||
[
|
||||
"bubble_positions",
|
||||
"bubble_size_logical",
|
||||
"click_through",
|
||||
"last_update_check_unix",
|
||||
"poll_interval_ms",
|
||||
"show_claude_code",
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -496,6 +497,15 @@ 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);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+7
-5
@@ -1,6 +1,7 @@
|
||||
/* Bubble palette. Accent and fill colours come from the payload. */
|
||||
:root[data-theme='dark'] {
|
||||
--bg: #1f1f1f;
|
||||
/* 60% opaque, so the desktop shows through; text and bars stay solid. */
|
||||
--bg: rgb(31 31 31 / 60%);
|
||||
--track: #2c2c2c;
|
||||
--time-track: #404040;
|
||||
--time-fill: #b0b0b0;
|
||||
@@ -9,7 +10,7 @@
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--bg: #f3f3f3;
|
||||
--bg: rgb(243 243 243 / 60%);
|
||||
--track: #e2e2e2;
|
||||
--time-track: #e0e0e0;
|
||||
--time-fill: #666666;
|
||||
@@ -26,7 +27,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 +35,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 +44,7 @@ body {
|
||||
}
|
||||
|
||||
/* Every pointer event targets the pill itself, whose hit area follows its
|
||||
rounded outline. */
|
||||
clipped outline. */
|
||||
.pill * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
cssPx,
|
||||
headLabelText,
|
||||
headMetrics,
|
||||
outlinePath,
|
||||
pulseActive,
|
||||
pulseT,
|
||||
remainingFraction,
|
||||
@@ -217,6 +218,8 @@ 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}`);
|
||||
placeCircle(ringTrack, head.ringCx, head.ringCy, head.ringRadius, head.ringStroke);
|
||||
placeCircle(ringArc, head.ringCx, head.ringCy, head.ringRadius, head.ringStroke);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user