feat: toggle a details panel window from bubble clicks with focus dismissal and live refresh

This commit is contained in:
tiennm99 committed 2026-10-21 15:29:26 +07:00
1 parent e805f8de8f
commit 768fb1e4ab
5 files changed
+534 -510

No files matched your search

+12 -2
View File
@@ -8,7 +8,8 @@ use tauri::{AppHandle, State, WebviewWindow};
use crate::bubble;
use crate::core::geom::RESIZE_STEP_LOGICAL;
use crate::core::i18n::LocaleStrings;
use crate::core::payload::BubblePayload;
use crate::core::payload::{BubblePayload, PanelPayload};
use crate::panel;
use crate::runtime::Runtime;
use crate::usage::ProviderId;
@@ -28,6 +29,13 @@ pub fn get_bubble_state(
Ok(runtime.bubble_payload(provider, scale))
}
/// The details-panel payload for the provider the panel last showed;
/// `None` before the first bubble click.
#[tauri::command]
pub fn get_panel_state(runtime: State<'_, Arc<Runtime>>) -> Option<PanelPayload> {
panel::current_provider().map(|provider| runtime.panel_payload(provider))
}
/// Every UI string, camelCase keys.
#[tauri::command]
pub fn get_strings(runtime: State<'_, Arc<Runtime>>) -> LocaleStrings {
@@ -54,8 +62,10 @@ pub fn bubble_ready(app: AppHandle, label: String) -> Result<(), String> {
}
/// A left click (a press released before the drag threshold) on a bubble.
/// Async, so the panel toggle is queued to the main thread instead of
/// running inside the webview's IPC callback.
#[tauri::command]
pub fn bubble_click(runtime: State<'_, Arc<Runtime>>, slug: String) -> Result<(), String> {
pub async fn bubble_click(runtime: State<'_, Arc<Runtime>>, slug: String) -> Result<(), String> {
let provider = live_provider(&slug)?;
runtime.bubble_clicked(provider);
Ok(())
+1
View File
@@ -13,6 +13,7 @@ pub mod diag;
pub mod handoff;
pub mod menu;
pub mod net;
pub mod panel;
pub mod platform;
pub mod runtime;
pub mod scheduler;
+419 -497
View File
@@ -1,542 +1,464 @@
// Expanded panel shown when a bubble is left-clicked.
//
// Plain opaque top-most popup with two horizontal usage bars (session/5h and
// weekly/7d) plus countdown text. Closes on focus loss.
//! The details panel: one opaque, undecorated, always-on-top webview
//! labelled `panel`, created hidden at startup and then only shown and
//! hidden, never destroyed.
//!
//! A bubble click toggles it for that bubble's provider, placed below the
//! bubble (or above it near the bottom edge). Losing focus hides it. While
//! it is open, every state push refreshes it in place.
//!
//! The page (`ui/panel.js`) only draws the [`PanelPayload`]. Every Tauri
//! call here runs on the main thread, which also serialises a toggle
//! against a refresh, and no function holds a lock across a Tauri call.
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use windows::core::PCWSTR;
use windows::Win32::Foundation::*;
use windows::Win32::Graphics::Dwm::{
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
use tauri::window::Color;
use tauri::{
AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow,
WebviewWindowBuilder, Window, WindowEvent,
};
use windows::Win32::Graphics::Gdi::*;
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::HiDpi::GetDpiForWindow;
use windows::Win32::UI::WindowsAndMessaging::*;
use crate::i18n::LocaleStrings;
use crate::os::dpi::scale as scale_to_dpi;
use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
use crate::core::geom::{self, Point, Rect};
use crate::core::payload::PanelPayload;
use crate::platform;
use crate::runtime::{self, Runtime};
use crate::usage::ProviderId;
const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel";
/// Window label of the panel.
pub const PANEL_LABEL: &str = "panel";
/// Event carrying a [`PanelPayload`] to the panel webview.
pub const PANEL_UPDATE_EVENT: &str = "panel:update";
const TITLE: &str = "Usage panel";
const PANEL_W_LOGICAL: i32 = 280;
const PANEL_H_LOGICAL: i32 = 120;
const PADDING_LOGICAL: i32 = 14;
const ROW_GAP_LOGICAL: i32 = 8;
const LABEL_W_LOGICAL: i32 = 28;
const RIGHT_TEXT_W_LOGICAL: i32 = 96;
const BAR_HEIGHT_LOGICAL: i32 = 14;
pub struct PanelData {
pub model: ProviderId,
pub session_pct: f64,
pub session_text: String,
pub weekly_pct: f64,
pub weekly_text: String,
pub is_dark: bool,
pub strings: LocaleStrings,
}
/// A click that reopens the panel this soon after focus loss hid it, for
/// the same provider, is the click that caused the focus loss: it closes
/// the panel instead of reopening it.
const BLUR_REOPEN_GUARD: Duration = Duration::from_millis(150);
/// Panel backgrounds, matching `ui/panel.css`, so a show never flashes white.
const DARK_BG: Color = Color(0x1F, 0x1F, 0x1F, 0xFF);
const LIGHT_BG: Color = Color(0xFA, 0xFA, 0xFA, 0xFF);
/// What the panel shows. Only the main thread changes it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct PanelState {
hwnd: HWND,
data: PanelData,
/// The provider on screen, `None` while the panel is hidden.
shown: Option<ProviderId>,
/// The provider last shown, so a reloaded page can ask for its data.
provider: Option<ProviderId>,
/// The provider and time of the last hide caused by focus loss.
blur_hide: Option<(ProviderId, Instant)>,
}
// HWND wraps `*mut c_void` which is `!Send`, but the panel state is only ever
// accessed from the UI thread — the Mutex exists to satisfy the `OnceLock`
// static contract, not because of real cross-thread sharing.
unsafe impl Send for PanelState {}
static STATE: Mutex<PanelState> = Mutex::new(PanelState {
shown: None,
provider: None,
blur_hide: None,
});
fn state() -> &'static Mutex<Option<PanelState>> {
static S: OnceLock<Mutex<Option<PanelState>>> = OnceLock::new();
S.get_or_init(|| Mutex::new(None))
fn with_state<T>(f: impl FnOnce(&mut PanelState) -> T) -> T {
let mut guard = STATE.lock().unwrap_or_else(PoisonError::into_inner);
f(&mut guard)
}
fn lock_state() -> MutexGuard<'static, Option<PanelState>> {
state().lock().expect("panel state mutex poisoned")
/// What a bubble click does to the panel.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Toggle {
Show,
Hide,
/// The click already closed the panel through focus loss.
Ignore,
}
pub fn register_class() {
static REGISTERED: OnceLock<()> = OnceLock::new();
REGISTERED.get_or_init(|| unsafe {
let class_w = wide_str(CLASS_NAME);
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
style: CS_HREDRAW | CS_VREDRAW,
lpfnWndProc: Some(wnd_proc),
hInstance: HINSTANCE(hinstance.0),
hCursor: LoadCursorW(HINSTANCE::default(), IDC_ARROW).unwrap_or_default(),
hbrBackground: HBRUSH(std::ptr::null_mut()),
lpszClassName: PCWSTR::from_raw(class_w.as_ptr()),
..Default::default()
};
if RegisterClassExW(&wc) == 0 {
log::error!("panel RegisterClassExW returned 0");
/// Decide a click on `provider`'s bubble and record the result in `state`.
fn toggle_state(state: &mut PanelState, provider: ProviderId, now: Instant) -> Toggle {
if state.shown == Some(provider) {
state.shown = None;
return Toggle::Hide;
}
let just_blurred = state.shown.is_none()
&& state.blur_hide.is_some_and(|(p, at)| {
p == provider && now.saturating_duration_since(at) < BLUR_REOPEN_GUARD
});
// Either way the guard is spent, so a later click opens the panel.
state.blur_hide = None;
if just_blurred {
return Toggle::Ignore;
}
state.shown = Some(provider);
state.provider = Some(provider);
Toggle::Show
}
/// Record a hide caused by focus loss. Returns whether the panel was shown.
fn blur_state(state: &mut PanelState, now: Instant) -> bool {
match state.shown.take() {
Some(provider) => {
state.blur_hide = Some((provider, now));
true
}
None => false,
}
}
/// The provider on screen, `None` while the panel is hidden.
pub fn shown_provider() -> Option<ProviderId> {
with_state(|s| s.shown)
}
/// The provider the panel last showed, hidden or not.
pub fn current_provider() -> Option<ProviderId> {
with_state(|s| s.provider)
}
fn runtime_of(app: &AppHandle) -> Option<Arc<Runtime>> {
app.try_state::<Arc<Runtime>>().map(|r| Arc::clone(&r))
}
fn background(is_dark: bool) -> Color {
if is_dark {
DARK_BG
} else {
LIGHT_BG
}
}
/// Create the hidden panel window. Called once at startup.
pub fn create(app: &AppHandle, is_dark: bool) -> tauri::Result<()> {
let window = WebviewWindowBuilder::new(app, PANEL_LABEL, WebviewUrl::App("panel.html".into()))
.title(TITLE)
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.transparent(false)
.shadow(false)
.visible(false)
.background_color(background(is_dark))
.inner_size(f64::from(PANEL_W_LOGICAL), f64::from(PANEL_H_LOGICAL))
.build()?;
prepare_native(app, &window);
log::info!("{PANEL_LABEL} created");
Ok(())
}
/// Windows: drop the panel from Alt-Tab and turn off the browser keys of
/// its webview, on the window's own (main) thread, before the first show.
#[cfg(windows)]
fn prepare_native(app: &AppHandle, window: &WebviewWindow) {
let window = window.clone();
let queued = app.run_on_main_thread(move || {
if let Err(err) = platform::windows::apply_toolwindow(&window) {
log::warn!("{PANEL_LABEL} may show in Alt-Tab: {err}");
}
if let Err(err) = platform::windows::disable_browser_accelerator_keys(&window) {
log::warn!("{PANEL_LABEL} keeps the browser accelerator keys: {err}");
}
});
}
pub fn is_visible() -> bool {
let s = lock_state();
s.as_ref()
.map(|p| unsafe { IsWindowVisible(p.hwnd).as_bool() })
.unwrap_or(false)
}
pub fn current_model() -> Option<ProviderId> {
lock_state().as_ref().map(|p| p.data.model)
}
/// Show the panel for the given model, anchored near the supplied bubble HWND.
/// If a panel is already visible, hide it instead (toggle).
pub fn toggle(data: PanelData, anchor_hwnd: HWND) {
if is_visible() && current_model() == Some(data.model) {
hide();
return;
}
show(data, anchor_hwnd);
}
pub fn show(data: PanelData, anchor_hwnd: HWND) {
register_class();
let mut anchor_rect = RECT::default();
unsafe {
let _ = GetWindowRect(anchor_hwnd, &mut anchor_rect);
}
let dpi = unsafe { GetDpiForWindow(anchor_hwnd).max(96) };
let panel_w = scale_to_dpi(PANEL_W_LOGICAL, dpi);
let panel_h = scale_to_dpi(PANEL_H_LOGICAL, dpi);
let (x, y) = place_near(anchor_rect, panel_w, panel_h);
let existing_hwnd = lock_state().as_ref().map(|p| p.hwnd);
let hwnd = match existing_hwnd {
Some(h) => h,
None => match create_panel_window(x, y, panel_w, panel_h) {
Some(h) => h,
None => return,
},
};
apply_win11_window_chrome(hwnd);
{
let mut guard = lock_state();
if let Some(p) = guard.as_mut() {
p.data = data;
} else {
*guard = Some(PanelState { hwnd, data });
}
}
unsafe {
let _ = SetWindowPos(
hwnd,
HWND::default(),
x,
y,
panel_w,
panel_h,
SWP_NOZORDER | SWP_NOACTIVATE,
);
let _ = InvalidateRect(hwnd, None, true);
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
let _ = SetForegroundWindow(hwnd);
if let Err(err) = queued {
log::warn!("panel set-up could not reach the main thread: {err}");
}
}
fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
let hwnd = unsafe {
let class_w = wide_str(CLASS_NAME);
let title_w = wide_str("Usage panel");
let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap_or_default();
CreateWindowExW(
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
PCWSTR::from_raw(class_w.as_ptr()),
PCWSTR::from_raw(title_w.as_ptr()),
WS_POPUP,
x,
y,
w,
h,
HWND::default(),
HMENU::default(),
hinstance,
None,
)
.unwrap_or_default()
};
if hwnd == HWND::default() {
log::error!("panel CreateWindowExW failed");
None
} else {
Some(hwnd)
#[cfg(not(windows))]
fn prepare_native(_app: &AppHandle, _window: &WebviewWindow) {}
/// A click on `provider`'s bubble: hide the panel when it shows that
/// provider, otherwise move it next to the bubble and show that provider.
pub fn toggle(app: &AppHandle, provider: ProviderId) {
let handle = app.clone();
let queued = app.run_on_main_thread(move || toggle_on_main(&handle, provider));
if let Err(err) = queued {
log::warn!("panel toggle could not reach the main thread: {err}");
}
}
/// Apply Windows 11 rounded corners. Win11-only — `DwmSetWindowAttribute`
/// returns an error on Win10 and earlier, which we deliberately swallow so
/// the panel falls back to square corners without complaint. Idempotent:
/// DWM ignores redundant identical-value sets, so calling this on every
/// `show()` is safe.
fn apply_win11_window_chrome(hwnd: HWND) {
unsafe {
let pref = DWMWCP_ROUND;
let _ = DwmSetWindowAttribute(
hwnd,
DWMWA_WINDOW_CORNER_PREFERENCE,
&pref as *const _ as *const _,
std::mem::size_of_val(&pref) as u32,
);
}
}
pub fn hide() {
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
if let Some(hwnd) = hwnd_opt {
unsafe {
let _ = ShowWindow(hwnd, SW_HIDE);
}
}
}
pub fn destroy() {
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
if let Some(hwnd) = hwnd_opt {
unsafe {
let _ = DestroyWindow(hwnd);
}
}
*lock_state() = None;
}
/// Refresh the panel's data (called from app when poll cycle completes).
pub fn refresh_data(data: PanelData) {
let hwnd_opt = {
let mut guard = lock_state();
let Some(p) = guard.as_mut() else {
return;
};
if p.data.model != data.model {
// Showing a different model right now — caller can decide whether to swap.
return;
}
p.data = data;
Some(p.hwnd)
};
if let Some(hwnd) = hwnd_opt {
unsafe {
let _ = InvalidateRect(hwnd, None, false);
}
}
}
// ---------- Window proc & painting ----------
unsafe extern "system" fn wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match msg {
WM_PAINT => {
let mut ps = PAINTSTRUCT::default();
let hdc = BeginPaint(hwnd, &mut ps);
paint(hwnd, hdc);
let _ = EndPaint(hwnd, &ps);
LRESULT(0)
}
WM_ERASEBKGND => LRESULT(1),
WM_KILLFOCUS => {
// Close the panel when it loses focus. Use ShowWindow rather than
// DestroyWindow so we can re-show it next click without re-creating.
let _ = ShowWindow(hwnd, SW_HIDE);
LRESULT(0)
}
WM_DESTROY => {
*lock_state() = None;
LRESULT(0)
}
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
}
}
fn paint(hwnd: HWND, hdc: HDC) {
let Some(data) = clone_data() else {
fn toggle_on_main(app: &AppHandle, provider: ProviderId) {
let Some(window) = app.get_webview_window(PANEL_LABEL) else {
log::warn!("bubble click on {} but no panel exists", provider.slug());
return;
};
let mut rc = RECT::default();
unsafe {
let _ = GetClientRect(hwnd, &mut rc);
}
let dpi = unsafe { GetDpiForWindow(hwnd).max(96) };
let scaled = |v: i32| scale_to_dpi(v, dpi);
let bg = if data.is_dark {
Color::from_hex("#1F1F1F")
} else {
Color::from_hex("#FAFAFA")
};
let text_color = if data.is_dark {
Color::from_hex("#EAEAEA")
} else {
Color::from_hex("#1F1F1F")
};
let track = if data.is_dark {
Color::from_hex("#3A3A3A")
} else {
Color::from_hex("#D6D6D6")
};
let session_accent =
crate::usage_color::bar_fill_color(data.model, data.is_dark, data.session_pct);
let weekly_accent =
crate::usage_color::bar_fill_color(data.model, data.is_dark, data.weekly_pct);
unsafe {
let bg_brush = CreateSolidBrush(COLORREF(bg.into_colorref()));
FillRect(hdc, &rc, bg_brush);
let _ = DeleteObject(bg_brush);
let stripe_color = crate::usage_color::accent_color_for(data.model, data.is_dark);
let stripe_w = scaled(4);
let stripe_rect = RECT {
left: 0,
top: 0,
right: stripe_w,
bottom: rc.bottom,
};
let stripe_brush = CreateSolidBrush(COLORREF(stripe_color.into_colorref()));
FillRect(hdc, &stripe_rect, stripe_brush);
let _ = DeleteObject(stripe_brush);
// Header row: model label
let header = match data.model {
ProviderId::Claude => data.strings.claude_label.clone(),
ProviderId::ChatGpt => data.strings.chatgpt_label.clone(),
ProviderId::OpenCodeGo => data.strings.opencode_go_label.clone(),
};
draw_text(
hdc,
&header,
text_color,
scaled(PADDING_LOGICAL),
scaled(PADDING_LOGICAL),
rc.right - 2 * scaled(PADDING_LOGICAL),
scaled(18),
true,
dpi,
);
let bar_x = scaled(PADDING_LOGICAL) + scaled(LABEL_W_LOGICAL) + scaled(4);
let bar_w =
rc.right - bar_x - scaled(PADDING_LOGICAL) - scaled(RIGHT_TEXT_W_LOGICAL) - scaled(4);
let row1_y = scaled(PADDING_LOGICAL) + scaled(24);
let row2_y = row1_y + scaled(BAR_HEIGHT_LOGICAL) + scaled(ROW_GAP_LOGICAL) + scaled(8);
draw_row(
hdc,
&data.strings.session_window,
scaled(PADDING_LOGICAL),
row1_y,
bar_x,
bar_w,
scaled(BAR_HEIGHT_LOGICAL),
data.session_pct,
&data.session_text,
text_color,
track,
session_accent,
dpi,
);
draw_row(
hdc,
&data.strings.weekly_window,
scaled(PADDING_LOGICAL),
row2_y,
bar_x,
bar_w,
scaled(BAR_HEIGHT_LOGICAL),
data.weekly_pct,
&data.weekly_text,
text_color,
track,
weekly_accent,
dpi,
);
match with_state(|s| toggle_state(s, provider, Instant::now())) {
Toggle::Hide => hide(&window),
Toggle::Ignore => log::debug!("panel just closed by this click; not reopened"),
Toggle::Show => show_for(app, &window, provider),
}
}
#[allow(clippy::too_many_arguments)]
fn draw_row(
hdc: HDC,
label: &str,
label_x: i32,
row_y: i32,
bar_x: i32,
bar_w: i32,
bar_h: i32,
pct: f64,
right_text: &str,
text_color: Color,
track: Color,
accent: Color,
dpi: u32,
) {
let scaled = |v: i32| scale_to_dpi(v, dpi);
let label_w = scaled(LABEL_W_LOGICAL);
draw_text(
hdc,
label,
text_color,
label_x,
row_y - scaled(2),
label_w,
bar_h + scaled(4),
false,
dpi,
/// Fill the panel with `provider`'s data, place it next to that bubble and
/// show it with focus, so that losing focus can dismiss it.
fn show_for(app: &AppHandle, window: &WebviewWindow, provider: ProviderId) {
let Some(runtime) = runtime_of(app) else {
log::warn!("{PANEL_LABEL} not shown: the runtime is not ready");
return;
};
let payload = runtime.panel_payload(provider);
place_near_bubble(app, window, provider);
if let Err(err) = window.set_background_color(Some(background(payload.is_dark))) {
log::debug!("{PANEL_LABEL} background not set: {err}");
}
emit(app, &payload);
#[cfg(windows)]
if let Err(err) = platform::windows::round_corners(window) {
// Windows 10 has no rounded corners.
log::debug!("{PANEL_LABEL} keeps square corners: {err}");
}
if let Err(err) = window.show() {
log::warn!("{PANEL_LABEL} not shown: {err}");
}
if let Err(err) = window.set_focus() {
log::debug!("{PANEL_LABEL} not focused: {err}");
}
}
/// Size the panel for the bubble's DPI and place it next to the bubble,
/// inside the union of all monitors. Without a bubble it stays where it is.
fn place_near_bubble(app: &AppHandle, window: &WebviewWindow, provider: ProviderId) {
let Some(bubble) = app.get_webview_window(&runtime::bubble_label(provider)) else {
log::warn!("no bubble for {}; panel not moved", provider.slug());
return;
};
let (Ok(pos), Ok(size), Ok(scale)) = (
bubble.outer_position(),
bubble.outer_size(),
bubble.scale_factor(),
) else {
log::warn!(
"bounds of the {} bubble unavailable; panel not moved",
provider.slug()
);
return;
};
let dpi = runtime::dpi_for_scale(scale);
let panel_size = (
geom::scale(PANEL_W_LOGICAL, dpi),
geom::scale(PANEL_H_LOGICAL, dpi),
);
unsafe {
let track_brush = CreateSolidBrush(COLORREF(track.into_colorref()));
let bar_rect = RECT {
left: bar_x,
top: row_y,
right: bar_x + bar_w,
bottom: row_y + bar_h,
};
FillRect(hdc, &bar_rect, track_brush);
let _ = DeleteObject(track_brush);
let fill_w = ((pct.clamp(0.0, 100.0) / 100.0) * bar_w as f64).round() as i32;
if fill_w > 0 {
let accent_brush = CreateSolidBrush(COLORREF(accent.into_colorref()));
let fill_rect = RECT {
left: bar_x,
top: row_y,
right: bar_x + fill_w,
bottom: row_y + bar_h,
};
FillRect(hdc, &fill_rect, accent_brush);
let _ = DeleteObject(accent_brush);
let physical = PhysicalSize::new(
panel_size.0.max(1).unsigned_abs(),
panel_size.1.max(1).unsigned_abs(),
);
if !platform::positioning_supported() {
if let Err(err) = window.set_size(physical) {
log::warn!("{PANEL_LABEL} not resized: {err}");
}
return;
}
let right_text_x = bar_x + bar_w + scaled(8);
let right_text_w = scaled(RIGHT_TEXT_W_LOGICAL);
draw_text(
hdc,
right_text,
text_color,
right_text_x,
row_y - scaled(2),
right_text_w,
bar_h + scaled(4),
false,
dpi,
let anchor = Rect::from_xywh(
pos.x,
pos.y,
i32::try_from(size.width).unwrap_or(i32::MAX),
i32::try_from(size.height).unwrap_or(i32::MAX),
);
let bounds = virtual_screen(&runtime::monitor_rects(app)).unwrap_or(anchor);
let target = geom::place_near(anchor, panel_size, bounds);
// Move first so a DPI change from crossing monitors resizes the window
// before the explicit size, then place it again at the final size.
set_position(window, target);
if let Err(err) = window.set_size(physical) {
log::warn!("{PANEL_LABEL} not resized: {err}");
}
set_position(window, target);
}
#[allow(clippy::too_many_arguments)]
fn draw_text(
hdc: HDC,
text: &str,
color: Color,
x: i32,
y: i32,
w: i32,
h: i32,
bold: bool,
dpi: u32,
) {
let mut text_w: Vec<u16> = text.encode_utf16().collect();
let font_size = if bold {
scale_to_dpi(13, dpi)
} else {
scale_to_dpi(11, dpi)
fn set_position(window: &WebviewWindow, pos: Point) {
if let Err(err) = window.set_position(PhysicalPosition::new(pos.x, pos.y)) {
log::warn!("{PANEL_LABEL} not moved: {err}");
}
}
/// The bounding rect of all monitors, `None` without any.
fn virtual_screen(monitors: &[Rect]) -> Option<Rect> {
monitors
.iter()
.copied()
.reduce(|a, b| Rect::new(a.l.min(b.l), a.t.min(b.t), a.r.max(b.r), a.b.max(b.b)))
}
fn hide(window: &WebviewWindow) {
if let Err(err) = window.hide() {
log::warn!("{PANEL_LABEL} not hidden: {err}");
}
}
fn emit(app: &AppHandle, payload: &PanelPayload) {
if let Err(err) = app.emit_to(PANEL_LABEL, PANEL_UPDATE_EVENT, payload) {
log::warn!("emit {PANEL_UPDATE_EVENT} failed: {err}");
}
}
/// Update the open panel in place, without moving it, when it shows
/// `payload`'s provider. Called after every state push, outside the state
/// lock; the check runs on the main thread, after any queued toggle.
pub fn refresh_if_visible(app: &AppHandle, payload: PanelPayload) {
let handle = app.clone();
let queued = app.run_on_main_thread(move || {
if shown_provider() == Some(payload.provider) {
emit(&handle, &payload);
}
});
if let Err(err) = queued {
log::warn!("panel refresh could not reach the main thread: {err}");
}
}
/// Handle the panel's window events; other windows are ignored.
pub fn on_window_event(window: &Window, event: &WindowEvent) {
if window.label() != PANEL_LABEL {
return;
}
match event {
// Alt+F4 hides the panel; it is never destroyed.
WindowEvent::CloseRequested { api, .. } => {
api.prevent_close();
with_state(|s| s.shown = None);
if let Err(err) = window.hide() {
log::warn!("{PANEL_LABEL} not hidden: {err}");
}
}
WindowEvent::Focused(false) => on_blur(window),
_ => {}
}
}
/// Focus left the panel: hide it if it was open, and remember when, so
/// the bubble click that took the focus does not reopen it.
fn on_blur(window: &Window) {
if !with_state(|s| blur_state(s, Instant::now())) {
return;
}
if let Err(err) = window.hide() {
log::warn!("{PANEL_LABEL} not hidden: {err}");
}
}
#[cfg(test)]
mod tests {
use super::*;
const HIDDEN: PanelState = PanelState {
shown: None,
provider: None,
blur_hide: None,
};
let font_name = wide_str("Segoe UI");
unsafe {
let weight = if bold { FW_SEMIBOLD.0 } else { FW_NORMAL.0 } as i32;
let font = CreateFontW(
-font_size,
0,
0,
0,
weight,
0,
0,
0,
DEFAULT_CHARSET.0 as u32,
OUT_DEFAULT_PRECIS.0 as u32,
CLIP_DEFAULT_PRECIS.0 as u32,
CLEARTYPE_QUALITY.0 as u32,
(FF_SWISS.0 | DEFAULT_PITCH.0) as u32,
PCWSTR::from_raw(font_name.as_ptr()),
);
let old_font = SelectObject(hdc, font);
SetTextColor(hdc, COLORREF(color.into_colorref()));
SetBkMode(hdc, TRANSPARENT);
let mut rect = RECT {
left: x,
top: y,
right: x + w,
bottom: y + h,
};
let _ = DrawTextW(
hdc,
&mut text_w,
&mut rect,
DT_LEFT | DT_VCENTER | DT_SINGLELINE,
);
SelectObject(hdc, old_font);
let _ = DeleteObject(font);
}
}
fn clone_data() -> Option<PanelData> {
let guard = lock_state();
let p = guard.as_ref()?;
Some(PanelData {
model: p.data.model,
session_pct: p.data.session_pct,
session_text: p.data.session_text.clone(),
weekly_pct: p.data.weekly_pct,
weekly_text: p.data.weekly_text.clone(),
is_dark: p.data.is_dark,
strings: p.data.strings.clone(),
})
}
#[test]
fn a_click_opens_the_hidden_panel() {
let mut state = HIDDEN;
let now = Instant::now();
assert_eq!(
toggle_state(&mut state, ProviderId::Claude, now),
Toggle::Show
);
assert_eq!(state.shown, Some(ProviderId::Claude));
assert_eq!(state.provider, Some(ProviderId::Claude));
}
fn place_near(anchor: RECT, panel_w: i32, panel_h: i32) -> (i32, i32) {
// Anchor below the bubble by default; flip above if it would clip.
let mut x = anchor.left;
let mut y = anchor.bottom + 8;
// The virtual screen spans all monitors. Its origin is offset from
// the primary monitor when a secondary monitor sits left of / above
// the primary, so clamps must include SM_XVIRTUALSCREEN /
// SM_YVIRTUALSCREEN — not just the width/height of the union.
let vx = unsafe { GetSystemMetrics(SM_XVIRTUALSCREEN) };
let vy = unsafe { GetSystemMetrics(SM_YVIRTUALSCREEN) };
let vw = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) };
let vh = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) };
let right = vx + vw;
let bottom = vy + vh;
if y + panel_h > bottom {
y = anchor.top - panel_h - 8;
#[test]
fn a_second_click_on_the_same_bubble_closes_it() {
let mut state = HIDDEN;
let now = Instant::now();
toggle_state(&mut state, ProviderId::Claude, now);
assert_eq!(
toggle_state(&mut state, ProviderId::Claude, now),
Toggle::Hide
);
assert_eq!(state.shown, None);
// The last provider is kept for a reloaded page.
assert_eq!(state.provider, Some(ProviderId::Claude));
}
if y < vy {
y = anchor.top.max(vy);
#[test]
fn a_click_on_the_other_bubble_swaps_the_content() {
let mut state = HIDDEN;
let now = Instant::now();
toggle_state(&mut state, ProviderId::Claude, now);
assert_eq!(
toggle_state(&mut state, ProviderId::ChatGpt, now),
Toggle::Show
);
assert_eq!(state.shown, Some(ProviderId::ChatGpt));
}
if x + panel_w > right {
x = right - panel_w - 8;
#[test]
fn a_click_right_after_a_blur_hide_does_not_reopen() {
let mut state = HIDDEN;
let t0 = Instant::now();
toggle_state(&mut state, ProviderId::Claude, t0);
assert!(blur_state(&mut state, t0));
assert_eq!(state.shown, None);
assert_eq!(
toggle_state(
&mut state,
ProviderId::Claude,
t0 + Duration::from_millis(149)
),
Toggle::Ignore
);
assert_eq!(state.shown, None);
// The guard is spent: the next click opens the panel again.
assert_eq!(
toggle_state(
&mut state,
ProviderId::Claude,
t0 + Duration::from_millis(160)
),
Toggle::Show
);
}
if x < vx {
x = vx + 8;
#[test]
fn the_blur_guard_expires_and_ignores_other_providers() {
let t0 = Instant::now();
let mut state = HIDDEN;
toggle_state(&mut state, ProviderId::Claude, t0);
blur_state(&mut state, t0);
assert_eq!(
toggle_state(&mut state, ProviderId::Claude, t0 + BLUR_REOPEN_GUARD),
Toggle::Show
);
let mut state = HIDDEN;
toggle_state(&mut state, ProviderId::Claude, t0);
blur_state(&mut state, t0);
assert_eq!(
toggle_state(
&mut state,
ProviderId::ChatGpt,
t0 + Duration::from_millis(10)
),
Toggle::Show
);
assert_eq!(state.shown, Some(ProviderId::ChatGpt));
}
#[test]
fn a_blur_while_hidden_records_nothing() {
let mut state = HIDDEN;
assert!(!blur_state(&mut state, Instant::now()));
assert_eq!(state.blur_hide, None);
}
#[test]
fn virtual_screen_is_the_union_of_the_monitors() {
assert_eq!(virtual_screen(&[]), None);
let primary = Rect::new(0, 0, 1920, 1080);
assert_eq!(virtual_screen(&[primary]), Some(primary));
let left_above = Rect::new(-1920, -200, 0, 880);
assert_eq!(
virtual_screen(&[primary, left_above]),
Some(Rect::new(-1920, -200, 1920, 1080))
);
}
#[test]
fn panel_backgrounds_match_the_palette() {
assert_eq!(background(true), Color(0x1F, 0x1F, 0x1F, 0xFF));
assert_eq!(background(false), Color(0xFA, 0xFA, 0xFA, 0xFF));
}
(x, y)
}
+66 -3
View File
@@ -4,11 +4,17 @@ use std::io;
use std::path::Path;
use std::time::Duration;
use webview2_com::Microsoft::Web::WebView2::Win32::GetAvailableCoreWebView2BrowserVersionString;
use windows::core::{HSTRING, PCWSTR, PWSTR};
use webview2_com::Microsoft::Web::WebView2::Win32::{
GetAvailableCoreWebView2BrowserVersionString, ICoreWebView2Settings3,
};
use windows::core::{Interface, HSTRING, PCWSTR, PWSTR};
use windows::Win32::Foundation::{
CloseHandle, SetLastError, HWND, LPARAM, LRESULT, WAIT_OBJECT_0, WIN32_ERROR, WPARAM,
};
use windows::Win32::Graphics::Dwm::{
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
DWM_WINDOW_CORNER_PREFERENCE,
};
use windows::Win32::System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE};
use windows::Win32::UI::Shell::{
DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage,
@@ -180,8 +186,21 @@ fn hwnd_of(window: &tauri::WebviewWindow) -> io::Result<HWND> {
/// Make a bubble a tool window that never activates: no Alt-Tab entry and
/// no focus taken on a click or a show. Run it before the first show.
pub fn apply_no_activate(window: &tauri::WebviewWindow) -> io::Result<()> {
add_ex_style(window, (WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW).0)
}
/// Make the details panel a tool window, so it has no Alt-Tab entry
/// (`skip_taskbar` only removes the taskbar button). It still activates:
/// the panel takes focus so that losing it can dismiss the panel. Run it
/// before the first show.
pub fn apply_toolwindow(window: &tauri::WebviewWindow) -> io::Result<()> {
add_ex_style(window, WS_EX_TOOLWINDOW.0)
}
/// OR `bits` into a window's extended style and apply the frame change.
fn add_ex_style(window: &tauri::WebviewWindow, bits: u32) -> io::Result<()> {
let hwnd = hwnd_of(window)?;
let wanted = (WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW).0 as isize;
let wanted = 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 {
@@ -213,6 +232,50 @@ pub fn apply_no_activate(window: &tauri::WebviewWindow) -> io::Result<()> {
.map_err(io::Error::from)
}
/// Ask DWM for rounded window corners. Only Windows 11 knows the
/// attribute; Windows 10 returns an error and keeps square corners. DWM
/// ignores a repeated identical value, so calling this on every show is
/// cheap.
pub fn round_corners(window: &tauri::WebviewWindow) -> io::Result<()> {
let hwnd = hwnd_of(window)?;
let preference = DWMWCP_ROUND;
// SAFETY: `hwnd` is a live window of this process; the pointer and size
// describe `preference`, which outlives the call.
unsafe {
DwmSetWindowAttribute(
hwnd,
DWMWA_WINDOW_CORNER_PREFERENCE,
std::ptr::from_ref(&preference).cast(),
std::mem::size_of::<DWM_WINDOW_CORNER_PREFERENCE>() as u32,
)
}
.map_err(io::Error::from)
}
/// Turn off WebView2's browser accelerator keys (F5, Ctrl+R, Ctrl+P,
/// Ctrl+F, zoom, F12 and the like) for `window`'s webview, then reload it,
/// because the setting applies from the next navigation. Runs on the main
/// thread; a failure inside the webview is logged there.
pub fn disable_browser_accelerator_keys(window: &tauri::WebviewWindow) -> io::Result<()> {
let label = window.label().to_string();
window
.with_webview(move |webview| {
// SAFETY: plain COM calls on the live controller Tauri handed
// over, on the main thread that owns it.
let result = unsafe {
webview.controller().CoreWebView2().and_then(|core| {
let settings: ICoreWebView2Settings3 = core.Settings()?.cast()?;
settings.SetAreBrowserAcceleratorKeysEnabled(false)?;
core.Reload()
})
};
if let Err(err) = result {
log::warn!("{label} keeps the browser accelerator keys: {err}");
}
})
.map_err(|err| io::Error::other(format!("no webview for {}: {err}", window.label())))
}
/// Show a bubble without activating it. Bubbles are shown and hidden only
/// through this and [`hide`], never through Tauri's `show`/`hide`.
pub fn show_no_activate(window: &tauri::WebviewWindow) -> io::Result<()> {
+36 -8
View File
@@ -23,7 +23,7 @@ use crate::cli::{self, CliArgs};
use crate::core::geom::{self, Rect, DEFAULT_BUBBLE_SIZE, MAX_BUBBLE_SIZE, MIN_BUBBLE_SIZE};
use crate::core::i18n::{I18n, LocaleStrings};
use crate::core::menu_model::MenuCommand;
use crate::core::payload::{self, BubblePayload, Canvas};
use crate::core::payload::{self, BubblePayload, Canvas, PanelPayload};
use crate::core::settings::{self, Settings};
use crate::core::state::{Alert, PollOutcome, UpdateStatus, UsageState};
use crate::core::visibility::{self, Visibility};
@@ -31,7 +31,7 @@ use crate::menu::{self, MenuCache, MenuSnapshot};
use crate::scheduler::{self, Action, Message, SchedulerHost};
use crate::tray::{self, Trays};
use crate::usage::{self, refresh, ProviderId, Registry};
use crate::{bubble, diag, handoff, net, platform};
use crate::{bubble, diag, handoff, net, panel, platform};
/// Event carrying a [`BubblePayload`] to a bubble webview.
pub const BUBBLE_UPDATE_EVENT: &str = "bubble:update";
@@ -197,16 +197,26 @@ impl Runtime {
snapshot.payload(provider, scale_factor, SystemTime::now())
}
/// The details-panel payload for `provider`.
pub fn panel_payload(&self, provider: ProviderId) -> PanelPayload {
self.with_state(|s| panel_payload_of(s, provider))
}
/// Push the current state to every UI surface, then re-arm the
/// countdown for the next visible change.
pub fn propagate(&self) {
let (snapshot, countdown) = self.with_state(|s| {
let panel_provider = panel::shown_provider();
let (snapshot, panel_payload, countdown) = self.with_state(|s| {
(
BubbleSnapshot::of(s),
panel_provider.map(|p| panel_payload_of(s, p)),
s.usage.countdown_delay(SystemTime::now()),
)
});
self.ui.emit_bubble_updates(snapshot);
if let Some(payload) = panel_payload {
panel::refresh_if_visible(&self.ui.app, payload);
}
self.refresh_shell(false);
self.send(Message::RecomputeCountdown(countdown));
}
@@ -510,10 +520,10 @@ impl Runtime {
self.propagate();
}
/// A left click on a bubble. The details panel does not exist yet, so
/// the click is only logged.
/// A left click on a bubble toggles the details panel for it.
pub fn bubble_clicked(&self, provider: ProviderId) {
log::debug!("bubble click on {}; no details panel yet", provider.slug());
log::debug!("bubble click on {}", provider.slug());
panel::toggle(&self.ui.app, provider);
}
/// Re-format the countdown texts after time passed and push them out.
@@ -596,6 +606,16 @@ fn poll_outcome(
}
}
/// The details-panel payload for `provider` from the state.
fn panel_payload_of(state: &AppState, provider: ProviderId) -> PanelPayload {
payload::panel_payload(
provider,
state.usage.snapshots.get(&provider),
&state.strings,
state.is_dark,
)
}
/// Window label of a provider's bubble.
pub fn bubble_label(provider: ProviderId) -> String {
format!("{BUBBLE_LABEL_PREFIX}{}", provider.slug())
@@ -701,6 +721,7 @@ pub fn run() {
.plugin(tauri_plugin_notification::init())
.invoke_handler(tauri::generate_handler![
crate::commands::get_bubble_state,
crate::commands::get_panel_state,
crate::commands::get_strings,
crate::commands::log_js,
crate::commands::bubble_ready,
@@ -709,7 +730,10 @@ pub fn run() {
crate::commands::bubble_resize_step,
crate::menu::show_context_menu,
])
.on_window_event(bubble::on_window_event)
.on_window_event(|window, event| {
bubble::on_window_event(window, event);
panel::on_window_event(window, event);
})
.on_menu_event(menu::on_menu_event)
.on_tray_icon_event(tray::on_tray_icon_event)
.setup(move |app| setup(app, &cli))
@@ -801,6 +825,10 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
for provider in providers {
bubble::create(app.handle(), provider)?;
}
// The app still works without the panel; a bubble click then only logs.
if let Err(err) = panel::create(app.handle(), is_dark) {
log::error!("details panel could not be created: {err}");
}
runtime.refresh_shell(true);
runtime.deliver_pending_update_alert();
Ok(())
@@ -830,7 +858,7 @@ fn register_notification_identity(app: &AppHandle) {
/// Monitor rects in physical px. Empty when they cannot be enumerated,
/// which keeps every saved position.
fn monitor_rects(app: &AppHandle) -> Vec<Rect> {
pub fn monitor_rects(app: &AppHandle) -> Vec<Rect> {
match app.available_monitors() {
Ok(monitors) => monitors
.iter()