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:
tiennm99 committed 2026-10-27 12:11:24 +07:00
1 parent 4299c8e602
commit 97100cfcc0
10 files changed
+126 -13

No files matched your search

+11 -4
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,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
View File
@@ -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());
+2
View File
@@ -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
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,
@@ -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());
}
+5
View File
@@ -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",
+10
View File
@@ -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) {
+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);
+7 -5
View File
@@ -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;
}
+3
View File
@@ -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);
+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