diff --git a/assets/panels/classic.html b/assets/panels/classic.html index e486154..50d8357 100644 --- a/assets/panels/classic.html +++ b/assets/panels/classic.html @@ -199,6 +199,22 @@ margin-left: auto; } + .card[data-card="codex"] .brand.usage-switch-host h1 { + flex: none; + overflow: visible; + } + + .card[data-card="codex"] .brand.usage-switch-host .codex-stale { + flex: 1 1 0; + overflow: hidden; + } + + .card[data-card="codex"] .brand.usage-switch-host [data-codex-stale-age] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + .codex-stale-info { position: relative; display: inline-flex; diff --git a/assets/panels/panel_core.js b/assets/panels/panel_core.js index 84bfd4a..8cc2555 100644 --- a/assets/panels/panel_core.js +++ b/assets/panels/panel_core.js @@ -66,6 +66,27 @@ const I18N = {{I18N_BUNDLE}}; const FALLBACK_LANGUAGE = "en"; const root = document.documentElement; + const switchHostStyle = document.createElement("style"); + switchHostStyle.textContent = ` + [data-card="codex"] .usage-switch-host > h1, + [data-card="codex"] .usage-switch-host > .header-copy { + flex: 0 0 auto !important; + min-width: max-content !important; + overflow: visible !important; + } + [data-card="codex"] .usage-switch-host > [data-codex-stale] { + flex: 1 1 0 !important; + min-width: 0 !important; + overflow: hidden !important; + } + [data-card="codex"] .usage-switch-host [data-codex-stale-age] { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + `; + document.head.appendChild(switchHostStyle); let currentLanguage = "en"; let projectRange = "1d"; let latestState = null; @@ -87,6 +108,7 @@ } function projectRangeLabel(range) { + if (range === "yesterday") return t("project_range_yesterday"); if (range === "7d") return t("project_range_7d"); if (range === "30d") return t("project_range_30d"); if (range === "all") return t("project_range_all"); @@ -99,6 +121,13 @@ const key = node.dataset.i18n; if (key) node.textContent = t(key); }); + document.querySelectorAll("[data-i18n-title]").forEach((node) => { + const key = node.dataset.i18nTitle; + if (!key) return; + const text = t(key); + node.title = text; + node.setAttribute("aria-label", text); + }); const rangeButton = document.querySelector('[data-action="toggle-project-range"]'); if (rangeButton) rangeButton.textContent = projectRangeLabel(projectRange); } @@ -262,6 +291,14 @@ }); } + function renderPeriodTotal(state) { + const total = document.querySelector('[data-footer="today"]'); + if (!total) return; + total.textContent = projectRange === "yesterday" + ? (state.footer.yesterday || t("yesterday_text", { cost: "0.00", tokens: "0" })) + : (state.footer.today || t("today_text", { cost: "0.00", tokens: "0" })); + } + @@ -277,7 +314,15 @@ } function relocateSwitchButton(state) { + document.querySelectorAll(".usage-switch-host").forEach((host) => { + host.classList.remove("usage-switch-host"); + }); window.PanelHooks.switchButtonStrategy(state); + const button = document.querySelector('[data-action="switch"]'); + const host = button && button.parentElement; + if (host && host.closest('[data-card="codex"]')) { + host.classList.add("usage-switch-host"); + } } const QUOTA_CARD_IDS = ["claude", "codex", "agy"]; @@ -315,6 +360,7 @@ latestState = state; renderProjects( projectRange === "1d" ? state.projects + : projectRange === "yesterday" ? state.projectsYesterday : projectRange === "7d" ? state.projects7d : projectRange === "30d" ? state.projects30d : projectRange === "all" ? state.projectsAll @@ -323,12 +369,11 @@ renderStatusline(state.statusline || {}); const rate = document.querySelector('[data-footer="rate"]'); const status = document.querySelector('[data-footer="status"]'); - const today = document.querySelector('[data-footer="today"]'); const serviceAlerts = document.querySelector('[data-footer="service-alerts"]'); const install = document.querySelector('[data-action="install"]'); if (rate) rate.textContent = state.footer.rate || t("rate_text", { value: "--" }); if (status) status.textContent = state.footer.status || t("status_text", { value: "--" }); - if (today) today.textContent = state.footer.today || t("today_text", { cost: "0.00", tokens: "0" }); + renderPeriodTotal(state); if (install) install.dataset.visible = state.footer.showInstall === true ? "true" : "false"; if (serviceAlerts) { const alerts = state.footer.serviceAlerts || []; @@ -346,16 +391,21 @@ const button = event.target.closest("[data-action]"); if (!button) return; if (button.dataset.action === "toggle-project-range") { - projectRange = projectRange === "1d" ? "7d" : projectRange === "7d" ? "30d" : projectRange === "30d" ? "all" : "1d"; + projectRange = projectRange === "1d" ? "yesterday" : projectRange === "yesterday" ? "7d" : projectRange === "7d" ? "30d" : projectRange === "30d" ? "all" : "1d"; button.textContent = projectRangeLabel(projectRange); if (latestState) { renderProjects( projectRange === "1d" ? latestState.projects + : projectRange === "yesterday" ? latestState.projectsYesterday : projectRange === "7d" ? latestState.projects7d : projectRange === "30d" ? latestState.projects30d : projectRange === "all" ? latestState.projectsAll : latestState.projects ); + renderPeriodTotal(latestState); + if (typeof window.usageRequestContentHeight === "function") { + window.usageRequestContentHeight(); + } } return; } @@ -421,9 +471,10 @@ cardOrder: ["claude", "codex", "agy"], hideAgy: true, projects: [], + projectsYesterday: [], projects7d: [], projects30d: [], projectsAll: [], statusline: {}, - footer: { rate: "Rate: --", status: "Status: Loading", today: "Today: $0.00 (0 tokens)", serviceAlerts: [], showInstall: false } + footer: { rate: "Rate: --", status: "Status: Loading", today: "Today: $0.00 (0 tokens)", yesterday: "Yesterday: $0.00 (0 tokens)", serviceAlerts: [], showInstall: false } }); diff --git a/assets/usage.ico b/assets/usage.ico new file mode 100644 index 0000000..651b3f0 Binary files /dev/null and b/assets/usage.ico differ diff --git a/codex_loader.py b/codex_loader.py index 4c897eb..6710899 100644 --- a/codex_loader.py +++ b/codex_loader.py @@ -181,6 +181,7 @@ class CodexRateLimits: has_credits: bool = False credit_balance: str | None = None credits_unlimited: bool = False + limit_id: str = "" def _seed_caches_from_disk() -> None: @@ -416,11 +417,16 @@ def _load_jsonl_rate_limits( return None models = _load_thread_models() # scan 30 recent sessions because short/interrupted Codex sessions write null rate_limits + fallback: CodexRateLimits | None = None for path in _recent_jsonl_files(jsonl_candidates=jsonl_candidates): rate_limits = _extract_rate_limits(path, models) - if rate_limits is not None: + if rate_limits is None: + continue + if rate_limits.limit_id == "codex": return rate_limits - return None + if fallback is None: + fallback = rate_limits + return fallback def _rate_limits_timestamp(rate_limits: CodexRateLimits) -> datetime: @@ -930,6 +936,7 @@ def _extract_rate_limits(path: Path, models: dict[str, str]) -> CodexRateLimits session_id = "" session_model = "unknown" last_rate_limits: tuple[dict[str, Any], str] | None = None + last_general_rate_limits: tuple[dict[str, Any], str] | None = None try: with path.open(encoding="utf-8") as file: for line in file: @@ -951,9 +958,12 @@ def _extract_rate_limits(path: Path, models: dict[str, str]) -> CodexRateLimits rate_limits = _as_dict(payload.get("rate_limits")) if rate_limits: last_rate_limits = (rate_limits, _as_str(data.get("timestamp"))) + if rate_limits.get("limit_id") == "codex": + last_general_rate_limits = last_rate_limits except (OSError, UnicodeDecodeError) as exc: logger.warning("failed to read codex session %s: %s", path, exc) return None + last_rate_limits = last_general_rate_limits or last_rate_limits if last_rate_limits is None: return None rate_limits, updated_at = last_rate_limits @@ -997,6 +1007,7 @@ def _extract_rate_limits(path: Path, models: dict[str, str]) -> CodexRateLimits has_credits=credits.get("has_credits") is True, credit_balance=_as_str(credits.get("balance")) or None, credits_unlimited=credits.get("unlimited") is True, + limit_id=_as_str(rate_limits.get("limit_id")), ) diff --git a/i18n.json b/i18n.json index a5397be..b6ea547 100644 --- a/i18n.json +++ b/i18n.json @@ -128,6 +128,14 @@ "talent_search_placeholder": "搜尋團隊", "talent_no_results": "搜尋不到符合的團隊", "switch_panel": "更換面板", + "language_menu": "語言", + "language_auto": "自動(依系統)", + "language_zh_cn": "简体中文", + "language_zh_tw": "繁體中文", + "language_en": "English", + "language_ja": "日本語", + "language_ko": "한국어", + "close_to_tray": "關閉到系統匣", "talent_launch": "啟動", "talent_back": "返回", "talent_restore": "修復", @@ -188,6 +196,7 @@ "service_partial_outage": "部分中斷", "service_major_outage": "大規模中斷", "today_text": "今日:${cost} ({tokens} tokens)", + "yesterday_text": "昨日:${cost} ({tokens} tokens)", "percent_used": "{value}% 已用", "reset_in": "重置 {time}", "reset_imminent": "即將重置", @@ -218,6 +227,7 @@ "history_load_error_tooltip": "目前顯示的是上次成功讀到的資料,等問題排除後會自動恢復。", "projects_title": "專案用量", "project_range_1d": "今日", + "project_range_yesterday": "昨日", "project_range_7d": "7 日", "project_range_30d": "月", "project_range_all": "全部", @@ -609,6 +619,14 @@ "talent_search_placeholder": "Search teams", "talent_no_results": "No matching teams found", "switch_panel": "Switch Panel", + "language_menu": "Language", + "language_auto": "Auto (System)", + "language_zh_cn": "简体中文", + "language_zh_tw": "繁體中文", + "language_en": "English", + "language_ja": "日本語", + "language_ko": "한국어", + "close_to_tray": "Close to tray", "talent_launch": "Launch", "talent_back": "Back", "talent_restore": "Restore", @@ -669,6 +687,7 @@ "service_partial_outage": "Partial outage", "service_major_outage": "Major outage", "today_text": "Today: ${cost} ({tokens} tokens)", + "yesterday_text": "Yesterday: ${cost} ({tokens} tokens)", "percent_used": "{value}% used", "reset_in": "Resets in {time}", "reset_imminent": "Reset imminent", @@ -699,6 +718,7 @@ "history_load_error_tooltip": "Showing the last successfully loaded data; this will recover automatically once resolved.", "projects_title": "Project Usage", "project_range_1d": "Today", + "project_range_yesterday": "Yesterday", "project_range_7d": "7 Days", "project_range_30d": "Month", "project_range_all": "All Time", @@ -1090,6 +1110,14 @@ "talent_search_placeholder": "搜索团队", "talent_no_results": "搜索不到符合的团队", "switch_panel": "切换面板", + "language_menu": "语言", + "language_auto": "自动(跟随系统)", + "language_zh_cn": "简体中文", + "language_zh_tw": "繁體中文", + "language_en": "English", + "language_ja": "日本語", + "language_ko": "한국어", + "close_to_tray": "关闭到托盘", "talent_launch": "启动", "talent_back": "返回", "talent_restore": "修复", @@ -1150,6 +1178,7 @@ "service_partial_outage": "部分中断", "service_major_outage": "大规模中断", "today_text": "今日:${cost} ({tokens} tokens)", + "yesterday_text": "昨天:${cost} ({tokens} tokens)", "percent_used": "{value}% 已用", "reset_in": "重置 {time}", "reset_imminent": "即将重置", @@ -1180,6 +1209,7 @@ "history_load_error_tooltip": "目前显示的是上次成功读到的数据,问题解决后会自动恢复。", "projects_title": "项目用量", "project_range_1d": "今日", + "project_range_yesterday": "昨天", "project_range_7d": "7 日", "project_range_30d": "月", "project_range_all": "全部", @@ -1571,6 +1601,14 @@ "talent_search_placeholder": "チームを検索", "talent_no_results": "該当するチームが見つかりません", "switch_panel": "パネル切替", + "language_menu": "言語", + "language_auto": "自動(システム)", + "language_zh_cn": "简体中文", + "language_zh_tw": "繁體中文", + "language_en": "English", + "language_ja": "日本語", + "language_ko": "한국어", + "close_to_tray": "トレイに閉じる", "talent_launch": "起動", "talent_back": "戻る", "talent_restore": "修復", @@ -1631,6 +1669,7 @@ "service_partial_outage": "一部障害", "service_major_outage": "大規模障害", "today_text": "今日: ${cost} ({tokens} tokens)", + "yesterday_text": "昨日: ${cost} ({tokens} tokens)", "percent_used": "{value}% 使用済み", "reset_in": "{time}後にリセット", "reset_imminent": "まもなくリセット", @@ -1661,6 +1700,7 @@ "history_load_error_tooltip": "現在表示中なのは前回正常に読み込めたデータです。問題が解消すると自動的に復旧します。", "projects_title": "プロジェクト使用量", "project_range_1d": "今日", + "project_range_yesterday": "昨日", "project_range_7d": "7日間", "project_range_30d": "月", "project_range_all": "全期間", @@ -2052,6 +2092,14 @@ "talent_search_placeholder": "팀 검색", "talent_no_results": "일치하는 팀을 찾을 수 없습니다", "switch_panel": "패널 전환", + "language_menu": "언어", + "language_auto": "자동 (시스템)", + "language_zh_cn": "简体中文", + "language_zh_tw": "繁體中文", + "language_en": "English", + "language_ja": "日本語", + "language_ko": "한국어", + "close_to_tray": "트레이로 닫기", "talent_launch": "실행", "talent_back": "뒤로", "talent_restore": "복원", @@ -2112,6 +2160,7 @@ "service_partial_outage": "부분 장애", "service_major_outage": "대규모 장애", "today_text": "오늘: ${cost} ({tokens} tokens)", + "yesterday_text": "어제: ${cost} ({tokens} tokens)", "percent_used": "{value}% 사용됨", "reset_in": "{time} 후 초기화", "reset_imminent": "곧 초기화", @@ -2142,6 +2191,7 @@ "history_load_error_tooltip": "마지막으로 정상적으로 불러온 데이터를 표시하고 있습니다. 문제가 해결되면 자동으로 복구됩니다.", "projects_title": "프로젝트 사용량", "project_range_1d": "오늘", + "project_range_yesterday": "어제", "project_range_7d": "7일", "project_range_30d": "월", "project_range_all": "전체", diff --git a/installer/windows/usage.iss b/installer/windows/usage.iss new file mode 100644 index 0000000..182d2bf --- /dev/null +++ b/installer/windows/usage.iss @@ -0,0 +1,54 @@ +#ifndef AppVersion + #error AppVersion must be supplied by the build script +#endif +#ifndef SourceDir + #error SourceDir must be supplied by the build script +#endif +#ifndef OutputDir + #error OutputDir must be supplied by the build script +#endif +#ifndef RepoRoot + #error RepoRoot must be supplied by the build script +#endif + +[Setup] +AppId={{5B469EB3-1018-4ACB-B137-E45606C13448} +AppName=Usage +AppVersion={#AppVersion} +AppPublisher=lollapalooza +AppPublisherURL=https://github.com/aqua5230/usage +AppSupportURL=https://github.com/aqua5230/usage/issues +DefaultDirName={localappdata}\Programs\Usage +DefaultGroupName=Usage +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +LicenseFile={#RepoRoot}\LICENSE +SetupIconFile={#RepoRoot}\assets\usage.ico +UninstallDisplayIcon={app}\usage.exe +OutputDir={#OutputDir} +OutputBaseFilename=UsageSetup-{#AppVersion} +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +CloseApplications=yes +RestartApplications=no + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut" +Name: "autostart"; Description: "Start Usage when I sign in to Windows" + +[Files] +Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#RepoRoot}\LICENSE"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\Usage"; Filename: "{app}\usage.exe" +Name: "{autodesktop}\Usage"; Filename: "{app}\usage.exe"; Tasks: desktopicon + +[Registry] +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "Usage"; ValueData: "{app}\usage.exe"; Flags: uninsdeletevalue; Tasks: autostart + +[Run] +Filename: "{app}\usage.exe"; Description: "Launch Usage"; Flags: nowait postinstall skipifsilent diff --git a/menubar_refresh.py b/menubar_refresh.py index 70b10b7..b5efee0 100644 --- a/menubar_refresh.py +++ b/menubar_refresh.py @@ -117,10 +117,12 @@ def build_result(app: _RefreshApp, sources: RefreshSources) -> dict[str, Any]: animation_groups = sources.animation_groups fallback_state = getattr(app, "latest_state", menubar_state._empty_state(app.language)) project_rows = list(fallback_state.projects) + project_rows_yesterday = list(fallback_state.projects_yesterday) project_rows_7d = list(fallback_state.projects_7d) project_rows_30d = list(fallback_state.projects_30d) project_rows_all = list(fallback_state.projects_all) today_text = fallback_state.today_text + yesterday_text = fallback_state.yesterday_text statusline = fallback_state.statusline hide_claude = fallback_state.hide_claude hide_codex = fallback_state.hide_codex @@ -136,18 +138,23 @@ def build_result(app: _RefreshApp, sources: RefreshSources) -> dict[str, Any]: started_at = time.monotonic() if sources.debug_timing else 0.0 if app.mock: project_rows = app._project_rows(hours_back=24, entries=all_entries) + project_rows_yesterday = project_rows project_rows_7d = app._project_rows(hours_back=168, entries=all_entries) project_rows_30d = app._project_rows(hours_back=720, entries=all_entries) project_rows_all = app._project_rows(hours_back=0, entries=all_entries) else: ( project_rows, + project_rows_yesterday, project_rows_7d, project_rows_30d, project_rows_all, ) = menubar_state.project_rows_for_windows(all_entries) sources.measure("project_rows_windows", started_at) today_text = menubar_state._today_title(app.mock, app.language, entries=all_entries) + yesterday_text = menubar_state._yesterday_title( + app.mock, app.language, entries=all_entries + ) statusline = menubar_state._statusline_payload(app.language) hide_claude = _hide_claude_enabled() hide_codex = _hide_codex_enabled() @@ -197,6 +204,7 @@ def build_result(app: _RefreshApp, sources: RefreshSources) -> dict[str, Any]: agy_rows=(agy_projection.session, agy_projection.weekly), agy_group_name=agy_projection.group_name, projects=project_rows, + projects_yesterday=project_rows_yesterday, projects_7d=project_rows_7d, projects_30d=project_rows_30d, projects_all=project_rows_all, @@ -204,6 +212,7 @@ def build_result(app: _RefreshApp, sources: RefreshSources) -> dict[str, Any]: group=group, burn_rate_trackers=app.burn_rate_trackers, today_text=today_text, + yesterday_text=yesterday_text, statusline=statusline, show_install_button=show_install_button, hide_claude=hide_claude, @@ -244,10 +253,12 @@ def build_result(app: _RefreshApp, sources: RefreshSources) -> dict[str, Any]: app._history_load_error_key, app.language ) state.projects = project_rows + state.projects_yesterday = project_rows_yesterday state.projects_7d = project_rows_7d state.projects_30d = project_rows_30d state.projects_all = project_rows_all state.today_text = today_text + state.yesterday_text = yesterday_text state.statusline = statusline state.hide_claude = hide_claude state.hide_codex = hide_codex diff --git a/menubar_state.py b/menubar_state.py index 1e5005d..0be778c 100644 --- a/menubar_state.py +++ b/menubar_state.py @@ -144,12 +144,14 @@ class PopoverState: agy_weekly: QuotaRowState agy_group_name: str projects: list[tuple[str, int, float | None]] + projects_yesterday: list[tuple[str, int, float | None]] projects_7d: list[tuple[str, int, float | None]] projects_30d: list[tuple[str, int, float | None]] projects_all: list[tuple[str, int, float | None]] rate_text: str status_text: str today_text: str + yesterday_text: str statusline: dict[str, object] service_alerts: tuple[str, ...] = () show_install_button: bool = False @@ -517,6 +519,7 @@ def project_rows_for_windows( list[tuple[str, int, float | None]], list[tuple[str, int, float | None]], list[tuple[str, int, float | None]], + list[tuple[str, int, float | None]], ]: """Aggregate the four project windows in one pass over the history.""" current_time = datetime.now(UTC) if now is None else now @@ -530,9 +533,13 @@ def project_rows_for_windows( tomorrow_start = datetime.combine( local_today + timedelta(days=1), datetime_time.min, tzinfo=local_tz ).astimezone(UTC) + yesterday_start = datetime.combine( + local_today - timedelta(days=1), datetime_time.min, tzinfo=local_tz + ).astimezone(UTC) cutoff_7d = current_time - timedelta(hours=168) cutoff_30d = current_time - timedelta(hours=720) aggregates_24h: dict[str, list[float]] = {} + aggregates_yesterday: dict[str, list[float]] = {} aggregates_7d: dict[str, list[float]] = {} aggregates_30d: dict[str, list[float]] = {} aggregates_all: dict[str, list[float]] = {} @@ -547,9 +554,12 @@ def project_rows_for_windows( _add_project_usage(aggregates_7d, entry.project, tokens, cost) if today_start <= entry.timestamp < tomorrow_start: _add_project_usage(aggregates_24h, entry.project, tokens, cost) + elif yesterday_start <= entry.timestamp < today_start: + _add_project_usage(aggregates_yesterday, entry.project, tokens, cost) return ( _rank_project_rows(aggregates_24h), + _rank_project_rows(aggregates_yesterday), _rank_project_rows(aggregates_7d), _rank_project_rows(aggregates_30d), _rank_project_rows(aggregates_all), @@ -793,6 +803,7 @@ def build_popover_state( agy_rows: tuple[QuotaRowState, QuotaRowState], agy_group_name: str, projects: list[tuple[str, int, float | None]], + projects_yesterday: list[tuple[str, int, float | None]], projects_7d: list[tuple[str, int, float | None]], projects_30d: list[tuple[str, int, float | None]], projects_all: list[tuple[str, int, float | None]], @@ -800,6 +811,7 @@ def build_popover_state( group: int, burn_rate_trackers: dict[str, BurnRateTracker], today_text: str, + yesterday_text: str, statusline: dict[str, object], show_install_button: bool, hide_claude: bool, @@ -909,12 +921,14 @@ def build_popover_state( agy_weekly=agy_rows[1], agy_group_name=agy_group_name, projects=projects, + projects_yesterday=projects_yesterday, projects_7d=projects_7d, projects_30d=projects_30d, projects_all=projects_all, rate_text=_t(language, "rate_text", value=group_name), status_text=status_text, today_text=today_text, + yesterday_text=yesterday_text, statusline=statusline, service_alerts=service_alerts, show_install_button=show_install_button, @@ -1079,12 +1093,14 @@ def _empty_state(language: str = "en") -> PopoverState: agy_weekly=_missing_row(_t(language, "weekly_label"), AGY_COLOR, language), agy_group_name="", projects=[], + projects_yesterday=[], projects_7d=[], projects_30d=[], projects_all=[], rate_text=_t(language, "rate_text", value="--"), status_text=_t(language, "status_text", value=_t(language, "status_loading")), today_text=_t(language, "today_text", cost="0.00", tokens="0"), + yesterday_text=_t(language, "yesterday_text", cost="0.00", tokens="0"), statusline=_statusline_payload(language), service_alerts=(), show_install_button=False, @@ -1148,3 +1164,34 @@ def _today_title( return _t(language, "today_text", cost="0.00", tokens="0") return _t(language, "today_text", cost=f"{total_cost:.2f}", tokens=f"{total_tokens:,}") + + +def _yesterday_title( + mock: bool = False, + language: str = "en", + entries: list[UsageEntry] | None = None, +) -> str: + if mock: + return _t(language, "yesterday_text", cost="41.10", tokens="48,200,000") + + try: + yesterday = datetime.now().astimezone().date() - timedelta(days=1) + all_entries = ( + entries + if entries is not None + else list(load_entries(hours_back=48)) + codex_loader.load_entries(hours_back=48) + ) + selected = [entry for entry in all_entries if entry.timestamp.astimezone().date() == yesterday] + total_tokens = sum(entry.total_tokens for entry in selected) + total_cost = sum(calculate_cost(entry) for entry in selected) + except Exception: + if os.environ.get("USAGE_DEBUG") == "1": + logger.warning("yesterday totals load failed", exc_info=True) + return _t(language, "yesterday_text", cost="0.00", tokens="0") + + return _t( + language, + "yesterday_text", + cost=f"{total_cost:.2f}", + tokens=f"{total_tokens:,}", + ) diff --git a/panels/dynamic_height.py b/panels/dynamic_height.py index 94a8f8a..0eb4e3e 100644 --- a/panels/dynamic_height.py +++ b/panels/dynamic_height.py @@ -12,6 +12,8 @@ (function() { var applyState = window.usageApplyState; if (typeof applyState !== "function") return; + var scheduled = false; + var lastPostedHeight = null; function naturalContentHeight() { var wrap = document.querySelector(".wrap"); if (!wrap) return null; @@ -76,17 +78,48 @@ }); } } - window.usageApplyState = function usageApplyStateWithDynamicHeight(state) { - var result = applyState.apply(this, arguments); + function reportContentHeight() { + scheduled = false; var height = naturalContentHeight(); var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.usage; - if (Number.isFinite(height) && height > 0 && bridge + if (Number.isFinite(height) && height > 0 && height !== lastPostedHeight && bridge && typeof bridge.postMessage === "function") { + lastPostedHeight = height; bridge.postMessage(JSON.stringify({ action: "content_height", height: height })); } + } + function requestContentHeight() { + if (scheduled) return; + scheduled = true; + // Measure after the browser has committed DOM, font, and layout changes. + // A second frame catches WebView2's first visible layout without relying + // on a user clicking another control. + requestAnimationFrame(function() { + requestAnimationFrame(reportContentHeight); + }); + } + window.usageRequestContentHeight = requestContentHeight; + window.usageApplyState = function usageApplyStateWithDynamicHeight(state) { + var result = applyState.apply(this, arguments); + requestContentHeight(); return result; }; + var wrap = document.querySelector(".wrap"); + if (wrap && typeof MutationObserver === "function") { + new MutationObserver(requestContentHeight).observe(wrap, { + childList: true, + characterData: true, + subtree: true + }); + } + if (wrap && typeof ResizeObserver === "function") { + new ResizeObserver(requestContentHeight).observe(wrap); + } + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(requestContentHeight); + } + requestContentHeight(); })(); """.strip() diff --git a/panels/payload.py b/panels/payload.py index 3f5caed..bdea633 100644 --- a/panels/payload.py +++ b/panels/payload.py @@ -107,7 +107,13 @@ def _state_payload(state: PopoverState) -> dict[str, object]: if row.title } project_payloads = [] - for rows in (state.projects, state.projects_7d, state.projects_30d, state.projects_all): + for rows in ( + state.projects, + state.projects_yesterday, + state.projects_7d, + state.projects_30d, + state.projects_all, + ): project_payloads.append( [ { @@ -137,9 +143,10 @@ def _state_payload(state: PopoverState) -> dict[str, object]: "stale": state.agy_stale, }, "projects": project_payloads[0], - "projects7d": project_payloads[1], - "projects30d": project_payloads[2], - "projectsAll": project_payloads[3], + "projectsYesterday": project_payloads[1], + "projects7d": project_payloads[2], + "projects30d": project_payloads[3], + "projectsAll": project_payloads[4], "hideClaude": state.hide_claude, "hideCodex": state.hide_codex, "hideAgy": state.hide_agy, @@ -151,6 +158,7 @@ def _state_payload(state: PopoverState) -> dict[str, object]: "rate": state.rate_text, "status": state.status_text, "today": state.today_text, + "yesterday": state.yesterday_text, "serviceAlerts": list(state.service_alerts), "showInstall": state.show_install_button, }, diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 index 3872092..3afe143 100644 --- a/scripts/build_windows.ps1 +++ b/scripts/build_windows.ps1 @@ -7,6 +7,7 @@ $OutputDir = Join-Path $DistRoot "usage-windows" $PyInstallerOutput = Join-Path $DistRoot "usage" $BuildDir = Join-Path $RepoRoot "build/pyinstaller-windows" $SpecDir = Join-Path $RepoRoot "build/pyinstaller-spec" +$IconPath = Join-Path $RepoRoot "assets/usage.ico" Remove-Item $OutputDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $PyInstallerOutput -Recurse -Force -ErrorAction SilentlyContinue @@ -20,6 +21,7 @@ try { --windowed ` --onedir ` --name usage ` + --icon $IconPath ` --distpath $DistRoot ` --workpath $BuildDir ` --specpath $SpecDir ` diff --git a/scripts/build_windows_installer.ps1 b/scripts/build_windows_installer.ps1 new file mode 100644 index 0000000..6bf6ed9 --- /dev/null +++ b/scripts/build_windows_installer.ps1 @@ -0,0 +1,43 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$RepoRoot = Split-Path -Parent $PSScriptRoot +$SourceDir = Join-Path $RepoRoot "dist/usage-windows" +$OutputDir = Join-Path $RepoRoot "dist/installer" +$InstallerScript = Join-Path $RepoRoot "installer/windows/usage.iss" +$ProjectFile = Join-Path $RepoRoot "pyproject.toml" + +& (Join-Path $PSScriptRoot "build_windows.ps1") + +$VersionLine = Select-String -LiteralPath $ProjectFile -Pattern '^version\s*=\s*"([^"]+)"$' +if ($null -eq $VersionLine) { + throw "Could not read the project version from $ProjectFile" +} +$AppVersion = $VersionLine.Matches[0].Groups[1].Value + +$CompilerCandidates = @( + (Join-Path $env:LOCALAPPDATA "Programs/Inno Setup 6/ISCC.exe"), + "C:/Program Files (x86)/Inno Setup 6/ISCC.exe", + "C:/Program Files/Inno Setup 6/ISCC.exe" +) +$Compiler = $CompilerCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 +if ($null -eq $Compiler) { + throw "Inno Setup 6 is required. Install it with: winget install JRSoftware.InnoSetup" +} + +Remove-Item $OutputDir -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Path $OutputDir | Out-Null + +& $Compiler ` + "/DAppVersion=$AppVersion" ` + "/DSourceDir=$SourceDir" ` + "/DOutputDir=$OutputDir" ` + "/DRepoRoot=$RepoRoot" ` + $InstallerScript + +$Installer = Join-Path $OutputDir "UsageSetup-$AppVersion.exe" +if (-not (Test-Path -LiteralPath $Installer -PathType Leaf)) { + throw "Inno Setup did not produce $Installer" +} + +Write-Output $Installer diff --git a/tests/test_codex_loader.py b/tests/test_codex_loader.py index 8f41f91..14fb38f 100644 --- a/tests/test_codex_loader.py +++ b/tests/test_codex_loader.py @@ -1967,6 +1967,68 @@ def test_load_rate_limits_picks_most_recent_valid(monkeypatch: pytest.MonkeyPatc assert result.updated_at == new_ts +def test_load_rate_limits_prefers_general_limit_over_newer_model_limit( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + sessions_dir = tmp_path / "sessions" + monkeypatch.setattr(codex_loader, "SESSIONS_DIR", sessions_dir) + monkeypatch.setattr(codex_loader, "_load_thread_models", lambda: {}) + general = { + "limit_id": "codex", + "primary": {"used_percent": 72, "window_minutes": 10080, "resets_at": 9_999_999_999}, + "secondary": None, + } + model_specific = { + "limit_id": "codex_bengalfox", + "primary": {"used_percent": 0, "window_minutes": 10080, "resets_at": 9_999_999_999}, + "secondary": None, + } + _write_rate_limit_session(sessions_dir / "general.jsonl", "2026-08-12T02:24:00+00:00", general, 100) + _write_rate_limit_session(sessions_dir / "spark.jsonl", "2026-08-12T02:39:00+00:00", model_specific, 200) + + result = codex_loader.load_rate_limits() + + assert result is not None + assert result.limit_id == "codex" + assert result.seven_day_pct == 72.0 + + +def test_load_rate_limits_prefers_general_limit_within_same_session( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + sessions_dir = tmp_path / "sessions" + monkeypatch.setattr(codex_loader, "SESSIONS_DIR", sessions_dir) + monkeypatch.setattr(codex_loader, "_load_thread_models", lambda: {}) + path = sessions_dir / "mixed.jsonl" + general = { + "limit_id": "codex", + "primary": {"used_percent": 72, "window_minutes": 10080, "resets_at": 9_999_999_999}, + } + model_specific = { + "limit_id": "codex_bengalfox", + "primary": {"used_percent": 0, "window_minutes": 10080, "resets_at": 9_999_999_999}, + } + _write_session( + path, + session_id="mixed", + timestamp="2026-08-12T02:24:00+00:00", + rate_limits=general, + mtime=200, + ) + with path.open("a", encoding="utf-8") as file: + file.write("\n" + json.dumps({ + "type": "event_msg", + "timestamp": "2026-08-12T02:39:00+00:00", + "payload": {"type": "token_count", "rate_limits": model_specific}, + })) + + result = codex_loader.load_rate_limits() + + assert result is not None + assert result.limit_id == "codex" + assert result.seven_day_pct == 72.0 + + def test_recent_jsonl_files_sorts_visible_sessions_by_mtime( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/tests/test_dynamic_height.py b/tests/test_dynamic_height.py index 3382a4d..5995a4b 100644 --- a/tests/test_dynamic_height.py +++ b/tests/test_dynamic_height.py @@ -27,7 +27,10 @@ def test_script_wraps_state_application_and_measures_without_height_constraints( assert 'element.style.minHeight = "0"' in CONTENT_HEIGHT_SCRIPT assert "wrap.getBoundingClientRect()" in CONTENT_HEIGHT_SCRIPT assert 'action: "content_height"' in CONTENT_HEIGHT_SCRIPT - assert "requestAnimationFrame" not in CONTENT_HEIGHT_SCRIPT + assert "requestAnimationFrame" in CONTENT_HEIGHT_SCRIPT + assert "MutationObserver" in CONTENT_HEIGHT_SCRIPT + assert "ResizeObserver" in CONTENT_HEIGHT_SCRIPT + assert "requestContentHeight();" in CONTENT_HEIGHT_SCRIPT # Panels draw their edges with padding on whichever layer wraps .wrap, and # the viewport-based panels nest an extra padded .viewport in between, so # the whole ancestor chain has to be released and measured — assuming a diff --git a/tests/test_menubar.py b/tests/test_menubar.py index cea2016..8d8413b 100644 --- a/tests/test_menubar.py +++ b/tests/test_menubar.py @@ -165,6 +165,7 @@ def _build_popover_state( ), agy_group_name="", projects=[], + projects_yesterday=[], projects_7d=[], projects_30d=[], projects_all=[], @@ -172,6 +173,7 @@ def _build_popover_state( group=delegate.tracker.group(), burn_rate_trackers=delegate.burn_rate_trackers, today_text=menubar._today_title(delegate.mock, delegate.language), + yesterday_text=menubar_state._yesterday_title(delegate.mock, delegate.language), statusline=menubar._statusline_payload(delegate.language), show_install_button=( not hide_claude diff --git a/tests/test_menubar_state.py b/tests/test_menubar_state.py index be2e8a0..7c6b423 100644 --- a/tests/test_menubar_state.py +++ b/tests/test_menubar_state.py @@ -374,25 +374,33 @@ def entry(project: str, timestamp: datetime, tokens: int) -> UsageEntry: project=project, ) - rows_24h, rows_7d, rows_30d, rows_all = menubar_state.project_rows_for_windows( + rows_24h, rows_yesterday, rows_7d, rows_30d, rows_all = ( + menubar_state.project_rows_for_windows( [ entry("today", now, 4), + entry("yesterday", now - timedelta(days=1), 5), entry("week", now - timedelta(days=2), 3), entry("month", now - timedelta(days=10), 2), entry("old", now - timedelta(days=40), 1), ], now=now, + ) ) assert rows_24h == [("today", 4, 4.0)] - assert rows_7d == [("today", 4, 4.0), ("week", 3, 3.0)] + assert rows_yesterday == [("yesterday", 5, 5.0)] + assert rows_7d == [ + ("yesterday", 5, 5.0), + ("today", 4, 4.0), + ("week", 3, 3.0), + ] assert rows_30d == [ + ("yesterday", 5, 5.0), ("today", 4, 4.0), ("week", 3, 3.0), - ("month", 2, 2.0), ] assert rows_all == [ + ("yesterday", 5, 5.0), ("today", 4, 4.0), ("week", 3, 3.0), - ("month", 2, 2.0), ] diff --git a/tests/test_panels.py b/tests/test_panels.py index d86e0e8..b206208 100644 --- a/tests/test_panels.py +++ b/tests/test_panels.py @@ -226,6 +226,17 @@ def test_classic_project_header_expands_for_action_row() -> None: assert "margin-bottom: 10px;" in project_brand_css +def test_shared_core_prioritizes_codex_title_when_switch_moves_into_header() -> None: + core = ( + Path(__file__).resolve().parent.parent / "assets" / "panels" / "panel_core.js" + ).read_text(encoding="utf-8") + + assert 'host.classList.add("usage-switch-host")' in core + assert '.usage-switch-host > h1' in core + assert '.usage-switch-host > .header-copy' in core + assert '.usage-switch-host > [data-codex-stale]' in core + + def test_missing_panel_id_falls_back_to_classic() -> None: panel = panels.get_panel("missing") diff --git a/tests/test_usage_lang.py b/tests/test_usage_lang.py index f7de52f..1cebf57 100644 --- a/tests/test_usage_lang.py +++ b/tests/test_usage_lang.py @@ -112,3 +112,15 @@ def test_detect_lang_env_var_beats_windows_ui_language( _fake_windll(monkeypatch, 1028) assert detect_lang() == "ja" + + +def test_detect_lang_ignores_posix_lang_on_windows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("USAGE_LANG", raising=False) + monkeypatch.delenv("TT_LANG", raising=False) + monkeypatch.setenv("LANG", "C.UTF-8") + monkeypatch.setattr(sys, "platform", "win32") + _fake_windll(monkeypatch, 2052) + + assert detect_lang() == "zh-CN" diff --git a/tests/test_wintray.py b/tests/test_wintray.py index 648ffbd..ca5900a 100644 --- a/tests/test_wintray.py +++ b/tests/test_wintray.py @@ -70,12 +70,14 @@ def _state() -> menubar_state.PopoverState: agy_weekly=weekly, agy_group_name="", projects=[], + projects_yesterday=[], projects_7d=[], projects_30d=[], projects_all=[], rate_text="", status_text="", today_text="", + yesterday_text="", statusline={}, ) @@ -156,6 +158,13 @@ def test_panel_html_installs_webkit_shim_without_changing_asset() -> None: assert "window.pywebview.api.postMessage(message)" in html assert "pywebview-drag-region" in html assert "usage-window-drag-handle" in html + assert "usage-window-controls" in html + assert "dataset.usageWindowAction = 'hide'" in html + assert "dataset.i18nTitle = 'close_to_tray'" in html + assert "minimize_to_tray" not in html + assert "window.usagePostPanelAction = post" in html + assert "window.usagePostPanelAction('hide_panel')" in html + assert "window.addEventListener('blur'" not in html assert "post('open_menu')" in html assert "usage-panel-menu-backdrop" in html assert "usage-panel-menu-accordion" in html @@ -178,6 +187,7 @@ def test_content_height_message_resizes_visible_panel_with_work_area_clamp( controller.visible = True calls: list[str] = [] monkeypatch.setattr(controller, "_working_area", lambda: (0, 0, 1000, 800)) + monkeypatch.setattr(controller, "_work_area_for_point", lambda _point: None) monkeypatch.setattr(controller, "_place_window", lambda: calls.append("place")) controller.handle_panel_message( @@ -205,7 +215,7 @@ def test_invalid_content_height_keeps_registered_fallback( assert controller.panel_height() == fallback -def test_panel_position_is_clamped_and_persisted_on_hide( +def test_panel_position_defaults_to_bottom_right_and_is_not_persisted_on_hide( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: preferences_path = tmp_path / "usage-preferences.json" @@ -220,6 +230,7 @@ def test_panel_position_is_clamped_and_persisted_on_hide( resize=lambda *args: None, move=lambda x, y: moves.append((x, y)), hide=lambda: None, + minimize=lambda: None, ) controller = wintray._WindowsTrayController(mock=True, interval=60) controller.window = window @@ -229,10 +240,10 @@ def test_panel_position_is_clamped_and_persisted_on_hide( controller._place_window() - assert moves == [(608, 12)] + assert moves == [(608, 64)] window.x, window.y = 123, 234 controller.show_panel() - assert prefs._load_preferences()["usage.windowPosition"] == {"x": 123, "y": 234} + assert prefs._load_preferences()["usage.windowPosition"] == {"x": 5000, "y": -100} def test_load_preferences_non_utf8( @@ -266,7 +277,7 @@ def test_reset_panel_position_clears_preference_and_repositions_visible_window( assert calls == [True] -def test_switch_panel_keeps_dragged_position_before_new_height_is_measured( +def test_switch_panel_reanchors_to_primary_bottom_right_after_height_measurement( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -301,14 +312,14 @@ def test_switch_panel_keeps_dragged_position_before_new_height_is_measured( controller.switch_panel("cloud_observation") # PANEL_HEIGHTS[...] == 1006 controller.on_loaded() - assert moves[-1] == (300, 200) + assert moves[-1] == (1528, 368) controller.handle_panel_message(json.dumps({"action": "content_height", "height": 650})) - assert moves[-1] == (300, 200) + assert moves[-1] == (1528, 418) -def test_switch_panel_keeps_dragged_position_on_secondary_monitor( +def test_switch_panel_ignores_stale_secondary_position( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -351,7 +362,7 @@ def work_area_for_point(point: tuple[int, int] | None) -> tuple[int, int, int, i controller.switch_panel("cloud_observation") controller.on_loaded() - assert moves[-1] == (2200, 300) + assert moves[-1] == (1528, 368) def test_js_api_forwards_panel_message() -> None: @@ -383,7 +394,7 @@ def test_switch_panel_message_returns_menu_instead_of_cycling( menu = controller.handle_panel_message("switch") assert isinstance(menu, list) - assert menu[2]["i18nKey"] == "switch_panel" + assert menu[3]["i18nKey"] == "switch_panel" assert switched_to == [] @@ -425,6 +436,7 @@ def test_panel_menu_data_is_localized_and_reads_current_checks( ) -> None: controller = wintray._WindowsTrayController(mock=True, interval=60) controller.language = "en" + controller.language_preference = "en" controller.active_panel_id = "matrix" monkeypatch.setattr(wintray, "_hide_claude_enabled", lambda: True) monkeypatch.setattr(wintray, "_hide_codex_enabled", lambda: False) @@ -444,9 +456,11 @@ def test_panel_menu_data_is_localized_and_reads_current_checks( } assert [entry.get("i18nKey", entry.get("type")) for entry in menu] == [ "panel_ai_daily", + "reset_panel_position", "separator", "switch_panel", "hide_sections_menu", + "language_menu", "separator", "launch_at_login", "quota_notifications_menu", @@ -456,17 +470,22 @@ def test_panel_menu_data_is_localized_and_reads_current_checks( "terse_mode_menu", "separator", "refresh_now", + "check_update", + "quit", ] - panels = cast(list[dict[str, object]], menu[2]["children"]) - hidden_sections = cast(list[dict[str, object]], menu[3]["children"]) + panels = cast(list[dict[str, object]], menu[3]["children"]) + hidden_sections = cast(list[dict[str, object]], menu[4]["children"]) + languages = cast(list[dict[str, object]], menu[5]["children"]) assert panels[1]["panelId"] == "matrix" assert panels[1]["checked"] is True assert [item["checked"] for item in hidden_sections] == [True, False, True] - assert menu[5]["checked"] is True - assert menu[6]["checked"] is False + assert [item["languageCode"] for item in languages] == list(wintray.LANGUAGE_OPTIONS) + assert [item["checked"] for item in languages] == [False, False, False, True, False, False] assert menu[7]["checked"] is True + assert menu[8]["checked"] is False assert menu[9]["checked"] is True - assert menu[10]["checked"] is False + assert menu[11]["checked"] is True + assert menu[12]["checked"] is False @pytest.mark.parametrize( @@ -486,6 +505,8 @@ def test_panel_menu_data_is_localized_and_reads_current_checks( ({"action": "toggle_window_keeper"}, "toggle_window_keeper", ()), ({"action": "toggle_session_resume"}, "toggle_session_resume", ()), ({"action": "toggle_terse_mode"}, "toggle_terse_mode", ()), + ({"action": "set_language", "language_code": "zh-CN"}, "set_language", ("zh-CN",)), + ({"action": "hide_panel"}, "hide_panel", ()), ({"action": "check_update"}, "check_update", ()), ({"action": "quit"}, "quit", ()), ], @@ -505,6 +526,38 @@ def test_panel_menu_actions_dispatch_to_controller_methods( assert calls == [expected] +def test_set_language_persists_and_updates_visible_panel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + preferences: dict[str, object] = {"unrelated": True} + evaluated: list[str] = [] + refreshed: list[str] = [] + controller = wintray._WindowsTrayController(mock=True, interval=60) + controller.visible = True + controller.window = SimpleNamespace(evaluate_js=evaluated.append) + monkeypatch.setattr(wintray, "_load_preferences", lambda: preferences.copy()) + monkeypatch.setattr(wintray, "_save_preferences", lambda value: preferences.update(value)) + monkeypatch.setattr(controller, "refresh", lambda: refreshed.append("refresh")) + + controller.set_language("zh-CN") + + assert preferences == {"unrelated": True, "usage.language": "zh-CN"} + assert controller.language_preference == "zh-CN" + assert controller.language == "zh-CN" + assert evaluated == ['window.usageSetLanguage("zh-CN")'] + assert refreshed == ["refresh"] + + +def test_set_language_rejects_unknown_value(monkeypatch: pytest.MonkeyPatch) -> None: + saved: list[dict[str, object]] = [] + controller = wintray._WindowsTrayController(mock=True, interval=60) + monkeypatch.setattr(wintray, "_save_preferences", saved.append) + + controller.set_language("de") + + assert saved == [] + + @pytest.mark.parametrize("panel_id", ["matrix", "aquarium", "win95"]) def test_card_order_persists_into_the_next_loaded_panel( monkeypatch: pytest.MonkeyPatch, @@ -544,6 +597,10 @@ def test_card_order_persists_into_the_next_loaded_panel( assert json.loads(payload)["cardOrder"] == order +def test_physical_work_area_is_converted_to_pywebview_logical_coordinates() -> None: + assert wintray._logical_work_area((0, 0, 2560, 1528), 144) == (0, 0, 1707, 1019) + + def test_run_app_wires_pystray_and_pywebview( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -569,8 +626,11 @@ def __iadd__(self, callback: object) -> Event: events.append("loaded_handler") return self - window = SimpleNamespace(events=SimpleNamespace(loaded=Event())) - + window = SimpleNamespace( + events=SimpleNamespace( + loaded=Event(), minimized=Event(), restored=Event(), closing=Event() + ) + ) def create_window(*args: object, **kwargs: object) -> object: events.append( ("window", args[0], kwargs["hidden"], kwargs["background_color"]) @@ -596,6 +656,9 @@ def create_window(*args: object, **kwargs: object) -> object: assert events == [ ("window", "usage", True, "#eef2f7"), "loaded_handler", + "loaded_handler", + "loaded_handler", + "loaded_handler", ("icon", "usage"), "run_detached", ("start", "edgechromium"), @@ -631,13 +694,67 @@ def test_show_panel_places_window_before_showing( ) monkeypatch.setattr(controller, "refresh", lambda: calls.append("refresh")) controller.window = SimpleNamespace( - show=lambda: calls.append("show"), hide=lambda: calls.append("hide") + restore=lambda: calls.append("restore"), + show=lambda: calls.append("show"), + hide=lambda: calls.append("hide"), ) controller.show_panel() assert controller.visible is True - assert calls == ["place", "show", "inject:True", "refresh"] + assert calls == ["place", "restore", "show", "inject:True", "refresh"] + + +def test_hide_panel_hides_window_to_tray( + monkeypatch: pytest.MonkeyPatch, +) -> None: + controller = wintray._WindowsTrayController(mock=True, interval=60) + controller.visible = True + calls: list[str] = [] + controller.window = SimpleNamespace(hide=lambda: calls.append("hide")) + + controller.hide_panel() + controller.hide_panel() + + assert controller.visible is False + assert controller._positioned_this_show is False + assert calls == ["hide"] + + +def test_native_close_hides_to_tray_and_only_quit_allows_destroy() -> None: + controller = wintray._WindowsTrayController(mock=True, interval=60) + calls: list[str] = [] + controller.visible = True + controller.window = SimpleNamespace(hide=lambda: calls.append("hide")) + + assert controller.on_closing() is False + assert controller.visible is False + assert calls == ["hide"] + + controller.stopping.set() + assert controller.on_closing() is True + + +def test_native_minimize_and_restore_keep_controller_state_in_sync( + monkeypatch: pytest.MonkeyPatch, +) -> None: + controller = wintray._WindowsTrayController(mock=True, interval=60) + calls: list[str] = [] + monkeypatch.setattr( + controller, + "_place_window", + lambda *, force_default=True: calls.append(f"place:{force_default}"), + ) + monkeypatch.setattr( + controller, "inject_state", lambda *, force=False: calls.append(f"inject:{force}") + ) + + controller.on_minimized() + assert controller.visible is False + controller.on_restored() + + assert controller.visible is True + assert calls == ["place:True", "inject:True"] def test_tray_update_skips_unchanged_values(monkeypatch: pytest.MonkeyPatch) -> None: @@ -670,8 +787,10 @@ def test_inject_state_skips_duplicate_but_forces_after_panel_reopens( injected: list[str] = [] controller.window = SimpleNamespace( evaluate_js=injected.append, + restore=lambda: None, show=lambda: None, hide=lambda: None, + minimize=lambda: None, ) monkeypatch.setattr(controller, "_place_window", lambda: None) monkeypatch.setattr(controller, "refresh", lambda: None) @@ -810,14 +929,12 @@ def record(name: str) -> int: assert calls == ["enable_resume", "disable_terse"] -def test_run_app_bails_out_when_another_instance_holds_the_lock( +def test_run_app_bails_out_silently_when_another_instance_is_activated( monkeypatch: pytest.MonkeyPatch, ) -> None: # Regression: a second tray instance used to fight the first over the # WebView2 user-data directory and linger as a bare white window. - notices: list[str] = [] monkeypatch.setattr(wintray, "_acquire_single_instance_lock", lambda: False) - monkeypatch.setattr(wintray, "_show_already_running_notice", lambda: notices.append("shown")) fake_webview = SimpleNamespace( create_window=lambda *args, **kwargs: pytest.fail("window must not be created"), start=lambda **kwargs: pytest.fail("webview must not start"), @@ -826,7 +943,28 @@ def test_run_app_bails_out_when_another_instance_holds_the_lock( wintray.run_app(mock=True, interval=60) - assert notices == ["shown"] + + +def test_activate_panel_opens_hidden_or_restores_visible( + monkeypatch: pytest.MonkeyPatch, +) -> None: + controller = wintray._WindowsTrayController(mock=True, interval=60) + calls: list[str] = [] + controller.window = SimpleNamespace( + restore=lambda: calls.append("restore"), show=lambda: calls.append("show") + ) + monkeypatch.setattr(controller, "show_panel", lambda: calls.append("open")) + monkeypatch.setattr( + controller, + "_place_window", + lambda *, force_default=True: calls.append(f"place:{force_default}"), + ) + + controller.activate_panel() + controller.visible = True + controller.activate_panel() + + assert calls == ["open", "restore", "show", "place:True"] @pytest.mark.skipif(sys.platform != "win32", reason="Windows named mutex") @@ -855,8 +993,10 @@ def test_menu_actions_pass_real_pystray_signature_validation() -> None: pytest.importorskip("pystray", reason="pystray is a Windows-only extra") controller = SimpleNamespace( language="en", + language_preference="auto", active_panel_id="classic", switch_panel=lambda panel_id: None, + set_language=lambda code: None, show_panel=lambda: None, reset_panel_position=lambda: None, refresh=lambda: None, diff --git a/usage_lang.py b/usage_lang.py index 52933a3..5df593f 100644 --- a/usage_lang.py +++ b/usage_lang.py @@ -64,7 +64,11 @@ def _detect_windows_lang() -> str: def detect_lang(env: Mapping[str, str] | None = None) -> str: source = os.environ if env is None else env - for key in ("USAGE_LANG", "TT_LANG", "LANG"): + override_keys = ("USAGE_LANG", "TT_LANG", "LANG") if env is not None else ( + "USAGE_LANG", + "TT_LANG", + ) + for key in override_keys: value = source.get(key, "").strip() if value: return _normalize_lang(value) diff --git a/wintray.py b/wintray.py index 5e92f01..4d5244c 100644 --- a/wintray.py +++ b/wintray.py @@ -12,7 +12,7 @@ import tomllib import webbrowser from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta from importlib import metadata from pathlib import Path from typing import TYPE_CHECKING, Any @@ -56,6 +56,19 @@ SLOW_POLL_INTERVAL_S = 300 HISTORY_SCAN_CACHE_SECONDS = 30.0 PANEL_WIDTH = 380 +LANGUAGE_PREFERENCE_KEY = "usage.language" +LANGUAGE_OPTIONS = ("auto", "zh-CN", "zh-TW", "en", "ja", "ko") + + +def _logical_work_area( + work_area: tuple[int, int, int, int], dpi: int, +) -> tuple[int, int, int, int]: + scale = max(96, dpi) / 96.0 + values = (round(value / scale) for value in work_area) + left, top, right, bottom = values + return (left, top, right, bottom) + + WINDOWS_PANELS = ( ("classic", "panel_default_name", "classic.html"), ("matrix", "panel_matrix", "matrix.html"), @@ -157,7 +170,8 @@ row.textContent = (item.checked ? '✓ ' : ' ') + item.label; row.addEventListener('click', function() { var extra = item.panelId ? { panel_id: item.panelId } : - item.preferenceKey ? { preference_key: item.preferenceKey } : undefined; + item.preferenceKey ? { preference_key: item.preferenceKey } : + item.languageCode ? { language_code: item.languageCode } : undefined; post(item.action, extra); closeMenu(); }); @@ -192,6 +206,7 @@ document.addEventListener('keydown', function(event) { if (event.key === 'Escape') closeMenu(); }); + window.usagePostPanelAction = post; })(); // Panel assets register their card reorder handler in the bubbling phase. This @@ -226,7 +241,27 @@ handle.className = 'usage-window-drag-handle pywebview-drag-region'; handle.setAttribute('aria-hidden', 'true'); document.body.appendChild(handle); + + var controls = document.createElement('div'); + controls.className = 'usage-window-controls'; + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'usage-window-control'; + button.textContent = '×'; + button.dataset.usageWindowAction = 'hide'; + button.dataset.i18nTitle = 'close_to_tray'; + controls.appendChild(button); + document.body.appendChild(controls); }); + +document.addEventListener('click', function(event) { + var button = event.target.closest && event.target.closest('[data-usage-window-action="hide"]'); + if (!button) return; + event.preventDefault(); + event.stopImmediatePropagation(); + window.usagePostPanelAction('hide_panel'); +}, true); +