diff --git a/.gitignore b/.gitignore index 0475229..907d0d9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,8 @@ workflow.cmd # Local WinGet manifest generation output /manifests/ + +# Local Claude Code development files (not relevant to upstream) +CLAUDE.md +ARCHITECTURE.md +.claude/ diff --git a/Cargo.toml b/Cargo.toml index a38d0aa..acefd9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ features = [ "Win32_Security", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_HiDpi", + "Win32_System_SystemInformation", ] [build-dependencies] diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index 0eb486d..511f48e 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -43,4 +43,14 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_body: "Voer 'codex' uit in een terminal en volg de aanmeldstappen. Ververs of herstart de app daarna.", codex_window_title: "Codex-gebruiksmonitor", second_suffix: "s", + show_pacing: "Pacingindicator tonen", + quiet_hours: "Inactieve uren", + quiet_start: "Begin", + quiet_end: "Einde", + quiet_set_time: "Tijd instellen...", + quiet_clear: "Wissen", + ok: "OK", + cancel: "Annuleren", + quiet_time_hint: "Formaat: UU:MM (beide leeg laten om uit te schakelen)", + quiet_time_error: "Vul beide velden in of laat ze allebei leeg", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index 2b92f36..5c3603a 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -39,8 +39,18 @@ pub(super) const STRINGS: Strings = Strings { minute_suffix: "m", token_expired_title: "Claude Code Auth Error", token_expired_body: "Run 'claude' in a terminal, then use '/login' and follow the prompts. After that, refresh or restart this app.", + show_pacing: "Show Pacing Indicator", codex_token_expired_title: "Codex Auth Error", codex_token_expired_body: "Run 'codex' in a terminal and follow the sign-in prompts. After that, refresh or restart this app.", codex_window_title: "Codex Usage Monitor", second_suffix: "s", + quiet_hours: "Idle Hours", + quiet_start: "Start", + quiet_end: "End", + quiet_set_time: "Set time...", + quiet_clear: "Clear", + ok: "OK", + cancel: "Cancel", + quiet_time_hint: "Format: HH:MM (clear both to disable)", + quiet_time_error: "Fill in both fields, or leave both empty", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index fa448fb..877082b 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -39,8 +39,18 @@ pub(super) const STRINGS: Strings = Strings { minute_suffix: "m", token_expired_title: "Erreur d'authentification", token_expired_body: "Exécutez 'claude' dans un terminal, puis utilisez '/login' et suivez les instructions. Ensuite, actualisez ou redémarrez cette application.", + show_pacing: "Afficher la progression", codex_token_expired_title: "Erreur d'authentification Codex", codex_token_expired_body: "Executez 'codex' dans un terminal et suivez les instructions de connexion. Ensuite, actualisez ou redemarrez cette application.", codex_window_title: "Moniteur d'utilisation Codex", second_suffix: "s", + quiet_hours: "Heures inactives", + quiet_start: "Start", + quiet_end: "End", + quiet_set_time: "Définir l'heure...", + quiet_clear: "Effacer", + ok: "OK", + cancel: "Annuler", + quiet_time_hint: "Format : HH:MM (effacer les deux pour désactiver)", + quiet_time_error: "Remplissez les deux champs ou laissez-les vides", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 5c7bb23..7833865 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -39,8 +39,18 @@ pub(super) const STRINGS: Strings = Strings { minute_suffix: "m", token_expired_title: "Authentifizierungsfehler", token_expired_body: "Führen Sie 'claude' in einem Terminal aus, verwenden Sie dann '/login' und folgen Sie den Anweisungen. Aktualisieren oder starten Sie diese App anschließend neu.", + show_pacing: "Fortschrittsanzeige", codex_token_expired_title: "Codex-Authentifizierungsfehler", codex_token_expired_body: "Fuhren Sie 'codex' in einem Terminal aus und folgen Sie den Anmeldeanweisungen. Aktualisieren oder starten Sie diese App anschliessend neu.", codex_window_title: "Codex-Nutzungsmonitor", second_suffix: "s", + quiet_hours: "Inaktive Stunden", + quiet_start: "Start", + quiet_end: "End", + quiet_set_time: "Uhrzeit festlegen...", + quiet_clear: "Löschen", + ok: "OK", + cancel: "Abbrechen", + quiet_time_hint: "Format: HH:MM (beide leeren zum Deaktivieren)", + quiet_time_error: "Beide Felder ausfüllen oder beide leer lassen", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 0020018..fd50c67 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -39,8 +39,18 @@ pub(super) const STRINGS: Strings = Strings { minute_suffix: "分", token_expired_title: "認証エラー", token_expired_body: "ターミナルで 'claude' を実行し、'/login' を使って案内に従ってください。その後、このアプリを更新するか再起動してください。", + show_pacing: "ペース表示", codex_token_expired_title: "Codex 認証エラー", codex_token_expired_body: "ターミナルで 'codex' を実行し、サインインの案内に従ってください。その後、このアプリを更新または再起動してください。", codex_window_title: "Codex 使用量モニター", second_suffix: "秒", + quiet_hours: "アイドル時間", + quiet_start: "開始", + quiet_end: "終了", + quiet_set_time: "時刻を設定...", + quiet_clear: "クリア", + ok: "OK", + cancel: "キャンセル", + quiet_time_hint: "形式:HH:MM(両方を空にすると無効)", + quiet_time_error: "両方入力するか、両方空にしてください", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 59e3829..4e9399c 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -39,8 +39,18 @@ pub(super) const STRINGS: Strings = Strings { minute_suffix: "분", token_expired_title: "인증 오류", token_expired_body: "터미널에서 'claude'를 실행한 다음 '/login'을 사용하고 안내에 따라 진행하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", + show_pacing: "페이스 표시", codex_token_expired_title: "Codex 인증 오류", codex_token_expired_body: "터미널에서 'codex'를 실행하고 로그인 안내를 따르세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", codex_window_title: "Codex 사용량 모니터", second_suffix: "초", + quiet_hours: "유휴 시간", + quiet_start: "시작", + quiet_end: "종료", + quiet_set_time: "시간 설정...", + quiet_clear: "지우기", + ok: "확인", + cancel: "취소", + quiet_time_hint: "형식: HH:MM (둘 다 비우면 비활성화)", + quiet_time_error: "두 필드를 모두 채우거나 모두 비워두세요", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 146b419..e316dac 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -105,12 +105,14 @@ impl LanguageId { "ja" => Some(Self::Japanese), "ko" => Some(Self::Korean), "zh" => { + // Distinguish Traditional Chinese (zh-TW, zh-HK, zh-Hant) from Simplified if normalized.contains("tw") || normalized.contains("hk") || normalized.contains("hant") { Some(Self::TraditionalChinese) } else { + // Simplified Chinese is not supported; fall back to system language None } } @@ -158,6 +160,16 @@ pub struct Strings { pub second_suffix: &'static str, pub token_expired_title: &'static str, pub token_expired_body: &'static str, + pub show_pacing: &'static str, + pub quiet_hours: &'static str, + pub quiet_start: &'static str, + pub quiet_end: &'static str, + pub quiet_set_time: &'static str, + pub quiet_clear: &'static str, + pub ok: &'static str, + pub cancel: &'static str, + pub quiet_time_hint: &'static str, + pub quiet_time_error: &'static str, pub codex_token_expired_title: &'static str, pub codex_token_expired_body: &'static str, pub codex_window_title: &'static str, diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index 8e6513e..ace4d05 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -39,8 +39,18 @@ pub(super) const STRINGS: Strings = Strings { minute_suffix: "m", token_expired_title: "Error de autenticación", token_expired_body: "Ejecuta 'claude' en una terminal, luego usa '/login' y sigue las indicaciones. Después, actualiza o reinicia esta aplicación.", + show_pacing: "Mostrar ritmo", codex_token_expired_title: "Error de autenticacion de Codex", codex_token_expired_body: "Ejecuta 'codex' en una terminal y sigue las indicaciones de inicio de sesion. Despues, actualiza o reinicia esta aplicacion.", codex_window_title: "Monitor de uso de Codex", second_suffix: "s", + quiet_hours: "Horas inactivas", + quiet_start: "Start", + quiet_end: "End", + quiet_set_time: "Set time...", + quiet_clear: "Clear", + ok: "OK", + cancel: "Cancel", + quiet_time_hint: "Format: HH:MM (clear both to disable)", + quiet_time_error: "Fill in both fields, or leave both empty", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 809ebba..a5df37e 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -39,8 +39,18 @@ pub(super) const STRINGS: Strings = Strings { minute_suffix: "分", token_expired_title: "驗證錯誤", token_expired_body: "請在終端機中執行 'claude',然後使用 '/login' 並依照提示操作。完成後,請重新整理或重新啟動此應用程式。", + show_pacing: "顯示配額進度", codex_token_expired_title: "Codex 驗證錯誤", codex_token_expired_body: "請在終端機中執行 'codex',並依照登入提示操作。完成後,請重新整理或重新啟動此應用程式。", codex_window_title: "Codex 使用量監控", second_suffix: "秒", + quiet_hours: "閒置時段", + quiet_start: "開始時間", + quiet_end: "結束時間", + quiet_set_time: "設定時間...", + quiet_clear: "清除", + ok: "確定", + cancel: "取消", + quiet_time_hint: "格式:HH:MM(清空兩欄以停用)", + quiet_time_error: "請兩欄都填寫,或兩欄都清空", }; diff --git a/src/native_interop.rs b/src/native_interop.rs index 9bccd18..02b811f 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -18,6 +18,7 @@ pub const TIMER_POLL: usize = 1; pub const TIMER_COUNTDOWN: usize = 2; pub const TIMER_RESET_POLL: usize = 3; pub const TIMER_UPDATE_CHECK: usize = 4; +pub const TIMER_QUIET_BOUNDARY: usize = 5; // Custom messages pub const WM_APP: u32 = 0x8000; diff --git a/src/window.rs b/src/window.rs index a38bbf7..68836b8 100644 --- a/src/window.rs +++ b/src/window.rs @@ -10,9 +10,12 @@ use windows::Win32::Graphics::Gdi::*; use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW}; use windows::Win32::System::Registry::*; use windows::Win32::System::Threading::CreateMutexW; +use windows::Win32::System::SystemInformation::GetLocalTime; use windows::Win32::UI::Accessibility::HWINEVENTHOOK; use windows::Win32::UI::HiDpi::*; -use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture}; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + GetFocus, GetKeyState, ReleaseCapture, SetCapture, SetFocus, VK_LBUTTON, +}; use windows::Win32::UI::Shell::ExtractIconExW; use windows::Win32::UI::WindowsAndMessaging::*; @@ -20,8 +23,8 @@ use crate::diagnose; use crate::localization::{self, LanguageId, Strings}; use crate::models::AppUsageData; use crate::native_interop::{ - self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, WM_APP_TRAY, - WM_APP_USAGE_UPDATED, + self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_QUIET_BOUNDARY, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, + WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; use crate::poller; use crate::theme; @@ -84,6 +87,19 @@ struct AppState { drag_start_offset: i32, widget_visible: bool, + + quiet_hours_start: Option<(u8, u8)>, + quiet_hours_end: Option<(u8, u8)>, + + show_pacing: bool, + session_pacing_pct: Option, + weekly_pacing_pct: Option, + session_resets_at: Option, + weekly_resets_at: Option, + codex_session_resets_at: Option, + codex_weekly_resets_at: Option, + codex_session_pacing_pct: Option, + codex_weekly_pacing_pct: Option, } #[derive(Clone, Debug)] @@ -122,6 +138,17 @@ const IDM_LANG_TRADITIONAL_CHINESE: u16 = 48; const IDM_MODEL_CLAUDE_CODE: u16 = 60; const IDM_MODEL_CODEX: u16 = 61; +const IDM_QUIET_SET_TIME: u16 = 62; +const IDM_QUIET_CLEAR: u16 = 63; +const IDM_TOGGLE_PACING: u16 = 64; + +// Dialog control IDs +const IDC_QUIET_START_EDIT: i32 = 201; +const IDC_QUIET_END_EDIT: i32 = 202; +const IDC_QUIET_OK: i32 = 203; +const IDC_QUIET_CANCEL: i32 = 204; +const IDC_QUIET_ERROR: i32 = 205; + const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN const WM_DPICHANGED_MSG: u32 = 0x02E0; @@ -209,6 +236,12 @@ struct SettingsFile { last_update_check_unix: Option, #[serde(default = "default_widget_visible")] widget_visible: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + quiet_time_start: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + quiet_time_end: Option, + #[serde(default)] + show_pacing: bool, #[serde(default = "default_show_claude_code")] show_claude_code: bool, #[serde(default = "default_show_codex")] @@ -223,6 +256,9 @@ impl Default for SettingsFile { language: None, last_update_check_unix: None, widget_visible: true, + quiet_time_start: None, + quiet_time_end: None, + show_pacing: false, show_claude_code: true, show_codex: false, } @@ -278,12 +314,486 @@ fn save_state_settings() { .map(|language| language.code().to_string()), last_update_check_unix: s.last_update_check_unix, widget_visible: s.widget_visible, + quiet_time_start: s.quiet_hours_start.map(|(h, m)| format!("{:02}:{:02}", h, m)), + quiet_time_end: s.quiet_hours_end.map(|(h, m)| format!("{:02}:{:02}", h, m)), + show_pacing: s.show_pacing, show_claude_code: s.show_claude_code, show_codex: s.show_codex, }); } } +/// Parses an "HH:MM" string; returns (hour, minute) or None on invalid format/out-of-range. +fn parse_hhmm(s: &str) -> Option<(u8, u8)> { + let s = s.trim(); + if s.len() != 5 || s.as_bytes().get(2) != Some(&b':') { + return None; + } + let h: u8 = s[..2].parse().ok()?; + let m: u8 = s[3..].parse().ok()?; + if h > 23 || m > 59 { + return None; + } + Some((h, m)) +} + +/// Checks whether we are in quiet hours given an already-held AppState (does not acquire the lock). +fn quiet_now(s: &AppState) -> bool { + let (start, end) = match (s.quiet_hours_start, s.quiet_hours_end) { + (Some(st), Some(en)) => (st, en), + _ => return false, + }; + let now = unsafe { GetLocalTime() }; + let now_min = now.wHour as u32 * 60 + now.wMinute as u32; + let start_min = start.0 as u32 * 60 + start.1 as u32; + let end_min = end.0 as u32 * 60 + end.1 as u32; + if start_min <= end_min { + now_min >= start_min && now_min < end_min + } else { + now_min >= start_min || now_min < end_min + } +} + +/// Checks whether we are currently in quiet hours (acquires the lock; must not be called while already holding it). +fn is_quiet_time() -> bool { + let (start, end) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => (s.quiet_hours_start, s.quiet_hours_end), + None => return false, + } + }; + let (start, end) = match (start, end) { + (Some(st), Some(en)) => (st, en), + _ => return false, + }; + let now = unsafe { GetLocalTime() }; + let now_min = now.wHour as u32 * 60 + now.wMinute as u32; + let start_min = start.0 as u32 * 60 + start.1 as u32; + let end_min = end.0 as u32 * 60 + end.1 as u32; + if start_min <= end_min { + now_min >= start_min && now_min < end_min + } else { + now_min >= start_min || now_min < end_min + } +} + +/// Shared state for the quiet-hours input dialog (passed into the window proc via GWLP_USERDATA). +struct QuietDlgState { + start_h_edit: HWND, // start-time hour field + start_m_edit: HWND, // start-time minute field + end_h_edit: HWND, // end-time hour field + end_m_edit: HWND, // end-time minute field + error_label: HWND, + strings: crate::localization::Strings, + /// None = cancelled; Some((start, end)) = confirmed, inner None means clear + result: Option<(Option<(u8, u8)>, Option<(u8, u8)>)>, +} + +/// Signals the outer message loop that the dialog has been closed. +static QUIET_DLG_DONE: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// Window proc for the quiet-hours dialog. +unsafe extern "system" fn quiet_dlg_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_CREATE => { + let cs = &*(lparam.0 as *const CREATESTRUCTW); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, cs.lpCreateParams as isize); + LRESULT(0) + } + WM_COMMAND => { + let ctrl_id = (wparam.0 & 0xFFFF) as i32; + let notif = ((wparam.0 >> 16) & 0xFFFF) as u16; + + // EN_SETFOCUS (0x0100): select all when an edit gains focus for easy overwrite + if notif == 0x0100 && lparam.0 != 0 { + let edit = HWND(lparam.0 as *mut _); + SendMessageW(edit, 0x00B1u32, WPARAM(0), LPARAM(-1isize)); // EM_SETSEL(0,-1) + return LRESULT(0); + } + + // EN_CHANGE (0x0300): auto-advance to next field when 2 digits are entered and the control is focused + if notif == 0x0300 && lparam.0 != 0 { + let state_ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut QuietDlgState; + if !state_ptr.is_null() { + let s = &*state_ptr; + let edit = HWND(lparam.0 as *mut _); + // Only the first 3 fields auto-advance (the last field, end-minute, does not) + let order = [s.start_h_edit, s.start_m_edit, s.end_h_edit, s.end_m_edit]; + if let Some(idx) = order.iter().position(|&h| h == edit) { + if idx < 3 + && GetWindowTextLengthW(edit) == 2 + && GetFocus() == edit + { + let _ = SetFocus(order[idx + 1]); + } + } + } + return LRESULT(0); + } + + let state_ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut QuietDlgState; + if state_ptr.is_null() { + return DefWindowProcW(hwnd, msg, wparam, lparam); + } + let state = &mut *state_ptr; + if ctrl_id == IDC_QUIET_OK { + let sh = get_edit_text(state.start_h_edit); + let sm = get_edit_text(state.start_m_edit); + let eh = get_edit_text(state.end_h_edit); + let em = get_edit_text(state.end_m_edit); + let sh = sh.trim(); let sm = sm.trim(); + let eh = eh.trim(); let em = em.trim(); + + let all_empty = sh.is_empty() && sm.is_empty() && eh.is_empty() && em.is_empty(); + let all_filled = !sh.is_empty() && !sm.is_empty() && !eh.is_empty() && !em.is_empty(); + + if all_empty { + state.result = Some((None, None)); + let _ = DestroyWindow(hwnd); + } else if all_filled { + let ok_sh = sh.parse::().ok().filter(|&h| h <= 23).map(|h| h as u8); + let ok_sm = sm.parse::().ok().filter(|&m| m <= 59).map(|m| m as u8); + let ok_eh = eh.parse::().ok().filter(|&h| h <= 23).map(|h| h as u8); + let ok_em = em.parse::().ok().filter(|&m| m <= 59).map(|m| m as u8); + match (ok_sh, ok_sm, ok_eh, ok_em) { + (Some(sh), Some(sm), Some(eh), Some(em)) => { + state.result = Some((Some((sh, sm)), Some((eh, em)))); + let _ = DestroyWindow(hwnd); + } + _ => { + let err = native_interop::wide_str(state.strings.quiet_time_error); + let _ = SetWindowTextW(state.error_label, PCWSTR::from_raw(err.as_ptr())); + let _ = ShowWindow(state.error_label, SW_SHOW); + } + } + } else { + let err = native_interop::wide_str(state.strings.quiet_time_error); + let _ = SetWindowTextW(state.error_label, PCWSTR::from_raw(err.as_ptr())); + let _ = ShowWindow(state.error_label, SW_SHOW); + } + LRESULT(0) + } else if ctrl_id == IDC_QUIET_CANCEL { + let _ = DestroyWindow(hwnd); + LRESULT(0) + } else { + DefWindowProcW(hwnd, msg, wparam, lparam) + } + } + WM_DESTROY => { + QUIET_DLG_DONE.store(true, std::sync::atomic::Ordering::Relaxed); + LRESULT(0) + } + WM_CLOSE => { + let _ = DestroyWindow(hwnd); + LRESULT(0) + } + WM_KEYDOWN => { + if wparam.0 == 0x1B { + // ESC + let _ = DestroyWindow(hwnd); + } + DefWindowProcW(hwnd, msg, wparam, lparam) + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +/// Gets the text from an EDIT control. +unsafe fn get_edit_text(hwnd: HWND) -> String { + let mut buf = [0u16; 16]; + let len = GetWindowTextW(hwnd, &mut buf); + if len <= 0 { + String::new() + } else { + String::from_utf16_lossy(&buf[..len as usize]) + } +} + +/// Shows the quiet-hours input dialog. Returns None if cancelled, Some((None, None)) if cleared. +fn show_quiet_hours_dialog( + parent: HWND, + current_start: Option<(u8, u8)>, + current_end: Option<(u8, u8)>, + strings: crate::localization::Strings, +) -> Option<(Option<(u8, u8)>, Option<(u8, u8)>)> { + // Client area size at 96 DPI base + const CLIENT_W: i32 = 280; + const CLIENT_H: i32 = 190; + + // Scaled client area size + let cw = sc(CLIENT_W); + let ch = sc(CLIENT_H); + + // Use AdjustWindowRectEx to compute the full window size including title bar and borders + let (dw, dh) = unsafe { + let mut r = RECT { left: 0, top: 0, right: cw, bottom: ch }; + let style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU; + let ex_style = WS_EX_DLGMODALFRAME | WS_EX_TOPMOST; + let _ = AdjustWindowRectEx(&mut r, style, false, ex_style); + (r.right - r.left, r.bottom - r.top) + }; + + // Center on the working area of the monitor under the cursor + let (cx, cy) = unsafe { + let mut cursor = POINT::default(); + let _ = GetCursorPos(&mut cursor); + let monitor = MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST); + let mut mi = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let work = if GetMonitorInfoW(monitor, &mut mi).as_bool() { + mi.rcWork + } else { + RECT { left: 0, top: 0, right: 1920, bottom: 1080 } + }; + ( + work.left + (work.right - work.left - dw) / 2, + work.top + (work.bottom - work.top - dh) / 2, + ) + }; + + // Heap-allocate dialog state so it outlives the stack frame + let mut state = Box::new(QuietDlgState { + start_h_edit: HWND::default(), + start_m_edit: HWND::default(), + end_h_edit: HWND::default(), + end_m_edit: HWND::default(), + error_label: HWND::default(), + strings, + result: None, + }); + let state_ptr = &mut *state as *mut QuietDlgState; + + unsafe { + let hinstance = GetModuleHandleW(PCWSTR::null()).unwrap(); + + // Register the window class exactly once + static QUIET_DLG_CLASS_ONCE: std::sync::Once = std::sync::Once::new(); + let class_name = native_interop::wide_str("QuietHoursDlgCls"); + QUIET_DLG_CLASS_ONCE.call_once(|| { + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + lpfnWndProc: Some(quiet_dlg_proc), + hInstance: HINSTANCE(hinstance.0), + hbrBackground: HBRUSH((COLOR_BTNFACE.0 + 1) as *mut _), + hCursor: LoadCursorW(HINSTANCE::default(), IDC_ARROW).unwrap_or_default(), + lpszClassName: PCWSTR::from_raw(class_name.as_ptr()), + ..Default::default() + }; + RegisterClassExW(&wc); + }); + + // Create the dialog window + let title = native_interop::wide_str(strings.quiet_hours); + let dlg_hwnd = CreateWindowExW( + WS_EX_DLGMODALFRAME | WS_EX_TOPMOST, + PCWSTR::from_raw(class_name.as_ptr()), + PCWSTR::from_raw(title.as_ptr()), + WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU, + cx, cy, dw, dh, + parent, + HMENU::default(), + hinstance, + Some(state_ptr as *const _ as *const std::ffi::c_void), + ) + .unwrap_or_default(); + + if dlg_hwnd.is_invalid() { + return None; + } + + // Get the default GUI font + let gui_font = GetStockObject(DEFAULT_GUI_FONT); + + // Helper macro: create a child control and set its font + macro_rules! make_ctrl { + ($ex:expr, $cls:expr, $text:expr, $style:expr, $x:expr, $y:expr, $w:expr, $h:expr, $id:expr) => {{ + let cls_w = native_interop::wide_str($cls); + let text_w = native_interop::wide_str($text); + let ctrl = CreateWindowExW( + $ex, PCWSTR::from_raw(cls_w.as_ptr()), + PCWSTR::from_raw(text_w.as_ptr()), + $style, $x, $y, $w, $h, + dlg_hwnd, HMENU($id as *mut _), hinstance, None, + ).unwrap_or_default(); + SendMessageW(ctrl, WM_SETFONT, WPARAM(gui_font.0 as usize), LPARAM(1)); + ctrl + }}; + } + + // ES_NUMBER=0x2000, ES_AUTOHSCROLL=0x0080 + let es_num = WINDOW_STYLE(0x2000 | 0x0080); + let ss_left = WINDOW_STYLE(0x0000); + let base_edit_style = WS_CHILD | WS_VISIBLE | WS_TABSTOP | es_num; + let base_label_style = WS_CHILD | WS_VISIBLE | ss_left; + + let pad = sc(12); + let lbl_w = sc(80); // label width + let h_w = sc(44); // hour input width + let colon = sc(14); // ":" separator width + let m_w = sc(44); // minute input width + let row_h = sc(22); + let row1_y = sc(18); + let row2_y = row1_y + row_h + sc(12); + let hint_y = row2_y + row_h + sc(10); + let err_y = hint_y + sc(20); + let btn_y = ch - sc(42); + let btn_w = sc(72); + let btn_h = sc(24); + + // X coordinates + let h_x = pad + lbl_w + sc(6); + let colon_x = h_x + h_w; + let m_x = colon_x + colon; + + // ---- Start time ---- + make_ctrl!(WINDOW_EX_STYLE(0), "STATIC", strings.quiet_start, + base_label_style, pad, row1_y + sc(2), lbl_w, row_h, 0); + + let start_h_edit = make_ctrl!(WS_EX_CLIENTEDGE, "EDIT", "", + base_edit_style, h_x, row1_y, h_w, row_h, IDC_QUIET_START_EDIT); + SendMessageW(start_h_edit, 0x00C5u32, WPARAM(2), LPARAM(0)); // EM_SETLIMITTEXT=2 + + make_ctrl!(WINDOW_EX_STYLE(0), "STATIC", ":", + base_label_style, colon_x, row1_y + sc(2), colon, row_h, 0); + + let start_m_edit = make_ctrl!(WS_EX_CLIENTEDGE, "EDIT", "", + base_edit_style, m_x, row1_y, m_w, row_h, IDC_QUIET_END_EDIT); + SendMessageW(start_m_edit, 0x00C5u32, WPARAM(2), LPARAM(0)); + + // ---- End time ---- + make_ctrl!(WINDOW_EX_STYLE(0), "STATIC", strings.quiet_end, + base_label_style, pad, row2_y + sc(2), lbl_w, row_h, 0); + + let end_h_edit = make_ctrl!(WS_EX_CLIENTEDGE, "EDIT", "", + base_edit_style, h_x, row2_y, h_w, row_h, 0); + SendMessageW(end_h_edit, 0x00C5u32, WPARAM(2), LPARAM(0)); + + make_ctrl!(WINDOW_EX_STYLE(0), "STATIC", ":", + base_label_style, colon_x, row2_y + sc(2), colon, row_h, 0); + + let end_m_edit = make_ctrl!(WS_EX_CLIENTEDGE, "EDIT", "", + base_edit_style, m_x, row2_y, m_w, row_h, 0); + SendMessageW(end_m_edit, 0x00C5u32, WPARAM(2), LPARAM(0)); + + // ---- Hint text ---- + make_ctrl!(WINDOW_EX_STYLE(0), "STATIC", strings.quiet_time_hint, + base_label_style, pad, hint_y, cw - pad * 2, sc(18), 0); + + // ---- Error message (initially hidden) ---- + let error_lbl = make_ctrl!(WINDOW_EX_STYLE(0), "STATIC", "", + WS_CHILD | ss_left, pad, err_y, cw - pad * 2, sc(18), IDC_QUIET_ERROR); + + // ---- OK / Cancel ---- + make_ctrl!(WINDOW_EX_STYLE(0), "BUTTON", strings.ok, + WS_CHILD | WS_VISIBLE | WS_TABSTOP | WINDOW_STYLE(0x0001), // BS_DEFPUSHBUTTON + cw - (btn_w + sc(8)) * 2 - sc(4), btn_y, btn_w, btn_h, IDC_QUIET_OK); + + make_ctrl!(WINDOW_EX_STYLE(0), "BUTTON", strings.cancel, + WS_CHILD | WS_VISIBLE | WS_TABSTOP | WINDOW_STYLE(0x0000), // BS_PUSHBUTTON + cw - btn_w - sc(8), btn_y, btn_w, btn_h, IDC_QUIET_CANCEL); + + // ---- Pre-fill current values ---- + let fill = |hwnd: HWND, val: u8| { + let s = native_interop::wide_str(&val.to_string()); + let _ = SetWindowTextW(hwnd, PCWSTR::from_raw(s.as_ptr())); + }; + if let Some((h, m)) = current_start { fill(start_h_edit, h); fill(start_m_edit, m); } + if let Some((h, m)) = current_end { fill(end_h_edit, h); fill(end_m_edit, m); } + + // ---- Sync HWNDs into state ---- + (*state_ptr).start_h_edit = start_h_edit; + (*state_ptr).start_m_edit = start_m_edit; + (*state_ptr).end_h_edit = end_h_edit; + (*state_ptr).end_m_edit = end_m_edit; + (*state_ptr).error_label = error_lbl; + + QUIET_DLG_DONE.store(false, std::sync::atomic::Ordering::Relaxed); + let _ = ShowWindow(dlg_hwnd, SW_SHOW); + let _ = SetForegroundWindow(dlg_hwnd); + let _ = SetFocus(start_h_edit); + + // Nested message loop + let tab_order = [start_h_edit, start_m_edit, end_h_edit, end_m_edit]; + let mut msg = MSG::default(); + loop { + if QUIET_DLG_DONE.load(std::sync::atomic::Ordering::Relaxed) { + break; + } + let has_msg = PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool(); + if has_msg { + // Tab — cycle focus through fields + if msg.message == WM_KEYDOWN && msg.wParam.0 == 0x09 { + let focused = GetFocus(); + let idx = tab_order.iter().position(|&h| h == focused).unwrap_or(0); + let _ = SetFocus(tab_order[(idx + 1) % tab_order.len()]); + continue; + } + // Enter — trigger OK + if msg.message == WM_KEYDOWN && msg.wParam.0 == 0x0D { + SendMessageW(dlg_hwnd, WM_COMMAND, WPARAM(IDC_QUIET_OK as usize), LPARAM(0)); + continue; + } + TranslateMessage(&msg); + DispatchMessageW(&msg); + } else { + WaitMessage(); + } + } + + let _ = SetForegroundWindow(parent); + } + + state.result +} + +/// Returns milliseconds until the next quiet-hours boundary (start or end), +/// used to schedule a precise redraw timer. +fn ms_until_quiet_boundary(s: &AppState) -> Option { + let start = s.quiet_hours_start?; + let end = s.quiet_hours_end?; + + let now = unsafe { GetLocalTime() }; + let now_sec = now.wHour as u32 * 3600 + + now.wMinute as u32 * 60 + + now.wSecond as u32; + + let start_sec = start.0 as u32 * 3600 + start.1 as u32 * 60; + let end_sec = end.0 as u32 * 3600 + end.1 as u32 * 60; + let day_sec: u32 = 24 * 3600; + + // Seconds until a given minute boundary; if already past, wrap to next day + let secs_until = |boundary: u32| -> u32 { + if boundary > now_sec { boundary - now_sec } + else { day_sec - now_sec + boundary } + }; + + let until_next = secs_until(start_sec).min(secs_until(end_sec)); + Some(until_next.max(1).saturating_mul(1000)) +} + +/// Schedules the quiet-hours boundary timer to fire exactly at the next start/end minute. +fn schedule_quiet_boundary_timer(hwnd: HWND) { + let ms = { + let state = lock_state(); + state.as_ref().and_then(ms_until_quiet_boundary) + }; + unsafe { + KillTimer(hwnd, TIMER_QUIET_BOUNDARY); + if let Some(ms) = ms { + SetTimer(hwnd, TIMER_QUIET_BOUNDARY, ms, None); + } + } +} + fn tray_icon_data_from_state() -> Vec { let state = lock_state(); match state.as_ref() { @@ -1024,6 +1534,17 @@ pub fn run() { drag_start_mouse_x: 0, drag_start_offset: 0, widget_visible: settings.widget_visible, + quiet_hours_start: settings.quiet_time_start.as_deref().and_then(parse_hhmm), + quiet_hours_end: settings.quiet_time_end.as_deref().and_then(parse_hhmm), + show_pacing: settings.show_pacing, + session_pacing_pct: None, + weekly_pacing_pct: None, + session_resets_at: None, + weekly_resets_at: None, + codex_session_resets_at: None, + codex_weekly_resets_at: None, + codex_session_pacing_pct: None, + codex_weekly_pacing_pct: None, }); } @@ -1096,6 +1617,7 @@ pub fn run() { .unwrap_or(POLL_15_MIN) }; SetTimer(hwnd, TIMER_POLL, initial_poll_ms, None); + schedule_quiet_boundary_timer(hwnd); // Initial poll let send_hwnd = SendHwnd::from_hwnd(hwnd); @@ -1148,25 +1670,101 @@ fn render_layered() { codex_weekly_text, show_claude_code, show_codex, + session_pacing, + weekly_pacing, + codex_session_pacing, + codex_weekly_pacing, ) = { let state = lock_state(); match state.as_ref() { - Some(s) => ( - s.hwnd, - s.is_dark, - s.embedded, - s.language.strings(), - s.session_percent, - s.session_text.clone(), - s.weekly_percent, - s.weekly_text.clone(), - s.codex_session_percent, - s.codex_session_text.clone(), - s.codex_weekly_percent, - s.codex_weekly_text.clone(), - s.show_claude_code, - s.show_codex, - ), + Some(s) => { + // Read quiet-hours state while holding the lock to avoid a deadlock from calling is_quiet_time() + let quiet = quiet_now(s); + let now = std::time::SystemTime::now(); + + // During quiet hours, check if each window has already reset. + // If reset: show 0% usage and no pacing (window restarted, no conversation yet). + // If not reset: show frozen usage and time-based pacing. + let session_reset_done = quiet && s.session_resets_at.map(|t| t <= now).unwrap_or(false); + let weekly_reset_done = quiet && s.weekly_resets_at.map(|t| t <= now).unwrap_or(false); + let codex_session_reset_done = quiet && s.codex_session_resets_at.map(|t| t <= now).unwrap_or(false); + let codex_weekly_reset_done = quiet && s.codex_weekly_resets_at.map(|t| t <= now).unwrap_or(false); + + let eff_session_pct = if session_reset_done { 0.0 } else { s.session_percent }; + let eff_weekly_pct = if weekly_reset_done { 0.0 } else { s.weekly_percent }; + let eff_codex_session_pct = if codex_session_reset_done { 0.0 } else { s.codex_session_percent }; + let eff_codex_weekly_pct = if codex_weekly_reset_done { 0.0 } else { s.codex_weekly_percent }; + + let session_text = if quiet { + s.language.strings().quiet_hours.to_string() + } else { + s.session_text.clone() + }; + // weekly_text retains the last polled value (never empty to avoid passing an empty slice to DrawTextW) + let weekly_text = s.weekly_text.clone(); + + let session_pacing = if quiet { + // Show pacing only before the window resets (time-based, no poll needed) + if !session_reset_done && s.show_pacing { + compute_pacing_pct(s.session_resets_at, 5.0 * 3600.0) + .filter(|&p| p > eff_session_pct) + } else { + None + } + } else { + s.session_pacing_pct.filter(|&p| p > s.session_percent) + }; + let weekly_pacing = if quiet { + if !weekly_reset_done && s.show_pacing { + compute_pacing_pct(s.weekly_resets_at, 7.0 * 24.0 * 3600.0) + .filter(|&p| p > eff_weekly_pct) + } else { + None + } + } else { + s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent) + }; + let codex_session_pacing = if quiet { + if !codex_session_reset_done && s.show_pacing { + compute_pacing_pct(s.codex_session_resets_at, 5.0 * 3600.0) + .filter(|&p| p > eff_codex_session_pct) + } else { + None + } + } else { + s.codex_session_pacing_pct.filter(|&p| p > s.codex_session_percent) + }; + let codex_weekly_pacing = if quiet { + if !codex_weekly_reset_done && s.show_pacing { + compute_pacing_pct(s.codex_weekly_resets_at, 7.0 * 24.0 * 3600.0) + .filter(|&p| p > eff_codex_weekly_pct) + } else { + None + } + } else { + s.codex_weekly_pacing_pct.filter(|&p| p > s.codex_weekly_percent) + }; + ( + s.hwnd, + s.is_dark, + s.embedded, + s.language.strings(), + eff_session_pct, + session_text, + eff_weekly_pct, + weekly_text, + eff_codex_session_pct, + s.codex_session_text.clone(), + eff_codex_weekly_pct, + s.codex_weekly_text.clone(), + s.show_claude_code, + s.show_codex, + session_pacing, + weekly_pacing, + codex_session_pacing, + codex_weekly_pacing, + ) + } None => return, } }; @@ -1235,6 +1833,11 @@ fn render_layered() { // Render once with the actual taskbar background colour. // Using an opaque background lets us use CLEARTYPE_QUALITY for // sub-pixel font rendering that matches the rest of the OS. + let pacing_color = if is_dark { + Color::from_hex("#5BA05E") + } else { + Color::from_hex("#4A8C53") + }; paint_content( mem_dc, width, @@ -1244,6 +1847,7 @@ fn render_layered() { &text_color, &accent, &track, + &pacing_color, strings, session_pct, &session_text, @@ -1256,6 +1860,10 @@ fn render_layered() { show_claude_code, show_codex, &codex_accent, + session_pacing, + weekly_pacing, + codex_session_pacing, + codex_weekly_pacing, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1314,6 +1922,7 @@ fn paint_content( text_color: &Color, accent: &Color, track: &Color, + pacing_color: &Color, strings: Strings, session_pct: f64, session_text: &str, @@ -1326,6 +1935,10 @@ fn paint_content( show_claude_code: bool, show_codex: bool, codex_accent: &Color, + session_pacing: Option, + weekly_pacing: Option, + codex_session_pacing: Option, + codex_weekly_pacing: Option, ) { unsafe { let client_rect = RECT { @@ -1418,6 +2031,9 @@ fn paint_content( accent, codex_accent, track, + pacing_color, + session_pacing, + codex_session_pacing, ); draw_row( hdc, @@ -1435,6 +2051,9 @@ fn paint_content( accent, codex_accent, track, + pacing_color, + weekly_pacing, + codex_weekly_pacing, ); SelectObject(hdc, old_font); @@ -1442,6 +2061,19 @@ fn paint_content( } } +/// Computes the expected-usage percentage based on elapsed time within a rolling window. +/// Returns None if `resets_at` is unavailable or the window has not yet started. +/// Returns true if the left mouse button is physically held down right now. +fn left_button_held() -> bool { + unsafe { (GetKeyState(VK_LBUTTON.0 as i32) & 0x8000u16 as i16) != 0 } +} + +fn compute_pacing_pct(resets_at: Option, window_secs: f64) -> Option { + let remaining = resets_at?.duration_since(std::time::SystemTime::now()).ok()?; + let elapsed = 1.0 - remaining.as_secs_f64() / window_secs; + Some((elapsed * 100.0).clamp(0.0, 100.0)) +} + fn do_poll(send_hwnd: SendHwnd) { let hwnd = send_hwnd.to_hwnd(); let (show_claude_code, show_codex) = { @@ -1459,6 +2091,12 @@ fn do_poll(send_hwnd: SendHwnd) { if let Some(claude_code) = data.claude_code.as_ref() { s.session_percent = claude_code.session.percentage; s.weekly_percent = claude_code.weekly.percentage; + s.session_resets_at = claude_code.session.resets_at; + s.weekly_resets_at = claude_code.weekly.resets_at; + if s.show_pacing { + s.session_pacing_pct = compute_pacing_pct(claude_code.session.resets_at, 5.0 * 3600.0); + s.weekly_pacing_pct = compute_pacing_pct(claude_code.weekly.resets_at, 7.0 * 24.0 * 3600.0); + } } else if s.show_claude_code { s.session_percent = 0.0; s.weekly_percent = 0.0; @@ -1466,6 +2104,12 @@ fn do_poll(send_hwnd: SendHwnd) { if let Some(codex) = data.codex.as_ref() { s.codex_session_percent = codex.session.percentage; s.codex_weekly_percent = codex.weekly.percentage; + s.codex_session_resets_at = codex.session.resets_at; + s.codex_weekly_resets_at = codex.weekly.resets_at; + if s.show_pacing { + s.codex_session_pacing_pct = compute_pacing_pct(codex.session.resets_at, 5.0 * 3600.0); + s.codex_weekly_pacing_pct = compute_pacing_pct(codex.weekly.resets_at, 7.0 * 24.0 * 3600.0); + } } else if s.show_codex { s.codex_session_percent = 0.0; s.codex_weekly_percent = 0.0; @@ -1880,42 +2524,44 @@ unsafe extern "system" fn wnd_proc( let timer_id = wparam.0; match timer_id { TIMER_POLL => { - let auth_watch = { - let state = lock_state(); - state.as_ref().map(|s| { - ( - s.auth_error_paused_polling, - s.auth_watch_mode, - s.auth_watch_snapshot.clone(), - ) - }) - }; - match auth_watch { - Some((true, watch_mode, previous_snapshot)) => { - let current_snapshot = poller::credential_watch_snapshot(watch_mode); - if current_snapshot != previous_snapshot { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - if s.auth_error_paused_polling - && s.auth_watch_mode == watch_mode - { - s.auth_watch_snapshot = current_snapshot; + if !is_quiet_time() { + let auth_watch = { + let state = lock_state(); + state.as_ref().map(|s| { + ( + s.auth_error_paused_polling, + s.auth_watch_mode, + s.auth_watch_snapshot.clone(), + ) + }) + }; + match auth_watch { + Some((true, watch_mode, previous_snapshot)) => { + let current_snapshot = poller::credential_watch_snapshot(watch_mode); + if current_snapshot != previous_snapshot { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + if s.auth_error_paused_polling + && s.auth_watch_mode == watch_mode + { + s.auth_watch_snapshot = current_snapshot; + } + drop(state); + let sh = SendHwnd::from_hwnd(hwnd); + std::thread::spawn(move || { + do_poll(sh); + }); } } - drop(state); + } + Some((false, _, _)) => { let sh = SendHwnd::from_hwnd(hwnd); std::thread::spawn(move || { do_poll(sh); }); } + None => {} } - Some((false, _, _)) => { - let sh = SendHwnd::from_hwnd(hwnd); - std::thread::spawn(move || { - do_poll(sh); - }); - } - None => {} } } TIMER_COUNTDOWN => { @@ -1931,13 +2577,17 @@ unsafe extern "system" fn wnd_proc( .map(|s| !s.auth_error_paused_polling) .unwrap_or(false) }; - if should_poll { + if should_poll && !is_quiet_time() { let sh = SendHwnd::from_hwnd(hwnd); std::thread::spawn(move || { do_poll(sh); }); } } + TIMER_QUIET_BOUNDARY => { + render_layered(); + schedule_quiet_boundary_timer(hwnd); + } TIMER_UPDATE_CHECK => { begin_update_check(hwnd, false); } @@ -1962,8 +2612,18 @@ unsafe extern "system" fn wnd_proc( } WM_SETCURSOR => { let is_dragging = { - let state = lock_state(); - state.as_ref().map(|s| s.dragging).unwrap_or(false) + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + // If we think we're dragging but the button is no longer held, + // the WM_LBUTTONUP was missed — cancel the drag immediately. + if s.dragging && !left_button_held() { + s.dragging = false; + let _ = ReleaseCapture(); + } + s.dragging + } else { + false + } }; // Always show resize cursor while dragging or when hovering divider zone let hit_test = (lparam.0 & 0xFFFF) as u16; @@ -2006,6 +2666,19 @@ unsafe extern "system" fn wnd_proc( state.as_ref().map(|s| s.dragging).unwrap_or(false) }; if is_dragging { + // If the button was released outside our window, WM_LBUTTONUP may + // have been missed. Detect this here and cancel the drag. + if !left_button_held() { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.dragging = false; + } + } + let _ = ReleaseCapture(); + save_state_settings(); + return LRESULT(0); + } let mut pt = POINT::default(); let _ = GetCursorPos(&mut pt); let move_target = { @@ -2041,7 +2714,11 @@ unsafe extern "system" fn wnd_proc( tray_left = tray_rect.left; } } - let widget_width = total_widget_width(); + // Use _for() variant to avoid re-acquiring the state lock + // while we already hold it (total_widget_width calls lock_state). + let widget_width = total_widget_width_for( + active_model_count(s.show_claude_code, s.show_codex), + ); let max_offset = tray_left - taskbar_rect.left - widget_width; if new_offset > max_offset { new_offset = max_offset; @@ -2117,6 +2794,37 @@ unsafe extern "system" fn wnd_proc( } LRESULT(0) } + // WM_CAPTURECHANGED fires whenever mouse capture is transferred away from this + // window (including when we call ReleaseCapture ourselves). Resetting dragging + // here ensures the resize cursor never gets stuck if WM_LBUTTONUP is missed. + WM_CAPTURECHANGED => { + let was_dragging = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + let was = s.dragging; + s.dragging = false; + was + } else { + false + } + }; + if was_dragging { + save_state_settings(); + } + LRESULT(0) + } + // WM_CANCELMODE is sent when a modal operation begins (e.g. Alt+Tab, context + // menu on another window). Cancel the drag and release capture immediately. + WM_CANCELMODE => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.dragging = false; + } + } + let _ = ReleaseCapture(); + LRESULT(0) + } WM_RBUTTONUP => { show_context_menu(hwnd); LRESULT(0) @@ -2276,6 +2984,70 @@ unsafe extern "system" fn wnd_proc( save_state_settings(); render_layered(); } + IDM_QUIET_SET_TIME => { + let (current_start, current_end, strings) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => (s.quiet_hours_start, s.quiet_hours_end, s.language.strings()), + None => return LRESULT(0), + } + }; + if let Some((new_start, new_end)) = + show_quiet_hours_dialog(hwnd, current_start, current_end, strings) + { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.quiet_hours_start = new_start; + s.quiet_hours_end = new_end; + } + } + save_state_settings(); + render_layered(); + schedule_quiet_boundary_timer(hwnd); + } + } + IDM_QUIET_CLEAR => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.quiet_hours_start = None; + s.quiet_hours_end = None; + } + } + save_state_settings(); + render_layered(); + schedule_quiet_boundary_timer(hwnd); + } + IDM_TOGGLE_PACING => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.show_pacing = !s.show_pacing; + if s.show_pacing { + // Compute immediately from cached data so the indicator + // appears at once without waiting for the next poll. + if let Some(data) = &s.data { + if let Some(claude_code) = data.claude_code.as_ref() { + s.session_pacing_pct = compute_pacing_pct(claude_code.session.resets_at, 5.0 * 3600.0); + s.weekly_pacing_pct = compute_pacing_pct(claude_code.weekly.resets_at, 7.0 * 24.0 * 3600.0); + } + if let Some(codex) = data.codex.as_ref() { + s.codex_session_pacing_pct = compute_pacing_pct(codex.session.resets_at, 5.0 * 3600.0); + s.codex_weekly_pacing_pct = compute_pacing_pct(codex.weekly.resets_at, 7.0 * 24.0 * 3600.0); + } + } + } else { + s.session_pacing_pct = None; + s.weekly_pacing_pct = None; + s.codex_session_pacing_pct = None; + s.codex_weekly_pacing_pct = None; + } + } + } + save_state_settings(); + render_layered(); + } id if id == tray_icon::IDM_TOGGLE_WIDGET => { toggle_widget_visibility(hwnd); } @@ -2321,6 +3093,9 @@ fn show_context_menu(hwnd: HWND) { install_channel, update_status, widget_visible, + quiet_hours_start, + quiet_hours_end, + show_pacing, show_claude_code, show_codex, ) = { @@ -2334,6 +3109,9 @@ fn show_context_menu(hwnd: HWND) { s.install_channel, s.update_status.clone(), s.widget_visible, + s.quiet_hours_start, + s.quiet_hours_end, + s.show_pacing, s.show_claude_code, s.show_codex, ), @@ -2345,6 +3123,9 @@ fn show_context_menu(hwnd: HWND) { InstallChannel::Portable, UpdateStatus::Idle, true, + None::<(u8, u8)>, + None::<(u8, u8)>, + false, true, false, ), @@ -2452,6 +3233,69 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(reset_pos_str.as_ptr()), ); + // Quiet hours submenu + let mut quiet_wide_strs: Vec> = Vec::new(); + + let quiet_menu = CreatePopupMenu().unwrap(); + + // Status row (greyed out, not clickable) + let status_text = match (quiet_hours_start, quiet_hours_end) { + (Some((sh, sm)), Some((eh, em))) => { + format!("{:02}:{:02} – {:02}:{:02}", sh, sm, eh, em) + } + _ => "—".to_string(), + }; + quiet_wide_strs.push(native_interop::wide_str(&status_text)); + let status_ptr = quiet_wide_strs.last().unwrap().as_ptr(); + let _ = AppendMenuW( + quiet_menu, + MF_GRAYED, + 0, + PCWSTR::from_raw(status_ptr), + ); + + let _ = AppendMenuW(quiet_menu, MF_SEPARATOR, 0, PCWSTR::null()); + + // Set time... + let set_time_str = native_interop::wide_str(strings.quiet_set_time); + let _ = AppendMenuW( + quiet_menu, + MENU_ITEM_FLAGS(0), + IDM_QUIET_SET_TIME as usize, + PCWSTR::from_raw(set_time_str.as_ptr()), + ); + + // Clear (enabled only when a time is already set) + let clear_str = native_interop::wide_str(strings.quiet_clear); + let clear_flags = if quiet_hours_start.is_some() { + MENU_ITEM_FLAGS(0) + } else { + MF_GRAYED + }; + let _ = AppendMenuW( + quiet_menu, + clear_flags, + IDM_QUIET_CLEAR as usize, + PCWSTR::from_raw(clear_str.as_ptr()), + ); + + let quiet_label_str = native_interop::wide_str(strings.quiet_hours); + let _ = AppendMenuW( + settings_menu, + MF_POPUP, + quiet_menu.0 as usize, + PCWSTR::from_raw(quiet_label_str.as_ptr()), + ); + + let pacing_str = native_interop::wide_str(strings.show_pacing); + let pacing_flags = if show_pacing { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }; + let _ = AppendMenuW( + settings_menu, + pacing_flags, + IDM_TOGGLE_PACING as usize, + PCWSTR::from_raw(pacing_str.as_ptr()), + ); + let language_menu = CreatePopupMenu().unwrap(); let system_label = native_interop::wide_str(strings.system_default); let system_flags = if language_override.is_none() { @@ -2466,8 +3310,8 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(system_label.as_ptr()), ); - for language in LanguageId::ALL { - let id = match language { + for lang in LanguageId::ALL { + let id = match lang { LanguageId::English => IDM_LANG_ENGLISH, LanguageId::Dutch => IDM_LANG_DUTCH, LanguageId::Spanish => IDM_LANG_SPANISH, @@ -2477,8 +3321,8 @@ fn show_context_menu(hwnd: HWND) { LanguageId::Korean => IDM_LANG_KOREAN, LanguageId::TraditionalChinese => IDM_LANG_TRADITIONAL_CHINESE, }; - let label_str = native_interop::wide_str(language.native_name()); - let flags = if language_override == Some(language) { + let label_str = native_interop::wide_str(lang.native_name()); + let flags = if language_override == Some(lang) { MF_CHECKED } else { MENU_ITEM_FLAGS(0) @@ -2573,23 +3417,93 @@ fn paint(hdc: HDC, hwnd: HWND) { codex_weekly_text, show_claude_code, show_codex, + session_pacing, + weekly_pacing, + codex_session_pacing, + codex_weekly_pacing, ) = { let state = lock_state(); match state.as_ref() { - Some(s) => ( - s.is_dark, - s.language.strings(), - s.session_percent, - s.session_text.clone(), - s.weekly_percent, - s.weekly_text.clone(), - s.codex_session_percent, - s.codex_session_text.clone(), - s.codex_weekly_percent, - s.codex_weekly_text.clone(), - s.show_claude_code, - s.show_codex, - ), + Some(s) => { + let quiet = quiet_now(s); + let now = std::time::SystemTime::now(); + + let session_reset_done = quiet && s.session_resets_at.map(|t| t <= now).unwrap_or(false); + let weekly_reset_done = quiet && s.weekly_resets_at.map(|t| t <= now).unwrap_or(false); + let codex_session_reset_done = quiet && s.codex_session_resets_at.map(|t| t <= now).unwrap_or(false); + let codex_weekly_reset_done = quiet && s.codex_weekly_resets_at.map(|t| t <= now).unwrap_or(false); + + let eff_session_pct = if session_reset_done { 0.0 } else { s.session_percent }; + let eff_weekly_pct = if weekly_reset_done { 0.0 } else { s.weekly_percent }; + let eff_codex_session_pct = if codex_session_reset_done { 0.0 } else { s.codex_session_percent }; + let eff_codex_weekly_pct = if codex_weekly_reset_done { 0.0 } else { s.codex_weekly_percent }; + + let session_text = if quiet { + s.language.strings().quiet_hours.to_string() + } else { + s.session_text.clone() + }; + // weekly_text retains the last polled value (never empty to avoid passing an empty slice to DrawTextW) + let weekly_text = s.weekly_text.clone(); + let session_pacing = if quiet { + if !session_reset_done && s.show_pacing { + compute_pacing_pct(s.session_resets_at, 5.0 * 3600.0) + .filter(|&p| p > eff_session_pct) + } else { + None + } + } else { + s.session_pacing_pct.filter(|&p| p > s.session_percent) + }; + let weekly_pacing = if quiet { + if !weekly_reset_done && s.show_pacing { + compute_pacing_pct(s.weekly_resets_at, 7.0 * 24.0 * 3600.0) + .filter(|&p| p > eff_weekly_pct) + } else { + None + } + } else { + s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent) + }; + let codex_session_pacing = if quiet { + if !codex_session_reset_done && s.show_pacing { + compute_pacing_pct(s.codex_session_resets_at, 5.0 * 3600.0) + .filter(|&p| p > eff_codex_session_pct) + } else { + None + } + } else { + s.codex_session_pacing_pct.filter(|&p| p > s.codex_session_percent) + }; + let codex_weekly_pacing = if quiet { + if !codex_weekly_reset_done && s.show_pacing { + compute_pacing_pct(s.codex_weekly_resets_at, 7.0 * 24.0 * 3600.0) + .filter(|&p| p > eff_codex_weekly_pct) + } else { + None + } + } else { + s.codex_weekly_pacing_pct.filter(|&p| p > s.codex_weekly_percent) + }; + ( + s.is_dark, + s.language.strings(), + eff_session_pct, + session_text, + eff_weekly_pct, + weekly_text, + eff_codex_session_pct, + s.codex_session_text.clone(), + eff_codex_weekly_pct, + s.codex_weekly_text.clone(), + s.show_claude_code, + s.show_codex, + session_pacing, + weekly_pacing, + codex_session_pacing, + codex_weekly_pacing, + ) + } None => return, } }; @@ -2611,6 +3525,11 @@ fn paint(hdc: HDC, hwnd: HWND) { } else { Color::from_hex("#F3F3F3") }; + let pacing_color = if is_dark { + Color::from_hex("#5BA05E") + } else { + Color::from_hex("#4A8C53") + }; unsafe { let mut client_rect = RECT::default(); @@ -2635,6 +3554,7 @@ fn paint(hdc: HDC, hwnd: HWND) { &text_color, &accent, &track, + &pacing_color, strings, session_pct, &session_text, @@ -2647,6 +3567,10 @@ fn paint(hdc: HDC, hwnd: HWND) { show_claude_code, show_codex, &codex_accent, + session_pacing, + weekly_pacing, + codex_session_pacing, + codex_weekly_pacing, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -2673,6 +3597,9 @@ fn draw_row( claude_accent: &Color, codex_accent: &Color, track: &Color, + pacing_color: &Color, + pacing_pct: Option, + codex_pacing_pct: Option, ) { let seg_h = sc(SEGMENT_H); let active_models = active_model_count(show_claude_code, show_codex); @@ -2717,6 +3644,8 @@ fn draw_row( claude_accent, track, &claude_value_color, + pacing_color, + pacing_pct, ); model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); } @@ -2731,6 +3660,8 @@ fn draw_row( codex_accent, track, &codex_value_color, + pacing_color, + codex_pacing_pct, ); } } @@ -2752,6 +3683,8 @@ fn draw_usage_bar( accent: &Color, track: &Color, text_color: &Color, + pacing_color: &Color, + pacing_pct: Option, ) { let seg_w = sc(SEGMENT_W); let seg_h = sc(SEGMENT_H); @@ -2774,36 +3707,53 @@ fn draw_usage_bar( bottom: y + seg_h, }; - if percent_clamped >= seg_end { - draw_rounded_rect(hdc, &seg_rect, accent, corner_r); - } else if percent_clamped <= seg_start { - draw_rounded_rect(hdc, &seg_rect, track, corner_r); - } else { - draw_rounded_rect(hdc, &seg_rect, track, corner_r); - let fraction = (percent_clamped - seg_start) / segment_percent; - let fill_width = (seg_w as f64 * fraction) as i32; - if fill_width > 0 { - let fill_rect = RECT { - left: seg_x, - top: y, - right: seg_x + fill_width, - bottom: y + seg_h, - }; - let rgn = CreateRoundRectRgn( - seg_rect.left, - seg_rect.top, - seg_rect.right + 1, - seg_rect.bottom + 1, - corner_r * 2, - corner_r * 2, - ); - let _ = SelectClipRgn(hdc, rgn); - let brush = CreateSolidBrush(COLORREF(accent.to_colorref())); - FillRect(hdc, &fill_rect, brush); - let _ = DeleteObject(brush); - let _ = SelectClipRgn(hdc, HRGN::default()); - let _ = DeleteObject(rgn); + let orange_end = percent_clamped; + // pacing_pct is already filtered (None when orange >= pacing) by the caller + let green_end = pacing_pct.unwrap_or(orange_end); + + // Step 1: draw gray base for the entire segment + draw_rounded_rect(hdc, &seg_rect, track, corner_r); + + // Helper: clip-fill a horizontal slice of this segment with a given color + let clip_fill = |left_pct: f64, right_pct: f64, color: &Color| { + let left_px = (left_pct / 10.0 * seg_w as f64) as i32; + let right_px = (right_pct / 10.0 * seg_w as f64) as i32; + if right_px <= left_px { + return; } + let fill_rect = RECT { + left: seg_x + left_px, + top: y, + right: seg_x + right_px, + bottom: y + seg_h, + }; + let rgn = CreateRoundRectRgn( + seg_rect.left, + seg_rect.top, + seg_rect.right + 1, + seg_rect.bottom + 1, + corner_r * 2, + corner_r * 2, + ); + let _ = SelectClipRgn(hdc, rgn); + let brush = CreateSolidBrush(COLORREF(color.to_colorref())); + FillRect(hdc, &fill_rect, brush); + let _ = DeleteObject(brush); + let _ = SelectClipRgn(hdc, HRGN::default()); + let _ = DeleteObject(rgn); + }; + + // Step 2: fill green zone (orange_end..green_end) if it overlaps this segment + if green_end > seg_start && orange_end < seg_end { + let left_pct = (orange_end - seg_start).max(0.0); + let right_pct = (green_end - seg_start).min(10.0); + clip_fill(left_pct, right_pct, pacing_color); + } + + // Step 3: fill orange zone (0..orange_end) if it overlaps this segment + if orange_end > seg_start { + let right_pct = (orange_end - seg_start).min(10.0); + clip_fill(0.0, right_pct, accent); } }