From 494484acf989a0147d68eaf8a0ef5de1f5ad335c Mon Sep 17 00:00:00 2001 From: owovouo <128667526+owovouo@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:09:06 +0800 Subject: [PATCH 01/10] Add files via upload --- src/localization/english.rs | 3 ++ src/localization/french.rs | 3 ++ src/localization/german.rs | 3 ++ src/localization/japanese.rs | 3 ++ src/localization/korean.rs | 3 ++ src/localization/mod.rs | 27 ++++++++++++++-- src/localization/spanish.rs | 3 ++ src/localization/traditional_chinese.rs | 41 +++++++++++++++++++++++++ 8 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/localization/traditional_chinese.rs diff --git a/src/localization/english.rs b/src/localization/english.rs index d730842..f983a4b 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -35,4 +35,7 @@ pub(super) const STRINGS: Strings = Strings { hour_suffix: "h", minute_suffix: "m", second_suffix: "s", + quiet_hours: "Quiet Hours", + quiet_start: "Start", + quiet_end: "End", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index e1d51b8..62f65cc 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -35,4 +35,7 @@ pub(super) const STRINGS: Strings = Strings { hour_suffix: "h", minute_suffix: "m", second_suffix: "s", + quiet_hours: "Quiet Hours", + quiet_start: "Start", + quiet_end: "End", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 70de6fb..dcbe083 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -35,4 +35,7 @@ pub(super) const STRINGS: Strings = Strings { hour_suffix: "h", minute_suffix: "m", second_suffix: "s", + quiet_hours: "Quiet Hours", + quiet_start: "Start", + quiet_end: "End", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 5078a21..7d8becc 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -35,4 +35,7 @@ pub(super) const STRINGS: Strings = Strings { hour_suffix: "時間", minute_suffix: "分", second_suffix: "秒", + quiet_hours: "サイレント時間", + quiet_start: "開始", + quiet_end: "終了", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index ca2a88e..57dd76f 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -35,4 +35,7 @@ pub(super) const STRINGS: Strings = Strings { hour_suffix: "시간", minute_suffix: "분", second_suffix: "초", + quiet_hours: "조용한 시간", + quiet_start: "시작", + quiet_end: "종료", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 2b741f6..a90e7b6 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -4,6 +4,7 @@ mod german; mod japanese; mod korean; mod spanish; +mod traditional_chinese; use windows::core::PWSTR; use windows::Win32::Globalization::{ @@ -19,16 +20,18 @@ pub enum LanguageId { German, Japanese, Korean, + TraditionalChinese, } impl LanguageId { - pub const ALL: [LanguageId; 6] = [ + pub const ALL: [LanguageId; 7] = [ LanguageId::English, LanguageId::Spanish, LanguageId::French, LanguageId::German, LanguageId::Japanese, LanguageId::Korean, + LanguageId::TraditionalChinese, ]; pub fn code(self) -> &'static str { @@ -39,6 +42,7 @@ impl LanguageId { Self::German => "de", Self::Japanese => "ja", Self::Korean => "ko", + Self::TraditionalChinese => "zh-TW", } } @@ -50,6 +54,7 @@ impl LanguageId { Self::German => "Deutsch", Self::Japanese => "日本語", Self::Korean => "한국어", + Self::TraditionalChinese => "繁體中文", } } @@ -61,6 +66,7 @@ impl LanguageId { Self::German => german::STRINGS, Self::Japanese => japanese::STRINGS, Self::Korean => korean::STRINGS, + Self::TraditionalChinese => traditional_chinese::STRINGS, } } @@ -72,6 +78,7 @@ impl LanguageId { Self::German => german::UPDATE_VIA_WINGET_LABEL, Self::Japanese => japanese::UPDATE_VIA_WINGET_LABEL, Self::Korean => korean::UPDATE_VIA_WINGET_LABEL, + Self::TraditionalChinese => traditional_chinese::UPDATE_VIA_WINGET_LABEL, } } @@ -81,13 +88,26 @@ impl LanguageId { return None; } - match normalized.split('-').next().unwrap_or_default() { + let prefix = normalized.split('-').next().unwrap_or_default(); + match prefix { "en" => Some(Self::English), "es" => Some(Self::Spanish), "fr" => Some(Self::French), "de" => Some(Self::German), "ja" => Some(Self::Japanese), "ko" => Some(Self::Korean), + "zh" => { + // 細分繁體(zh-TW、zh-HK、zh-Hant)與簡體 + if normalized.contains("tw") + || normalized.contains("hk") + || normalized.contains("hant") + { + Some(Self::TraditionalChinese) + } else { + // 簡體中文目前不支援,fallback 至系統語言 + None + } + } _ => None, } } @@ -127,6 +147,9 @@ pub struct Strings { pub hour_suffix: &'static str, pub minute_suffix: &'static str, pub second_suffix: &'static str, + pub quiet_hours: &'static str, + pub quiet_start: &'static str, + pub quiet_end: &'static str, } pub fn resolve_language(language_override: Option) -> LanguageId { diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index f3adace..d7d48aa 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -35,4 +35,7 @@ pub(super) const STRINGS: Strings = Strings { hour_suffix: "h", minute_suffix: "m", second_suffix: "s", + quiet_hours: "Quiet Hours", + quiet_start: "Start", + quiet_end: "End", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs new file mode 100644 index 0000000..7b1a395 --- /dev/null +++ b/src/localization/traditional_chinese.rs @@ -0,0 +1,41 @@ +use super::Strings; + +pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "透過 WinGet 更新"; + +pub(super) const STRINGS: Strings = Strings { + window_title: "Claude Code 使用量監控", + refresh: "重新整理", + update_frequency: "更新頻率", + one_minute: "1 分鐘", + five_minutes: "5 分鐘", + fifteen_minutes: "15 分鐘", + one_hour: "1 小時", + settings: "設定", + start_with_windows: "開機時啟動", + reset_position: "重置位置", + language: "語言", + system_default: "系統預設", + check_for_updates: "檢查更新", + checking_for_updates: "正在檢查更新...", + updates: "更新", + update_in_progress: "已有更新檢查正在進行中。", + up_to_date: "您已使用最新版本。", + up_to_date_short: "已是最新", + update_failed: "無法自動更新", + applying_update: "正在套用更新...", + update_to: "更新至", + update_available: "有可用更新", + update_prompt_now: "版本 {version} 已可用。是否立即更新?", + exit: "結束", + show_widget: "顯示小工具", + session_window: "5h", + weekly_window: "7d", + now: "現在", + day_suffix: "天", + hour_suffix: "時", + minute_suffix: "分", + second_suffix: "秒", + quiet_hours: "安靜時刻", + quiet_start: "開始時間", + quiet_end: "結束時間", +}; From 18978f8ac57e4b2808c2bac936ce8fb34f7f0b9f Mon Sep 17 00:00:00 2001 From: owovouo <128667526+owovouo@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:09:43 +0800 Subject: [PATCH 02/10] Add files via upload --- src/window.rs | 279 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 247 insertions(+), 32 deletions(-) diff --git a/src/window.rs b/src/window.rs index 43024c7..ab745fc 100644 --- a/src/window.rs +++ b/src/window.rs @@ -10,6 +10,7 @@ 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}; @@ -74,6 +75,10 @@ struct AppState { drag_start_offset: i32, widget_visible: bool, + + quiet_hours_enabled: bool, + quiet_hours_start: u8, + quiet_hours_end: u8, } #[derive(Clone, Debug)] @@ -107,6 +112,11 @@ const IDM_LANG_FRENCH: u16 = 43; const IDM_LANG_GERMAN: u16 = 44; const IDM_LANG_JAPANESE: u16 = 45; const IDM_LANG_KOREAN: u16 = 46; +const IDM_LANG_TRADITIONAL_CHINESE: u16 = 47; + +const IDM_QUIET_TOGGLE: u16 = 60; +const IDM_QUIET_START_BASE: u16 = 70; // 70..93 對應小時 0..23 +const IDM_QUIET_END_BASE: u16 = 100; // 100..123 對應小時 0..23 const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN @@ -192,6 +202,12 @@ struct SettingsFile { last_update_check_unix: Option, #[serde(default = "default_widget_visible")] widget_visible: bool, + #[serde(default)] + quiet_hours_enabled: bool, + #[serde(default = "default_quiet_start")] + quiet_hours_start: u8, + #[serde(default = "default_quiet_end")] + quiet_hours_end: u8, } impl Default for SettingsFile { @@ -202,6 +218,9 @@ impl Default for SettingsFile { language: None, last_update_check_unix: None, widget_visible: true, + quiet_hours_enabled: false, + quiet_hours_start: default_quiet_start(), + quiet_hours_end: default_quiet_end(), } } } @@ -214,6 +233,14 @@ fn default_widget_visible() -> bool { true } +fn default_quiet_start() -> u8 { + 22 +} + +fn default_quiet_end() -> u8 { + 8 +} + fn load_settings() -> SettingsFile { let content = match std::fs::read_to_string(settings_path()) { Ok(c) => c, @@ -243,10 +270,50 @@ 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_hours_enabled: s.quiet_hours_enabled, + quiet_hours_start: s.quiet_hours_start, + quiet_hours_end: s.quiet_hours_end, }); } } +/// 從已持有的 AppState 判斷目前是否在安靜時刻(不取鎖) +fn quiet_now(s: &AppState) -> bool { + if !s.quiet_hours_enabled { + return false; + } + let st = unsafe { GetLocalTime() }; + let hour = st.wHour as u8; + let start = s.quiet_hours_start; + let end = s.quiet_hours_end; + if start <= end { + hour >= start && hour < end + } else { + hour >= start || hour < end + } +} + +/// 檢查目前是否在安靜時刻範圍內(需自行取鎖,不可在持有鎖時呼叫) +fn is_quiet_time() -> bool { + let (enabled, start, end) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => (s.quiet_hours_enabled, s.quiet_hours_start, s.quiet_hours_end), + None => return false, + } + }; + if !enabled { + return false; + } + let st = unsafe { GetLocalTime() }; + let hour = st.wHour as u8; + if start <= end { + hour >= start && hour < end + } else { + hour >= start || hour < end + } +} + fn tray_icon_data_from_state() -> (Option, String) { let state = lock_state(); match state.as_ref() { @@ -862,6 +929,9 @@ pub fn run() { drag_start_mouse_x: 0, drag_start_offset: 0, widget_visible: settings.widget_visible, + quiet_hours_enabled: settings.quiet_hours_enabled, + quiet_hours_start: settings.quiet_hours_start, + quiet_hours_end: settings.quiet_hours_end, }); } @@ -975,16 +1045,27 @@ fn render_layered() { let (hwnd_val, is_dark, embedded, strings, session_pct, session_text, weekly_pct, weekly_text) = { 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(), - ), + Some(s) => { + // 在鎖內直接讀取安靜時刻狀態,避免再呼叫 is_quiet_time() 造成死結 + let quiet = quiet_now(s); + let session_text = if quiet { + s.language.strings().quiet_hours.to_string() + } else { + s.session_text.clone() + }; + // weekly_text 保留最後一次 poll 的資料(不設空字串以避免 DrawTextW 收到空切片) + let weekly_text = s.weekly_text.clone(); + ( + s.hwnd, + s.is_dark, + s.embedded, + s.language.strings(), + s.session_percent, + session_text, + s.weekly_percent, + weekly_text, + ) + } None => return, } }; @@ -1533,10 +1614,12 @@ unsafe extern "system" fn wnd_proc( let timer_id = wparam.0; match timer_id { TIMER_POLL => { - let sh = SendHwnd::from_hwnd(hwnd); - std::thread::spawn(move || { - do_poll(sh); - }); + if !is_quiet_time() { + let sh = SendHwnd::from_hwnd(hwnd); + std::thread::spawn(move || { + do_poll(sh); + }); + } } TIMER_COUNTDOWN => { update_display(); @@ -1544,10 +1627,12 @@ unsafe extern "system" fn wnd_proc( schedule_countdown_timer(); } TIMER_RESET_POLL => { - let sh = SendHwnd::from_hwnd(hwnd); - std::thread::spawn(move || { - do_poll(sh); - }); + if !is_quiet_time() { + let sh = SendHwnd::from_hwnd(hwnd); + std::thread::spawn(move || { + do_poll(sh); + }); + } } TIMER_UPDATE_CHECK => { begin_update_check(hwnd, false); @@ -1817,8 +1902,14 @@ unsafe extern "system" fn wnd_proc( // Reset the poll timer with the new interval SetTimer(hwnd, TIMER_POLL, new_interval, None); } - IDM_LANG_SYSTEM | IDM_LANG_ENGLISH | IDM_LANG_SPANISH | IDM_LANG_FRENCH - | IDM_LANG_GERMAN | IDM_LANG_JAPANESE | IDM_LANG_KOREAN => { + IDM_LANG_SYSTEM + | IDM_LANG_ENGLISH + | IDM_LANG_SPANISH + | IDM_LANG_FRENCH + | IDM_LANG_GERMAN + | IDM_LANG_JAPANESE + | IDM_LANG_KOREAN + | IDM_LANG_TRADITIONAL_CHINESE => { let language_override = match id { IDM_LANG_SYSTEM => None, IDM_LANG_ENGLISH => Some(LanguageId::English), @@ -1827,6 +1918,7 @@ unsafe extern "system" fn wnd_proc( IDM_LANG_GERMAN => Some(LanguageId::German), IDM_LANG_JAPANESE => Some(LanguageId::Japanese), IDM_LANG_KOREAN => Some(LanguageId::Korean), + IDM_LANG_TRADITIONAL_CHINESE => Some(LanguageId::TraditionalChinese), _ => None, }; { @@ -1838,6 +1930,36 @@ unsafe extern "system" fn wnd_proc( save_state_settings(); render_layered(); } + IDM_QUIET_TOGGLE => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.quiet_hours_enabled = !s.quiet_hours_enabled; + } + } + save_state_settings(); + render_layered(); + } + id if (IDM_QUIET_START_BASE..IDM_QUIET_START_BASE + 24).contains(&id) => { + let hour = (id - IDM_QUIET_START_BASE) as u8; + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.quiet_hours_start = hour; + } + } + save_state_settings(); + } + id if (IDM_QUIET_END_BASE..IDM_QUIET_END_BASE + 24).contains(&id) => { + let hour = (id - IDM_QUIET_END_BASE) as u8; + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.quiet_hours_end = hour; + } + } + save_state_settings(); + } id if id == tray_icon::IDM_TOGGLE_WIDGET => { toggle_widget_visibility(hwnd); } @@ -1883,6 +2005,9 @@ fn show_context_menu(hwnd: HWND) { install_channel, update_status, widget_visible, + quiet_hours_enabled, + quiet_hours_start, + quiet_hours_end, ) = { let state = lock_state(); match state.as_ref() { @@ -1894,6 +2019,9 @@ fn show_context_menu(hwnd: HWND) { s.install_channel, s.update_status.clone(), s.widget_visible, + s.quiet_hours_enabled, + s.quiet_hours_start, + s.quiet_hours_end, ), None => ( POLL_15_MIN, @@ -1903,6 +2031,9 @@ fn show_context_menu(hwnd: HWND) { InstallChannel::Portable, UpdateStatus::Idle, true, + false, + 22u8, + 8u8, ), } }; @@ -1972,6 +2103,79 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(reset_pos_str.as_ptr()), ); + // Quiet Hours 子選單 + // 儲存動態字串的 Vec,讓其生命週期延伸至 TrackPopupMenu 完成 + let mut quiet_wide_strs: Vec> = Vec::new(); + + let quiet_menu = CreatePopupMenu().unwrap(); + + let quiet_toggle_str = native_interop::wide_str(strings.quiet_hours); + let quiet_toggle_flags = if quiet_hours_enabled { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }; + let _ = AppendMenuW( + quiet_menu, + quiet_toggle_flags, + IDM_QUIET_TOGGLE as usize, + PCWSTR::from_raw(quiet_toggle_str.as_ptr()), + ); + + let _ = AppendMenuW(quiet_menu, MF_SEPARATOR, 0, PCWSTR::null()); + + // Start hour 子選單(0..23) + let start_menu = CreatePopupMenu().unwrap(); + for h in 0u8..24 { + let label = format!("{:02}:00", h); + quiet_wide_strs.push(native_interop::wide_str(&label)); + let label_ptr = quiet_wide_strs.last().unwrap().as_ptr(); + let flags = if h == quiet_hours_start { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }; + let _ = AppendMenuW( + start_menu, + flags, + (IDM_QUIET_START_BASE + h as u16) as usize, + PCWSTR::from_raw(label_ptr), + ); + } + let start_label_str = format!("{}: {:02}:00", strings.quiet_start, quiet_hours_start); + quiet_wide_strs.push(native_interop::wide_str(&start_label_str)); + let start_label_ptr = quiet_wide_strs.last().unwrap().as_ptr(); + let _ = AppendMenuW( + quiet_menu, + MF_POPUP, + start_menu.0 as usize, + PCWSTR::from_raw(start_label_ptr), + ); + + // End hour 子選單(0..23) + let end_menu = CreatePopupMenu().unwrap(); + for h in 0u8..24 { + let label = format!("{:02}:00", h); + quiet_wide_strs.push(native_interop::wide_str(&label)); + let label_ptr = quiet_wide_strs.last().unwrap().as_ptr(); + let flags = if h == quiet_hours_end { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }; + let _ = AppendMenuW( + end_menu, + flags, + (IDM_QUIET_END_BASE + h as u16) as usize, + PCWSTR::from_raw(label_ptr), + ); + } + let end_label_str = format!("{}: {:02}:00", strings.quiet_end, quiet_hours_end); + quiet_wide_strs.push(native_interop::wide_str(&end_label_str)); + let end_label_ptr = quiet_wide_strs.last().unwrap().as_ptr(); + let _ = AppendMenuW( + quiet_menu, + MF_POPUP, + end_menu.0 as usize, + PCWSTR::from_raw(end_label_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 language_menu = CreatePopupMenu().unwrap(); let system_label = native_interop::wide_str(strings.system_default); let system_flags = if language_override.is_none() { @@ -1986,17 +2190,18 @@ 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::Spanish => IDM_LANG_SPANISH, LanguageId::French => IDM_LANG_FRENCH, LanguageId::German => IDM_LANG_GERMAN, LanguageId::Japanese => IDM_LANG_JAPANESE, 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) @@ -2075,14 +2280,24 @@ fn paint(hdc: HDC, hwnd: HWND) { let (is_dark, strings, session_pct, session_text, weekly_pct, weekly_text) = { 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(), - ), + Some(s) => { + let quiet = quiet_now(s); + let session_text = if quiet { + s.language.strings().quiet_hours.to_string() + } else { + s.session_text.clone() + }; + // weekly_text 保留最後一次 poll 的資料(不設空字串以避免 DrawTextW 收到空切片) + let weekly_text = s.weekly_text.clone(); + ( + s.is_dark, + s.language.strings(), + s.session_percent, + session_text, + s.weekly_percent, + weekly_text, + ) + } None => return, } }; From e7cb253deaad3fe29fcac10650ddd96161e4fda8 Mon Sep 17 00:00:00 2001 From: owovouo <128667526+owovouo@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:55:22 +0800 Subject: [PATCH 03/10] feat: add quiet hours time input dialog (HH:MM) Replace preset quiet hours selection with a custom HH:MM input dialog, allowing users to set any start/end time for quiet hours. --- src/localization/english.rs | 6 + src/localization/french.rs | 6 + src/localization/german.rs | 6 + src/localization/japanese.rs | 6 + src/localization/korean.rs | 6 + src/localization/mod.rs | 6 + src/localization/spanish.rs | 6 + src/localization/traditional_chinese.rs | 6 + src/window.rs | 653 +++++++++++++++++++----- 9 files changed, 582 insertions(+), 119 deletions(-) diff --git a/src/localization/english.rs b/src/localization/english.rs index f983a4b..f8e72d3 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -38,4 +38,10 @@ pub(super) const STRINGS: Strings = Strings { quiet_hours: "Quiet 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 62f65cc..c72a4b0 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -38,4 +38,10 @@ pub(super) const STRINGS: Strings = Strings { quiet_hours: "Quiet Hours", 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 dcbe083..a78e796 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -38,4 +38,10 @@ pub(super) const STRINGS: Strings = Strings { quiet_hours: "Quiet Hours", 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 7d8becc..e185144 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -38,4 +38,10 @@ pub(super) const STRINGS: Strings = Strings { 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 57dd76f..2291574 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -38,4 +38,10 @@ pub(super) const STRINGS: Strings = Strings { 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 a90e7b6..031f53e 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -150,6 +150,12 @@ pub struct Strings { 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 fn resolve_language(language_override: Option) -> LanguageId { diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index d7d48aa..d802661 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -38,4 +38,10 @@ pub(super) const STRINGS: Strings = Strings { quiet_hours: "Quiet 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/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 7b1a395..f078a70 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -38,4 +38,10 @@ pub(super) const STRINGS: Strings = Strings { 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/window.rs b/src/window.rs index ab745fc..30350cf 100644 --- a/src/window.rs +++ b/src/window.rs @@ -13,7 +13,7 @@ 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, ReleaseCapture, SetCapture, SetFocus}; use windows::Win32::UI::Shell::ExtractIconExW; use windows::Win32::UI::WindowsAndMessaging::*; @@ -21,7 +21,7 @@ use crate::diagnose; use crate::localization::{self, LanguageId, Strings}; use crate::models::UsageData; use crate::native_interop::{ - self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, + self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_QUIET_BOUNDARY, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; use crate::tray_icon; @@ -76,9 +76,8 @@ struct AppState { widget_visible: bool, - quiet_hours_enabled: bool, - quiet_hours_start: u8, - quiet_hours_end: u8, + quiet_hours_start: Option<(u8, u8)>, + quiet_hours_end: Option<(u8, u8)>, } #[derive(Clone, Debug)] @@ -114,9 +113,15 @@ const IDM_LANG_JAPANESE: u16 = 45; const IDM_LANG_KOREAN: u16 = 46; const IDM_LANG_TRADITIONAL_CHINESE: u16 = 47; -const IDM_QUIET_TOGGLE: u16 = 60; -const IDM_QUIET_START_BASE: u16 = 70; // 70..93 對應小時 0..23 -const IDM_QUIET_END_BASE: u16 = 100; // 100..123 對應小時 0..23 +const IDM_QUIET_SET_TIME: u16 = 62; +const IDM_QUIET_CLEAR: u16 = 63; + +// 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 @@ -202,12 +207,10 @@ struct SettingsFile { last_update_check_unix: Option, #[serde(default = "default_widget_visible")] widget_visible: bool, - #[serde(default)] - quiet_hours_enabled: bool, - #[serde(default = "default_quiet_start")] - quiet_hours_start: u8, - #[serde(default = "default_quiet_end")] - quiet_hours_end: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + quiet_time_start: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + quiet_time_end: Option, } impl Default for SettingsFile { @@ -218,9 +221,8 @@ impl Default for SettingsFile { language: None, last_update_check_unix: None, widget_visible: true, - quiet_hours_enabled: false, - quiet_hours_start: default_quiet_start(), - quiet_hours_end: default_quiet_end(), + quiet_time_start: None, + quiet_time_end: None, } } } @@ -233,14 +235,6 @@ fn default_widget_visible() -> bool { true } -fn default_quiet_start() -> u8 { - 22 -} - -fn default_quiet_end() -> u8 { - 8 -} - fn load_settings() -> SettingsFile { let content = match std::fs::read_to_string(settings_path()) { Ok(c) => c, @@ -270,47 +264,480 @@ 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_hours_enabled: s.quiet_hours_enabled, - quiet_hours_start: s.quiet_hours_start, - quiet_hours_end: s.quiet_hours_end, + 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)), }); } } +/// 解析 "HH:MM" 格式字串,傳回 (hour, minute);格式錯誤或超出範圍傳回 None +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)) +} + /// 從已持有的 AppState 判斷目前是否在安靜時刻(不取鎖) fn quiet_now(s: &AppState) -> bool { - if !s.quiet_hours_enabled { - return false; - } - let st = unsafe { GetLocalTime() }; - let hour = st.wHour as u8; - let start = s.quiet_hours_start; - let end = s.quiet_hours_end; - if start <= end { - hour >= start && hour < end + 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 { - hour >= start || hour < end + now_min >= start_min || now_min < end_min } } /// 檢查目前是否在安靜時刻範圍內(需自行取鎖,不可在持有鎖時呼叫) fn is_quiet_time() -> bool { - let (enabled, start, end) = { + let (start, end) = { let state = lock_state(); match state.as_ref() { - Some(s) => (s.quiet_hours_enabled, s.quiet_hours_start, s.quiet_hours_end), + Some(s) => (s.quiet_hours_start, s.quiet_hours_end), None => return false, } }; - if !enabled { - 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 } - let st = unsafe { GetLocalTime() }; - let hour = st.wHour as u8; - if start <= end { - hour >= start && hour < end +} + +/// 安靜時刻輸入對話框的共享狀態(透過 GWLP_USERDATA 傳入視窗 proc) +struct QuietDlgState { + start_h_edit: HWND, // 開始時間「時」 + start_m_edit: HWND, // 開始時間「分」 + end_h_edit: HWND, // 結束時間「時」 + end_m_edit: HWND, // 結束時間「分」 + error_label: HWND, + strings: crate::localization::Strings, + /// None = 使用者取消;Some((start, end)) = 確定,None 值代表清除 + result: Option<(Option<(u8, u8)>, Option<(u8, u8)>)>, +} + +/// 用來通知外層訊息迴圈對話框已關閉 +static QUIET_DLG_DONE: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// 安靜時刻對話框的視窗 Proc +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):輸入框取得焦點時全選,方便直接覆蓋 + 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):輸入框內容變更時,若已達 2 位數且目前有焦點,自動跳下一格 + 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 _); + // 僅前 3 格需要自動跳(最後一格結束分不跳) + 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), + } +} + +/// 取得 EDIT 控制項的文字 +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 { - hour >= start || hour < end + String::from_utf16_lossy(&buf[..len as usize]) + } +} + +/// 顯示安靜時刻輸入對話框;傳回 None 代表取消,Some((None,None)) 代表清除 +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)>)> { + // 客戶區尺寸(96 DPI 基準) + const CLIENT_W: i32 = 280; + const CLIENT_H: i32 = 190; + + // 縮放後客戶區尺寸 + let cw = sc(CLIENT_W); + let ch = sc(CLIENT_H); + + // 用 AdjustWindowRectEx 計算含標題列/邊框的實際視窗尺寸 + 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) + }; + + // 以滑鼠所在螢幕的工作區置中 + 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, + ) + }; + + // 建立對話框狀態(Box 確保在對話框生命週期內有效) + 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(); + + // 一次性註冊視窗類別 + 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); + }); + + // 建立對話框視窗 + 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; + } + + // 取得預設 GUI 字型 + let gui_font = GetStockObject(DEFAULT_GUI_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); // "開始時間" 標籤寬 + let h_w = sc(44); // 時輸入框 + let colon = sc(14); // ":" 隔字 + let m_w = sc(44); // 分輸入框 + 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 座標 + let h_x = pad + lbl_w + sc(6); + let colon_x = h_x + h_w; + let m_x = colon_x + colon; + + // ---- 開始時間 ---- + 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)); + + // ---- 結束時間 ---- + 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)); + + // ---- 提示文字 ---- + make_ctrl!(WINDOW_EX_STYLE(0), "STATIC", strings.quiet_time_hint, + base_label_style, pad, hint_y, cw - pad * 2, sc(18), 0); + + // ---- 錯誤訊息(初始隱藏)---- + 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); + + // ---- 確定 / 取消 ---- + 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); + + // ---- 預填目前值 ---- + 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); } + + // ---- 同步 HWND 到 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); + + // 巢狀訊息迴圈 + 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 — 循環切換焦點 + 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 — 觸發確定 + 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 +} + +/// 傳回距下一個安靜時刻邊界(開始或結束)的毫秒數, +/// 用以排程精確的畫面切換計時器。 +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; + + // 距某整分邊界的剩餘秒數(已過則算明天) + 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)) +} + +/// 設定安靜時刻邊界計時器(精確在開始/結束分鐘觸發重繪) +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); + } } } @@ -929,9 +1356,8 @@ pub fn run() { drag_start_mouse_x: 0, drag_start_offset: 0, widget_visible: settings.widget_visible, - quiet_hours_enabled: settings.quiet_hours_enabled, - quiet_hours_start: settings.quiet_hours_start, - quiet_hours_end: settings.quiet_hours_end, + 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), }); } @@ -1005,6 +1431,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); @@ -1634,6 +2061,11 @@ unsafe extern "system" fn wnd_proc( }); } } + TIMER_QUIET_BOUNDARY => { + // 安靜時刻邊界到了,立即重繪並排程下一個邊界 + render_layered(); + schedule_quiet_boundary_timer(hwnd); + } TIMER_UPDATE_CHECK => { begin_update_check(hwnd, false); } @@ -1930,35 +2362,40 @@ unsafe extern "system" fn wnd_proc( save_state_settings(); render_layered(); } - IDM_QUIET_TOGGLE => { - { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.quiet_hours_enabled = !s.quiet_hours_enabled; + 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), } - } - save_state_settings(); - render_layered(); - } - id if (IDM_QUIET_START_BASE..IDM_QUIET_START_BASE + 24).contains(&id) => { - let hour = (id - IDM_QUIET_START_BASE) as u8; + }; + 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 = hour; + { + 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); } - save_state_settings(); } - id if (IDM_QUIET_END_BASE..IDM_QUIET_END_BASE + 24).contains(&id) => { - let hour = (id - IDM_QUIET_END_BASE) as u8; + IDM_QUIET_CLEAR => { { let mut state = lock_state(); if let Some(s) = state.as_mut() { - s.quiet_hours_end = hour; + s.quiet_hours_start = None; + s.quiet_hours_end = None; } } save_state_settings(); + render_layered(); + schedule_quiet_boundary_timer(hwnd); } id if id == tray_icon::IDM_TOGGLE_WIDGET => { toggle_widget_visibility(hwnd); @@ -2005,7 +2442,6 @@ fn show_context_menu(hwnd: HWND) { install_channel, update_status, widget_visible, - quiet_hours_enabled, quiet_hours_start, quiet_hours_end, ) = { @@ -2019,7 +2455,6 @@ fn show_context_menu(hwnd: HWND) { s.install_channel, s.update_status.clone(), s.widget_visible, - s.quiet_hours_enabled, s.quiet_hours_start, s.quiet_hours_end, ), @@ -2031,9 +2466,8 @@ fn show_context_menu(hwnd: HWND) { InstallChannel::Portable, UpdateStatus::Idle, true, - false, - 22u8, - 8u8, + None::<(u8, u8)>, + None::<(u8, u8)>, ), } }; @@ -2104,68 +2538,49 @@ fn show_context_menu(hwnd: HWND) { ); // Quiet Hours 子選單 - // 儲存動態字串的 Vec,讓其生命週期延伸至 TrackPopupMenu 完成 let mut quiet_wide_strs: Vec> = Vec::new(); let quiet_menu = CreatePopupMenu().unwrap(); - let quiet_toggle_str = native_interop::wide_str(strings.quiet_hours); - let quiet_toggle_flags = if quiet_hours_enabled { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }; + // 狀態資訊列(灰色,不可點擊) + 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, - quiet_toggle_flags, - IDM_QUIET_TOGGLE as usize, - PCWSTR::from_raw(quiet_toggle_str.as_ptr()), + MF_GRAYED, + 0, + PCWSTR::from_raw(status_ptr), ); let _ = AppendMenuW(quiet_menu, MF_SEPARATOR, 0, PCWSTR::null()); - // Start hour 子選單(0..23) - let start_menu = CreatePopupMenu().unwrap(); - for h in 0u8..24 { - let label = format!("{:02}:00", h); - quiet_wide_strs.push(native_interop::wide_str(&label)); - let label_ptr = quiet_wide_strs.last().unwrap().as_ptr(); - let flags = if h == quiet_hours_start { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }; - let _ = AppendMenuW( - start_menu, - flags, - (IDM_QUIET_START_BASE + h as u16) as usize, - PCWSTR::from_raw(label_ptr), - ); - } - let start_label_str = format!("{}: {:02}:00", strings.quiet_start, quiet_hours_start); - quiet_wide_strs.push(native_interop::wide_str(&start_label_str)); - let start_label_ptr = quiet_wide_strs.last().unwrap().as_ptr(); + // 設定時間... + let set_time_str = native_interop::wide_str(strings.quiet_set_time); let _ = AppendMenuW( quiet_menu, - MF_POPUP, - start_menu.0 as usize, - PCWSTR::from_raw(start_label_ptr), + MENU_ITEM_FLAGS(0), + IDM_QUIET_SET_TIME as usize, + PCWSTR::from_raw(set_time_str.as_ptr()), ); - // End hour 子選單(0..23) - let end_menu = CreatePopupMenu().unwrap(); - for h in 0u8..24 { - let label = format!("{:02}:00", h); - quiet_wide_strs.push(native_interop::wide_str(&label)); - let label_ptr = quiet_wide_strs.last().unwrap().as_ptr(); - let flags = if h == quiet_hours_end { MF_CHECKED } else { MENU_ITEM_FLAGS(0) }; - let _ = AppendMenuW( - end_menu, - flags, - (IDM_QUIET_END_BASE + h as u16) as usize, - PCWSTR::from_raw(label_ptr), - ); - } - let end_label_str = format!("{}: {:02}:00", strings.quiet_end, quiet_hours_end); - quiet_wide_strs.push(native_interop::wide_str(&end_label_str)); - let end_label_ptr = quiet_wide_strs.last().unwrap().as_ptr(); + // 清除(只有已設定時才啟用) + 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, - MF_POPUP, - end_menu.0 as usize, - PCWSTR::from_raw(end_label_ptr), + 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); From 873f161c38b24165bfc6620cfb21ca4337bb38b3 Mon Sep 17 00:00:00 2001 From: owovouo <128667526+owovouo@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:06:43 +0800 Subject: [PATCH 04/10] fix: resolve position_at_taskbar deadlock Fix non-reentrant mutex deadlock where MoveWindow dispatches WM_PAINT synchronously, causing wnd_proc to re-enter lock_state() on the same thread. Credit: aa333 (PR #14) --- src/window.rs | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/window.rs b/src/window.rs index 30350cf..f5fd002 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1884,27 +1884,28 @@ fn update_display() { fn position_at_taskbar() { refresh_dpi(); - let state = lock_state(); - let s = match state.as_ref() { - Some(s) => s, - None => return, - }; - // Don't fight the user's drag - if s.dragging { - return; - } - - let hwnd = s.hwnd.to_hwnd(); - let embedded = s.embedded; - let tray_offset = s.tray_offset; - - let taskbar_hwnd = match s.taskbar_hwnd { - Some(h) => h, - None => { - diagnose::log("position_at_taskbar skipped: no taskbar handle"); + // Extract everything we need from state, then DROP the lock before making + // any Win32 calls. MoveWindow dispatches WM_PAINT synchronously and our + // wnd_proc also calls lock_state() — Rust's Mutex is not reentrant, + // so holding it across Win32 calls causes a deadlock. + let (hwnd, embedded, tray_offset, taskbar_hwnd) = { + let state = lock_state(); + let s = match state.as_ref() { + Some(s) => s, + None => return, + }; + if s.dragging { return; } + let taskbar_hwnd = match s.taskbar_hwnd { + Some(h) => h, + None => { + diagnose::log("position_at_taskbar skipped: no taskbar handle"); + return; + } + }; + (s.hwnd.to_hwnd(), s.embedded, s.tray_offset, taskbar_hwnd) }; let taskbar_rect = match native_interop::get_taskbar_rect(taskbar_hwnd) { From 3f2d88aa295b4d4a0655255202f8e1531ce0b695 Mon Sep 17 00:00:00 2001 From: yure Date: Mon, 20 Apr 2026 13:47:23 +0800 Subject: [PATCH 05/10] refactor: translate Traditional Chinese comments to English Co-Authored-By: Claude Sonnet 4.6 --- src/localization/mod.rs | 4 +- src/window.rs | 102 ++++++++++++++++++++-------------------- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 933bd3d..357623f 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -97,14 +97,14 @@ impl LanguageId { "ja" => Some(Self::Japanese), "ko" => Some(Self::Korean), "zh" => { - // 細分繁體(zh-TW、zh-HK、zh-Hant)與簡體 + // 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 { - // 簡體中文目前不支援,fallback 至系統語言 + // Simplified Chinese is not supported; fall back to system language None } } diff --git a/src/window.rs b/src/window.rs index 9504318..6eff05a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -274,7 +274,7 @@ fn save_state_settings() { } } -/// 解析 "HH:MM" 格式字串,傳回 (hour, minute);格式錯誤或超出範圍傳回 None +/// 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':') { @@ -288,7 +288,7 @@ fn parse_hhmm(s: &str) -> Option<(u8, u8)> { Some((h, m)) } -/// 從已持有的 AppState 判斷目前是否在安靜時刻(不取鎖) +/// 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), @@ -305,7 +305,7 @@ fn quiet_now(s: &AppState) -> bool { } } -/// 檢查目前是否在安靜時刻範圍內(需自行取鎖,不可在持有鎖時呼叫) +/// 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(); @@ -329,23 +329,23 @@ fn is_quiet_time() -> bool { } } -/// 安靜時刻輸入對話框的共享狀態(透過 GWLP_USERDATA 傳入視窗 proc) +/// Shared state for the quiet-hours input dialog (passed into the window proc via GWLP_USERDATA). struct QuietDlgState { - start_h_edit: HWND, // 開始時間「時」 - start_m_edit: HWND, // 開始時間「分」 - end_h_edit: HWND, // 結束時間「時」 - end_m_edit: HWND, // 結束時間「分」 + 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 = 使用者取消;Some((start, end)) = 確定,None 值代表清除 + /// 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); -/// 安靜時刻對話框的視窗 Proc +/// Window proc for the quiet-hours dialog. unsafe extern "system" fn quiet_dlg_proc( hwnd: HWND, msg: u32, @@ -362,20 +362,20 @@ unsafe extern "system" fn quiet_dlg_proc( let ctrl_id = (wparam.0 & 0xFFFF) as i32; let notif = ((wparam.0 >> 16) & 0xFFFF) as u16; - // EN_SETFOCUS (0x0100):輸入框取得焦點時全選,方便直接覆蓋 + // 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):輸入框內容變更時,若已達 2 位數且目前有焦點,自動跳下一格 + // 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 _); - // 僅前 3 格需要自動跳(最後一格結束分不跳) + // 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 @@ -456,7 +456,7 @@ unsafe extern "system" fn quiet_dlg_proc( } } -/// 取得 EDIT 控制項的文字 +/// 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); @@ -467,22 +467,22 @@ unsafe fn get_edit_text(hwnd: HWND) -> String { } } -/// 顯示安靜時刻輸入對話框;傳回 None 代表取消,Some((None,None)) 代表清除 +/// 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)>)> { - // 客戶區尺寸(96 DPI 基準) + // 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); - // 用 AdjustWindowRectEx 計算含標題列/邊框的實際視窗尺寸 + // 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; @@ -491,7 +491,7 @@ fn show_quiet_hours_dialog( (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); @@ -511,7 +511,7 @@ fn show_quiet_hours_dialog( ) }; - // 建立對話框狀態(Box 確保在對話框生命週期內有效) + // 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(), @@ -526,7 +526,7 @@ fn show_quiet_hours_dialog( 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(|| { @@ -542,7 +542,7 @@ fn show_quiet_hours_dialog( 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, @@ -561,10 +561,10 @@ fn show_quiet_hours_dialog( return None; } - // 取得預設 GUI 字型 + // 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); @@ -587,10 +587,10 @@ fn show_quiet_hours_dialog( let base_label_style = WS_CHILD | WS_VISIBLE | ss_left; let pad = sc(12); - let lbl_w = sc(80); // "開始時間" 標籤寬 - let h_w = sc(44); // 時輸入框 - let colon = sc(14); // ":" 隔字 - let m_w = sc(44); // 分輸入框 + 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); @@ -600,12 +600,12 @@ fn show_quiet_hours_dialog( let btn_w = sc(72); let btn_h = sc(24); - // X 座標 + // 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); @@ -620,7 +620,7 @@ fn show_quiet_hours_dialog( 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); @@ -635,15 +635,15 @@ fn show_quiet_hours_dialog( 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); @@ -652,7 +652,7 @@ fn show_quiet_hours_dialog( 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())); @@ -660,7 +660,7 @@ fn show_quiet_hours_dialog( 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); } - // ---- 同步 HWND 到 state ---- + // ---- 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; @@ -672,7 +672,7 @@ fn show_quiet_hours_dialog( 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 { @@ -681,14 +681,14 @@ fn show_quiet_hours_dialog( } let has_msg = PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool(); if has_msg { - // Tab — 循環切換焦點 + // 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 — 觸發確定 + // 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; @@ -706,8 +706,8 @@ fn show_quiet_hours_dialog( 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?; @@ -721,7 +721,7 @@ fn ms_until_quiet_boundary(s: &AppState) -> Option { 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 } @@ -731,7 +731,7 @@ fn ms_until_quiet_boundary(s: &AppState) -> Option { 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(); @@ -1481,14 +1481,14 @@ fn render_layered() { let state = lock_state(); match state.as_ref() { Some(s) => { - // 在鎖內直接讀取安靜時刻狀態,避免再呼叫 is_quiet_time() 造成死結 + // Read quiet-hours state while holding the lock to avoid a deadlock from calling is_quiet_time() let quiet = quiet_now(s); let session_text = if quiet { s.language.strings().quiet_hours.to_string() } else { s.session_text.clone() }; - // weekly_text 保留最後一次 poll 的資料(不設空字串以避免 DrawTextW 收到空切片) + // weekly_text retains the last polled value (never empty to avoid passing an empty slice to DrawTextW) let weekly_text = s.weekly_text.clone(); ( s.hwnd, @@ -2654,12 +2654,12 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(reset_pos_str.as_ptr()), ); - // Quiet Hours 子選單 + // 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) @@ -2677,7 +2677,7 @@ fn show_context_menu(hwnd: HWND) { 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, @@ -2686,7 +2686,7 @@ fn show_context_menu(hwnd: HWND) { 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) @@ -2819,7 +2819,7 @@ fn paint(hdc: HDC, hwnd: HWND) { } else { s.session_text.clone() }; - // weekly_text 保留最後一次 poll 的資料(不設空字串以避免 DrawTextW 收到空切片) + // weekly_text retains the last polled value (never empty to avoid passing an empty slice to DrawTextW) let weekly_text = s.weekly_text.clone(); ( s.is_dark, From 85dc12eb5295ff30b881eb134a158283ac95a417 Mon Sep 17 00:00:00 2001 From: yure Date: Mon, 20 Apr 2026 14:48:34 +0800 Subject: [PATCH 06/10] feat: add pacing indicator to progress bars Shows a green zone between actual usage and expected usage based on elapsed time within the 5h/7d rolling windows. Updated at each poll interval so the indicator grows in discrete steps matching the chosen update frequency. Toggle via Settings > Show Pacing Indicator. Also add Win32_System_SystemInformation feature (required for GetLocalTime) and TIMER_QUIET_BOUNDARY constant to native_interop. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 + Cargo.toml | 1 + src/localization/english.rs | 1 + src/localization/french.rs | 1 + src/localization/german.rs | 1 + src/localization/japanese.rs | 1 + src/localization/korean.rs | 1 + src/localization/mod.rs | 1 + src/localization/spanish.rs | 1 + src/localization/traditional_chinese.rs | 1 + src/native_interop.rs | 1 + src/window.rs | 163 +++++++++++++++++++----- 12 files changed, 146 insertions(+), 32 deletions(-) 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 a452c78..8bd5acf 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/english.rs b/src/localization/english.rs index f14b0da..7d61d07 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -36,6 +36,7 @@ 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", second_suffix: "s", quiet_hours: "Quiet Hours", quiet_start: "Start", diff --git a/src/localization/french.rs b/src/localization/french.rs index dfc1516..81e8e82 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -36,6 +36,7 @@ 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", second_suffix: "s", quiet_hours: "Quiet Hours", quiet_start: "Start", diff --git a/src/localization/german.rs b/src/localization/german.rs index 07f13c7..737deda 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -36,6 +36,7 @@ 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", second_suffix: "s", quiet_hours: "Quiet Hours", quiet_start: "Start", diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 54850f0..941ff11 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -36,6 +36,7 @@ pub(super) const STRINGS: Strings = Strings { minute_suffix: "分", token_expired_title: "認証エラー", token_expired_body: "ターミナルで 'claude' を実行し、'/login' を使って案内に従ってください。その後、このアプリを更新するか再起動してください。", + show_pacing: "ペース表示", second_suffix: "秒", quiet_hours: "サイレント時間", quiet_start: "開始", diff --git a/src/localization/korean.rs b/src/localization/korean.rs index cded6c1..b557fd3 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -36,6 +36,7 @@ pub(super) const STRINGS: Strings = Strings { minute_suffix: "분", token_expired_title: "인증 오류", token_expired_body: "터미널에서 'claude'를 실행한 다음 '/login'을 사용하고 안내에 따라 진행하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", + show_pacing: "페이스 표시", second_suffix: "초", quiet_hours: "조용한 시간", quiet_start: "시작", diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 357623f..8193cbc 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -149,6 +149,7 @@ 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, diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index fbb82cc..192d334 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -36,6 +36,7 @@ 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", second_suffix: "s", quiet_hours: "Quiet Hours", quiet_start: "Start", diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index eafe34e..9a99713 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -36,6 +36,7 @@ pub(super) const STRINGS: Strings = Strings { minute_suffix: "分", token_expired_title: "驗證錯誤", token_expired_body: "請在終端機中執行 'claude',然後使用 '/login' 並依照提示操作。完成後,請重新整理或重新啟動此應用程式。", + show_pacing: "顯示配額進度", second_suffix: "秒", quiet_hours: "安靜時刻", quiet_start: "開始時間", 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 6eff05a..a6071d7 100644 --- a/src/window.rs +++ b/src/window.rs @@ -82,6 +82,10 @@ struct AppState { quiet_hours_start: Option<(u8, u8)>, quiet_hours_end: Option<(u8, u8)>, + + show_pacing: bool, + session_pacing_pct: Option, + weekly_pacing_pct: Option, } #[derive(Clone, Debug)] @@ -119,6 +123,7 @@ const IDM_LANG_TRADITIONAL_CHINESE: u16 = 47; 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; @@ -215,6 +220,8 @@ struct SettingsFile { quiet_time_start: Option, #[serde(default, skip_serializing_if = "Option::is_none")] quiet_time_end: Option, + #[serde(default)] + show_pacing: bool, } impl Default for SettingsFile { @@ -227,6 +234,7 @@ impl Default for SettingsFile { widget_visible: true, quiet_time_start: None, quiet_time_end: None, + show_pacing: false, } } } @@ -270,6 +278,7 @@ fn save_state_settings() { 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, }); } } @@ -1366,6 +1375,9 @@ pub fn run() { 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, }); } @@ -1477,7 +1489,7 @@ pub fn run() { /// ClearType sub-pixel font rendering can be used for crisp, OS-native text. fn render_layered() { refresh_dpi(); - let (hwnd_val, is_dark, embedded, strings, session_pct, session_text, weekly_pct, weekly_text) = { + let (hwnd_val, is_dark, embedded, strings, session_pct, session_text, weekly_pct, weekly_text, session_pacing, weekly_pacing) = { let state = lock_state(); match state.as_ref() { Some(s) => { @@ -1490,6 +1502,9 @@ fn render_layered() { }; // weekly_text retains the last polled value (never empty to avoid passing an empty slice to DrawTextW) let weekly_text = s.weekly_text.clone(); + // Filter out pacing when actual usage exceeds the expected pace + let session_pacing = s.session_pacing_pct.filter(|&p| p > s.session_percent); + let weekly_pacing = s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent); ( s.hwnd, s.is_dark, @@ -1499,6 +1514,8 @@ fn render_layered() { session_text, s.weekly_percent, weekly_text, + session_pacing, + weekly_pacing, ) } None => return, @@ -1568,6 +1585,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, @@ -1577,11 +1599,14 @@ fn render_layered() { &text_color, &accent, &track, + &pacing_color, strings, session_pct, &session_text, weekly_pct, &weekly_text, + session_pacing, + weekly_pacing, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1640,11 +1665,14 @@ fn paint_content( text_color: &Color, accent: &Color, track: &Color, + pacing_color: &Color, strings: Strings, session_pct: f64, session_text: &str, weekly_pct: f64, weekly_text: &str, + session_pacing: Option, + weekly_pacing: Option, ) { unsafe { let client_rect = RECT { @@ -1730,6 +1758,8 @@ fn paint_content( session_text, accent, track, + pacing_color, + session_pacing, ); draw_row( hdc, @@ -1740,6 +1770,8 @@ fn paint_content( weekly_text, accent, track, + pacing_color, + weekly_pacing, ); SelectObject(hdc, old_font); @@ -1747,6 +1779,14 @@ 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. +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(); match poller::poll() { @@ -1755,6 +1795,10 @@ fn do_poll(send_hwnd: SendHwnd) { if let Some(s) = state.as_mut() { s.session_percent = data.session.percentage; s.weekly_percent = data.weekly.percentage; + if s.show_pacing { + s.session_pacing_pct = compute_pacing_pct(data.session.resets_at, 5.0 * 3600.0); + s.weekly_pacing_pct = compute_pacing_pct(data.weekly.resets_at, 7.0 * 24.0 * 3600.0); + } // Stop fast-poll if reset data is now fresh if !poller::is_past_reset(&data) { unsafe { @@ -2514,6 +2558,20 @@ unsafe extern "system" fn wnd_proc( 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 { + s.session_pacing_pct = None; + s.weekly_pacing_pct = None; + } + } + } + save_state_settings(); + render_layered(); + } id if id == tray_icon::IDM_TOGGLE_WIDGET => { toggle_widget_visibility(hwnd); } @@ -2561,6 +2619,7 @@ fn show_context_menu(hwnd: HWND) { widget_visible, quiet_hours_start, quiet_hours_end, + show_pacing, ) = { let state = lock_state(); match state.as_ref() { @@ -2574,6 +2633,7 @@ fn show_context_menu(hwnd: HWND) { s.widget_visible, s.quiet_hours_start, s.quiet_hours_end, + s.show_pacing, ), None => ( POLL_15_MIN, @@ -2585,6 +2645,7 @@ fn show_context_menu(hwnd: HWND) { true, None::<(u8, u8)>, None::<(u8, u8)>, + false, ), } }; @@ -2708,6 +2769,15 @@ fn show_context_menu(hwnd: HWND) { 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() { @@ -2809,7 +2879,7 @@ fn show_context_menu(hwnd: HWND) { /// Paint for non-embedded fallback (normal WM_PAINT path) fn paint(hdc: HDC, hwnd: HWND) { - let (is_dark, strings, session_pct, session_text, weekly_pct, weekly_text) = { + let (is_dark, strings, session_pct, session_text, weekly_pct, weekly_text, session_pacing, weekly_pacing) = { let state = lock_state(); match state.as_ref() { Some(s) => { @@ -2821,6 +2891,8 @@ fn paint(hdc: HDC, hwnd: HWND) { }; // 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 = s.session_pacing_pct.filter(|&p| p > s.session_percent); + let weekly_pacing = s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent); ( s.is_dark, s.language.strings(), @@ -2828,6 +2900,8 @@ fn paint(hdc: HDC, hwnd: HWND) { session_text, s.weekly_percent, weekly_text, + session_pacing, + weekly_pacing, ) } None => return, @@ -2850,6 +2924,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(); @@ -2874,11 +2953,14 @@ fn paint(hdc: HDC, hwnd: HWND) { &text_color, &accent, &track, + &pacing_color, strings, session_pct, &session_text, weekly_pct, &weekly_text, + session_pacing, + weekly_pacing, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -2898,6 +2980,8 @@ fn draw_row( text: &str, accent: &Color, track: &Color, + pacing_color: &Color, + pacing_pct: Option, ) { let seg_w = sc(SEGMENT_W); let seg_h = sc(SEGMENT_H); @@ -2920,7 +3004,9 @@ fn draw_row( ); let bar_x = x + sc(LABEL_WIDTH) + sc(LABEL_RIGHT_MARGIN); - let percent_clamped = percent.clamp(0.0, 100.0); + let orange_end = percent.clamp(0.0, 100.0); + // pacing_pct is already filtered (None when orange >= pacing) by the caller + let green_end = pacing_pct.unwrap_or(orange_end); for i in 0..SEGMENT_COUNT { let seg_x = bar_x + i * (seg_w + seg_gap); @@ -2934,36 +3020,49 @@ fn draw_row( 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) / 10.0; - 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); + // 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); } } From 1dc1d9db8ed7b7228ddaa806e29c7292d37390fa Mon Sep 17 00:00:00 2001 From: yure Date: Mon, 20 Apr 2026 16:32:17 +0800 Subject: [PATCH 07/10] feat: hide pacing indicator during quiet hours Green pacing zone is now suppressed in both render paths when quiet hours are active, consistent with the quiet-hours design intent of pausing all live activity indicators. --- src/window.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/window.rs b/src/window.rs index a6071d7..2108951 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1502,9 +1502,9 @@ fn render_layered() { }; // weekly_text retains the last polled value (never empty to avoid passing an empty slice to DrawTextW) let weekly_text = s.weekly_text.clone(); - // Filter out pacing when actual usage exceeds the expected pace - let session_pacing = s.session_pacing_pct.filter(|&p| p > s.session_percent); - let weekly_pacing = s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent); + // Filter out pacing during quiet hours or when actual usage exceeds the expected pace + let session_pacing = if quiet { None } else { s.session_pacing_pct.filter(|&p| p > s.session_percent) }; + let weekly_pacing = if quiet { None } else { s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent) }; ( s.hwnd, s.is_dark, @@ -2563,7 +2563,14 @@ unsafe extern "system" fn wnd_proc( let mut state = lock_state(); if let Some(s) = state.as_mut() { s.show_pacing = !s.show_pacing; - if !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 { + s.session_pacing_pct = compute_pacing_pct(data.session.resets_at, 5.0 * 3600.0); + s.weekly_pacing_pct = compute_pacing_pct(data.weekly.resets_at, 7.0 * 24.0 * 3600.0); + } + } else { s.session_pacing_pct = None; s.weekly_pacing_pct = None; } @@ -2891,8 +2898,8 @@ fn paint(hdc: HDC, hwnd: HWND) { }; // 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 = s.session_pacing_pct.filter(|&p| p > s.session_percent); - let weekly_pacing = s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent); + let session_pacing = if quiet { None } else { s.session_pacing_pct.filter(|&p| p > s.session_percent) }; + let weekly_pacing = if quiet { None } else { s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent) }; ( s.is_dark, s.language.strings(), From 7ee8e7d08096208fc43fbc63bd19faf672207d2a Mon Sep 17 00:00:00 2001 From: yure Date: Sat, 9 May 2026 21:39:21 +0800 Subject: [PATCH 08/10] fix: resolve drag deadlock and stuck cursor in taskbar widget Three related fixes: 1. Fix thread deadlock during drag (root cause of stuck cursor): WM_MOUSEMOVE held lock_state() mutex and called total_widget_width(), which also called lock_state(). Rust's std::sync::Mutex (SRWLOCK on Windows) is non-reentrant, causing the message thread to deadlock. Fixed by using total_widget_width_for() with active_models read from the already-held state. 2. Add WM_CAPTURECHANGED and WM_CANCELMODE handlers: Reset dragging state whenever mouse capture is transferred away for any reason, providing a safety net beyond WM_LBUTTONUP. 3. Add left_button_held() self-correction in WM_SETCURSOR and WM_MOUSEMOVE: If dragging=true but the left button is not physically held, cancel the drag immediately rather than waiting for a message that may never arrive. Co-Authored-By: Claude Sonnet 4.6 --- src/localization/dutch.rs | 10 +++ src/window.rs | 153 ++++++++++++++++++++++++++++++++++---- 2 files changed, 150 insertions(+), 13 deletions(-) diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index 0eb486d..e3cb79e 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: "Stille 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/window.rs b/src/window.rs index 5b840d3..67ce4ef 100644 --- a/src/window.rs +++ b/src/window.rs @@ -13,7 +13,9 @@ 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::{GetFocus, ReleaseCapture, SetCapture, SetFocus}; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + GetFocus, GetKeyState, ReleaseCapture, SetCapture, SetFocus, VK_LBUTTON, +}; use windows::Win32::UI::Shell::ExtractIconExW; use windows::Win32::UI::WindowsAndMessaging::*; @@ -92,6 +94,8 @@ struct AppState { show_pacing: bool, session_pacing_pct: Option, weekly_pacing_pct: Option, + session_resets_at: Option, + weekly_resets_at: Option, } #[derive(Clone, Debug)] @@ -1531,6 +1535,8 @@ pub fn run() { show_pacing: settings.show_pacing, session_pacing_pct: None, weekly_pacing_pct: None, + session_resets_at: None, + weekly_resets_at: None, }); } @@ -1664,6 +1670,17 @@ fn render_layered() { 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 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 session_text = if quiet { s.language.strings().quiet_hours.to_string() } else { @@ -1671,17 +1688,36 @@ fn render_layered() { }; // weekly_text retains the last polled value (never empty to avoid passing an empty slice to DrawTextW) let weekly_text = s.weekly_text.clone(); - // Filter out pacing during quiet hours or when actual usage exceeds the expected pace - let session_pacing = if quiet { None } else { s.session_pacing_pct.filter(|&p| p > s.session_percent) }; - let weekly_pacing = if quiet { None } else { s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent) }; + + 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) + }; ( s.hwnd, s.is_dark, s.embedded, s.language.strings(), - s.session_percent, + eff_session_pct, session_text, - s.weekly_percent, + eff_weekly_pct, weekly_text, s.codex_session_percent, s.codex_session_text.clone(), @@ -1985,6 +2021,11 @@ 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; @@ -2008,6 +2049,8 @@ 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); @@ -2521,8 +2564,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; @@ -2565,6 +2618,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 = { @@ -2600,7 +2666,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; @@ -2676,6 +2746,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) @@ -3269,6 +3370,14 @@ fn paint(hdc: HDC, hwnd: HWND) { match state.as_ref() { 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 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 session_text = if quiet { s.language.strings().quiet_hours.to_string() } else { @@ -3276,14 +3385,32 @@ fn paint(hdc: HDC, hwnd: HWND) { }; // 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 { None } else { s.session_pacing_pct.filter(|&p| p > s.session_percent) }; - let weekly_pacing = if quiet { None } else { s.weekly_pacing_pct.filter(|&p| p > s.weekly_percent) }; + 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) + }; ( s.is_dark, s.language.strings(), - s.session_percent, + eff_session_pct, session_text, - s.weekly_percent, + eff_weekly_pct, weekly_text, s.codex_session_percent, s.codex_session_text.clone(), From a4203af8a634da62c9a2e2125ab5231094ac62d2 Mon Sep 17 00:00:00 2001 From: yure Date: Sat, 9 May 2026 21:46:41 +0800 Subject: [PATCH 09/10] feat: apply quiet hours and pacing indicator to Codex widget Codex now has the same behavior as Claude Code during quiet hours: - Usage bar frozen at last polled value before reset_at - Pacing bar (green) continues growing time-based during quiet hours - At reset_at: both bars go to 0% (window reset, no conversation started) - After reset_at (still quiet): both bars stay at 0% Also adds codex_session_pacing_pct / codex_weekly_pacing_pct fields so the Codex pacing indicator updates correctly on every poll and when the user toggles the pacing setting. Co-Authored-By: Claude Sonnet 4.6 --- src/window.rs | 95 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 5 deletions(-) diff --git a/src/window.rs b/src/window.rs index 67ce4ef..68836b8 100644 --- a/src/window.rs +++ b/src/window.rs @@ -96,6 +96,10 @@ struct AppState { 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)] @@ -1537,6 +1541,10 @@ pub fn run() { 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, }); } @@ -1664,6 +1672,8 @@ fn render_layered() { show_codex, session_pacing, weekly_pacing, + codex_session_pacing, + codex_weekly_pacing, ) = { let state = lock_state(); match state.as_ref() { @@ -1677,9 +1687,13 @@ fn render_layered() { // 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() @@ -1710,6 +1724,26 @@ fn render_layered() { } 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, @@ -1719,14 +1753,16 @@ fn render_layered() { session_text, eff_weekly_pct, weekly_text, - s.codex_session_percent, + eff_codex_session_pct, s.codex_session_text.clone(), - s.codex_weekly_percent, + 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, @@ -1826,6 +1862,8 @@ fn render_layered() { &codex_accent, session_pacing, weekly_pacing, + codex_session_pacing, + codex_weekly_pacing, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1899,6 +1937,8 @@ fn paint_content( codex_accent: &Color, session_pacing: Option, weekly_pacing: Option, + codex_session_pacing: Option, + codex_weekly_pacing: Option, ) { unsafe { let client_rect = RECT { @@ -1993,6 +2033,7 @@ fn paint_content( track, pacing_color, session_pacing, + codex_session_pacing, ); draw_row( hdc, @@ -2012,6 +2053,7 @@ fn paint_content( track, pacing_color, weekly_pacing, + codex_weekly_pacing, ); SelectObject(hdc, old_font); @@ -2062,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; @@ -2984,10 +3032,16 @@ unsafe extern "system" fn wnd_proc( 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; } } } @@ -3365,6 +3419,8 @@ fn paint(hdc: HDC, hwnd: HWND) { show_codex, session_pacing, weekly_pacing, + codex_session_pacing, + codex_weekly_pacing, ) = { let state = lock_state(); match state.as_ref() { @@ -3374,9 +3430,13 @@ fn paint(hdc: HDC, hwnd: HWND) { 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() @@ -3405,6 +3465,26 @@ fn paint(hdc: HDC, hwnd: HWND) { } 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(), @@ -3412,14 +3492,16 @@ fn paint(hdc: HDC, hwnd: HWND) { session_text, eff_weekly_pct, weekly_text, - s.codex_session_percent, + eff_codex_session_pct, s.codex_session_text.clone(), - s.codex_weekly_percent, + 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, @@ -3487,6 +3569,8 @@ fn paint(hdc: HDC, hwnd: HWND) { &codex_accent, session_pacing, weekly_pacing, + codex_session_pacing, + codex_weekly_pacing, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -3515,6 +3599,7 @@ fn draw_row( 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); @@ -3576,7 +3661,7 @@ fn draw_row( track, &codex_value_color, pacing_color, - None, + codex_pacing_pct, ); } } From dcaeb5e4fdea89de0fe3c94b751a155278ac94ed Mon Sep 17 00:00:00 2001 From: yure Date: Sat, 9 May 2026 22:21:43 +0800 Subject: [PATCH 10/10] refactor: rename Quiet Hours to Idle Hours across all locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature is fundamentally about time periods when the user is not actively using Claude Code — the reset clock is unaffected because no conversation is started. "Idle Hours" conveys this intent more clearly than "Quiet Hours" (which implies only notification silencing). Translations: en: Idle Hours nl: Inactieve uren fr: Heures inactives de: Inaktive Stunden ja: アイドル時間 ko: 유휴 시간 es: Horas inactivas zh-TW: 閒置時段 Co-Authored-By: Claude Sonnet 4.6 --- src/localization/dutch.rs | 2 +- src/localization/english.rs | 2 +- src/localization/french.rs | 2 +- src/localization/german.rs | 2 +- src/localization/japanese.rs | 2 +- src/localization/korean.rs | 2 +- src/localization/spanish.rs | 2 +- src/localization/traditional_chinese.rs | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index e3cb79e..511f48e 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -44,7 +44,7 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex-gebruiksmonitor", second_suffix: "s", show_pacing: "Pacingindicator tonen", - quiet_hours: "Stille uren", + quiet_hours: "Inactieve uren", quiet_start: "Begin", quiet_end: "Einde", quiet_set_time: "Tijd instellen...", diff --git a/src/localization/english.rs b/src/localization/english.rs index 4cbbaa6..5c3603a 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -44,7 +44,7 @@ pub(super) const STRINGS: Strings = Strings { 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: "Quiet Hours", + quiet_hours: "Idle Hours", quiet_start: "Start", quiet_end: "End", quiet_set_time: "Set time...", diff --git a/src/localization/french.rs b/src/localization/french.rs index df623ef..877082b 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -44,7 +44,7 @@ pub(super) const STRINGS: Strings = Strings { 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: "Quiet Hours", + quiet_hours: "Heures inactives", quiet_start: "Start", quiet_end: "End", quiet_set_time: "Définir l'heure...", diff --git a/src/localization/german.rs b/src/localization/german.rs index b71d8d3..7833865 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -44,7 +44,7 @@ pub(super) const STRINGS: Strings = Strings { 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: "Quiet Hours", + quiet_hours: "Inaktive Stunden", quiet_start: "Start", quiet_end: "End", quiet_set_time: "Uhrzeit festlegen...", diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index fb58331..fd50c67 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -44,7 +44,7 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_body: "ターミナルで 'codex' を実行し、サインインの案内に従ってください。その後、このアプリを更新または再起動してください。", codex_window_title: "Codex 使用量モニター", second_suffix: "秒", - quiet_hours: "サイレント時間", + quiet_hours: "アイドル時間", quiet_start: "開始", quiet_end: "終了", quiet_set_time: "時刻を設定...", diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 19fca7a..4e9399c 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -44,7 +44,7 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_body: "터미널에서 'codex'를 실행하고 로그인 안내를 따르세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", codex_window_title: "Codex 사용량 모니터", second_suffix: "초", - quiet_hours: "조용한 시간", + quiet_hours: "유휴 시간", quiet_start: "시작", quiet_end: "종료", quiet_set_time: "시간 설정...", diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index 1d50a18..ace4d05 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -44,7 +44,7 @@ pub(super) const STRINGS: Strings = Strings { 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: "Quiet Hours", + quiet_hours: "Horas inactivas", quiet_start: "Start", quiet_end: "End", quiet_set_time: "Set time...", diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 5d7030a..a5df37e 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -44,7 +44,7 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_body: "請在終端機中執行 'codex',並依照登入提示操作。完成後,請重新整理或重新啟動此應用程式。", codex_window_title: "Codex 使用量監控", second_suffix: "秒", - quiet_hours: "安靜時刻", + quiet_hours: "閒置時段", quiet_start: "開始時間", quiet_end: "結束時間", quiet_set_time: "設定時間...",