From 97100cfcc04656590bffbb452090e32019051ddc Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 27 Oct 2026 12:11:24 +0700 Subject: [PATCH] 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. --- README.md | 15 +++++++++++---- src/bubble.rs | 25 ++++++++++++++++++++++++- src/core/i18n.rs | 2 ++ src/core/menu_model.rs | 19 ++++++++++++++++--- src/core/settings.rs | 5 +++++ src/runtime.rs | 10 ++++++++++ tests/js/layout.test.js | 13 +++++++++++++ ui/bubble.css | 12 +++++++----- ui/bubble.js | 3 +++ ui/layout.js | 35 +++++++++++++++++++++++++++++++++++ 10 files changed, 126 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b952aa8..400dc00 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/bubble.rs b/src/bubble.rs index 5dd570b..191662b 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -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()); diff --git a/src/core/i18n.rs b/src/core/i18n.rs index ed03656..71828cb 100644 --- a/src/core/i18n.rs +++ b/src/core/i18n.rs @@ -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(), diff --git a/src/core/menu_model.rs b/src/core/menu_model.rs index 214944f..a4bb26b 100644 --- a/src/core/menu_model.rs +++ b/src/core/menu_model.rs @@ -23,6 +23,7 @@ pub enum MenuCommand { SizeLarger, SizeReset, ToggleWidget, + ToggleClickThrough, Restart, Exit, } @@ -31,7 +32,7 @@ impl MenuCommand { /// The item id: `refresh`, `freq:`, `provider:`, `autostart`, /// `reset-position`, `version`, `update-interval:`, /// `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 { 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()); } diff --git a/src/core/settings.rs b/src/core/settings.rs index 53a8468..98c3f91 100644 --- a/src/core/settings.rs +++ b/src/core/settings.rs @@ -119,6 +119,9 @@ pub struct Settings { pub update_check_interval_secs: Option, #[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", diff --git a/src/runtime.rs b/src/runtime.rs index e2839af..918fd33 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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) { diff --git a/tests/js/layout.test.js b/tests/js/layout.test.js index 32b1d21..04e7492 100644 --- a/tests/js/layout.test.js +++ b/tests/js/layout.test.js @@ -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); diff --git a/ui/bubble.css b/ui/bubble.css index 99ac6f2..50f270f 100644 --- a/ui/bubble.css +++ b/ui/bubble.css @@ -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; } diff --git a/ui/bubble.js b/ui/bubble.js index 554a6ab..a076db5 100644 --- a/ui/bubble.js +++ b/ui/bubble.js @@ -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); diff --git a/ui/layout.js b/ui/layout.js index d475ae3..3081918 100644 --- a/ui/layout.js +++ b/ui/layout.js @@ -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