From 768fb1e4abc06f712c5c7f1d98ea0b20a00f0d23 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 21 Oct 2026 15:29:26 +0700 Subject: [PATCH] feat: toggle a details panel window from bubble clicks with focus dismissal and live refresh --- src/commands.rs | 14 +- src/lib.rs | 1 + src/panel.rs | 916 ++++++++++++++++++---------------------- src/platform/windows.rs | 69 ++- src/runtime.rs | 44 +- 5 files changed, 534 insertions(+), 510 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 5bb3c04..7719658 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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>) -> Option { + panel::current_provider().map(|provider| runtime.panel_payload(provider)) +} + /// Every UI string, camelCase keys. #[tauri::command] pub fn get_strings(runtime: State<'_, Arc>) -> 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>, slug: String) -> Result<(), String> { +pub async fn bubble_click(runtime: State<'_, Arc>, slug: String) -> Result<(), String> { let provider = live_provider(&slug)?; runtime.bubble_clicked(provider); Ok(()) diff --git a/src/lib.rs b/src/lib.rs index a57c4da..4a8132e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/panel.rs b/src/panel.rs index 46b6856..959f622 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -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, + /// The provider last shown, so a reloaded page can ask for its data. + provider: Option, + /// 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 = Mutex::new(PanelState { + shown: None, + provider: None, + blur_hide: None, +}); -fn state() -> &'static Mutex> { - static S: OnceLock>> = OnceLock::new(); - S.get_or_init(|| Mutex::new(None)) +fn with_state(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> { - 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::() 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 { + with_state(|s| s.shown) +} + +/// The provider the panel last showed, hidden or not. +pub fn current_provider() -> Option { + with_state(|s| s.provider) +} + +fn runtime_of(app: &AppHandle) -> Option> { + app.try_state::>().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 { - 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 { - 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 = 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 { + 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 { - 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) } diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 9d9dfc7..b80ef5a 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -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 { /// 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::() 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<()> { diff --git a/src/runtime.rs b/src/runtime.rs index 35cf9de..db660c6 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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> { 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 { +pub fn monitor_rects(app: &AppHandle) -> Vec { match app.available_monitors() { Ok(monitors) => monitors .iter()