diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3a0518..27f328c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: New-Item -ItemType Directory -Path 'package\dist' -Force | Out-Null Copy-Item -LiteralPath 'dist\WeeklyUsageIndicator.exe','dist\SHA256SUMS.txt' -Destination 'package\dist' Copy-Item -LiteralPath 'scripts\install.ps1','scripts\uninstall.ps1','scripts\install-environment.ps1' -Destination 'package\scripts' - Copy-Item -LiteralPath 'README.md','PRIVACY.md','SECURITY.md','LICENSE' -Destination 'package' + Copy-Item -LiteralPath 'README.md','PRIVACY.md','SECURITY.md','LICENSE','THIRD_PARTY_NOTICES.txt' -Destination 'package' Compress-Archive -Path 'package\*' -DestinationPath "CodexWeeklyUsageIndicator-$env:GITHUB_REF_NAME-win-x64.zip" - name: Create draft release for artifact verification shell: pwsh diff --git a/PRIVACY.md b/PRIVACY.md index e0e34de..220bbf9 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -5,7 +5,7 @@ Codex + Claude Usage Indicator reads Codex rate-limit windows from the locally i The application: - does not store a timeline of usage history; optional Codex account management retains each account's latest usage snapshot; -- stores the last window coordinates and the Claude visibility preference in a local `settings.json` file; +- stores the last window coordinates, Claude visibility preference and UI language in a local `settings.json` file; - stores one latest successful Claude snapshot in local `claude-usage-cache.json`, containing only usage percentages, reset times, and the update time; - does not read, print, log, copy, or persist Claude authentication tokens; - reads Codex account identifiers only after optional account registration, and stores them with labels and login snapshots in a Windows CurrentUser DPAPI encrypted vault; diff --git a/README.md b/README.md index 6306bf6..df840f2 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,28 @@ # Codex + Claude Usage Indicator -An unofficial Windows widget that stays on top while Codex Desktop is running and shows Codex weekly usage alongside Claude Fable usage. +Keep your remaining Codex and Claude usage in sight, then check and switch your saved Codex accounts from one place. + +**Windows · 한국어 / English · Manual account switching** + +Compact widget showing 62% Codex and 60% Claude Fable remaining + +The blue bar is Codex; the orange bar is Claude Fable. The numbers show how much weekly usage remains. The widget stays visible while Codex is open, so you can check without leaving your work. + +![A light account manager showing 168% remaining across three accounts, reset times and manual switch actions](docs/design/2026-09-22/native/en-manager-100.png) + +- **See what is left across your accounts.** The manager adds their remaining weekly percentages and shows which account resets next. +- **Check another account without switching.** Refresh one account or all accounts when you need an update. +- **Switch when you choose.** Pick an account, finish your Codex work and confirm the switch. The tool saves your logins for the next time. + +Screenshots use synthetic accounts and usage. This is an unofficial community project. + +[Download the Windows release](https://github.com/extsvforest/codex-weekly-usage-indicator/releases/latest) · [Installation](#install-from-a-release) · [Language](#language) ## What it does +
+Widget behavior and refresh details + - Appears while Codex Desktop is running and hides when Codex exits. - Keeps the existing 272 × 64 window and uses one or two fluid panels depending on which providers are available. - Shows only white remaining percentages: Codex is identified by a blue bar and Claude Fable by a terracotta-orange bar. @@ -16,18 +35,37 @@ An unofficial Windows widget that stays on top while Codex Desktop is running an - Refreshes every 60 seconds; double-click to refresh Codex immediately while Claude continues to honor its ten-minute cache. - Supports dragging, copying the current values, toggling always-on-top, and turning the Claude panel on or off from the right-click menu. - Remembers the last dragged position and restores it on the next launch. +- Switches the interface between Korean and English from **Language / 언어** in the widget or tray menu. - Hides while another foreground app is fullscreen, then returns at the saved position. - Uses a per-user Windows scheduled task at sign-in, so the widget runs independently of Codex. A lightweight supervisor restarts the widget after an abnormal exit, waiting one minute (up to 999 retries per supervisor run). -Account management is optional. After you register a Codex account, the widget stores account labels, identities, each account's latest usage snapshot, and Codex login snapshots encrypted with Windows CurrentUser DPAPI. The live authentication file remains authoritative for the active account. Claude credentials are never accessed; Claude Code owns its authentication and token refresh. See [PRIVACY.md](PRIVACY.md). +
+ +You can use the widget on its own. Saving accounts is optional; it adds the account manager shown above. Saved Codex logins are encrypted for your Windows user. Claude Code handles its own login. See [PRIVACY.md](PRIVACY.md) for storage details. ## Manual Codex accounts -Open **Codex 계정 관리…** from the widget menu, or double-click the tray icon. The light-themed manager uses one overview and one account list, sorted by the nearest weekly reset. Its default height fits three accounts without scrolling on a sufficiently tall display. Account names appear in the manager and hover details; the compact indicator remains percentages and bars. Hover details separate the current account, the last combined observation with each account reset, and Claude. Hover never requests usage; the combined observation remains available after closing the manager and lasts for the widget process. +Right-click the widget and open **Codex accounts… / Codex 계정 관리…**, or double-click its tray icon. + +1. **Save the account you already use.** Choose **Save current account**. Open its **···** menu to give it a name you recognize. +2. **Add another account.** Choose **+ Add account** and complete the official sign-in in your browser. Your current Codex session stays signed in. +3. **Check the balances.** Opening the manager checks your saved accounts once. Press **Refresh all** for another complete check, or use a row's **···** menu to check just that account. +4. **Switch when you are ready.** Press **Switch** on the account you want. Save your work and close Codex Desktop and any Codex terminal or IDE sessions. The confirmation button becomes available when they have stopped; it applies the saved login and tries to reopen Codex. Check that Codex shows the intended account. + +The list puts the soonest reset first. Three accounts fit in the default window on a sufficiently tall display; a smaller window can scroll. Names appear here and in hover details, while the small widget stays focused on percentages. Hover details open outside the widget after a short delay and stay in place while you read. New observations appear on the next hover; moving away, dragging, or opening the context menu closes the details. The hover window never takes keyboard focus. -The overview adds the remaining weekly percentages: for example, **168% of 300%** across three accounts. Each account contributes up to 100%; this is an unweighted sum of account percentages, not a shared service limit or a comparison of different plans' absolute quotas. **다음 초기화** identifies the next confirmed reset and that account's remaining amount. The latest reset appears as secondary context; it is not a common deadline for the whole total. +Hover details with the active account, combined weekly remaining usage and each account's reset time + +In the example above, the accounts have **63%, 81% and 24%** left. Adding them gives **168% out of 300%**. Each full account contributes 100%, so this helps you compare the saved balances. Accounts on different plans can have different actual quotas; the sum does not measure an equal number of messages or tokens. + +**Next reset** tells you which account resets first. **Latest reset** shows the last reset among the accounts. Each balance still follows its own reset time, so the latest date is not a deadline for spending the whole 168%. + +Other accounts are checked when you open the manager or request a refresh. Moving the mouse, changing the language or returning to the window does not request another check. If a check fails or a reset has passed, the manager marks what needs attention instead of presenting an old balance as a current total. + +
+Refresh rules, saved logins and recovery Opening a new manager window starts one sequential usage query for all registered accounts. After that, **전체 갱신** is the only way to refresh the whole observation set. Restoring focus, changing selection, the local five-second UI timer, and the active widget's own polling do not trigger another batch or change this snapshot. Closing the window during a batch cancels the request and waits for safe cleanup. Failure, cancellation, missing/expired weekly data, or changed membership withholds the total and distinguishes the confirmed subtotal from previous values. Cleanup or credential recovery stops the remaining batch. @@ -45,9 +83,17 @@ If credentials are already saved and only temporary files remain, the manager in The first version supports local Windows file-based ChatGPT authentication. Unsupported keyring/managed configurations fail closed. The vault is stored separately at `%LOCALAPPDATA%\CodexWeeklyUsageIndicator.Accounts`; uninstall preserves it. Delete inactive accounts from the manager before removing the app if you no longer want their saved credentials. This convenience tool does not establish that any particular multi-account usage pattern is permitted by the service terms. +
+ > [!IMPORTANT] > This is an unofficial community project. It relies on an experimental local Codex app-server method (`account/rateLimits/read`) and the text output of Claude Code's built-in `/usage` command. Either may change without notice. +## Language + +Right-click the widget or tray icon, then choose **Language / 언어 → 한국어 / English**. Menus, account management, dialogs, usage details and known application errors change immediately. An open idle account manager keeps its size and last usage observation when the language changes; changing language does not request usage or sign you in again. Finish or cancel an account operation or close its dialog before changing language. + +The choice is saved locally. Existing installations with a settings file keep Korean; a new installation starts in Korean on Korean Windows and English otherwise. Account names, identities, credentials and usage values are not translated. Windows-provided confirmation buttons follow the Windows display language. Diagnostic text supplied by the operating system or an external tool may remain in its original language. + ## Requirements - Windows 10 or 11 @@ -58,7 +104,7 @@ The first version supports local Windows file-based ChatGPT authentication. Unsu ## Install from a release -1. Download and extract the Windows zip from [Releases](https://github.com/GiantForestStudio/codex-weekly-usage-indicator/releases). +1. Download and extract the Windows zip from [Releases](https://github.com/extsvforest/codex-weekly-usage-indicator/releases). 2. Review the included PowerShell scripts. 3. Open a standalone Windows PowerShell window (outside packaged apps such as Codex), change to the extracted directory, and run: @@ -67,7 +113,7 @@ The first version supports local Windows file-based ChatGPT authentication. Unsu ``` The app is installed to `%LOCALAPPDATA%\CodexWeeklyUsageIndicator`. A per-user `CodexWeeklyUsageIndicator-` scheduled task starts its supervisor at sign-in, using the signed-in user's normal privileges without storing a password. The supervisor launches and watches the widget; two processes from the same EXE are expected, but only one window. Installation also starts the task immediately, checks that both processes appear, and then removes the old Startup shortcut. Windows Task Scheduler must be available; an installation error must be resolved before relying on automatic recovery. -The saved window position and Claude visibility preference are kept locally in `settings.json` inside that install directory. One sanitized Claude recovery snapshot may be kept in `claude-usage-cache.json` and is ignored after 24 hours or after its Fable reset. +The saved window position, Claude visibility preference and UI language are kept locally in `settings.json` inside that install directory. One sanitized Claude recovery snapshot may be kept in `claude-usage-cache.json` and is ignored after 24 hours or after its Fable reset. Run installation and removal outside packaged app terminals: Windows can redirect their AppData writes into an app-private folder that Task Scheduler cannot see, causing `0x80070002` even when that terminal reports the EXE exists. Both scripts check the real directory path and refuse redirected locations before changing tasks or running widgets. @@ -115,4 +161,4 @@ The app-server child process is stopped whenever Codex is no longer running. ## License -[MIT](LICENSE) +[MIT](LICENSE). Bundled Pretendard fonts use the SIL Open Font License; see [THIRD_PARTY_NOTICES.txt](THIRD_PARTY_NOTICES.txt). diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt new file mode 100644 index 0000000..c3c6f62 --- /dev/null +++ b/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,97 @@ +Pretendard 1.3.9 - Regular, SemiBold, Bold (unmodified static TrueType files). +Source: https://github.com/orioncactus/pretendard/tree/v1.3.9/packages/pretendard/dist/public/static/alternative + +Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard), +with Reserved Font Name Pretendard. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/account-opening-stability.md b/docs/account-opening-stability.md new file mode 100644 index 0000000..3d69430 --- /dev/null +++ b/docs/account-opening-stability.md @@ -0,0 +1,61 @@ +# Account manager opening stability + +The 1.8.0-preview.2 candidate fixes the visible layout churn reported after the +Soft Paper restyle. It keeps the existing manager structure, fonts, palette, +account actions and manual query boundaries. + +## Reproduction and cause + +The production form was tested at 175% Windows scaling with three synthetic +accounts, both with saved observations and without them. Geometry was observed +from the first native `Paint`, not just a settled `DrawToBitmap` capture. + +Before the fix, the first paint contained no account rows. `Shown` then populated +rows at a temporary width of 262 pixels before expanding them to 1,526 pixels, +and opened the query notice below the overview. With no cached reset times, +individual responses also changed the row order while the batch was running. + +The new transparent paper surfaces exposed these intermediate states. A native +paint stack confirmed that disabling the whole account table recursively called +`Control.OnEnabledChanged`, which forces `UpdateWindow`. Suspending layout alone +did not stop that synchronous paint; moving preparation to `Load` alone was also +insufficient. The relevant framework behavior is in the official +[WinForms Control source](https://github.com/dotnet/winforms/blob/v8.0.0/src/System.Windows.Forms/src/System/Windows/Forms/Control.cs). + +## Fix + +- Prepare the initial account list, DPI-adjusted size and query state in `Load` + before presenting the window. The query continues asynchronously; display and + cancellation do not wait for a network response. +- Batch layout changes through the nested tables. Compose the manager's child + windows together so transparent controls do not expose separate paper layers. +- Disable the account action buttons individually instead of disabling all + labels and containers. Custom buttons keep native enabled/accessibility + semantics but enqueue repainting instead of forcing an immediate update. +- Preserve row positions throughout a batch. When it finishes, apply the final + nearest-reset order once. Value changes do not rebuild an unchanged row grid. + +The existing query notice still closes on success, so the list moves up once +when that notice disappears. Errors, cancellation and recovery retain their +existing visible messages. No authentication or account query implementation +was changed, and the widget/tooltip retain their separate nonactivation behavior. + +## Verification + +`AccountOpeningSmoke` exercises actual production forms with held synthetic +responses. It checks all three rows and final widths from the first native paint, +stable geometry in painted loading frames and between individual responses, +final reset ordering, scrolling, quiet local refresh, hide/show and unchanged +active credentials. Run it with `--account-opening-smoke`; `--trace-only` is a +diagnostic mode that records failures without substituting for the required test. + +The in-flight query test verifies every row's switch/menu button and the +add/rename/delete actions are disabled, and tries the disabled row buttons. It +does not equate a disabled container with protected account actions. + +Preserved synthetic captures: [loading](images/soft-paper/opening-loading.png), +[completed](images/soft-paper/opening-complete.png). These document appearance; +the native paint/geometry regression is the evidence for opening stability. + +Full build and local installation verification are recorded in +[the Soft Paper UI record](soft-paper-ui.md). diff --git a/docs/design/2026-09-22/README.md b/docs/design/2026-09-22/README.md new file mode 100644 index 0000000..73085cc --- /dev/null +++ b/docs/design/2026-09-22/README.md @@ -0,0 +1,155 @@ +# Account manager design exploration · 2026-09-22 + +The user asked to improve the current interface together, following the latest +design-partner guidance, and selected **A — ordered list** on 2026-09-22. +Its bright canvas, unboxed three-part summary and aligned account rows are now +implemented in native WinForms for 1.8.0. The approved raster is the +visual reference; the earlier HTML sketches are not the implementation source. + +## Compared directions + +- [A — ordered list](direction-a.png): one light canvas, a clear total and reset + summary, aligned account/value/reset/action columns, restrained row boundaries. + Recommended for quick comparisons, longer names and more accounts. Its visual + character is quieter, and the large reset date could be reduced further in the + native pass if it competes with the balance. +- [B — three cards](direction-b.png): each account becomes a distinct paper card. + The three-account composition is stronger and more tactile. It uses more space, + and additional accounts or long aliases need a layout rule before implementation. + +Both use synthetic values: 63 + 81 + 24 = 168 percent remaining out of 300 percent, +with distinct future reset times. The latest reset is explicitly a reset time, +not an expiry deadline for all remaining capacity. No real identities or usage +were accessed for these concepts. + +## Critique and comparison basis + +The current [installed-design capture](../../images/soft-paper/opening-complete.png) +has a large bordered summary, repeated row borders and status phrases, and a +decorative three-shape motif detached from the task. The alternatives emphasize +typographic hierarchy and alignment instead of adding more decoration. + +The Library's Soft Paper specimen supplied the warm ivory/plum palette and the +relationship of firm type to gentle paper surfaces. The Library's preserved +Microsoft WinUI settings illustration and Carbon table captures were inspected +for shared baselines, quiet default states, restrained borders and action emphasis. +Their upstream references are +[Microsoft settings layout](https://learn.microsoft.com/en-us/windows/apps/design/app-settings/guidelines-for-app-settings#layout) +and [Carbon data table](https://carbondesignsystem.com/components/data-table/usage/). +This round used the preserved reference images, not a fresh live product audit. + +The v1 and refined images are retained. Refinement removed invented account-role +subtitles, reduced a competing headline, removed an unquantified pie icon, and +restored the active account management menu. These are generated raster concepts: +minor icon/text details are not new feature commitments. In particular B's small +sort chevron does not establish a selectable sorting feature. + +## Artifacts and limits + +- `direction-a.png`, `direction-b.png`: current comparison proposals, generated + with built-in imagegen. [Prompt set and focused edits](prompts.md). +- `direction-a-v1.png`, `direction-b-v1.png`: first versions, kept so intermediate + visuals remain recoverable rather than disappearing with temporary work. +- `index.html`, `styles.css`, `app.js`: independent exploratory HTML with synthetic + normal/loading/partial states, name editor and simulated actions. It predates + the generated refinements and is **not a pixel-equivalent implementation** of + either final image. JavaScript syntax was checked; browser rendering and + interactions were not verified. A local server launch was blocked by automatic + approval review; local file navigation was blocked by browser URL policy. + No alternate browser or security workaround was attempted. + +## Native implementation + +- The overview separates total remaining capacity, nearest reset and latest + confirmed reset. A failed or incomplete batch still shows an unknown total + and a clearly labelled confirmed subtotal. Reset times remain per-account. +- Shared account/value/reset/action columns replace individual bordered cards. + Muted identity tiles and meter colors stay attached to accounts during sorting; + the active account has one quiet background and a readable status badge. +- Names occupy one line with ellipsis; hover exposes the full alias. Compact + query states expose their complete reason and last observation through hover + and accessibility descriptions. Rename, individual refresh, details, delete + and switching remain available through the existing controls and menus. +- Native text and vector painting reproduce the direction without rasterized + text, new dependencies or any change to authentication/query rules. The + original first-paint batching, in-place partial responses and native disabled + action semantics remain in place. +- Default size remains 920 × 740 logical pixels so all three accounts also fit + while the progress or partial-result message is present. Deliberately reducing + the window height permits scrolling rather than shrinking controls. + +The user approved the A direction, then confirmed satisfaction with its installed +preview.3 execution and requested a public release. The release also includes +Korean/English switching at the user's explicit request. + +## Actual output and checks + +Native production forms with synthetic accounts: +[175% manager](native/manager-175.png), [100% manager](native/manager-100.png), +[long alias](native/long-name.png), [name editor](native/rename.png), +[loading](native/progress.png), [partial failure](native/partial.png), +[minimum window](native/minimum.png). The 100% fixture uses a DPI-unaware process +to exercise the native 96-DPI layout without changing Windows display settings. +The 175% captures use the actual per-monitor DPI mode. These are WinForms +DrawToBitmap captures; nonclient title-bar rendering may differ from DWM. + +The full 30-group regression suite passed during this implementation, including +four-corner tooltip stability, focus, authentication isolation, account operations +and first-paint geometry. The final row sizing refinements were then checked with +the combined-manager, opening and style fixtures at 96 and 168 DPI. Parent-bound +assertions now catch clipped child layouts as well as clipped text and buttons. +The small-window fixture resizes the outer window to its scaled native minimum; +an impossible ClientSize request had left WinForms reporting rejected bounds. + +Two final full-suite attempts stopped at the initial pointer-driven hover check: +the trace recorded actual pointer departure (`cursorHeld=False`, enter/leave), +not an observed stationary-hover regression. The earlier four-corner checks +passed twice. No hover assertions were weakened, and no further cursor retries +were made. Final packaging used the same publish options after the focused UI +checks; the complete suite was not reported as a fresh pass on that final run. + +Final opening inspection recorded 72 events with zero violated expectations. +The normal-user installer upgraded preview.2 to **1.8.0-preview.3** on 2026-09-22. +The installed executable matched the packaged SHA-256 +`476cdfea0b10216ad92b31578f91866893e14586adc2b50263b472bbf336fe45` +(8,455,350 bytes). One supervisor and one widget remained running; the one-time +helper completed with exit code 0. The executable passed the private-path scan +and contains no PDB. This is a local preview installation, not a public release. + +## Korean and English release candidate + +The native interface, menus, dialogs, tooltip and known application errors use +explicit translation templates. Existing account aliases and stored authentication +remain unchanged. **Language / 언어** is in the widget and tray context menu. +An idle open manager is recreated from the same dependencies and observation set +without querying. Physical bounds are restored during Load after DPI scaling. +Busy operations, owned dialogs and a disabled modal owner block language changes. + +English output with synthetic accounts is preserved at [175%](native/en-manager-175.png) +and [100%](native/en-manager-100.png), with the [name editor](native/en-rename.png), +[switch preparation](native/en-switch.png) and [tooltip renderer](native/en-tooltip.png). +Korean account names in these images demonstrate that aliases are not translated. + +`LanguageUiSmoke` passed at actual 175% scaling: KO→EN→KO retained physical bounds, +the completed batch, aliases and active auth bytes, with zero usage calls. It also +tested in-flight and modal blockers. `LocalizationTests` passed resource loading, +format parity and rendering, known-message round trips, direct literal coverage, +tooltip labels and isolated settings migration/preservation. Language resources +explicitly disable culture-based satellite splitting so the single-file binary +contains both catalogs. Existing settings retain Korean; new settings use the +Windows language, including the first position/visibility save. + +A fresh local full build stopped at its first real-pointer hover fixture because +the pointer left the widget (`cursorHeld=False`). The full suite was not claimed +as passed and was not retried locally. The required full-suite result and final +artifact verification are recorded in the v1.8.0 PR, Windows CI and release. + +Claude implementation advice identified the open-window DPI restoration risk and +catalog validation gap; both received specific fixes and native/runtime checks. +The separate frozen-candidate reviewer recommended ready with no remaining code +blockers. Its clean-CI test-root finding was fixed and the localization test passed +with that environment variable absent. Passing full Windows CI and downloaded +artifact checks remain required before publishing. The font's OFL notice is +included in release packaging. The README now starts with native widget and +manager images, a simple usage path, a concrete 63 + 81 + 24 example and hover +details; implementation and recovery details remain available in expandable sections. diff --git a/docs/design/2026-09-22/app.js b/docs/design/2026-09-22/app.js new file mode 100644 index 0000000..1f01ff6 --- /dev/null +++ b/docs/design/2026-09-22/app.js @@ -0,0 +1,52 @@ +const app = document.querySelector('#app'); +const accounts = [ + {name:'메인 계정',remaining:63,date:'09.24 20:40',relative:'2일 6시간 뒤',wash:'#efe1d2',tone:'#9c765e'}, + {name:'서브 계정',remaining:81,date:'09.26 20:40',relative:'4일 6시간 뒤',wash:'#e3e8eb',tone:'#7b8a9d'}, + {name:'서브 계정2',remaining:24,date:'09.28 20:40',relative:'6일 6시간 뒤',wash:'#eee6cd',tone:'#ad965d'} +]; +let current=0, selected=0, mode='ready', direction='a', confirmAction=null, refreshTimer=null; +const escapeHtml = value => value.replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); +function render(){ + document.querySelector('#accounts').innerHTML = accounts.map((a,i)=>`
+
0${i+1}

${escapeHtml(a.name)}

+
주간 잔여
${mode==='partial'&&i===1?'이전 ':''}${a.remaining}%
+
${a.date}
${a.relative}
+
${i===current?'사용 중':``}
`).join(''); + document.querySelector('#total').textContent=mode==='partial'?'—':'168'; + document.querySelector('#capacity').textContent=mode==='partial'?'전체 확인 필요':'/ 300%'; + document.querySelector('#coverage').innerHTML=mode==='partial'?'확인된 잔여 87% · 2/3개 계정':'3개 계정 '+(mode==='loading'?'이전 확인값':'모두 확인')+' · 계정당 100% 기준'; + const line=document.querySelector('.statusline');line.className='statusline '+(mode==='partial'?'warn':mode==='loading'?'loading':''); + document.querySelector('#status-text').innerHTML=mode==='partial'?'서브 계정을 확인하지 못했어요. 이전 값은 보관했습니다.':mode==='loading'?'◌ 전체 조회 중 · 1/3개 확인 · 이전 값을 표시합니다.':'✓ 오늘 14:30에 전체 확인'; + const stateAction=document.querySelector('#status-action');stateAction.hidden=mode==='ready';stateAction.textContent=mode==='partial'?'전체 다시 갱신':'조회 취소'; + document.querySelector('#refresh-text').textContent=mode==='loading'?'조회 중':'전체 갱신'; + document.querySelector('[data-action=refresh]').disabled=mode==='loading';document.querySelector('[data-action=add]').disabled=mode==='loading'; + app.classList.toggle('loading',mode==='loading');document.querySelector('#state').value=mode; +} +function setMode(next){clearTimeout(refreshTimer);mode=next;document.querySelector('#menu').hidden=true;render();} +function refresh(){setMode('loading');refreshTimer=setTimeout(()=>setMode('ready'),2400);} +function dialog(title,body,confirm,action,name){ + document.querySelector('#menu').hidden=true;document.querySelector('#dialog-title').textContent=title;document.querySelector('#dialog-body').textContent=body; + document.querySelector('#name-label').hidden=name===undefined;document.querySelector('#alias').value=name??''; + document.querySelector('#confirm').hidden=!confirm;document.querySelector('#confirm').textContent=confirm||'';confirmAction=action; + document.querySelector('#modal').hidden=false;if(name!==undefined)document.querySelector('#alias').focus();else document.querySelector('#dismiss').focus(); +} +function closeDialog(){document.querySelector('#modal').hidden=true;} +function choose(view){direction=view;app.classList.toggle('direction-a',view==='a');app.classList.toggle('direction-b',view==='b');document.querySelectorAll('[data-view]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.view===view)));document.querySelector('#critique').innerHTML=view==='a'?'A · 추천한 장의 밝은 바탕, 일정한 열, 가벼운 동작 버튼. 전체 잔여량을 보고 곧바로 계정을 비교하기 좋습니다.':'B · 대안세 계정을 각자의 작은 종이 카드로 다룹니다. 세 개일 때 균형이 좋고 전환 대상이 또렷하지만, 계정이 늘거나 이름이 길어지면 목록보다 불리합니다.';document.querySelector('#menu').hidden=true;} +document.addEventListener('click',event=>{ + const button=event.target.closest('button');if(!button){if(!event.target.closest('.popover'))document.querySelector('#menu').hidden=true;return;} + if(button.dataset.view)choose(button.dataset.view); + if(button.dataset.action==='refresh')refresh(); + if(button.dataset.action==='cancel'){if(mode==='partial')refresh();else {setMode('ready');document.querySelector('#status-text').textContent='조회 취소 · 이전 확인값을 유지합니다.';}} + if(button.dataset.action==='reload'){document.querySelector('#status-text').textContent='저장된 목록을 다시 읽었습니다. 사용량은 새로 조회하지 않았습니다.';} + if(button.dataset.action==='add')dialog('다른 계정 추가','실제 앱에서는 계정 이름을 정하고 브라우저 로그인으로 이어집니다.\n이 시안에서는 화면 구성만 확인할 수 있어요.','확인',closeDialog,'새 계정'); + if(button.dataset.switch!==undefined){const index=Number(button.dataset.switch);dialog('계정을 전환할까요?',`${accounts[current].name} → ${accounts[index].name}\n실제 앱에서는 Codex 종료와 전환 준비 상태를 먼저 확인합니다.`, '시안에서 전환',()=>{current=index;render();closeDialog();});} + if(button.dataset.more!==undefined){selected=Number(button.dataset.more);const menu=document.querySelector('#menu');menu.hidden=false;document.querySelector('#menu-name').textContent=accounts[selected].name;menu.querySelector('[data-menu=delete]').disabled=selected===current;const b=button.getBoundingClientRect(),a=app.getBoundingClientRect();menu.style.left=Math.min(b.right-a.left-185,720)+'px';menu.style.top=Math.min(b.bottom-a.top+5,490)+'px';} + if(button.dataset.menu==='rename')dialog('계정 이름 변경','목록에서 알아보기 쉬운 이름으로 바꿔주세요.','저장',()=>{const value=document.querySelector('#alias').value.trim();if(value){accounts[selected].name=value;render();closeDialog();}},accounts[selected].name); + if(button.dataset.menu==='details')dialog(accounts[selected].name,'5시간 잔여 92%\n초기화 09.22 18:40\n마지막 확인 09.22 14:30\n\n시안용 가상 데이터입니다.',null,null); + if(button.dataset.menu==='usage'){document.querySelector('#menu').hidden=true;document.querySelector('#status-text').textContent=accounts[selected].name+' 확인 완료 · 시안용 동작';} + if(button.dataset.menu==='delete')dialog('저장된 로그인 삭제',accounts[selected].name+'의 저장된 로그인을 삭제하는 확인 화면입니다.\n비교 시안에서는 계정 수를 유지합니다.',null,null); + if(button.id==='dismiss')closeDialog();if(button.id==='confirm')confirmAction?.(); +}); +document.querySelector('#state').addEventListener('change',e=>setMode(e.target.value)); +document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeDialog();document.querySelector('#menu').hidden=true;}}); +const params=new URLSearchParams(location.search);if(params.has('capture'))document.body.classList.add('capture');choose(params.get('view')==='b'?'b':'a');setMode(params.get('state')||'ready'); diff --git a/docs/design/2026-09-22/direction-a-v1.png b/docs/design/2026-09-22/direction-a-v1.png new file mode 100644 index 0000000..3aa2ef3 Binary files /dev/null and b/docs/design/2026-09-22/direction-a-v1.png differ diff --git a/docs/design/2026-09-22/direction-a.png b/docs/design/2026-09-22/direction-a.png new file mode 100644 index 0000000..e0dda0e Binary files /dev/null and b/docs/design/2026-09-22/direction-a.png differ diff --git a/docs/design/2026-09-22/direction-b-v1.png b/docs/design/2026-09-22/direction-b-v1.png new file mode 100644 index 0000000..73c361c Binary files /dev/null and b/docs/design/2026-09-22/direction-b-v1.png differ diff --git a/docs/design/2026-09-22/direction-b.png b/docs/design/2026-09-22/direction-b.png new file mode 100644 index 0000000..2ad5a51 Binary files /dev/null and b/docs/design/2026-09-22/direction-b.png differ diff --git a/docs/design/2026-09-22/index.html b/docs/design/2026-09-22/index.html new file mode 100644 index 0000000..48458f2 --- /dev/null +++ b/docs/design/2026-09-22/index.html @@ -0,0 +1,38 @@ + + + + + Codex 계정 · 두 가지 디자인 방향 + + + + +
+
+
Codex 계정 관리
+
+
CODEX / ACCOUNTS

계정 관리

+
+
168%/ 300%
3개 계정 모두 확인 · 계정당 100% 기준
+
다음 초기화
2일 6시간 뒤

메인 계정 · 09.24 20:40

가장 늦은 초기화 09.28 20:40
+
+
✓ 오늘 14:30에 전체 확인
+
+

내 계정 3

초기화가 가까운 순
+
계정주간 잔여초기화
+
+
+
창을 열 때 한 번 확인하며, 이후에는 직접 갱신합니다.
+
+ + +
+
+ + + + diff --git a/docs/design/2026-09-22/native/en-manager-100.png b/docs/design/2026-09-22/native/en-manager-100.png new file mode 100644 index 0000000..d8ba4d0 Binary files /dev/null and b/docs/design/2026-09-22/native/en-manager-100.png differ diff --git a/docs/design/2026-09-22/native/en-manager-175.png b/docs/design/2026-09-22/native/en-manager-175.png new file mode 100644 index 0000000..4ab257d Binary files /dev/null and b/docs/design/2026-09-22/native/en-manager-175.png differ diff --git a/docs/design/2026-09-22/native/en-rename.png b/docs/design/2026-09-22/native/en-rename.png new file mode 100644 index 0000000..bc104a6 Binary files /dev/null and b/docs/design/2026-09-22/native/en-rename.png differ diff --git a/docs/design/2026-09-22/native/en-switch.png b/docs/design/2026-09-22/native/en-switch.png new file mode 100644 index 0000000..4f995a0 Binary files /dev/null and b/docs/design/2026-09-22/native/en-switch.png differ diff --git a/docs/design/2026-09-22/native/en-tooltip.png b/docs/design/2026-09-22/native/en-tooltip.png new file mode 100644 index 0000000..9eb769d Binary files /dev/null and b/docs/design/2026-09-22/native/en-tooltip.png differ diff --git a/docs/design/2026-09-22/native/long-name.png b/docs/design/2026-09-22/native/long-name.png new file mode 100644 index 0000000..c6aa749 Binary files /dev/null and b/docs/design/2026-09-22/native/long-name.png differ diff --git a/docs/design/2026-09-22/native/manager-100.png b/docs/design/2026-09-22/native/manager-100.png new file mode 100644 index 0000000..d4a5efb Binary files /dev/null and b/docs/design/2026-09-22/native/manager-100.png differ diff --git a/docs/design/2026-09-22/native/manager-175.png b/docs/design/2026-09-22/native/manager-175.png new file mode 100644 index 0000000..903b882 Binary files /dev/null and b/docs/design/2026-09-22/native/manager-175.png differ diff --git a/docs/design/2026-09-22/native/minimum.png b/docs/design/2026-09-22/native/minimum.png new file mode 100644 index 0000000..4a232a3 Binary files /dev/null and b/docs/design/2026-09-22/native/minimum.png differ diff --git a/docs/design/2026-09-22/native/partial.png b/docs/design/2026-09-22/native/partial.png new file mode 100644 index 0000000..47e50cb Binary files /dev/null and b/docs/design/2026-09-22/native/partial.png differ diff --git a/docs/design/2026-09-22/native/progress.png b/docs/design/2026-09-22/native/progress.png new file mode 100644 index 0000000..8a46b3e Binary files /dev/null and b/docs/design/2026-09-22/native/progress.png differ diff --git a/docs/design/2026-09-22/native/rename.png b/docs/design/2026-09-22/native/rename.png new file mode 100644 index 0000000..0766fda Binary files /dev/null and b/docs/design/2026-09-22/native/rename.png differ diff --git a/docs/design/2026-09-22/native/widget.png b/docs/design/2026-09-22/native/widget.png new file mode 100644 index 0000000..3fe1c5d Binary files /dev/null and b/docs/design/2026-09-22/native/widget.png differ diff --git a/docs/design/2026-09-22/prompts.md b/docs/design/2026-09-22/prompts.md new file mode 100644 index 0000000..3cb30d8 --- /dev/null +++ b/docs/design/2026-09-22/prompts.md @@ -0,0 +1,18 @@ +# Design exploration prompts + +## Final focused edits + +A: Refine this UI concept without changing its window size, palette, three horizontal rows or structure. Keep the large 168% headline. Reduce the big next-reset text '2일 6시간 뒤' about 25% so the balance is the only hero. Remove the three invented subtitles below account names ('주 계정 · 항상 사용', '보조 작업용', '테스트용') and leave the account names optically centered. Remove the unnecessary sentence below the main heading, retaining calm whitespace but tighten that header space a little. Keep every percentage, date, per-account last checked timestamp, switch button and three-dot account menu. Remove the dropdown chevron after '초기화가 가까운 순': this is a passive sorting label, not a selectable sort control. Keep the small label '시안 A'. Everything else preserve. Deliver the complete crisp flat window, same frame, no cropping. + +B: Refine this UI concept, maintaining its three vertical paper cards, palette, frame, balanced margins and all information. Remove the decorative pie chart icon next to the total 168%; it suggests a quantified chart but isn't data accurate. Align that summary text cleanly. On the FIRST active card retain its top small '사용 중' badge but remove the wide duplicate '사용 중' button at the bottom. Instead put '14:30 확인' small quiet text at bottom left and a small three-dot menu button at bottom right, identical to the other card menus. Add '14:30 확인' in small quiet text to the second and third cards near their bottom actions without crowding, retaining their switch button and menu. The first account MUST have a three-dot management menu so it can be renamed. Add a tiny '초기화가 가까운 순' aligned right of the '내 계정 3' section heading. Keep '시안 B'. All names percentages and reset dates preserve. Deliver complete crisp flat window, same frame, no cropping. + +Method: built-in imagegen, two independent UI mockups, followed by focused edits. +These are synthetic concept images, not captured application screens. + +## A + +Use case: ui-mockup. Produce a polished, professionally art-directed, flat front-facing Windows desktop account-manager UI design mockup. This is a proposal, not a photographed screen. Full window, aspect ratio 920:740. Korean text perfectly typeset in Pretendard-like sans serif with excellent hierarchy and optical alignment. Warm light paper palette: almost white ivory #fbfaf7, dark plum #493842 and charcoal text; restrained warm apricot, slate blue, wheat accents. No wallpaper, no perspective, no giant decorative illustrations, no macOS traffic-light buttons, no 3D text. A thin Windows titlebar with title 'Codex 계정 관리' and standard small window controls. All three accounts visible without scrolling. SAME synthetic data: main account '메인 계정', 63% remaining, active '사용 중', reset '09.24 20:40', '2일 6시간 뒤'; second '서브 계정',81%, reset '09.26 20:40','4일 6시간 뒤'; third '서브 계정2',24%, reset '09.28 20:40','6일 6시간 뒤'. Main heading '계정 관리', header right '+ 계정 추가' secondary and '전체 갱신' plum primary refresh action. Overview '전체 주간 잔여' with '168%' big and '/ 300%' small, '3개 계정 · 계정당 100% 기준'. Next reset '다음 초기화', '2일 6시간 뒤', '메인 계정 · 09.24 20:40'. Latest reset label '가장 늦은 초기화 09.28 20:40'. A small quiet observation line '오늘 14:30에 전체 확인'. Footer '창을 열 때 한 번 확인하며, 이후에는 직접 갱신합니다.' Subtle typography, intentional whitespace, rounded buttons 6px logical radius, clean thin lines. Include '시안 A' or '시안 B' ONLY as a small external corner label outside the window. Don't repeat unnecessary labels or make things huge. DIRECTION A, label '시안 A'. Quiet editorial ledger. One seamless pale paper canvas. The summary is a graceful unboxed two-column section: large balance left, smaller next-reset schedule right with very subtle vertical divider. A slim horizontal line beneath, then a small status line. Below that a list section '내 계정 3' with '초기화가 가까운 순' far right. THREE HORIZONTAL aligned rows, NOT individual floating cards. Four columns account identity, weekly remaining percentage with thin short progress line, reset time and relative interval, small switch action and three-dot menu. Account identity has small muted square monogram 01/02/03. Current account highlighted with a very subtle warm grey continuous row, no chunky border or shadow. Column headers tiny and quiet '계정', '주간 잔여', '초기화'. 63%,81%,24% are medium size 22px-equivalent numbers, smaller than overview 60px. Rows approx88px tall logical, not too tall. Uniform baseline. Button '전환 ↗' on second and third; first '사용 중' status. Tiny per-row '14:30 확인'. Minimize surface borders, remove random pastel logo shapes. A calm excellent daily utility, attractive through proportion and type. Balanced 38px logical outer margins, no vast empty bottom. + +## B + +Use case: ui-mockup. Produce a polished, professionally art-directed, flat front-facing Windows desktop account-manager UI design mockup. This is a proposal, not a photographed screen. Full window, aspect ratio 920:740. Korean text perfectly typeset in Pretendard-like sans serif with excellent hierarchy and optical alignment. Warm light paper palette: almost white ivory #fbfaf7, dark plum #493842 and charcoal text; restrained warm apricot, slate blue, wheat accents. No wallpaper, no perspective, no giant decorative illustrations, no macOS traffic-light buttons, no 3D text. A thin Windows titlebar with title 'Codex 계정 관리' and standard small window controls. All three accounts visible without scrolling. SAME synthetic data: main account '메인 계정', 63% remaining, active '사용 중', reset '09.24 20:40', '2일 6시간 뒤'; second '서브 계정',81%, reset '09.26 20:40','4일 6시간 뒤'; third '서브 계정2',24%, reset '09.28 20:40','6일 6시간 뒤'. Main heading '계정 관리', header right '+ 계정 추가' secondary and '전체 갱신' plum primary refresh action. Overview '전체 주간 잔여' with '168%' big and '/ 300%' small, '3개 계정 · 계정당 100% 기준'. Next reset '다음 초기화', '2일 6시간 뒤', '메인 계정 · 09.24 20:40'. Latest reset label '가장 늦은 초기화 09.28 20:40'. A small quiet observation line '오늘 14:30에 전체 확인'. Footer '창을 열 때 한 번 확인하며, 이후에는 직접 갱신합니다.' Subtle typography, intentional whitespace, rounded buttons 6px logical radius, clean thin lines. Include '시안 A' or '시안 B' ONLY as a small external corner label outside the window. Don't repeat unnecessary labels or make things huge. DIRECTION B, label '시안 B'. Warm modern desktop account wallet. Same overall window size and same content as A, but THREE VERTICAL PAPER CARDS SIDE BY SIDE, one per account. Above cards a compact horizontal balance summary:168% on left and next-reset block on right, a small status line below. Section header '내 계정 3'. Each card approx260 logical px wide, 275 tall, warm white #fffdf7 on pale linen background #f4f0e9, subtle border and very soft contact shadow. Active first card has narrow dark plum top accent, small '사용 중' pill. Top account name/01 or02 or03 small square monogram, then '주간 잔여' small, prominent 63% /81% /24% about38px with thin full-width muted accent progress bar. Low down reset date and relative remaining time on two distinct well-aligned lines. Bottom of each card '사용 중' status or wide quiet '전환 ↗' button with adjacent three-dot menu. The three cards have understated apricot, blue, wheat identity accents, not large colored fills. Respect compact native-app feel with deliberate information density. No paper-fold illustrations or gratuitous decoration. The card objects and beautiful Korean typography ARE the visual character. Visually distinct spatial grouping from A without adding or removing information. diff --git a/docs/design/2026-09-22/styles.css b/docs/design/2026-09-22/styles.css new file mode 100644 index 0000000..6a5c09e --- /dev/null +++ b/docs/design/2026-09-22/styles.css @@ -0,0 +1,11 @@ +@font-face{font-family:Pretendard;src:url('../../../src/Assets/Fonts/Pretendard-Regular.ttf');font-weight:400;font-display:swap} +@font-face{font-family:Pretendard;src:url('../../../src/Assets/Fonts/Pretendard-SemiBold.ttf');font-weight:600;font-display:swap} +@font-face{font-family:Pretendard;src:url('../../../src/Assets/Fonts/Pretendard-Bold.ttf');font-weight:700;font-display:swap} +*{box-sizing:border-box}body{margin:0;background:#e8e6e2;color:#2e2c30;font:14px/1.5 Pretendard,sans-serif;-webkit-font-smoothing:antialiased}button,input,select{font:inherit}button{cursor:pointer;border:0;background:none;color:inherit;white-space:nowrap}button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid #675666;outline-offset:3px}button:disabled{cursor:default;color:#989190;background:#eeebe7}button:hover:not(:disabled){filter:brightness(.96)}svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}[hidden]{display:none!important} +.review{display:flex;gap:24px;align-items:center;justify-content:space-between;max-width:1024px;margin:0 auto;padding:22px 22px 18px;font-size:12px}.review>div:first-child{display:grid;gap:2px}.review strong{font-weight:600;font-size:14px}.review span{color:#716a6a;font-size:11px}.review label{display:flex;gap:8px;align-items:center;color:#706968}.review select{border:1px solid #cbc6c0;border-radius:7px;background:#f7f5f1;padding:7px}.segmented{background:#dcd8d2;border-radius:8px;padding:3px;display:flex}.segmented button{border-radius:6px;padding:7px 13px;font-size:12px;color:#716864}.segmented button[aria-pressed=true]{background:#fffdfa;color:#3c323b;box-shadow:0 1px 3px #30282c15} +.stage{width:920px;margin:0 auto}.window{--bg:#fbfaf7;--surface:#fffefb;--line:#e6e1db;--muted:#79716e;--ink:#302d31;--accent:#493842;position:relative;width:920px;height:740px;border:1px solid #d4cfc8;border-radius:12px;overflow:hidden;background:var(--bg);box-shadow:0 20px 50px #43393912,0 2px 6px #43393908;color:var(--ink)}.titlebar{height:32px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;color:#89817d;font-size:11px;background:#f3f0eb}.titlebar>div{font-family:Arial,sans-serif;font-size:14px;letter-spacing:4px;color:#817b77}.window main{height:708px;padding:30px 38px 0;display:flex;flex-direction:column} +.pagehead{display:flex;justify-content:space-between;align-items:center;margin-bottom:28px}.eyebrow{font-size:10px;letter-spacing:1.5px;font-weight:600;color:#8a7f79;margin-bottom:4px}h1,h2,p{margin:0}h1{font-size:25px;letter-spacing:-1px;font-weight:700;line-height:1.3}.head-actions{display:flex;align-items:center;gap:12px}.primary,.secondary,.plain{height:36px;padding:0 15px;border-radius:7px;font-weight:600;font-size:12px;display:inline-flex;align-items:center;justify-content:center;gap:7px}.primary{background:var(--accent);color:#fffdf8;border:1px solid var(--accent)}.secondary{background:#fffdfa;border:1px solid #dcd4cc}.plain{color:#655952}.plus{font-size:18px;font-weight:400} +.summary{display:grid;grid-template-columns:1fr 305px;align-items:center;min-height:174px;border-bottom:1px solid var(--line);padding-bottom:25px}.sectionlabel{font-size:13px;font-weight:600;color:#5e535a;margin-bottom:2px}.total{display:flex;align-items:baseline;gap:3px;line-height:1.18;font-variant-numeric:tabular-nums;letter-spacing:-3px}.total strong{font-size:66px;font-weight:600;color:var(--accent)}.total>span{font-size:31px;font-weight:400;letter-spacing:-1px}.total small{font-size:16px;letter-spacing:-.3px;color:#8e8680;margin-left:10px}.balance-meta{font-size:11px;color:var(--muted);margin-top:10px}.balance-meta>span{margin:0 5px;color:#b4aaa3}.schedule{border-left:1px solid var(--line);padding-left:34px}.next-label{display:flex;align-items:center;gap:7px;font-size:11px;color:#7a6d63;margin-bottom:6px}.tiny-dot{display:inline-block;width:5px;height:5px;background:#b98666;border-radius:50%}.next-time{font-size:23px;line-height:1.35;letter-spacing:-.6px;font-weight:600}.next-time>span{font-size:13px;font-weight:400;color:#8c8279;margin-left:3px}.schedule p{font-size:12px;color:#79706c;margin-top:6px}.schedule p>span{margin:0 5px;color:#b4aaa3}.last-reset{font-size:10px;color:#8b817a;margin-top:17px}.last-reset b{font-weight:400;color:#625852;margin-left:14px;font-variant-numeric:tabular-nums} +.statusline{height:44px;flex-shrink:0;display:flex;justify-content:space-between;align-items:center;color:#827972;font-size:11px;border-bottom:1px solid var(--line)}.check{color:#7b8b79;margin-right:6px}.text-action{font-size:11px;color:#756669;padding:5px 0}.statusline.warn{color:#926544}.statusline.warn .text-action{color:#89573d;text-decoration:underline;text-underline-offset:3px}.statusline.loading .check{color:#776784}.accounts-section{margin-top:27px}.sectionhead{display:flex;justify-content:space-between;align-items:center;margin-bottom:13px}.sectionhead h2{font-size:14px;font-weight:600}.sectionhead h2 span{font-size:11px;color:#8c8078;margin-left:5px}.sectionhead>span{font-size:10px;color:#8b827b}.columnhead,.account{display:grid;grid-template-columns:1.35fr 1fr 1fr 109px;column-gap:22px;align-items:center}.columnhead{font-size:10px;color:#968b84;padding:0 16px 10px}.account{height:88px;padding:0 16px;border-top:1px solid var(--line);position:relative}.account.active{background:#f0eee9;border-radius:8px;border-color:transparent}.account.active+.account{border-color:transparent}.account:last-child{border-bottom:1px solid var(--line)}.identity{display:flex;align-items:center;gap:12px;min-width:0}.avatar{width:34px;height:36px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;border-radius:8px;background:var(--wash);color:var(--tone)}.name-line{display:flex;align-items:center;gap:8px;min-width:0}.account h3{font-size:13px;font-weight:600;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:155px}.account-meta{font-size:10px;color:#8b8078;margin-top:5px}.account-meta .active-dot{display:inline-block;width:5px;height:5px;background:#70816f;border-radius:50%;margin-right:5px}.weekly{max-width:155px}.weekly .number{font-size:23px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.6px}.number span{font-size:12px;font-weight:400;margin-left:2px;color:#8b817a}.meter{height:3px;background:#e7e1da;border-radius:2px;margin-top:7px;overflow:hidden}.meter>i{display:block;height:100%;background:var(--tone);border-radius:2px}.reset-date{font-size:12px;font-weight:400;color:#635b55;font-variant-numeric:tabular-nums}.reset-relative{font-size:10px;color:#978a80;margin-top:5px}.actions{display:flex;gap:9px;align-items:center;justify-content:flex-end}.switch{font-size:11px;font-weight:600;border:1px solid #ddd5cd;background:#fffdf9;border-radius:6px;padding:6px 10px;min-width:64px;height:31px}.using{font-size:11px;color:#908780;padding:6px 10px;min-width:64px;text-align:center}.more{font-size:17px;color:#8a7e76;width:28px;height:31px;border-radius:6px}.card-weekly-label{display:none}footer{margin-top:auto;height:57px;display:flex;align-items:center;gap:6px;color:#978b81;font-size:10px}footer .text-action{margin-left:auto;font-size:10px}.footer-dot{border:1px solid #afa399;border-radius:50%;width:4px;height:4px}.account.failed .account-meta{color:#98623d}.account.failed .number{color:#9a8a7e;font-size:19px}.account.failed .meter{opacity:.45} +.direction-b{--bg:#f4f0e9;--surface:#fffdf7;--line:#e0d8ce;--accent:#4b383c}.direction-b .pagehead{margin-bottom:24px}.direction-b .summary{grid-template-columns:1fr 303px;min-height:160px;padding:0 0 22px}.direction-b .total strong{font-size:60px}.direction-b .schedule{border:0;padding-left:32px}.direction-b .accounts-section{margin-top:26px}.direction-b .sectionhead{margin-bottom:16px}.direction-b .columnhead{display:none}.direction-b .accounts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.direction-b .account{display:flex;flex-direction:column;align-items:stretch;height:278px;padding:23px 21px 17px;border:1px solid #e3dacf;border-radius:10px;background:var(--surface);box-shadow:0 2px 2px #56423804}.direction-b .account.active{border-color:#8c777a;box-shadow:inset 0 3px #6f5258,0 2px 2px #56423804;background:#fffcf7}.direction-b .identity{gap:10px}.direction-b .avatar{width:31px;height:32px;border-radius:7px}.direction-b .account h3{font-size:14px;max-width:143px}.direction-b .weekly{max-width:none;margin-top:26px}.direction-b .card-weekly-label{display:block;font-size:10px;color:#9a8b7d;margin-bottom:2px}.direction-b .weekly .number{font-size:37px;letter-spacing:-1px;line-height:1.2}.direction-b .weekly .number span{font-size:17px}.direction-b .meter{height:4px;margin-top:10px}.direction-b .reset{display:flex;flex-direction:row-reverse;justify-content:space-between;align-items:center;margin-top:14px}.direction-b .reset-relative{margin:0;font-size:10px}.direction-b .reset-date{font-size:10px}.direction-b .actions{margin-top:auto;justify-content:space-between}.direction-b .switch,.direction-b .using{width:calc(100% - 37px);height:33px}.direction-b .using{text-align:left;padding-left:0;color:#796469}.direction-b .account.failed .number{font-size:27px}.direction-b footer{height:62px} +.critique{max-width:920px;margin:18px auto 35px;font-size:12px;display:flex;gap:13px;color:#766c65;line-height:1.6;padding:0 5px}.critique b{color:#473a40;white-space:nowrap;font-weight:600}.popover{position:absolute;background:#fffdf9;box-shadow:0 8px 28px #382b3320;border:1px solid #e1d9d1;padding:8px;border-radius:9px;width:185px;z-index:5}.popover strong{display:block;padding:7px 9px;font-size:11px;color:#97847a;font-weight:400}.popover button{display:block;width:100%;text-align:left;font-size:12px;padding:9px;border-radius:4px}.popover button:hover{background:#f0ebe5}.popover hr{border:0;border-top:1px solid #eee7df;margin:4px}.danger{color:#a04e47}.modal-layer{position:absolute;inset:32px 0 0;background:#38313724;display:flex;align-items:center;justify-content:center;z-index:10;backdrop-filter:blur(2px)}.modal-layer section{width:385px;padding:29px;background:#fffdf8;border:1px solid #ded5ca;border-radius:12px;box-shadow:0 18px 50px #30272c15}.modal-layer h2{font-size:21px;margin:6px 0 12px;letter-spacing:-.7px}.modal-layer p{color:#8b7a70;font-size:12px;line-height:1.8;white-space:pre-line}.modal-layer label{display:block;font-size:11px;color:#796960;margin-top:20px}.modal-layer input{display:block;width:100%;height:40px;margin-top:7px;padding:8px 10px;border:1px solid #cdbfb3;border-radius:6px;background:#fffefa;font-size:14px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:26px}.capture .review,.capture .critique{display:none}.capture{background:transparent}.capture .stage{margin:0}.capture .window{border-radius:0;box-shadow:none}.loading .account .switch,.loading .account .more{opacity:.5}@media(max-width:960px){.stage{margin:0 16px}.review{width:952px}.critique{margin-left:22px;width:900px}} diff --git a/docs/images/soft-paper/before.png b/docs/images/soft-paper/before.png new file mode 100644 index 0000000..50925c7 Binary files /dev/null and b/docs/images/soft-paper/before.png differ diff --git a/docs/images/soft-paper/long-name.png b/docs/images/soft-paper/long-name.png new file mode 100644 index 0000000..97562da Binary files /dev/null and b/docs/images/soft-paper/long-name.png differ diff --git a/docs/images/soft-paper/manager.png b/docs/images/soft-paper/manager.png new file mode 100644 index 0000000..dc96117 Binary files /dev/null and b/docs/images/soft-paper/manager.png differ diff --git a/docs/images/soft-paper/opening-complete.png b/docs/images/soft-paper/opening-complete.png new file mode 100644 index 0000000..af025fc Binary files /dev/null and b/docs/images/soft-paper/opening-complete.png differ diff --git a/docs/images/soft-paper/opening-loading.png b/docs/images/soft-paper/opening-loading.png new file mode 100644 index 0000000..460ca96 Binary files /dev/null and b/docs/images/soft-paper/opening-loading.png differ diff --git a/docs/images/soft-paper/partial.png b/docs/images/soft-paper/partial.png new file mode 100644 index 0000000..d40163f Binary files /dev/null and b/docs/images/soft-paper/partial.png differ diff --git a/docs/images/soft-paper/rename.png b/docs/images/soft-paper/rename.png new file mode 100644 index 0000000..3af6aeb Binary files /dev/null and b/docs/images/soft-paper/rename.png differ diff --git a/docs/images/soft-paper/tooltip.png b/docs/images/soft-paper/tooltip.png new file mode 100644 index 0000000..61836b9 Binary files /dev/null and b/docs/images/soft-paper/tooltip.png differ diff --git a/docs/soft-paper-ui.md b/docs/soft-paper-ui.md new file mode 100644 index 0000000..972f2ce --- /dev/null +++ b/docs/soft-paper-ui.md @@ -0,0 +1,71 @@ +# Soft paper account interface + +The current release candidate is **1.8.0**, implementing the user's +[selected A ordered-list design](design/2026-09-22/README.md). The description +below records the earlier preview.2 treatment and its validation history; its +two-column overview, paper borders and decorative motif have been superseded. + +The existing 920 × 740 logical-pixel manager, two-column overview, account rows, +actions, dialogs and manual query rules retain their structure. This is the local +1.8.0-preview.2 styling candidate, based on the user's selected “부드러운 종이” +style: firm Korean typography against warm, thin paper surfaces. + +- Pretendard 1.3.9 Regular, SemiBold and Bold are embedded unchanged from the + upstream static TrueType distribution. Fonts are registered privately for this + process; installation and network access are not required at runtime. +- Ivory #f6f3ed, charcoal #292d32 and plum #402e38 anchor the interface. Apricot, + pale blue and butter yellow form the small header motif. The active row uses a + pale blue sheet and side marker; actions retain text and native button semantics. +- Paper is interpreted as clean color planes, thin borders, shallow shadows and a + turned corner. Small native UI surfaces omit bitmap grain and sculptural imagery; + neither text nor data is baked into an image. +- Buttons have consistent hover, pressed, disabled and keyboard-focus treatments. + The name field keeps a native TextBox inside a padded frame. Menus and hover + details use the same paper palette and typography. +- Tooltip placement, mouse transparency, nonactivation and hover lifecycle are + unchanged. The widget's dimensions, provider colors and alias-free display stay + unchanged. No account authentication or usage query implementation changed. + +Font source: https://github.com/orioncactus/pretendard/tree/v1.3.9/packages/pretendard/dist/public/static/alternative +See `THIRD_PARTY_NOTICES.txt` for the full SIL Open Font License and copyright. + +## Visual review + +Native captures use synthetic values: [manager](images/soft-paper/manager.png), +[before](images/soft-paper/before.png), [name editor](images/soft-paper/rename.png), +[partial results](images/soft-paper/partial.png), +[long name](images/soft-paper/long-name.png), [tooltip](images/soft-paper/tooltip.png). + +`AccountStylePreview` opens production WinForms with synthetic accounts. Set +`GFS_ACCOUNT_TEST_ROOT` to a task workspace and `GFS_ACCOUNT_UI_CAPTURE` to a PNG +path, then run the test project with `--account-style-preview`. It captures the +manager, a long mixed Korean/English name, and the name editor without real queries. +The ordinary regression suite covers registration, rename, cancellation, aggregate +coverage, keyboard focus, three-account scrolling and actual tooltip hover. + +The target is Windows at 175% DPI. Native captures, partial-result and progress +states are reviewed alongside the selected style specimen. Aesthetic acceptance +belongs to the user's next feedback; successful tests are not a taste verdict. + +## Local validation + +The initial preview.1 `scripts/build.ps1` run passed all 29 regression groups. Actual hover +survived all four corners with no post-show hiding and with keyboard focus +preserved. This short cursor-driven check now runs first, before the slower +fixtures, to avoid surprising someone using the mouse later in a local run. +The final executable passed the private-path scan and was installed through the +normal per-user scheduled-task installer. The installed file matched SHA-256 +`d39113101c88a4dcad3e9caa324690011678c8cabf685bebd3f8b3e3be8c6757`; +one supervisor and one widget were running after startup. No public release was +created for this preview. + +On 2026-09-15, preview.2 corrected opening-time layout churn exposed by the paper +restyle. The full build passed all 30 regression groups, including the new native +first-paint/partial-response geometry test, disabled account-action checks, real +tooltip hover and keyboard focus. See [the cause and fix](account-opening-stability.md). +The final executable passed the private-path scan. The normal per-user installer +upgraded preview.1 to preview.2 and verified the installed file against SHA-256 +`b4eaf2410672a4d6fbaa48784954ece1b014d43c0ee95b2489693c1acd2f2f35` +(8,452,278 bytes). One supervisor and one widget remained running after startup. +This is a local preview installation; no public release was created. User review +of the installed opening behavior remains separate from the automated result. diff --git a/src/AccountDialogs.cs b/src/AccountDialogs.cs index 1e34973..40901c7 100644 --- a/src/AccountDialogs.cs +++ b/src/AccountDialogs.cs @@ -13,7 +13,7 @@ internal AccountNameDialog(string initialName, bool adding = false) _allowEmpty = adding; Name = "AccountNameDialog"; AccountUiTheme.SetForm(this); - Text = adding ? "다른 계정 추가" : "계정 이름 변경"; + Text = adding ? UiText.T("다른 계정 추가") : UiText.T("계정 이름 변경"); StartPosition = FormStartPosition.CenterParent; FormBorderStyle = FormBorderStyle.FixedDialog; MinimizeBox = false; MaximizeBox = false; ShowInTaskbar = false; @@ -28,24 +28,24 @@ internal AccountNameDialog(string initialName, bool adding = false) stack.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); stack.Controls.Add(AccountUiTheme.Label(Text, 17, true), 0, 0); var description = AccountUiTheme.Label(adding - ? "공식 로그인 화면이 브라우저에서 열립니다.\n추가할 계정으로 로그인하세요. 현재 계정은 유지됩니다." - : "알아보기 쉬운 이름을 붙여주세요.\n로그인 정보와 사용량에는 영향을 주지 않습니다.", color: AccountUiTheme.Muted); + ? UiText.T("공식 로그인 화면이 브라우저에서 열립니다.\n추가할 계정으로 로그인하세요. 현재 계정은 유지됩니다.") + : UiText.T("알아보기 쉬운 이름을 붙여주세요.\n로그인 정보와 사용량에는 영향을 주지 않습니다."), color: AccountUiTheme.Muted); description.Dock = DockStyle.Fill; description.AutoSize = false; stack.Controls.Add(description, 0, 1); - stack.Controls.Add(AccountUiTheme.Label(adding ? "계정 이름 · 선택" : "계정 이름"), 0, 2); + stack.Controls.Add(AccountUiTheme.Label(adding ? UiText.T("계정 이름 · 선택") : UiText.T("계정 이름")), 0, 2); _input.Text = initialName; _input.BackColor = AccountUiTheme.Raised; _input.ForeColor = AccountUiTheme.Text; - _input.PlaceholderText = adding ? "비우면 계정 번호로 지정됩니다" : "1~40자"; + _input.PlaceholderText = adding ? UiText.T("비우면 계정 번호로 지정됩니다") : UiText.T("1~40자"); _input.TextChanged += (_, _) => _error.Text = ""; - stack.Controls.Add(_input, 0, 3); + stack.Controls.Add(new AccountInputFrame(_input), 0, 3); _error.Name = "NameErrorLabel"; _error.Dock = DockStyle.Fill; _error.Margin = new Padding(0, 3, 0, 0); stack.Controls.Add(_error, 0, 4); var actions = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft, WrapContents = false, Margin = new Padding(0) }; - var save = AccountUiTheme.Button("SaveNameButton", adding ? "브라우저에서 로그인" : "저장", true); - var cancel = AccountUiTheme.Button("CancelNameButton", "취소"); + var save = AccountUiTheme.Button("SaveNameButton", adding ? UiText.T("브라우저에서 로그인") : UiText.T("저장"), true); + var cancel = AccountUiTheme.Button("CancelNameButton", UiText.T("취소")); cancel.Margin = new Padding(0, 0, 10, 0); cancel.DialogResult = DialogResult.Cancel; save.Click += (_, _) => @@ -53,7 +53,7 @@ internal AccountNameDialog(string initialName, bool adding = false) var name = AccountName; if ((!_allowEmpty && name.Length == 0) || name.Length > 40 || name.Any(char.IsControl)) { - _error.Text = "이름을 1~40자로 입력해 주세요. 줄바꿈은 사용할 수 없습니다."; + _error.Text = UiText.T("이름을 1~40자로 입력해 주세요. 줄바꿈은 사용할 수 없습니다."); _input.Focus(); return; } @@ -73,7 +73,7 @@ internal sealed class AccountSwitchDialog : Form { private readonly Action _assertWritersStopped; private readonly System.Windows.Forms.Timer _checkTimer = new() { Interval = 1000 }; - private readonly Label _readiness = AccountUiTheme.Label("실행 중인 앱을 확인하고 있습니다…"); + private readonly Label _readiness = AccountUiTheme.Label(UiText.T("실행 중인 앱을 확인하고 있습니다…")); private readonly Button _confirm; internal AccountSwitchDialog(string sourceName, string targetName, Action? assertWritersStopped = null, bool recovery = false) @@ -82,7 +82,7 @@ internal AccountSwitchDialog(string sourceName, string targetName, Action? asser _assertWritersStopped = assertWritersStopped ?? CodexAccountRuntime.AssertWritersStopped; Name = "AccountSwitchDialog"; AccountUiTheme.SetForm(this); - Text = recovery ? "중단된 계정 작업 복구" : "계정 전환 준비"; + Text = recovery ? UiText.T("중단된 계정 작업 복구") : UiText.T("계정 전환 준비"); StartPosition = FormStartPosition.CenterParent; FormBorderStyle = FormBorderStyle.FixedDialog; MinimizeBox = false; MaximizeBox = false; ShowInTaskbar = false; @@ -95,10 +95,10 @@ internal AccountSwitchDialog(string sourceName, string targetName, Action? asser stack.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); stack.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); stack.Controls.Add(AccountUiTheme.Label(Text, 17, true), 0, 0); - var route = AccountUiTheme.Label(recovery ? "저장된 복구 정보를 확인합니다" : $"{sourceName} → {targetName}", 12, true, AccountUiTheme.Accent); + var route = AccountUiTheme.Label(recovery ? UiText.T("저장된 복구 정보를 확인합니다") : $"{sourceName} → {targetName}", 12, true, AccountUiTheme.Accent); route.Dock = DockStyle.Fill; route.AutoSize = false; route.AutoEllipsis = true; stack.Controls.Add(route, 0, 1); - var instructions = AccountUiTheme.Label("작업을 저장한 뒤 Codex 앱과 Codex 터미널·IDE 작업을\n직접 종료해 주세요. 앱이 닫히면 아래 버튼이 활성화됩니다.\n실행 중인 프로그램을 자동으로 종료하지 않습니다.", color: AccountUiTheme.Muted); + var instructions = AccountUiTheme.Label(UiText.T("작업을 저장한 뒤 Codex 앱과 Codex 터미널·IDE 작업을\n직접 종료해 주세요. 앱이 닫히면 아래 버튼이 활성화됩니다.\n실행 중인 프로그램을 자동으로 종료하지 않습니다."), color: AccountUiTheme.Muted); instructions.Dock = DockStyle.Fill; instructions.AutoSize = false; stack.Controls.Add(instructions, 0, 2); @@ -109,10 +109,10 @@ internal AccountSwitchDialog(string sourceName, string targetName, Action? asser _readiness.Margin = new Padding(0, 0, 0, 16); stack.Controls.Add(_readiness, 0, 3); var actions = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft, WrapContents = false, Margin = new Padding(0) }; - _confirm = AccountUiTheme.Button("ConfirmSwitchButton", recovery ? "복구 확인" : "전환하고 Codex 열기", true); + _confirm = AccountUiTheme.Button("ConfirmSwitchButton", recovery ? UiText.T("복구 확인") : UiText.T("전환하고 Codex 열기"), true); _confirm.Enabled = false; _confirm.Click += (_, _) => { if (CheckReadiness()) { DialogResult = DialogResult.OK; Close(); } }; - var cancel = AccountUiTheme.Button("CancelSwitchButton", "취소"); + var cancel = AccountUiTheme.Button("CancelSwitchButton", UiText.T("취소")); cancel.Margin = new Padding(0, 0, 10, 0); cancel.DialogResult = DialogResult.Cancel; actions.Controls.Add(_confirm); actions.Controls.Add(cancel); stack.Controls.Add(actions, 0, 4); @@ -129,14 +129,14 @@ private bool CheckReadiness() try { _assertWritersStopped(); - _readiness.Text = "● 준비 완료 · 모든 Codex 작업이 종료되었습니다."; + _readiness.Text = UiText.T("● 준비 완료 · 모든 Codex 작업이 종료되었습니다."); _readiness.ForeColor = AccountUiTheme.Accent; _confirm.Enabled = true; return true; } catch (Exception) { - _readiness.Text = "● 종료 대기 · Codex 앱 또는 관련 작업이 실행 중입니다.\n앱을 닫으면 자동으로 다시 확인합니다."; + _readiness.Text = UiText.T("● 종료 대기 · Codex 앱 또는 관련 작업이 실행 중입니다.\n앱을 닫으면 자동으로 다시 확인합니다."); _readiness.ForeColor = AccountUiTheme.Warning; _confirm.Enabled = false; return false; diff --git a/src/AccountFonts.cs b/src/AccountFonts.cs new file mode 100644 index 0000000..dceede1 --- /dev/null +++ b/src/AccountFonts.cs @@ -0,0 +1,42 @@ +using System.Drawing.Text; +using System.Runtime.InteropServices; + +namespace WeeklyUsageIndicator; + +// Private to this process: the portable app never installs fonts on the user's PC. +internal static class AccountFonts +{ + private static readonly PrivateFontCollection Families = new(); + private static readonly List Buffers = new(); + private static readonly List Registrations = new(); + + static AccountFonts() + { + foreach (var weight in new[] { "Regular", "SemiBold", "Bold" }) + { + using var stream = typeof(AccountFonts).Assembly.GetManifestResourceStream($"WeeklyUsageIndicator.Assets.Fonts.Pretendard-{weight}.ttf") + ?? throw new InvalidOperationException("The bundled UI font is missing."); + using var data = new MemoryStream(); stream.CopyTo(data); + var bytes = data.ToArray(); var memory = Marshal.AllocHGlobal(bytes.Length); + Marshal.Copy(bytes, 0, memory, bytes.Length); + uint count = 0; + var registration = AddFontMemResourceEx(memory, (uint)bytes.Length, IntPtr.Zero, ref count); + if (registration == IntPtr.Zero) { Marshal.FreeHGlobal(memory); throw new InvalidOperationException("The UI font could not be loaded."); } + // GDI (native controls) and GDI+ (FontFamily) both need the same faces. + Families.AddMemoryFont(memory, bytes.Length); + Buffers.Add(memory); Registrations.Add(registration); + } + // These three registrations and buffers intentionally live until process exit, + // after all forms, labels and native font handles have finished using them. + } + + internal static Font Create(float size, bool bold = false, bool semibold = false) + { + var name = semibold ? "Pretendard SemiBold" : "Pretendard"; + return new Font(Families.Families.Single(family => family.Name == name), size, + bold && !semibold ? FontStyle.Bold : FontStyle.Regular, GraphicsUnit.Point); + } + + [DllImport("gdi32.dll")] + private static extern IntPtr AddFontMemResourceEx(IntPtr data, uint size, IntPtr reserved, ref uint count); +} diff --git a/src/AccountManagerForm.CombinedUsage.cs b/src/AccountManagerForm.CombinedUsage.cs index c87d4eb..eb07f2f 100644 --- a/src/AccountManagerForm.CombinedUsage.cs +++ b/src/AccountManagerForm.CombinedUsage.cs @@ -5,14 +5,14 @@ internal sealed partial class AccountManagerForm private async Task ReadAllUsageAsync() { if (_busy || _items.Count == 0) return; - await RunAsync(false, "전체 계정의 주간 사용량을 확인하고 있습니다…", async () => + await RunAsync(false, UiText.T("전체 계정의 주간 사용량을 확인하고 있습니다…"), async () => { var original = _store.ListAccounts(); var activeId = original.SingleOrDefault(a => a.IsActive)?.Id; _combined.Initialize(original, batch: true); _batchQuerying = true; _batchCompleted = 0; using var cancellation = new CancellationTokenSource(); - _loginCancellation = cancellation; _cancel.Text = "전체 조회 취소"; _cancel.Visible = true; _cancel.Enabled = true; + _loginCancellation = cancellation; _cancel.Text = UiText.T("전체 조회 취소"); _cancel.Visible = true; _cancel.Enabled = true; UpdateActions(); try { @@ -65,8 +65,8 @@ await RunAsync(false, "전체 계정의 주간 사용량을 확인하고 있습 throw new InvalidOperationException("조회 중 계정 구성이 바뀌었습니다. 전체 갱신이 필요합니다."); _combined.CompletedAt = DateTimeOffset.UtcNow; var complete = _combined.IsComplete(DateTimeOffset.UtcNow); - SetStatus(complete ? $"{original.Count}개 계정의 주간 잔여량을 확인했습니다." - : "일부 계정의 확인이 필요합니다. 계정별 상태를 확인하고 다시 갱신하세요.", success: complete); + SetStatus(complete ? UiText.F($"{original.Count}개 계정의 주간 잔여량을 확인했습니다.") + : UiText.T("일부 계정의 확인이 필요합니다. 계정별 상태를 확인하고 다시 갱신하세요."), success: complete); } catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { _combined.WasCanceled = true; throw; } @@ -76,7 +76,7 @@ await RunAsync(false, "전체 계정의 주간 사용량을 확인하고 있습 _batchQuerying = false; _loginCancellation = null; _combined.CompletedAt ??= DateTimeOffset.UtcNow; } - }, acquireGate: false, canceledMessage: "전체 조회를 취소했습니다. 확인된 값과 이전 값은 보존했습니다."); + }, acquireGate: false, canceledMessage: UiText.T("전체 조회를 취소했습니다. 확인된 값과 이전 값은 보존했습니다.")); } private async Task QueryBatchMemberAsync(SavedCodexAccount account, CancellationToken token) diff --git a/src/AccountManagerForm.cs b/src/AccountManagerForm.cs index c1f299b..f7849d1 100644 --- a/src/AccountManagerForm.cs +++ b/src/AccountManagerForm.cs @@ -8,27 +8,27 @@ internal sealed partial class AccountManagerForm : Form private readonly Action? _accountsChanged; private readonly Func _queryUsage; private readonly AccountTable _accounts = new(); - private readonly Label _count = AccountUiTheme.Label("저장된 계정", 11, true); + private readonly Label _count = AccountUiTheme.Label(UiText.T("내 계정"), 14, true); private readonly Label _status = AccountUiTheme.Label(""); - private readonly Button _add = AccountUiTheme.Button("AddAccountButton", "+ 계정 추가"); - private readonly Button _refreshAll = AccountUiTheme.Button("RefreshAllUsageButton", "전체 갱신", true); - private readonly Button _register = AccountUiTheme.Button("RegisterCurrentButton", "현재 계정 등록", true); - private readonly Button _registerActive = AccountUiTheme.Button("RegisterActiveAccountButton", "현재 계정 등록"); - private readonly ToolStripMenuItem _rename = new("이름 변경") { Name = "RenameAccountButton" }; - private readonly ToolStripMenuItem _readUsage = new("이 계정 사용량 조회") { Name = "ReadAccountUsageButton" }; - private readonly ToolStripMenuItem _delete = new("저장된 로그인 삭제") { Name = "DeleteAccountButton" }; - private readonly ToolStripMenuItem _refresh = new("저장된 계정 목록 다시 읽기") { Name = "RefreshAccountsButton" }; - private readonly ToolStripMenuItem _info = new("상세 정보") { Name = "AccountInfoMenuItem" }; + private readonly Button _add = AccountUiTheme.Button("AddAccountButton", UiText.T("+ 계정 추가")); + private readonly Button _refreshAll = AccountUiTheme.Button("RefreshAllUsageButton", UiText.T("전체 갱신"), true); + private readonly Button _register = AccountUiTheme.Button("RegisterCurrentButton", UiText.T("현재 계정 등록"), true); + private readonly Button _registerActive = AccountUiTheme.Button("RegisterActiveAccountButton", UiText.T("현재 계정 등록")); + private readonly ToolStripMenuItem _rename = new(UiText.T("이름 변경")) { Name = "RenameAccountButton" }; + private readonly ToolStripMenuItem _readUsage = new(UiText.T("이 계정 사용량 조회")) { Name = "ReadAccountUsageButton" }; + private readonly ToolStripMenuItem _delete = new(UiText.T("저장된 로그인 삭제")) { Name = "DeleteAccountButton" }; + private readonly ToolStripMenuItem _refresh = new(UiText.T("저장된 계정 목록 다시 읽기")) { Name = "RefreshAccountsButton" }; + private readonly ToolStripMenuItem _info = new(UiText.T("상세 정보")) { Name = "AccountInfoMenuItem" }; private readonly ContextMenuStrip _menu = new(); private readonly ContextMenuStrip _pageMenu = new(); - private readonly Button _recover = AccountUiTheme.Button("RecoverAccountsButton", "미완료 전환 복구"); - private readonly Button _cancel = AccountUiTheme.Button("CancelLoginButton", "조회 취소"); + private readonly Button _recover = AccountUiTheme.Button("RecoverAccountsButton", UiText.T("미완료 전환 복구")); + private readonly Button _cancel = AccountUiTheme.Button("CancelLoginButton", UiText.T("조회 취소")); private readonly Panel _empty = new() { Dock = DockStyle.Fill, BackColor = AccountUiTheme.Surface }; private readonly ProgressBar _progress = new() { Dock = DockStyle.Bottom, Height = 3, Style = ProgressBarStyle.Marquee, Visible = false }; private readonly System.Windows.Forms.Timer _refreshTimer = new() { Interval = 5000 }; private readonly TableLayoutPanel _root = AccountUiTheme.Stack(6); private readonly TableLayoutPanel _notice = new() { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, Margin = new Padding(0, 0, 0, 12), Padding = new Padding(12, 6, 8, 6), BackColor = AccountUiTheme.Raised }; - private readonly CombinedUsagePanel _overview = new() { Dock = DockStyle.Fill, Margin = new Padding(0, 0, 0, 20) }; + private readonly CombinedUsagePanel _overview = new() { Dock = DockStyle.Fill, Margin = new Padding(0) }; private readonly CombinedUsageSnapshot _combined; private readonly Action? _usageChanged; private IReadOnlyList _items = Array.Empty(); @@ -41,6 +41,8 @@ internal sealed partial class AccountManagerForm : Form private UsageQueryStatus _usageQueryStatus = new(UsageQueryState.None); internal CombinedUsageSnapshot CombinedUsage => _combined; internal bool IsOperationInProgress => _busy; + internal AccountManagerForm CreateLocalizedCopy() => new(_store, _suspend, _resume, _accountsChanged, + _queryUsage, refreshAllOnOpen: false, combined: _combined, usageChanged: _usageChanged); internal ToolStripItem MenuAction(string name) => _menu.Items.Cast().Concat(_pageMenu.Items.Cast()).Single(i => i.Name == name); internal Button? SelectedSwitchButton => _accounts.SelectedSwitchButton; private SavedCodexAccount? Selected => _accounts.SelectedItem as SavedCodexAccount; @@ -52,14 +54,16 @@ public AccountManagerForm(CodexAccountStore store, Func suspend, Action re _queryUsage = queryUsage ?? ((account, token) => CodexAccountUsageReader.ReadInactiveAsync(store, account.Id, token)); _combined = combined ?? new(); _usageChanged = usageChanged; _refreshAllOnOpen = refreshAllOnOpen; Name = "AccountManagerForm"; - AccountUiTheme.SetForm(this); Text = "Codex 계정 관리"; + AccountUiTheme.SetForm(this); Text = UiText.T("Codex 계정 관리"); ClientSize = new Size(920, 740); MinimumSize = new Size(780, 550); StartPosition = FormStartPosition.CenterScreen; KeyPreview = true; - _root.Padding = new Padding(24); - foreach (var h in new[] { 66, 192, 0, 42 }) _root.RowStyles.Add(new RowStyle(SizeType.Absolute, h)); - _root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); _root.RowStyles.Add(new RowStyle(SizeType.Absolute, 32)); + _root.Padding = new Padding(30, 30, 30, 20); + foreach (var h in new[] { 82, 154, 0, 56 }) _root.RowStyles.Add(new RowStyle(SizeType.Absolute, h)); + _root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); _root.RowStyles.Add(new RowStyle(SizeType.Absolute, 30)); var header = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 3, RowCount = 1, Margin = new Padding(0) }; header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); - header.Controls.Add(AccountUiTheme.Label("Codex 계정", 22, true), 0, 0); + header.Controls.Add(AccountUiTheme.Label(UiText.T("계정 관리"), 26, true), 0, 0); + _add.AutoSize = _refreshAll.AutoSize = false; + _add.Size = _refreshAll.Size = _add.MinimumSize = _refreshAll.MinimumSize = new Size(110, 38); _add.Anchor = _refreshAll.Anchor = AnchorStyles.Top | AnchorStyles.Right; _add.Margin = new Padding(0, 0, 10, 0); header.Controls.Add(_add, 1, 0); header.Controls.Add(_refreshAll, 2, 0); _root.Controls.Add(header, 0, 0); _root.Controls.Add(_overview, 0, 1); @@ -71,45 +75,53 @@ public AccountManagerForm(CodexAccountStore store, Func suspend, Action re _notice.Controls.Add(_status, 0, 0); _notice.Controls.Add(noticeActions, 1, 0); _root.Controls.Add(_notice, 0, 2); var listHeader = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, Margin = new Padding(0) }; listHeader.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); listHeader.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); - listHeader.Controls.Add(_count, 0, 0); - var order = AccountUiTheme.Label("초기화가 가까운 순", color: AccountUiTheme.Muted); order.Anchor = AnchorStyles.Top | AnchorStyles.Right; + _count.Anchor = AnchorStyles.Left; listHeader.Controls.Add(_count, 0, 0); + var order = AccountUiTheme.Label(UiText.T("초기화가 가까운 순"), 9, color: AccountUiTheme.Muted); order.Anchor = AnchorStyles.Right; listHeader.Controls.Add(order, 1, 0); _root.Controls.Add(listHeader, 0, 3); var body = new Panel { Dock = DockStyle.Fill, Margin = new Padding(0) }; BuildEmpty(); body.Controls.Add(_accounts); body.Controls.Add(_empty); _root.Controls.Add(body, 0, 4); - var footer = AccountUiTheme.Label("패널을 새로 열 때 한 번 확인합니다. 이후에는 ‘전체 갱신’을 눌러 주세요.", 8.5f, color: AccountUiTheme.Muted); - footer.Margin = new Padding(0, 12, 0, 0); _root.Controls.Add(footer, 0, 5); Controls.Add(_root); Controls.Add(_progress); - foreach (var menu in new[] { _menu, _pageMenu }) { menu.BackColor = AccountUiTheme.Raised; menu.ForeColor = AccountUiTheme.Text; menu.Font = Font; menu.ShowImageMargin = false; } + var footer = AccountUiTheme.Label(UiText.T("ⓘ 창을 열 때 한 번 확인하며, 이후에는 직접 갱신합니다."), 9, color: AccountUiTheme.Muted); + footer.Margin = new Padding(0, 10, 0, 0); _root.Controls.Add(footer, 0, 5); Controls.Add(_root); Controls.Add(_progress); + foreach (var menu in new[] { _menu, _pageMenu }) { menu.BackColor = AccountUiTheme.Surface; menu.ForeColor = AccountUiTheme.Text; menu.Font = Font; menu.ShowImageMargin = false; menu.Renderer = new ToolStripProfessionalRenderer(new AccountMenuColors()); } _menu.Items.AddRange(new ToolStripItem[] { _readUsage, _rename, _info, new ToolStripSeparator(), _delete }); _pageMenu.Items.Add(_refresh); ContextMenuStrip = _pageMenu; _accounts.ManageRequested += (_, anchor) => { UpdateActions(); _menu.Show(anchor, new Point(0, anchor.Height)); }; _accounts.SwitchRequested += async _ => await SwitchSelectedAsync(); _accounts.SelectedIndexChanged += (_, _) => { if (!_reloading) UpdateActions(); }; - _refresh.Click += (_, _) => { if (!_busy && Reload()) SetStatus("저장된 계정 목록을 다시 읽었습니다."); }; + _refresh.Click += (_, _) => { if (!_busy && Reload()) SetStatus(UiText.T("저장된 계정 목록을 다시 읽었습니다.")); }; _refreshAll.Click += async (_, _) => await ReadAllUsageAsync(); _readUsage.Click += async (_, _) => await ReadSelectedUsageAsync(); _rename.Click += (_, _) => RenameSelected(); _info.Click += (_, _) => ShowAccountInfo(); _delete.Click += (_, _) => DeleteSelected(); _register.Click += async (_, _) => await RegisterCurrentAsync(); _registerActive.Click += async (_, _) => await RegisterCurrentAsync(); _add.Click += async (_, _) => await AddAccountAsync(); _recover.Click += async (_, _) => await RecoverAsync(); - _cancel.Click += (_, _) => { _loginCancellation?.Cancel(); _cancel.Enabled = false; SetStatus("요청을 취소하고 로그인 정보를 안전하게 정리하고 있습니다…"); }; + _cancel.Click += (_, _) => { _loginCancellation?.Cancel(); _cancel.Enabled = false; SetStatus(UiText.T("요청을 취소하고 로그인 정보를 안전하게 정리하고 있습니다…")); }; KeyDown += (_, e) => { if (e.KeyCode == Keys.F2 && !_busy && Selected is not null) { e.Handled = true; RenameSelected(); } }; _refreshTimer.Tick += (_, _) => { if (!_busy && !_menu.Visible && !_pageMenu.Visible && !OwnedForms.Any(f => f.Visible)) Reload(quiet: true); }; - Shown += async (_, _) => + // Load runs after DPI scaling but before the first visible paint. Shown was + // too late: empty controls and each intermediate row size reached the screen. + Load += async (_, _) => { - var area = Screen.FromControl(this).WorkingArea; Size = new Size(Math.Min(Width, area.Width), Math.Min(Height, area.Height)); - Location = new Point(Math.Clamp(Left, area.Left, Math.Max(area.Left, area.Right - Width)), Math.Clamp(Top, area.Top, Math.Max(area.Top, area.Bottom - Height))); - _desktopPath = CodexAccountRuntime.CaptureDesktopLaunchPath(); Reload(); _refreshTimer.Start(); - if (_refreshAllOnOpen && _items.Count > 0 && !_store.HasPendingRecovery && _usageQueryStatus.State == UsageQueryState.None) await ReadAllUsageAsync(); + var initialRead = Task.CompletedTask; + Present(() => + { + var area = Screen.FromControl(this).WorkingArea; Size = new Size(Math.Min(Width, area.Width), Math.Min(Height, area.Height)); + Location = new Point(Math.Clamp(Left, area.Left, Math.Max(area.Left, area.Right - Width)), Math.Clamp(Top, area.Top, Math.Max(area.Top, area.Bottom - Height))); + _desktopPath = CodexAccountRuntime.CaptureDesktopLaunchPath(); Reload(); _refreshTimer.Start(); + if (_refreshAllOnOpen && _items.Count > 0 && !_store.HasPendingRecovery && _usageQueryStatus.State == UsageQueryState.None) initialRead = ReadAllUsageAsync(); + }); + await initialRead; }; FormClosing += (_, e) => { if (!_busy) return; e.Cancel = true; - if (_batchQuerying) { _closeAfterBatch = true; _loginCancellation?.Cancel(); SetStatus("전체 조회를 취소하고 안전하게 정리한 뒤 닫습니다…"); } - else SetStatus("진행 중인 작업이 있습니다. 취소 버튼으로 요청을 마친 뒤 닫아주세요."); + if (_batchQuerying) { _closeAfterBatch = true; _loginCancellation?.Cancel(); SetStatus(UiText.T("전체 조회를 취소하고 안전하게 정리한 뒤 닫습니다…")); } + else SetStatus(UiText.T("진행 중인 작업이 있습니다. 취소 버튼으로 요청을 마친 뒤 닫아주세요.")); }; FormClosed += (_, _) => _refreshTimer.Stop(); ResumeLayout(true); + DpiChanged += (_, _) => BeginInvoke((Action)(() => { if (!IsDisposed) UpdateActions(); })); } - private Task RegisterCurrentAsync() => RunAsync(true, "현재 계정을 등록하고 있습니다…", async () => + private Task RegisterCurrentAsync() => RunAsync(true, UiText.T("현재 계정을 등록하고 있습니다…"), async () => { var account = _store.RegisterCurrent(NextName()); Reload(account.Id); - SetStatus($"‘{account.Label}’ 등록 완료. 계정의 ··· 메뉴에서 이름을 바꿀 수 있습니다.", success: true); + SetStatus(UiText.F($"‘{account.Label}’ 등록 완료. 계정의 ··· 메뉴에서 이름을 바꿀 수 있습니다."), success: true); _accountsChanged?.Invoke(); await Task.CompletedTask; }); private void BuildEmpty() @@ -117,23 +129,23 @@ private void BuildEmpty() var layout = AccountUiTheme.Stack(4); layout.Padding = new Padding(24); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 60)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - layout.Controls.Add(AccountUiTheme.Label("첫 계정을 등록하세요", 18, true), 0, 0); - var description = AccountUiTheme.Label("지금 사용하는 Codex 계정을 저장하면 시작할 수 있습니다.\n이름은 나중에 바꿀 수 있고, 현재 로그인은 유지됩니다.", color: AccountUiTheme.Muted); + layout.Controls.Add(AccountUiTheme.Label(UiText.T("첫 계정을 등록하세요"), 18, true), 0, 0); + var description = AccountUiTheme.Label(UiText.T("지금 사용하는 Codex 계정을 저장하면 시작할 수 있습니다.\n이름은 나중에 바꿀 수 있고, 현재 로그인은 유지됩니다."), color: AccountUiTheme.Muted); layout.Controls.Add(description, 0, 1); layout.Controls.Add(_register, 0, 2); _empty.Controls.Add(layout); } private void ShowAccountInfo() { if (Selected is not { } account) return; - using var dialog = new Form { Name = "AccountInfoDialog", Text = "계정 상세", ClientSize = new Size(460, 280), StartPosition = FormStartPosition.CenterParent, FormBorderStyle = FormBorderStyle.FixedDialog, MaximizeBox = false, MinimizeBox = false }; + using var dialog = new Form { Name = "AccountInfoDialog", Text = UiText.T("계정 상세"), ClientSize = new Size(460, 280), StartPosition = FormStartPosition.CenterParent, FormBorderStyle = FormBorderStyle.FixedDialog, MaximizeBox = false, MinimizeBox = false }; AccountUiTheme.SetForm(dialog); var layout = AccountUiTheme.Stack(5); layout.Padding = new Padding(24); foreach (var h in new[] { 44, 34, 58, 52, 44 }) layout.RowStyles.Add(new RowStyle(SizeType.Absolute, h)); - layout.Controls.Add(AccountUiTheme.Label(account.Label, 16, true), 0, 0); layout.Controls.Add(AccountUiTheme.Label(account.IdentityHint, color: AccountUiTheme.Muted), 0, 1); + layout.Controls.Add(AccountUiTheme.Label(account.Label, 16, true), 0, 0); layout.Controls.Add(AccountUiTheme.Label(UiText.Message(account.IdentityHint), color: AccountUiTheme.Muted), 0, 1); var shortWindow = account.Usage?.ShortWindow; var now = DateTimeOffset.Now; - var remaining = shortWindow is null ? "정보 없음" : shortWindow.ResetsAt <= now ? "갱신 필요" : $"{100 - shortWindow.UsedPercent}%"; - layout.Controls.Add(AccountUiTheme.Label($"5시간 잔여 {remaining}\n초기화 {(shortWindow?.ResetsAt is { } reset ? reset.ToLocalTime().ToString("MM/dd HH:mm") : "—")}"), 0, 2); - layout.Controls.Add(AccountUiTheme.Label(account.ObservedAt is { } observed ? $"개별 조회의 마지막 확인 {observed.ToLocalTime():MM/dd HH:mm}\n전체 잔여량은 마지막 전체 확인 기준입니다." : "아직 사용량을 확인하지 않았습니다.", color: AccountUiTheme.Muted), 0, 3); - var close = AccountUiTheme.Button("CloseAccountInfoButton", "닫기"); close.DialogResult = DialogResult.OK; layout.Controls.Add(close, 0, 4); dialog.AcceptButton = dialog.CancelButton = close; + var remaining = shortWindow is null ? UiText.T("정보 없음") : shortWindow.ResetsAt <= now ? UiText.T("갱신 필요") : $"{100 - shortWindow.UsedPercent}%"; + layout.Controls.Add(AccountUiTheme.Label(UiText.F($"5시간 잔여 {remaining}\n초기화 {(shortWindow?.ResetsAt is { } reset ? reset.ToLocalTime().ToString("MM/dd HH:mm") : "—")}")), 0, 2); + layout.Controls.Add(AccountUiTheme.Label(account.ObservedAt is { } observed ? UiText.F($"개별 조회의 마지막 확인 {observed.ToLocalTime():MM/dd HH:mm}\n전체 잔여량은 마지막 전체 확인 기준입니다.") : UiText.T("아직 사용량을 확인하지 않았습니다."), color: AccountUiTheme.Muted), 0, 3); + var close = AccountUiTheme.Button("CloseAccountInfoButton", UiText.T("닫기")); close.DialogResult = DialogResult.OK; layout.Controls.Add(close, 0, 4); dialog.AcceptButton = dialog.CancelButton = close; dialog.Controls.Add(layout); dialog.ShowDialog(this); } private void UpdateNotice() @@ -144,31 +156,43 @@ private void UpdateNotice() var height = show ? (int)Math.Round((_operationError || _usageQueryStatus.State != UsageQueryState.None ? 100 : 72) * DeviceDpi / 96f) : 0; if (_root.RowStyles.Count > 2 && _root.RowStyles[2].Height != height) _root.RowStyles[2].Height = height; } - private void UpdateActions() + private void UpdateActions() => Present(UpdateActionState); + private void Present(Action update) + { + using var layout = AccountUiTheme.DeferLayout(_root); + update(); + } + private void UpdateActionState() { var pending = _store.HasPendingRecovery || _usageQueryStatus.State == UsageQueryState.RecoveryRequired; - _accounts.Enabled = !_busy; _refresh.Enabled = !_busy; _readUsage.Enabled = !_busy && !pending && Selected is not null; + // Disable actions individually. Disabling the entire table recursively + // forces every transparent label to repaint its ancestors immediately. + _refresh.Enabled = !_busy; _readUsage.Enabled = !_busy && !pending && Selected is not null; _add.Enabled = !_busy && !pending && _items.Count > 0; _register.Enabled = !_busy && !pending; _registerActive.Visible = _items.Count > 0 && !_items.Any(a => a.IsActive) && !pending; _registerActive.Enabled = !_busy; _rename.Enabled = _info.Enabled = !_busy && !pending && Selected is not null; _delete.Enabled = !_busy && !pending && Selected is { IsActive: false }; _recover.Visible = pending || _usageQueryStatus.State == UsageQueryState.CleanupPending; _recover.Enabled = !_busy; - _recover.Text = _store.HasPendingRecovery ? "미완료 전환 복구" : _usageQueryStatus.State == UsageQueryState.CleanupPending ? "임시 파일 정리" : "중단된 조회 복구"; + _recover.Text = _store.HasPendingRecovery ? UiText.T("미완료 전환 복구") : _usageQueryStatus.State == UsageQueryState.CleanupPending ? UiText.T("임시 파일 정리") : UiText.T("중단된 조회 복구"); _refreshAll.Enabled = !_busy && !pending && _items.Count > 0; _overview.Display(_combined, _busy || pending, _batchCompleted, _batchQuerying); - _accounts.Display(_combined, _busy, !pending, _items.Any(a => a.IsActive)); UpdateNotice(); _usageChanged?.Invoke(); + _accounts.Display(_combined, _busy, !pending, _items.Any(a => a.IsActive), keepOrder: _batchQuerying); UpdateNotice(); _usageChanged?.Invoke(); } private string NextName() { - for (var n = 1; ; n++) if (!_items.Any(a => a.Label == $"계정 {n}")) return $"계정 {n}"; + for (var n = 1; ; n++) + { + var candidate = UiText.F($"계정 {n}"); + if (!_items.Any(a => a.Label == candidate)) return candidate; + } } private void RenameSelected() { if (_busy || Selected is not { } account) return; using var dialog = new AccountNameDialog(account.Label); - if (dialog.ShowDialog(this) != DialogResult.OK) { SetStatus("이름 변경을 취소했습니다."); return; } - try { _store.Rename(account.Id, dialog.AccountName); Reload(account.Id); _accountsChanged?.Invoke(); SetStatus("계정 이름을 변경했습니다.", success: true); } + if (dialog.ShowDialog(this) != DialogResult.OK) { SetStatus(UiText.T("이름 변경을 취소했습니다.")); return; } + try { _store.Rename(account.Id, dialog.AccountName); Reload(account.Id); _accountsChanged?.Invoke(); SetStatus(UiText.T("계정 이름을 변경했습니다."), success: true); } catch (Exception ex) { SetStatus(ex.Message, error: true); } } @@ -176,18 +200,18 @@ private async Task AddAccountAsync() { if (_busy || _items.Count == 0) return; using var dialog = new AccountNameDialog("", adding: true); - if (dialog.ShowDialog(this) != DialogResult.OK) { SetStatus("계정 추가를 취소했습니다."); return; } + if (dialog.ShowDialog(this) != DialogResult.OK) { SetStatus(UiText.T("계정 추가를 취소했습니다.")); return; } var name = string.IsNullOrWhiteSpace(dialog.AccountName) ? NextName() : dialog.AccountName; - await RunAsync(true, "브라우저에서 추가할 계정으로 로그인하세요. 현재 계정은 유지됩니다.", async () => + await RunAsync(true, UiText.T("브라우저에서 추가할 계정으로 로그인하세요. 현재 계정은 유지됩니다."), async () => { using var cancellation = new CancellationTokenSource(); - _loginCancellation = cancellation; _cancel.Text = "로그인 취소"; _cancel.Visible = true; _cancel.Enabled = true; + _loginCancellation = cancellation; _cancel.Text = UiText.T("로그인 취소"); _cancel.Visible = true; _cancel.Enabled = true; try { using var login = await CodexAccountRuntime.LoginAsync(_store.RootPath, cancellation.Token); var account = _store.ImportLoginFile(login.AuthPath, name); Reload(account.Id); _accountsChanged?.Invoke(); - SetStatus($"‘{account.Label}’ 추가 완료. 목록에서 선택해 전환할 수 있습니다.", success: true); + SetStatus(UiText.F($"‘{account.Label}’ 추가 완료. 목록에서 선택해 전환할 수 있습니다."), success: true); } finally { _loginCancellation = null; } }); @@ -196,12 +220,12 @@ await RunAsync(true, "브라우저에서 추가할 계정으로 로그인하세 private async Task ReadSelectedUsageAsync() { if (_busy || Selected is not { } account) return; - await RunAsync(false, $"‘{account.Label}’ 사용량을 조회하고 있습니다…", async () => + await RunAsync(false, UiText.F($"‘{account.Label}’ 사용량을 조회하고 있습니다…"), async () => { using var cancellation = new CancellationTokenSource(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellation.Token, timeout.Token); - _loginCancellation = cancellation; _cancel.Text = "조회 취소"; _cancel.Visible = true; _cancel.Enabled = true; + _loginCancellation = cancellation; _cancel.Text = UiText.T("조회 취소"); _cancel.Visible = true; _cancel.Enabled = true; try { var requestedAt = DateTimeOffset.UtcNow; @@ -211,7 +235,7 @@ await RunAsync(false, $"‘{account.Label}’ 사용량을 조회하고 있습 throw new InvalidOperationException("새 확인값을 저장하지 못했습니다. 다시 조회해 주세요."); _combined.RecordIndividual(observed); Reload(account.Id); - SetStatus($"‘{account.Label}’ 사용량을 확인했습니다.", success: true); + SetStatus(UiText.F($"‘{account.Label}’ 사용량을 확인했습니다."), success: true); } catch (OperationCanceledException) when (timeout.IsCancellationRequested && !cancellation.IsCancellationRequested) { throw new TimeoutException("조회 시간이 초과되었습니다. 마지막 확인값은 유지됩니다. 잠시 후 다시 시도하세요."); } @@ -222,24 +246,24 @@ await RunAsync(false, $"‘{account.Label}’ 사용량을 조회하고 있습 catch (IOException) { throw new InvalidOperationException("사용량을 가져오지 못했습니다. 마지막 확인값은 유지됩니다. 연결을 확인한 뒤 다시 시도하세요."); } finally { _loginCancellation = null; } - }, acquireGate: account.IsActive, canceledMessage: "사용량 조회를 취소했습니다. 마지막 확인값은 유지됩니다."); + }, acquireGate: account.IsActive, canceledMessage: UiText.T("사용량 조회를 취소했습니다. 마지막 확인값은 유지됩니다.")); } private async Task SwitchSelectedAsync() { if (_busy || Selected is not { IsActive: false } target) return; - await RunAsync(true, "전환 조건을 확인하고 있습니다…", async () => + await RunAsync(true, UiText.T("전환 조건을 확인하고 있습니다…"), async () => { - var source = _items.FirstOrDefault(a => a.IsActive)?.Label ?? "현재 로그인"; + var source = _items.FirstOrDefault(a => a.IsActive)?.Label ?? UiText.T("현재 로그인"); using var dialog = new AccountSwitchDialog(source, target.Label); - if (dialog.ShowDialog(this) != DialogResult.OK) { SetStatus("전환을 취소했습니다. 현재 계정은 유지됩니다."); return; } + if (dialog.ShowDialog(this) != DialogResult.OK) { SetStatus(UiText.T("전환을 취소했습니다. 현재 계정은 유지됩니다.")); return; } CodexAccountRuntime.AssertWritersStopped(); CodexAccountRuntime.ClearStaleLoginDirectories(_store.RootPath); _store.SwitchTo(target.Id, CodexAccountRuntime.AssertWritersStopped); Reload(target.Id); _accountsChanged?.Invoke(); - SetStatus($"‘{target.Label}’ 적용 완료. 열린 Codex에서 계정을 확인하세요.", success: true); + SetStatus(UiText.F($"‘{target.Label}’ 적용 완료. 열린 Codex에서 계정을 확인하세요."), success: true); try { CodexAccountRuntime.LaunchDesktop(_desktopPath); } - catch { SetStatus($"‘{target.Label}’은 적용되었습니다. 시작 메뉴에서 Codex를 열어주세요."); } + catch { SetStatus(UiText.F($"‘{target.Label}’은 적용되었습니다. 시작 메뉴에서 Codex를 열어주세요.")); } await Task.CompletedTask; }); } @@ -247,9 +271,9 @@ await RunAsync(true, "전환 조건을 확인하고 있습니다…", async () = private void DeleteSelected() { if (_busy || Selected is not { IsActive: false } account) return; - if (MessageBox.Show(this, $"‘{account.Label}’의 저장된 로그인을 삭제할까요?\n다시 사용하려면 해당 계정으로 로그인해야 합니다.", "저장된 로그인 삭제", - MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) != DialogResult.OK) { SetStatus("삭제를 취소했습니다."); return; } - try { _store.Remove(account.Id); Reload(); _accountsChanged?.Invoke(); SetStatus("저장된 로그인을 삭제했습니다.", success: true); } + if (MessageBox.Show(this, UiText.F($"‘{account.Label}’의 저장된 로그인을 삭제할까요?\n다시 사용하려면 해당 계정으로 로그인해야 합니다."), UiText.T("저장된 로그인 삭제"), + MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) != DialogResult.OK) { SetStatus(UiText.T("삭제를 취소했습니다.")); return; } + try { _store.Remove(account.Id); Reload(); _accountsChanged?.Invoke(); SetStatus(UiText.T("저장된 로그인을 삭제했습니다."), success: true); } catch (Exception ex) { SetStatus(ex.Message, error: true); } } @@ -257,23 +281,23 @@ private async Task RecoverAsync() { if (!_store.HasPendingRecovery && _usageQueryStatus.State == UsageQueryState.CleanupPending) { - await RunAsync(false, "임시 파일을 정리하고 있습니다…", async () => + await RunAsync(false, UiText.T("임시 파일을 정리하고 있습니다…"), async () => { await Task.Run(_store.RetryUsageCleanup); Reload(); - SetStatus(_usageQueryStatus.State == UsageQueryState.None ? "임시 파일 정리를 마쳤습니다." - : "임시 파일을 아직 정리하지 못했습니다. 잠시 후 다시 시도하세요.", + SetStatus(_usageQueryStatus.State == UsageQueryState.None ? UiText.T("임시 파일 정리를 마쳤습니다.") + : UiText.T("임시 파일을 아직 정리하지 못했습니다. 잠시 후 다시 시도하세요."), success: _usageQueryStatus.State == UsageQueryState.None); }, acquireGate: false); return; } - await RunAsync(true, "복구 조건을 확인하고 있습니다…", async () => + await RunAsync(true, UiText.T("복구 조건을 확인하고 있습니다…"), async () => { using var dialog = new AccountSwitchDialog("", "", recovery: true); - if (dialog.ShowDialog(this) != DialogResult.OK) { SetStatus("복구를 취소했습니다. 미완료 기록은 보존됩니다."); return; } + if (dialog.ShowDialog(this) != DialogResult.OK) { SetStatus(UiText.T("복구를 취소했습니다. 미완료 기록은 보존됩니다.")); return; } if (_store.IsEnabled) CodexAccountRuntime.ClearStaleLoginDirectories(_store.RootPath); _store.Recover(CodexAccountRuntime.AssertWritersStopped); - Reload(); _accountsChanged?.Invoke(); SetStatus("중단된 계정 작업을 복구했습니다.", success: true); + Reload(); _accountsChanged?.Invoke(); SetStatus(UiText.T("중단된 계정 작업을 복구했습니다."), success: true); await Task.CompletedTask; }); } @@ -282,7 +306,7 @@ private async Task RunAsync(bool suspend, string message, Func action, boo string canceledMessage = "로그인을 취소했습니다. 현재 계정은 유지됩니다.") { if (_busy) return; - _busy = true; UpdateActions(); _progress.Visible = true; SetStatus(message); + Present(() => { _busy = true; _progress.Visible = true; SetStatus(message); UpdateActions(); }); using var gate = new Mutex(false, CodexAccountStore.TransactionMutexName); var ownsGate = false; try @@ -301,12 +325,15 @@ private async Task RunAsync(bool suspend, string message, Func action, boo finally { if (ownsGate) gate.ReleaseMutex(); - _busy = false; _cancel.Visible = false; _progress.Visible = false; - Reload(quiet: true); UpdateActions(); + Present(() => + { + _busy = false; _cancel.Visible = false; _progress.Visible = false; + Reload(quiet: true); UpdateActions(); + }); if (suspend) { try { _resume(); } - catch { SetStatus("계정 작업은 끝났지만 사용량 조회를 재개하지 못했습니다. 위젯을 다시 열어주세요.", error: true); } + catch { SetStatus(UiText.T("계정 작업은 끝났지만 사용량 조회를 재개하지 못했습니다. 위젯을 다시 열어주세요."), error: true); } } if (_closeAfterBatch) { _closeAfterBatch = false; BeginInvoke(new Action(Close)); } } @@ -329,7 +356,7 @@ internal bool Reload(string? selectId = null, bool quiet = false) _accounts.SelectedIndex = index >= 0 ? index : sorted.Length > 0 ? 0 : -1; _accounts.EndUpdate(); _reloading = false; } - _count.Text = $"계정 {_items.Count}개"; + _count.Text = UiText.F($"내 계정 {_items.Count}"); _empty.Visible = _items.Count == 0; _accounts.Visible = _items.Count > 0; _usageQueryStatus = _store.GetUsageQueryStatus(); if (!_overviewInitialized) { if (_combined.Accounts.Count == 0) _combined.Initialize(fresh, batch: false); else _combined.Reconcile(fresh); _overviewInitialized = true; } @@ -344,20 +371,21 @@ internal bool Reload(string? selectId = null, bool quiet = false) private void SetStatus(string message, bool error = false, bool success = false) { - _operationMessage = message; _operationError = error; _operationSuccess = success; + _operationMessage = UiText.Message(message); _operationError = error; _operationSuccess = success; RenderStatus(); } - private void RenderStatus() + private void RenderStatus() => Present(RenderStatusState); + private void RenderStatusState() { - var recovery = _store.HasPendingRecovery ? "미완료 전환이 있습니다. 복구를 완료해 주세요." - : _usageQueryStatus.State == UsageQueryState.RecoveryRequired ? "중단된 조회의 로그인 정보 복구가 필요합니다." : null; + var recovery = _store.HasPendingRecovery ? UiText.T("미완료 전환이 있습니다. 복구를 완료해 주세요.") + : _usageQueryStatus.State == UsageQueryState.RecoveryRequired ? UiText.T("중단된 조회의 로그인 정보 복구가 필요합니다.") : null; var cleanup = _usageQueryStatus.State == UsageQueryState.CleanupPending - ? "로그인 정보 저장 완료 · 임시 파일 정리 대기" + (_usageQueryStatus.Failure is { } failure ? $" ({failure.Summary})" : "") : null; - var fallback = _items.Count == 0 ? "현재 계정을 먼저 등록하세요. 이름은 자동으로 지정됩니다." - : !_items.Any(a => a.IsActive) ? "현재 로그인은 아직 등록되지 않았습니다. 전환하려면 현재 계정을 먼저 등록하세요." + ? UiText.T("로그인 정보 저장 완료 · 임시 파일 정리 대기") + (_usageQueryStatus.Failure is { } failure ? $" ({UiText.Message(failure.Summary)})" : "") : null; + var fallback = _items.Count == 0 ? UiText.T("현재 계정을 먼저 등록하세요. 이름은 자동으로 지정됩니다.") + : !_items.Any(a => a.IsActive) ? UiText.T("현재 로그인은 아직 등록되지 않았습니다. 전환하려면 현재 계정을 먼저 등록하세요.") : ""; - _status.Text = string.Join("\n", new[] { _operationMessage, recovery, cleanup, _items.Count > 0 && !_items.Any(a => a.IsActive) ? "현재 로그인은 미등록 상태입니다. 먼저 등록해 주세요." : null }.Where(text => text is not null)); + _status.Text = string.Join("\n", new[] { _operationMessage, recovery, cleanup, _items.Count > 0 && !_items.Any(a => a.IsActive) ? UiText.T("현재 로그인은 미등록 상태입니다. 먼저 등록해 주세요.") : null }.Where(text => text is not null)); if (_status.Text.Length == 0) _status.Text = fallback; _status.ForeColor = _operationError || recovery is not null ? AccountUiTheme.Error : _operationSuccess && cleanup is null ? AccountUiTheme.Accent : AccountUiTheme.Text; @@ -369,4 +397,17 @@ protected override void Dispose(bool disposing) if (disposing) { _refreshTimer.Dispose(); _menu.Dispose(); _pageMenu.Dispose(); } base.Dispose(disposing); } + + protected override CreateParams CreateParams + { + get + { + var parameters = base.CreateParams; + // Present the paper surfaces and their transparent native children as + // one frame. Form.DoubleBuffered alone does not buffer child HWNDs. + // Keep this local to the manager; the nonactivating widget/tooltip differ. + parameters.ExStyle |= 0x02000000; // WS_EX_COMPOSITED + return parameters; + } + } } diff --git a/src/AccountTable.cs b/src/AccountTable.cs index 954aea3..f690c01 100644 --- a/src/AccountTable.cs +++ b/src/AccountTable.cs @@ -4,7 +4,11 @@ namespace WeeklyUsageIndicator; internal sealed class AccountTable : UserControl { private readonly TableLayoutPanel _rows = AccountUiTheme.Stack(0); + private readonly TableLayoutPanel _headings; + private readonly Dictionary _identitySlots = new(StringComparer.Ordinal); + private int _nextSlot; private readonly Dictionary _controls = new(StringComparer.Ordinal); + private string[] _order = Array.Empty(); private int _selectedIndex = -1; internal List Items { get; } = new(); internal object? SelectedItem => _selectedIndex >= 0 && _selectedIndex < Items.Count ? Items[_selectedIndex] : null; @@ -18,58 +22,114 @@ internal AccountTable() AutoScaleMode = AutoScaleMode.Dpi; AutoScaleDimensions = new SizeF(96, 96); Name = "AccountList"; Dock = DockStyle.Fill; AutoScroll = true; _rows.Dock = DockStyle.Top; _rows.AutoSize = true; _rows.GrowStyle = TableLayoutPanelGrowStyle.AddRows; Controls.Add(_rows); + var headings = _headings = new TableLayoutPanel { Dock = DockStyle.Top, Height = 28, Margin = new Padding(0) }; + SetColumns(headings, 0); + foreach (var (title, index) in new[] { (UiText.T("계정"), 0), (UiText.T("주간 잔여"), 1), (UiText.T("초기화"), 2) }) + headings.Controls.Add(AccountUiTheme.Label(title, 9, color: AccountUiTheme.Muted), index, 0); + Controls.Add(headings); } internal void BeginUpdate() => SuspendLayout(); internal void EndUpdate() => ResumeLayout(); - internal void Display(CombinedUsageSnapshot snapshot, bool busy, bool mutationsAllowed, bool hasActive) + internal void Display(CombinedUsageSnapshot snapshot, bool busy, bool mutationsAllowed, bool hasActive, bool keepOrder = false) { - var ordered = snapshot.ByReset(); var ids = ordered.Select(r => r.Account.Id).ToHashSet(StringComparer.Ordinal); - foreach (var removed in _controls.Keys.Where(id => !ids.Contains(id)).ToArray()) { _controls[removed].Dispose(); _controls.Remove(removed); } - _rows.SuspendLayout(); _rows.RowCount = ordered.Count; _rows.RowStyles.Clear(); - for (var i = 0; i < ordered.Count; i++) + // A batch updates values in place; apply the new reset order once it ends. + var ordered = snapshot.ByReset().ToArray(); + if (keepOrder) + { + var positions = _order.Select((id, index) => (id, index)).ToDictionary(p => p.id, p => p.index, StringComparer.Ordinal); + ordered = ordered.OrderBy(row => positions.GetValueOrDefault(row.Account.Id, int.MaxValue)).ToArray(); + } + var nextOrder = ordered.Select(r => r.Account.Id).ToArray(); + // Identity colors stay attached to the account when reset order changes. + foreach (var account in snapshot.Accounts) + if (!_identitySlots.ContainsKey(account.Account.Id)) _identitySlots[account.Account.Id] = _nextSlot++; + var changed = !_order.SequenceEqual(nextOrder); + using var layout = AccountUiTheme.DeferLayout(this); + var scale = DeviceDpi / 96f; + _headings.Height = (int)(28 * scale); ScaleColumns(_headings, scale, 0); + if (changed) + { + var ids = nextOrder.ToHashSet(StringComparer.Ordinal); + foreach (var removed in _controls.Keys.Where(id => !ids.Contains(id)).ToArray()) { _controls[removed].Dispose(); _controls.Remove(removed); } + _rows.RowCount = ordered.Length; _rows.RowStyles.Clear(); + } + for (var i = 0; i < ordered.Length; i++) { var value = ordered[i]; var id = value.Account.Id; if (!_controls.TryGetValue(id, out var row)) { - row = new AccountRow(id); + row = new AccountRow(id, _identitySlots[id]); row.Switch.Click += (_, _) => { SelectId(id); SwitchRequested?.Invoke(id); }; row.Manage.Click += (_, _) => { SelectId(id); ManageRequested?.Invoke(id, row.Manage); }; _controls.Add(id, row); _rows.Controls.Add(row); } - row.TabIndex = i; _rows.SetCellPosition(row, new TableLayoutPanelCellPosition(0, i)); _rows.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + if (changed) + { + row.TabIndex = i; _rows.SetCellPosition(row, new TableLayoutPanelCellPosition(0, i)); + _rows.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + } row.Display(value, busy, mutationsAllowed, hasActive); } - _rows.ResumeLayout(true); + _order = nextOrder; } private void SelectId(string id) => SelectedIndex = Items.FindIndex(a => a.Id == id); + private static void SetColumns(TableLayoutPanel panel, int verticalInset) + { + panel.ColumnCount = 5; panel.RowCount = 1; panel.Padding = new Padding(12, verticalInset, 8, verticalInset); + panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + foreach (var percent in new[] { 36, 28, 36 }) panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, percent)); + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 94)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 34)); + } + private static void ScaleColumns(TableLayoutPanel panel, float scale, int verticalInset) + { + panel.Padding = new Padding((int)(12 * scale), (int)(verticalInset * scale), (int)(8 * scale), (int)(verticalInset * scale)); + panel.ColumnStyles[3].Width = 94 * scale; panel.ColumnStyles[4].Width = 34 * scale; + } private sealed class AccountRow : UserControl { private readonly Label _name = AccountUiTheme.Label("", 12, true); - private readonly Label _state = AccountUiTheme.Label("", color: AccountUiTheme.Muted); private readonly Label _remaining = AccountUiTheme.Label("", 18, true); - private readonly Label _reset = AccountUiTheme.Label(""); + private readonly Label _reset = AccountUiTheme.Label("", 11, true); private readonly Label _relative = AccountUiTheme.Label("", color: AccountUiTheme.Muted); private readonly Label _observed = AccountUiTheme.Label("", 8.5f, color: AccountUiTheme.Muted); private readonly AccountUsageBar _bar = new() { Dock = DockStyle.Fill }; + private readonly AccountRowLayout _layout; + private readonly TableLayoutPanel _identity, _usage, _resetStack, _action; + private readonly AccountIdentityTile _tile; + private readonly ToolTip _details = new() { InitialDelay = 500, ReshowDelay = 100, AutoPopDelay = 10000, ShowAlways = true }; internal Button Switch { get; } internal Button Manage { get; } - internal AccountRow(string id) + internal AccountRow(string id, int slot) { AutoScaleMode = AutoScaleMode.Dpi; AutoScaleDimensions = new SizeF(96, 96); - Name = "AccountRow-" + id; Dock = DockStyle.Fill; AutoSize = false; Height = 92; Margin = new Padding(0, 0, 0, 2); BackColor = AccountUiTheme.Surface; - Switch = AccountUiTheme.Button("SwitchAccount-" + id, "전환"); Manage = AccountUiTheme.Button("ManageAccount-" + id, "···"); - Switch.AutoSize = Manage.AutoSize = false; Switch.Size = Switch.MinimumSize = new Size(72, 34); Manage.Size = Manage.MinimumSize = new Size(38, 34); Manage.Padding = new Padding(4); - Switch.Anchor = Manage.Anchor = AnchorStyles.Right; - var layout = new TableLayoutPanel { Dock = DockStyle.Fill, AutoSize = false, ColumnCount = 5, RowCount = 1, Padding = new Padding(20, 12, 16, 12), Margin = new Padding(0) }; - foreach (var percent in new[] { 37, 25, 38 }) layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, percent)); - layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); - var identity = Stack(_name, _state, _observed); identity.Margin = new Padding(0, 0, 14, 0); - var usage = AccountUiTheme.Stack(3); _remaining.Dock = DockStyle.Fill; _remaining.AutoSize = false; usage.Margin = new Padding(0, 0, 30, 0); - foreach (var h in new[] { 34, 5, 26 }) usage.RowStyles.Add(new RowStyle(SizeType.Percent, h)); + Name = "AccountRow-" + id; Dock = DockStyle.Fill; AutoSize = false; Height = 88; Margin = new Padding(0); BackColor = AccountUiTheme.Background; + _remaining.Font = AccountFonts.Create(18, semibold: true); + Switch = AccountUiTheme.Button("SwitchAccount-" + id, UiText.T("전환 ↗")); + Manage = new AccountButton { Quiet = true, Name = "ManageAccount-" + id, Text = "···", Font = AccountFonts.Create(14, true), FlatStyle = FlatStyle.Flat, FlatAppearance = { BorderSize = 0 }, Cursor = Cursors.Hand }; + Switch.AutoSize = Manage.AutoSize = false; Switch.Size = Switch.MinimumSize = new Size(80, 30); Manage.Size = Manage.MinimumSize = new Size(32, 30); Manage.Padding = new Padding(0); + Switch.Anchor = AnchorStyles.None; Manage.Anchor = AnchorStyles.Top | AnchorStyles.Right; + var layout = _layout = new AccountRowLayout { Dock = DockStyle.Fill, AutoSize = false, Margin = new Padding(0) }; + SetColumns(layout, 16); + var tiles = new[] { Color.FromArgb(240, 226, 216), Color.FromArgb(214, 222, 234), Color.FromArgb(235, 221, 204) }; + var inks = new[] { Color.FromArgb(234, 137, 104), Color.FromArgb(120, 150, 187), Color.FromArgb(201, 161, 108) }; + var tile = _tile = new AccountIdentityTile { Text = (slot + 1).ToString("00"), TileColor = tiles[slot % tiles.Length], Size = new Size(42, 46), Anchor = AnchorStyles.Left, Margin = new Padding(0) }; + var identity = _identity = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, Margin = new Padding(0, 0, 18, 0) }; + identity.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + identity.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 58)); identity.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + _name.AutoSize = false; _name.AutoEllipsis = true; _name.Anchor = AnchorStyles.Left | AnchorStyles.Right; _name.TextAlign = ContentAlignment.MiddleLeft; + identity.Controls.Add(tile, 0, 0); identity.Controls.Add(_name, 1, 0); + var usage = _usage = AccountUiTheme.Stack(3); _remaining.Dock = DockStyle.Fill; _remaining.AutoSize = false; usage.Margin = new Padding(0, 0, 30, 0); + _remaining.AutoEllipsis = true; _remaining.TextAlign = ContentAlignment.TopLeft; + usage.RowStyles.Add(new RowStyle(SizeType.Absolute, 35)); usage.RowStyles.Add(new RowStyle(SizeType.Absolute, 5)); usage.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + _bar.Margin = new Padding(0); _bar.FillColor = inks[slot % inks.Length]; usage.Controls.Add(_remaining, 0, 0); usage.Controls.Add(_bar, 0, 1); - var reset = Stack(_reset, _relative); reset.Margin = new Padding(0, 0, 12, 0); + var reset = _resetStack = Stack(_reset, _relative); reset.Margin = new Padding(0, 0, 12, 0); layout.Controls.Add(identity, 0, 0); layout.Controls.Add(usage, 1, 0); layout.Controls.Add(reset, 2, 0); - Switch.Margin = new Padding(0, 0, 8, 0); layout.Controls.Add(Switch, 3, 0); layout.Controls.Add(Manage, 4, 0); Controls.Add(layout); + var action = _action = AccountUiTheme.Stack(2); action.Margin = new Padding(0, 0, 8, 0); + action.RowStyles.Add(new RowStyle(SizeType.Absolute, 32)); action.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + _observed.Dock = DockStyle.Fill; _observed.AutoSize = false; _observed.AutoEllipsis = true; _observed.TextAlign = ContentAlignment.MiddleCenter; + action.Controls.Add(Switch, 0, 0); action.Controls.Add(_observed, 0, 1); + Switch.Margin = Manage.Margin = new Padding(0); layout.Controls.Add(action, 3, 0); layout.Controls.Add(Manage, 4, 0); Controls.Add(layout); } private static TableLayoutPanel Stack(params Label[] labels) { @@ -83,22 +143,45 @@ private static TableLayoutPanel Stack(params Label[] labels) } internal void Display(CombinedAccountUsage value, bool busy, bool mutationsAllowed, bool hasActive) { + using var layout = AccountUiTheme.DeferLayout(this); var scale = DeviceDpi / 96f; Height = (int)Math.Round(88 * scale); - Switch.Size = Switch.MinimumSize = new Size((int)(72 * scale), (int)(34 * scale)); - Manage.Size = Manage.MinimumSize = new Size((int)(38 * scale), (int)(34 * scale)); + ScaleColumns(_layout, scale, 16); + _identity.ColumnStyles[0].Width = 58 * scale; _identity.Margin = new Padding(0, 0, (int)(18 * scale), 0); + _name.Height = (int)(30 * scale); + _tile.Size = new Size((int)(42 * scale), (int)(46 * scale)); + _usage.Margin = new Padding(0, 0, (int)(30 * scale), 0); + _usage.RowStyles[0].Height = 35 * scale; _usage.RowStyles[1].Height = 5 * scale; + _resetStack.Margin = new Padding(0, 0, (int)(12 * scale), 0); + _action.RowStyles[0].Height = 32 * scale; _action.Margin = new Padding(0, 0, (int)(8 * scale), 0); + Switch.Size = Switch.MinimumSize = new Size((int)(80 * scale), (int)(30 * scale)); + Manage.Size = Manage.MinimumSize = new Size((int)(32 * scale), (int)(30 * scale)); var now = DateTimeOffset.Now; var a = value.Account; var expired = a.Usage?.ResetsAt is { } at && at <= now; - _name.Text = a.Label; _state.Text = a.IsActive ? "● 사용 중" : "저장된 계정"; _state.ForeColor = a.IsActive ? AccountUiTheme.Accent : AccountUiTheme.Muted; - _observed.Text = value.State == CombinedReadState.Success && a.ObservedAt is { } observed ? $"{observed.ToLocalTime():MM/dd HH:mm} 확인" : - (a.ObservedAt is { } savedAt ? $"{savedAt.ToLocalTime():MM/dd HH:mm} 값 · " : "") + value.Status(now); + _layout.Active = a.IsActive; + _layout.Invalidate(); + _name.Text = a.Label; + _observed.Text = value.State == CombinedReadState.Success && a.ObservedAt is { } observed + ? UiText.F($"{observed.ToLocalTime().ToString(observed.LocalDateTime.Date == now.LocalDateTime.Date ? "HH:mm" : "MM.dd")} 확인") : value.State switch + { + CombinedReadState.Failed => UiText.T("조회 실패"), CombinedReadState.Canceled => UiText.T("조회 취소됨"), + CombinedReadState.Waiting => UiText.T("확인 대기"), _ => UiText.T("저장된 값") + }; + var details = value.Status(now) + (a.ObservedAt is { } savedAt ? UiText.F($"\n마지막 확인 {savedAt.ToLocalTime():MM.dd HH:mm}") : ""); + // The name label already exposes its full text through AutoEllipsis; + // attaching another tooltip there would create competing popups. + _details.SetToolTip(_observed, details); _observed.AccessibleDescription = details; _observed.ForeColor = value.State is CombinedReadState.Failed or CombinedReadState.Canceled ? AccountUiTheme.Warning : AccountUiTheme.Muted; - _remaining.Text = expired ? "갱신 필요" : value.HasWeeklyValue ? $"{(value.State != CombinedReadState.Success ? "이전 " : "")}{100 - a.Usage!.UsedPercent}%" : "—"; + _remaining.Text = expired ? UiText.T("갱신 필요") : !value.HasWeeklyValue ? "—" + : value.State != CombinedReadState.Success ? UiText.F($"이전 {100 - a.Usage!.UsedPercent}%") : $"{100 - a.Usage!.UsedPercent}%"; _remaining.ForeColor = value.IsCurrent(now) ? AccountUiTheme.Text : AccountUiTheme.Muted; _bar.Remaining = !expired && value.HasWeeklyValue ? 100 - a.Usage!.UsedPercent : null; - _reset.Text = a.Usage?.ResetsAt is { } reset ? $"{reset.ToLocalTime():MM/dd HH:mm}" : "초기화 미확인"; - _relative.Text = a.Usage?.ResetsAt is { } r ? CombinedUsagePanel.TimeUntil(r, now) : "전체 갱신으로 확인"; - Switch.Text = a.IsActive ? "사용 중" : "전환"; Switch.Enabled = !busy && mutationsAllowed && hasActive && !a.IsActive; - Switch.AccessibleName = a.Label + (a.IsActive ? " 사용 중" : " 계정으로 전환"); Manage.Enabled = !busy; Manage.AccessibleName = a.Label + " 계정 관리 메뉴"; + _reset.Text = a.Usage?.ResetsAt is { } reset ? $"{reset.ToLocalTime():MM.dd HH:mm}" : UiText.T("초기화 미확인"); + _relative.Text = a.Usage?.ResetsAt is { } r ? CombinedUsagePanel.TimeUntil(r, now) : UiText.T("전체 갱신으로 확인"); + ((AccountButton)Switch).ActiveBadge = a.IsActive; + Switch.Text = a.IsActive ? UiText.T("사용 중") : UiText.T("전환 ↗"); Switch.Enabled = !busy && mutationsAllowed && hasActive && !a.IsActive; + Switch.AccessibleName = a.IsActive ? UiText.F($"{a.Label} 사용 중") : UiText.F($"{a.Label} 계정으로 전환"); + Manage.Enabled = !busy; Manage.AccessibleName = UiText.F($"{a.Label} 계정 관리 메뉴"); } + protected override void Dispose(bool disposing) { if (disposing) _details.Dispose(); base.Dispose(disposing); } } } diff --git a/src/AccountUiTheme.cs b/src/AccountUiTheme.cs index b449842..b6f7428 100644 --- a/src/AccountUiTheme.cs +++ b/src/AccountUiTheme.cs @@ -4,36 +4,42 @@ namespace WeeklyUsageIndicator; internal static class AccountUiTheme { - internal static readonly Color Background = Color.FromArgb(246, 247, 244); - internal static readonly Color Surface = Color.FromArgb(255, 255, 253); - internal static readonly Color Raised = Color.FromArgb(233, 237, 232); - internal static readonly Color Border = Color.FromArgb(210, 218, 211); - internal static readonly Color Text = Color.FromArgb(34, 43, 37); - internal static readonly Color Muted = Color.FromArgb(94, 108, 99); - internal static readonly Color Accent = Color.FromArgb(34, 115, 86); + internal static readonly Color Background = Color.FromArgb(251, 250, 248); + internal static readonly Color Surface = Color.FromArgb(255, 254, 252); + internal static readonly Color Raised = Color.FromArgb(235, 232, 232); + internal static readonly Color Border = Color.FromArgb(225, 220, 223); + internal static readonly Color Text = Color.FromArgb(44, 28, 39); + internal static readonly Color Muted = Color.FromArgb(112, 104, 113); + internal static readonly Color Accent = Color.FromArgb(64, 46, 56); + internal static readonly Color Apricot = Color.FromArgb(234, 152, 111); + internal static readonly Color Blue = Color.FromArgb(194, 205, 226); + internal static readonly Color Butter = Color.FromArgb(240, 213, 155); + internal static readonly Color ActiveSurface = Color.FromArgb(244, 241, 239); internal static readonly Color Error = Color.FromArgb(171, 52, 42); internal static readonly Color Warning = Color.FromArgb(143, 86, 13); internal static Label Label(string text, float size = 9.5f, bool bold = false, Color? color = null) => new() { Text = text, AutoSize = true, ForeColor = color ?? Text, - Font = new Font("맑은 고딕", size, bold ? FontStyle.Bold : FontStyle.Regular), + Font = AccountFonts.Create(size, bold), BackColor = Color.Transparent, Margin = new Padding(0), UseMnemonic = false }; - internal static Button Button(string name, string text, bool primary = false) => new AccountButton() + internal static Button Button(string name, string text, bool primary = false) => new AccountButton { + Primary = primary, Name = name, Text = text, AutoSize = true, MinimumSize = new Size(92, 38), Padding = new Padding(12, 4, 12, 4), Margin = new Padding(0), - FlatStyle = FlatStyle.Flat, BackColor = primary ? Accent : Raised, + Font = AccountFonts.Create(9.5f, semibold: true), + FlatStyle = FlatStyle.Flat, BackColor = primary ? Accent : Surface, ForeColor = primary ? Background : Text, Cursor = Cursors.Hand, - UseVisualStyleBackColor = false, FlatAppearance = { BorderSize = 0, MouseOverBackColor = primary ? Color.FromArgb(26, 98, 72) : Color.FromArgb(220, 228, 221) } + UseVisualStyleBackColor = false, FlatAppearance = { BorderSize = 0 } }; internal static void SetForm(Form form) { form.AutoScaleMode = AutoScaleMode.Dpi; - form.Font = new Font("맑은 고딕", 9.5f); + form.Font = AccountFonts.Create(9.5f); form.AutoScaleDimensions = new SizeF(96, 96); form.BackColor = Background; form.ForeColor = Text; @@ -44,12 +50,56 @@ internal static void SetForm(Form form) { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = rows, ColumnStyles = { new ColumnStyle(SizeType.Percent, 100) }, - Margin = new Padding(0), Padding = new Padding(0) + Margin = new Padding(0), Padding = new Padding(0), BackColor = Color.Transparent }; + + // Suspending only the outer panel leaves nested tables free to resize their + // children on every text change. Resume from the inside out once values agree. + internal static IDisposable DeferLayout(Control root) => new LayoutBatch(root); + private sealed class LayoutBatch : IDisposable + { + private readonly List _controls = new(); + internal LayoutBatch(Control root) => Suspend(root); + private void Suspend(Control control) + { + control.SuspendLayout(); _controls.Add(control); + foreach (Control child in control.Controls) Suspend(child); + } + public void Dispose() + { + for (var i = _controls.Count - 1; i >= 0; i--) + if (!_controls[i].IsDisposed) _controls[i].ResumeLayout(true); + } + } + + internal static GraphicsPath Rounded(RectangleF bounds, float radius) + { + var path = new GraphicsPath(); var diameter = Math.Min(radius * 2, Math.Min(bounds.Width, bounds.Height)); + if (diameter <= 0) { path.AddRectangle(bounds); return path; } + path.AddArc(bounds.Left, bounds.Top, diameter, diameter, 180, 90); + path.AddArc(bounds.Right - diameter, bounds.Top, diameter, diameter, 270, 90); + path.AddArc(bounds.Right - diameter, bounds.Bottom - diameter, diameter, diameter, 0, 90); + path.AddArc(bounds.Left, bounds.Bottom - diameter, diameter, diameter, 90, 90); + path.CloseFigure(); return path; + } } internal sealed class AccountButton : Button { + protected override void OnEnabledChanged(EventArgs e) + { + // Keep native Enabled/accessibility behavior, but defer the repaint until + // the message loop. Control.OnEnabledChanged otherwise calls UpdateWindow + // synchronously while the manager is still arranging the paper surfaces. + SetStyle(ControlStyles.UserPaint, false); + try { base.OnEnabledChanged(e); } + finally { SetStyle(ControlStyles.UserPaint, true); Invalidate(); } + } + internal bool Primary { get; init; } + internal bool Quiet { get; init; } + internal bool ActiveBadge { get; set; } + private bool _hover, _pressed; + internal AccountButton() => SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); public override Size GetPreferredSize(Size proposedSize) { var preferred = base.GetPreferredSize(proposedSize); @@ -58,35 +108,153 @@ public override Size GetPreferredSize(Size proposedSize) protected override void OnPaint(PaintEventArgs e) { - if (Enabled) { base.OnPaint(e); return; } - e.Graphics.Clear(AccountUiTheme.Raised); - TextRenderer.DrawText(e.Graphics, Text, Font, ClientRectangle, AccountUiTheme.Muted, + var scale = DeviceDpi / 96f; var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; + // Paint the parent first so eased corners remain clean on every paper tone. + ButtonRenderer.DrawParentBackground(g, ClientRectangle, this); + var bounds = new RectangleF(scale, scale, Width - 2 * scale, Height - 3 * scale); + using var shape = AccountUiTheme.Rounded(bounds, (ActiveBadge ? 14 : 5) * scale); + var fill = ActiveBadge ? Color.FromArgb(251, 224, 213) : !Enabled ? AccountUiTheme.Raised : Primary + ? (_pressed ? Color.FromArgb(47, 33, 41) : _hover ? Color.FromArgb(87, 63, 77) : AccountUiTheme.Accent) + : (_pressed ? AccountUiTheme.Blue : _hover ? Color.FromArgb(243, 237, 229) : AccountUiTheme.Surface); + if (!Quiet || (_hover && Enabled)) + { + using var brush = new SolidBrush(fill); g.FillPath(brush, shape); + if (!ActiveBadge && !Quiet) + { + using var border = new Pen(Primary && Enabled ? fill : Color.FromArgb(181, 166, 179), scale); g.DrawPath(border, shape); + } + } + if (Focused && ShowFocusCues) + { + using var focus = new Pen(Primary ? AccountUiTheme.Butter : AccountUiTheme.Accent, 1.5f * scale); + using var ring = AccountUiTheme.Rounded(RectangleF.Inflate(bounds, -3 * scale, -3 * scale), 3 * scale); g.DrawPath(focus, ring); + } + TextRenderer.DrawText(g, Text, Font, Rectangle.Round(bounds), ActiveBadge ? Color.FromArgb(123, 65, 49) : !Enabled ? AccountUiTheme.Muted : Primary ? AccountUiTheme.Surface : AccountUiTheme.Text, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + } + protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); } + protected override void OnMouseLeave(EventArgs e) { _hover = _pressed = false; Invalidate(); base.OnMouseLeave(e); } + protected override void OnMouseDown(MouseEventArgs e) { if (e.Button == MouseButtons.Left) _pressed = true; Invalidate(); base.OnMouseDown(e); } + protected override void OnMouseUp(MouseEventArgs e) { _pressed = false; Invalidate(); base.OnMouseUp(e); } + protected override void OnKeyDown(KeyEventArgs e) { if (e.KeyCode == Keys.Space) { _pressed = true; Invalidate(); } base.OnKeyDown(e); } + protected override void OnKeyUp(KeyEventArgs e) { _pressed = false; Invalidate(); base.OnKeyUp(e); } + protected override void OnLostFocus(EventArgs e) { _pressed = false; Invalidate(); base.OnLostFocus(e); } + protected override void OnGotFocus(EventArgs e) { Invalidate(); base.OnGotFocus(e); } +} + +// Flat surfaces use rules and a single active-row tint, rather than nested cards. +internal sealed class AccountSummaryLayout : TableLayoutPanel +{ + internal AccountSummaryLayout() => DoubleBuffered = true; + protected override void OnPaintBackground(PaintEventArgs e) + { + base.OnPaintBackground(e); + var scale = DeviceDpi / 96f; + using var rule = new Pen(AccountUiTheme.Border, scale); + e.Graphics.DrawLine(rule, 0, Height - scale, Width, Height - scale); + var x = 0; + foreach (var width in GetColumnWidths().Take(2)) + { + x += width; + e.Graphics.DrawLine(rule, x, 6 * scale, x, Height - 28 * scale); + } + } +} + +internal sealed class AccountRowLayout : TableLayoutPanel +{ + internal bool Active { get; set; } + internal AccountRowLayout() { DoubleBuffered = true; BackColor = Color.Transparent; } + protected override void OnPaintBackground(PaintEventArgs e) + { + base.OnPaintBackground(e); + var scale = DeviceDpi / 96f; + if (Active) + { + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + using var shape = AccountUiTheme.Rounded(new RectangleF(0, 0, Width, Height), 5 * scale); + using var fill = new SolidBrush(AccountUiTheme.ActiveSurface); e.Graphics.FillPath(fill, shape); + } + using var rule = new Pen(AccountUiTheme.Border, scale); + e.Graphics.DrawLine(rule, 0, Height - scale, Width, Height - scale); + } +} + +internal sealed class AccountIdentityTile : Control +{ + internal Color TileColor { get; set; } + internal AccountIdentityTile() + { + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.SupportsTransparentBackColor, true); + BackColor = Color.Transparent; Font = AccountFonts.Create(12, semibold: true); + } + protected override void OnPaint(PaintEventArgs e) + { + var scale = DeviceDpi / 96f; e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + using var shape = AccountUiTheme.Rounded(new RectangleF(0, 0, Width, Height), 8 * scale); + using var fill = new SolidBrush(TileColor); e.Graphics.FillPath(fill, shape); + TextRenderer.DrawText(e.Graphics, Text, Font, ClientRectangle, AccountUiTheme.Text, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPrefix); } } +internal sealed class AccountInputFrame : Panel +{ + private readonly TextBox _input; + internal AccountInputFrame(TextBox input) + { + _input = input; Dock = DockStyle.Fill; Margin = new Padding(0); Padding = new Padding(10, 8, 10, 6); + BackColor = AccountUiTheme.Surface; DoubleBuffered = true; + input.BorderStyle = BorderStyle.None; input.BackColor = AccountUiTheme.Surface; + input.GotFocus += (_, _) => Invalidate(); input.LostFocus += (_, _) => Invalidate(); + Controls.Add(input); + } + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); var scale = DeviceDpi / 96f; + using var pen = new Pen(_input.Focused ? AccountUiTheme.Accent : AccountUiTheme.Border, (_input.Focused ? 1.5f : 1) * scale); + e.Graphics.DrawRectangle(pen, scale, scale, Width - 2 * scale, Height - 2 * scale); + } +} + +internal sealed class AccountMenuColors : ProfessionalColorTable +{ + public override Color ToolStripDropDownBackground => AccountUiTheme.Surface; + public override Color MenuBorder => AccountUiTheme.Border; + public override Color MenuItemSelected => AccountUiTheme.Blue; + public override Color MenuItemBorder => AccountUiTheme.Blue; + public override Color SeparatorDark => AccountUiTheme.Border; + public override Color SeparatorLight => AccountUiTheme.Surface; +} + internal sealed class AccountUsageBar : Control { private int? _remaining; internal int? Remaining { get => _remaining; set { _remaining = value; Invalidate(); } } + internal Color FillColor { get; set; } = AccountUiTheme.Accent; internal AccountUsageBar() { - SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); - Height = 8; - MinimumSize = new Size(30, 8); - AccessibleName = "주간 잔여 사용량"; + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.SupportsTransparentBackColor, true); + BackColor = Color.Transparent; + Height = 5; + MinimumSize = new Size(30, 5); + AccessibleName = UiText.T("주간 잔여 사용량"); } protected override void OnPaint(PaintEventArgs e) { e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; - using var track = new SolidBrush(AccountUiTheme.Border); - e.Graphics.FillRectangle(track, ClientRectangle); + var bounds = new RectangleF(0, 0, Width, Height); + using var shape = AccountUiTheme.Rounded(bounds, Height / 2f); + using var track = new SolidBrush(AccountUiTheme.Raised); + e.Graphics.FillPath(track, shape); if (_remaining is { } remaining) { - using var fill = new SolidBrush(remaining <= 15 ? AccountUiTheme.Warning : AccountUiTheme.Accent); + using var fill = new SolidBrush(remaining <= 15 ? AccountUiTheme.Warning : FillColor); + var state = e.Graphics.Save(); e.Graphics.SetClip(shape); e.Graphics.FillRectangle(fill, 0, 0, Width * Math.Clamp(remaining, 0, 100) / 100f, Height); + e.Graphics.Restore(state); } } } diff --git a/src/Assets/Fonts/OFL.txt b/src/Assets/Fonts/OFL.txt new file mode 100644 index 0000000..ae8d98d --- /dev/null +++ b/src/Assets/Fonts/OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard), +with Reserved Font Name Pretendard. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/Assets/Fonts/Pretendard-Bold.ttf b/src/Assets/Fonts/Pretendard-Bold.ttf new file mode 100644 index 0000000..fb07fc6 Binary files /dev/null and b/src/Assets/Fonts/Pretendard-Bold.ttf differ diff --git a/src/Assets/Fonts/Pretendard-Regular.ttf b/src/Assets/Fonts/Pretendard-Regular.ttf new file mode 100644 index 0000000..01147e9 Binary files /dev/null and b/src/Assets/Fonts/Pretendard-Regular.ttf differ diff --git a/src/Assets/Fonts/Pretendard-SemiBold.ttf b/src/Assets/Fonts/Pretendard-SemiBold.ttf new file mode 100644 index 0000000..9f2690f Binary files /dev/null and b/src/Assets/Fonts/Pretendard-SemiBold.ttf differ diff --git a/src/CombinedUsagePanel.cs b/src/CombinedUsagePanel.cs index 9b039da..ce15c93 100644 --- a/src/CombinedUsagePanel.cs +++ b/src/CombinedUsagePanel.cs @@ -3,62 +3,89 @@ namespace WeeklyUsageIndicator; internal sealed class CombinedUsagePanel : UserControl { private readonly Label _total = AccountUiTheme.Label("—", 34, true); + private readonly Label _capacity = AccountUiTheme.Label("", 17, color: AccountUiTheme.Muted); private readonly Label _coverage = AccountUiTheme.Label("", color: AccountUiTheme.Muted); - private readonly Label _next = AccountUiTheme.Label("—", 18, true); + private readonly Label _next = AccountUiTheme.Label("—", 23, true); private readonly Label _nextAccount = AccountUiTheme.Label("", color: AccountUiTheme.Muted); private readonly Label _stamp = AccountUiTheme.Label("", color: AccountUiTheme.Muted); - private readonly Label _last = AccountUiTheme.Label("", color: AccountUiTheme.Muted); + private readonly Label _last = AccountUiTheme.Label("", 20, true); + private readonly Label _lastTitle = AccountUiTheme.Label(UiText.T("가장 늦은 초기화"), color: AccountUiTheme.Muted); + private readonly TableLayoutPanel _totalLine; + private readonly AccountSummaryLayout _layout; internal CombinedUsagePanel() { AutoScaleMode = AutoScaleMode.Dpi; AutoScaleDimensions = new SizeF(96, 96); - Name = "CombinedUsagePanel"; BackColor = AccountUiTheme.Surface; + Name = "CombinedUsagePanel"; BackColor = AccountUiTheme.Background; + _total.Font = AccountFonts.Create(34, semibold: true); _next.Font = AccountFonts.Create(23, semibold: true); _total.Name = "CombinedRemainingLabel"; _coverage.Name = "CombinedCoverageLabel"; _next.Name = "NextResetLabel"; _stamp.Name = "CombinedCheckedAtLabel"; _last.Name = "LatestResetLabel"; - var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 4, - Padding = new Padding(24, 18, 24, 16), Margin = new Padding(0) }; - layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); - foreach (var height in new[] { 24, 60, 28, 26 }) layout.RowStyles.Add(new RowStyle(SizeType.Percent, height)); - layout.Controls.Add(AccountUiTheme.Label("전체 주간 잔여", color: AccountUiTheme.Muted), 0, 0); - layout.Controls.Add(AccountUiTheme.Label("다음 초기화", color: AccountUiTheme.Muted), 1, 0); - layout.Controls.Add(_total, 0, 1); layout.Controls.Add(_next, 1, 1); - layout.Controls.Add(_coverage, 0, 2); layout.Controls.Add(_nextAccount, 1, 2); - layout.Controls.Add(_stamp, 0, 3); layout.Controls.Add(_last, 1, 3); - foreach (var label in new[] { _total, _next, _coverage, _nextAccount, _stamp, _last }) + var layout = _layout = new AccountSummaryLayout { Dock = DockStyle.Fill, ColumnCount = 3, RowCount = 1, + Padding = new Padding(0, 0, 0, 26), Margin = new Padding(0) }; + foreach (var width in new[] { 36, 36, 28 }) layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, width)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + var totalLine = _totalLine = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, Margin = new Padding(0) }; + totalLine.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + totalLine.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 150)); totalLine.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + _total.AutoSize = false; _total.Dock = DockStyle.Fill; _total.TextAlign = ContentAlignment.MiddleLeft; _capacity.Dock = DockStyle.Fill; + _capacity.AutoSize = false; _capacity.TextAlign = ContentAlignment.MiddleLeft; _capacity.Padding = new Padding(4, 10, 0, 0); + totalLine.Controls.Add(_total, 0, 0); totalLine.Controls.Add(_capacity, 1, 0); + layout.Controls.Add(Column(AccountUiTheme.Label(UiText.T("전체 주간 잔여"), color: AccountUiTheme.Muted), totalLine, _coverage, 0), 0, 0); + layout.Controls.Add(Column(AccountUiTheme.Label(UiText.T("다음 초기화"), color: AccountUiTheme.Muted), _next, _nextAccount, 28), 1, 0); + layout.Controls.Add(Column(_lastTitle, _last, _stamp, 28), 2, 0); + foreach (var label in new[] { _next, _coverage, _nextAccount, _stamp, _last, _lastTitle }) { label.Dock = DockStyle.Fill; label.AutoSize = false; label.AutoEllipsis = true; label.TextAlign = ContentAlignment.MiddleLeft; } Controls.Add(layout); } + private static TableLayoutPanel Column(Label title, Control value, Label detail, int inset) + { + var column = AccountUiTheme.Stack(3); column.Padding = new Padding(inset, 4, 10, 0); + title.Dock = DockStyle.Fill; title.AutoSize = false; title.TextAlign = ContentAlignment.MiddleLeft; + column.RowStyles.Add(new RowStyle(SizeType.Absolute, 26)); column.RowStyles.Add(new RowStyle(SizeType.Absolute, 68)); + column.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + column.Controls.Add(title, 0, 0); column.Controls.Add(value, 0, 1); column.Controls.Add(detail, 0, 2); + return column; + } internal void Display(CombinedUsageSnapshot snapshot, bool busy, int completed = 0, bool querying = false) { var now = DateTimeOffset.Now; var complete = snapshot.IsComplete(now); var count = snapshot.ConfirmedCount(now); _total.Text = complete ? $"{snapshot.ConfirmedRemaining(now)}%" : "—"; + _totalLine.ColumnStyles[0].Width = TextRenderer.MeasureText(_total.Text, _total.Font).Width; + _layout.Padding = new Padding(0, 0, 0, (int)(26 * DeviceDpi / 96f)); + _capacity.Text = snapshot.Accounts.Count > 0 ? $"/ {snapshot.Accounts.Count * 100}%" : ""; _total.ForeColor = complete ? AccountUiTheme.Accent : AccountUiTheme.Muted; - _coverage.Text = complete ? $"총 {snapshot.Accounts.Count * 100}% 중 · 계정당 100% 기준" : !snapshot.IsBatch ? (snapshot.CompletedAt is null ? "저장된 값 · 전체 갱신 필요" : "개별 확인값 · 전체 갱신 필요") : count > 0 - ? $"확인된 잔여 {snapshot.ConfirmedRemaining(now)}% · {count}/{snapshot.Accounts.Count}개" : "전체 합계 확인 필요"; + _coverage.Text = complete ? UiText.F($"{snapshot.Accounts.Count}개 계정 · 계정당 100% 기준") : !snapshot.IsBatch ? (snapshot.CompletedAt is null ? UiText.T("저장된 값 · 전체 갱신 필요") : UiText.T("개별 확인값 · 전체 갱신 필요")) : count > 0 + ? UiText.F($"확인된 잔여 {snapshot.ConfirmedRemaining(now)}% · {count}/{snapshot.Accounts.Count}개") : UiText.T("전체 합계 확인 필요"); _coverage.ForeColor = complete ? AccountUiTheme.Muted : AccountUiTheme.Warning; var dated = snapshot.ByReset().Where(row => row.IsCurrent(now)).ToArray(); var next = dated.FirstOrDefault(); - _next.Text = next?.Account.Usage?.ResetsAt is { } reset ? TimeUntil(reset, now) : "확인 필요"; - _nextAccount.Text = next is null ? "계정별 확인 상태를 살펴보세요." : $"{next.Account.Label} · 잔여 {100 - next.Account.Usage!.UsedPercent}% · {next.Account.Usage.ResetsAt!.Value.ToLocalTime():MM/dd HH:mm}"; + _next.Text = next?.Account.Usage?.ResetsAt is { } reset ? TimeUntil(reset, now) : UiText.T("확인 필요"); + _nextAccount.Text = next is null ? UiText.T("계정별 확인 상태를 살펴보세요.") : $"{next.Account.Label} · {next.Account.Usage!.ResetsAt!.Value.ToLocalTime():MM.dd HH:mm}"; var uncertain = snapshot.ByReset().FirstOrDefault(row => !row.IsCurrent(now) && (row.Account.Usage?.ResetsAt is null || next is null || row.Account.Usage.ResetsAt <= next.Account.Usage!.ResetsAt)); if (uncertain is not null) { - _next.Text = "확인 필요"; - _nextAccount.Text = uncertain.Account.Label + (uncertain.Account.Usage?.ResetsAt is { } previous - ? $" · 이전 일정 {previous.ToLocalTime():MM/dd HH:mm}" : " · 초기화 시각 미확인"); + _next.Text = UiText.T("확인 필요"); + _nextAccount.Text = uncertain.Account.Usage?.ResetsAt is { } previous + ? UiText.F($"{uncertain.Account.Label} · 이전 일정 {previous.ToLocalTime():MM/dd HH:mm}") : UiText.F($"{uncertain.Account.Label} · 초기화 시각 미확인"); } var latest = dated.LastOrDefault()?.Account.Usage?.ResetsAt; - _last.Text = latest is { } last ? $"{(complete ? "가장 늦은 초기화" : "확인된 계정 중 마지막")} {last.ToLocalTime():MM/dd HH:mm}" : "초기화 시각은 계정마다 다릅니다."; - _stamp.Text = querying ? $"전체 조회 중 · {completed}/{snapshot.Accounts.Count}개 확인" : snapshot.MembershipChanged ? "계정 구성 변경 · 전체 갱신 필요" - : snapshot.WasCanceled ? "전체 조회 취소 · 다시 갱신해 주세요." : snapshot.Interrupted ? "전체 조회 중단 · 확인 필요" - : !snapshot.IsBatch && count > 0 ? "개별 확인 · 전체 합계 갱신 필요" - : snapshot.CompletedAt is { } checkedAt ? $"{(complete ? "전체 확인" : "일부 확인")} {checkedAt.ToLocalTime():MM/dd HH:mm} 기준" : "저장된 값 · 전체 갱신 필요"; + _lastTitle.Text = complete ? UiText.T("가장 늦은 초기화") : UiText.T("확인된 계정 중 마지막"); + _last.Text = latest is { } last ? $"{last.ToLocalTime():MM.dd HH:mm}" : "—"; + _stamp.Text = querying ? UiText.F($"전체 조회 중 · {completed}/{snapshot.Accounts.Count}개 확인") : snapshot.MembershipChanged ? UiText.T("계정 구성 변경 · 전체 갱신 필요") + : snapshot.WasCanceled ? UiText.T("전체 조회 취소 · 다시 갱신해 주세요.") : snapshot.Interrupted ? UiText.T("전체 조회 중단 · 확인 필요") + : !snapshot.IsBatch && count > 0 ? UiText.T("개별 확인 · 전체 합계 갱신 필요") + : snapshot.CompletedAt is { } checkedAt ? CheckedAtText(checkedAt, now, complete) : UiText.T("저장된 값 · 전체 갱신 필요"); + } + private static string CheckedAtText(DateTimeOffset checkedAt, DateTimeOffset now, bool complete) + { + var day = checkedAt.LocalDateTime.Date == now.LocalDateTime.Date ? UiText.T("오늘") : checkedAt.ToLocalTime().ToString("MM.dd"); + return complete ? UiText.F($"{day} {checkedAt.ToLocalTime():HH:mm}에 전체 확인") : UiText.F($"{day} {checkedAt.ToLocalTime():HH:mm}에 일부 확인"); } internal static string TimeUntil(DateTimeOffset reset, DateTimeOffset now) { var span = reset - now; - if (span <= TimeSpan.Zero) return "갱신 필요"; - if (span.TotalDays >= 1) return $"{(int)span.TotalDays}일 {span.Hours}시간 뒤"; - if (span.TotalHours >= 1) return $"{(int)span.TotalHours}시간 {span.Minutes}분 뒤"; - return $"{Math.Max(1, (int)Math.Ceiling(span.TotalMinutes))}분 뒤"; + if (span <= TimeSpan.Zero) return UiText.T("갱신 필요"); + if (span.TotalDays >= 1) return UiText.F($"{(int)span.TotalDays}일 {span.Hours}시간 뒤"); + if (span.TotalHours >= 1) return UiText.F($"{(int)span.TotalHours}시간 {span.Minutes}분 뒤"); + return UiText.F($"{Math.Max(1, (int)Math.Ceiling(span.TotalMinutes))}분 뒤"); } } diff --git a/src/CombinedUsageSnapshot.cs b/src/CombinedUsageSnapshot.cs index 72fdfe1..f074a7f 100644 --- a/src/CombinedUsageSnapshot.cs +++ b/src/CombinedUsageSnapshot.cs @@ -8,13 +8,13 @@ internal bool IsCurrent(DateTimeOffset now) => State == CombinedReadState.Succes Account.ObservedAt is not null && Account.Usage!.ResetsAt is { } reset && reset > now; internal string Status(DateTimeOffset now) { - if (State == CombinedReadState.Failed) return Error ?? "조회 실패"; - if (State == CombinedReadState.Canceled) return "조회 취소 · 이전 값"; - if (State == CombinedReadState.Waiting) return "이번 전체 조회에서 미확인"; - if (!HasWeeklyValue) return "주간 사용량 미확인"; - if (Account.Usage!.ResetsAt is null) return "초기화 시각 미확인"; - if (Account.Usage.ResetsAt <= now) return "초기화 시점 지남 · 갱신 필요"; - return State == CombinedReadState.Saved ? "저장된 값 · 전체 갱신 필요" : "확인 완료"; + if (State == CombinedReadState.Failed) return Error is { } error ? UiText.Message(error) : UiText.T("조회 실패"); + if (State == CombinedReadState.Canceled) return UiText.T("조회 취소 · 이전 값"); + if (State == CombinedReadState.Waiting) return UiText.T("이번 전체 조회에서 미확인"); + if (!HasWeeklyValue) return UiText.T("주간 사용량 미확인"); + if (Account.Usage!.ResetsAt is null) return UiText.T("초기화 시각 미확인"); + if (Account.Usage.ResetsAt <= now) return UiText.T("초기화 시점 지남 · 갱신 필요"); + return State == CombinedReadState.Saved ? UiText.T("저장된 값 · 전체 갱신 필요") : UiText.T("확인 완료"); } } diff --git a/src/Localization/accounts.en.json b/src/Localization/accounts.en.json new file mode 100644 index 0000000..bc9754e --- /dev/null +++ b/src/Localization/accounts.en.json @@ -0,0 +1,154 @@ +{ + "내 계정": "My accounts", + "+ 계정 추가": "+ Add account", + "전체 갱신": "Refresh all", + "현재 계정 등록": "Save current account", + "이름 변경": "Rename", + "이 계정 사용량 조회": "Refresh this account", + "저장된 로그인 삭제": "Remove saved login", + "저장된 계정 목록 다시 읽기": "Reload saved accounts", + "상세 정보": "Details", + "미완료 전환 복구": "Recover account switch", + "조회 취소": "Cancel refresh", + "Codex 계정 관리": "Codex accounts", + "계정 관리": "Accounts", + "초기화가 가까운 순": "Soonest reset first", + "ⓘ 창을 열 때 한 번 확인하며, 이후에는 직접 갱신합니다.": "ⓘ Refreshes once when opened. After that, refresh manually.", + "저장된 계정 목록을 다시 읽었습니다.": "Saved accounts reloaded.", + "요청을 취소하고 로그인 정보를 안전하게 정리하고 있습니다…": "Canceling the request and safely cleaning up sign-in data…", + "전체 조회를 취소하고 안전하게 정리한 뒤 닫습니다…": "Canceling the refresh. This window will close after cleanup…", + "진행 중인 작업이 있습니다. 취소 버튼으로 요청을 마친 뒤 닫아주세요.": "An operation is in progress. Cancel it before closing this window.", + "현재 계정을 등록하고 있습니다…": "Saving the current account…", + "‘{0}’ 등록 완료. 계정의 ··· 메뉴에서 이름을 바꿀 수 있습니다.": "Saved ‘{0}’. You can rename it from its ··· menu.", + "첫 계정을 등록하세요": "Save your first account", + "지금 사용하는 Codex 계정을 저장하면 시작할 수 있습니다.\n이름은 나중에 바꿀 수 있고, 현재 로그인은 유지됩니다.": "Start by saving the account you use in Codex.\nYou can rename it later. Your current sign-in stays active.", + "계정 상세": "Account details", + "5시간 잔여 {0}\n초기화 {1}": "5-hour remaining {0}\nResets {1}", + "개별 조회의 마지막 확인 {0:MM/dd HH:mm}\n전체 잔여량은 마지막 전체 확인 기준입니다.": "Last account check {0:MM/dd HH:mm}\nThe total reflects the last full refresh.", + "아직 사용량을 확인하지 않았습니다.": "Usage has not been checked yet.", + "임시 파일 정리": "Clean up temporary files", + "중단된 조회 복구": "Recover interrupted refresh", + "계정 {0}": "Account {0}", + "이름 변경을 취소했습니다.": "Rename canceled.", + "계정 이름을 변경했습니다.": "Account renamed.", + "계정 추가를 취소했습니다.": "Add account canceled.", + "브라우저에서 추가할 계정으로 로그인하세요. 현재 계정은 유지됩니다.": "Sign in to the account you want to add in your browser. Your current account stays active.", + "로그인 취소": "Cancel sign-in", + "‘{0}’ 추가 완료. 목록에서 선택해 전환할 수 있습니다.": "Added ‘{0}’. Select it in the list to switch accounts.", + "‘{0}’ 사용량을 조회하고 있습니다…": "Checking usage for ‘{0}’…", + "새 확인값을 저장하지 못했습니다. 다시 조회해 주세요.": "Could not save the new result. Please refresh again.", + "‘{0}’ 사용량을 확인했습니다.": "Usage checked for ‘{0}’.", + "조회 시간이 초과되었습니다. 마지막 확인값은 유지됩니다. 잠시 후 다시 시도하세요.": "The request timed out. Your last result is kept. Try again shortly.", + "현재 로그인을 갱신할 수 없습니다. Codex 앱에서 다시 로그인한 뒤 조회해 주세요.": "The current sign-in could not be refreshed. Sign in again in Codex, then retry.", + "저장된 로그인을 갱신할 수 없습니다. ‘다른 계정 추가’에서 같은 계정으로 다시 로그인해 주세요.": "The saved sign-in could not be refreshed. Use ‘Add account’ to sign in to this account again.", + "사용량을 가져오지 못했습니다. 마지막 확인값은 유지됩니다. 연결을 확인한 뒤 다시 시도하세요.": "Could not fetch usage. Your last result is kept. Check your connection and try again.", + "사용량 조회를 취소했습니다. 마지막 확인값은 유지됩니다.": "Refresh canceled. Your last result is kept.", + "전환 조건을 확인하고 있습니다…": "Checking whether accounts can be switched…", + "현재 로그인": "Current sign-in", + "전환을 취소했습니다. 현재 계정은 유지됩니다.": "Switch canceled. Your current account stays active.", + "‘{0}’ 적용 완료. 열린 Codex에서 계정을 확인하세요.": "Switched to ‘{0}’. Check the account in Codex.", + "‘{0}’은 적용되었습니다. 시작 메뉴에서 Codex를 열어주세요.": "Switched to ‘{0}’. Open Codex from the Start menu.", + "‘{0}’의 저장된 로그인을 삭제할까요?\n다시 사용하려면 해당 계정으로 로그인해야 합니다.": "Remove the saved sign-in for ‘{0}’?\nYou will need to sign in to this account again to use it.", + "삭제를 취소했습니다.": "Removal canceled.", + "저장된 로그인을 삭제했습니다.": "Saved sign-in removed.", + "임시 파일을 정리하고 있습니다…": "Cleaning up temporary files…", + "임시 파일 정리를 마쳤습니다.": "Temporary files cleaned up.", + "임시 파일을 아직 정리하지 못했습니다. 잠시 후 다시 시도하세요.": "Temporary files could not be cleaned up yet. Try again shortly.", + "복구 조건을 확인하고 있습니다…": "Checking recovery requirements…", + "복구를 취소했습니다. 미완료 기록은 보존됩니다.": "Recovery canceled. The unfinished operation is preserved.", + "중단된 계정 작업을 복구했습니다.": "The interrupted account operation was recovered.", + "로그인을 취소했습니다. 현재 계정은 유지됩니다.": "Sign-in canceled. Your current account stays active.", + "설치 또는 다른 계정 작업이 진행 중입니다. 완료 후 다시 시도하세요.": "An installation or another account operation is in progress. Try again when it finishes.", + "계정 작업은 끝났지만 사용량 조회를 재개하지 못했습니다. 위젯을 다시 열어주세요.": "The account operation finished, but usage updates could not resume. Restart the widget.", + "내 계정 {0}": "My accounts {0}", + "미완료 전환이 있습니다. 복구를 완료해 주세요.": "An account switch is unfinished. Complete recovery to continue.", + "중단된 조회의 로그인 정보 복구가 필요합니다.": "Sign-in data from an interrupted refresh needs recovery.", + "로그인 정보 저장 완료 · 임시 파일 정리 대기": "Sign-in saved · Temporary file cleanup pending", + "현재 계정을 먼저 등록하세요. 이름은 자동으로 지정됩니다.": "Save your current account first. A name will be assigned automatically.", + "현재 로그인은 아직 등록되지 않았습니다. 전환하려면 현재 계정을 먼저 등록하세요.": "Your current sign-in is not saved. Save it before switching accounts.", + "현재 로그인은 미등록 상태입니다. 먼저 등록해 주세요.": "Your current sign-in is not saved. Save it first.", + "전체 계정의 주간 사용량을 확인하고 있습니다…": "Checking weekly usage for all accounts…", + "전체 조회 취소": "Cancel refresh", + "임시 파일 정리를 완료한 뒤 전체 갱신해 주세요.": "Finish cleaning up temporary files before refreshing all accounts.", + "조회 중 계정 구성이 바뀌었습니다. 목록을 확인한 뒤 전체 갱신해 주세요.": "The account list changed during refresh. Check the list, then refresh all accounts.", + "중단된 계정 작업을 복구한 뒤 전체 갱신해 주세요.": "Recover the interrupted account operation before refreshing all accounts.", + "새 확인값을 저장하지 못했습니다. 다시 갱신해 주세요.": "Could not save the new result. Please refresh again.", + "로그인 정보는 저장됐지만 임시 파일 정리가 남아 전체 조회를 멈췄습니다. ‘임시 파일 정리’ 후 다시 갱신하세요.": "Sign-in data is saved, but refresh stopped because cleanup is pending. Clean up temporary files, then refresh again.", + "조회 중 계정 구성이 바뀌었습니다. 전체 갱신이 필요합니다.": "The account list changed during refresh. Refresh all accounts again.", + "{0}개 계정의 주간 잔여량을 확인했습니다.": "Weekly remaining usage checked for {0} accounts.", + "일부 계정의 확인이 필요합니다. 계정별 상태를 확인하고 다시 갱신하세요.": "Some accounts need attention. Check their status, then refresh again.", + "전체 조회를 취소했습니다. 확인된 값과 이전 값은 보존했습니다.": "Full refresh canceled. New results and previous values are kept.", + "조회 시간 초과": "Refresh timed out", + "로그인 확인 필요": "Check sign-in", + "중단된 조회 복구 필요": "Interrupted refresh needs recovery", + "다른 계정 작업 진행 중": "Another account operation is in progress", + "조회 실패 · 연결 확인 필요": "Refresh failed · Check your connection", + "조회 실패 · 계정 상태 확인 필요": "Refresh failed · Check account status", + "다른 계정 추가": "Add account", + "계정 이름 변경": "Rename account", + "공식 로그인 화면이 브라우저에서 열립니다.\n추가할 계정으로 로그인하세요. 현재 계정은 유지됩니다.": "The official sign-in page will open in your browser.\nSign in to the account to add. Your current account stays active.", + "알아보기 쉬운 이름을 붙여주세요.\n로그인 정보와 사용량에는 영향을 주지 않습니다.": "Choose a name you can easily recognize.\nThis does not affect sign-in data or usage.", + "계정 이름 · 선택": "Account name · Optional", + "계정 이름": "Account name", + "비우면 계정 번호로 지정됩니다": "Leave blank to use an account number", + "1~40자": "1–40 characters", + "브라우저에서 로그인": "Sign in with browser", + "저장": "Save", + "취소": "Cancel", + "이름을 1~40자로 입력해 주세요. 줄바꿈은 사용할 수 없습니다.": "Enter 1–40 characters. Line breaks are not allowed.", + "실행 중인 앱을 확인하고 있습니다…": "Checking running apps…", + "중단된 계정 작업 복구": "Recover account operation", + "계정 전환 준비": "Prepare to switch accounts", + "저장된 복구 정보를 확인합니다": "Check the saved recovery information", + "작업을 저장한 뒤 Codex 앱과 Codex 터미널·IDE 작업을\n직접 종료해 주세요. 앱이 닫히면 아래 버튼이 활성화됩니다.\n실행 중인 프로그램을 자동으로 종료하지 않습니다.": "Save your work, then close Codex and its terminal or IDE tasks.\nThe button below becomes available when they are closed.\nRunning programs will not be closed automatically.", + "복구 확인": "Confirm recovery", + "전환하고 Codex 열기": "Switch and open Codex", + "● 준비 완료 · 모든 Codex 작업이 종료되었습니다.": "● Ready · All Codex tasks are closed.", + "● 종료 대기 · Codex 앱 또는 관련 작업이 실행 중입니다.\n앱을 닫으면 자동으로 다시 확인합니다.": "● Waiting · Codex or a related task is still running.\nThis is checked again automatically after you close it.", + "계정": "Account", + "주간 잔여": "Weekly remaining", + "초기화": "Resets", + "전환 ↗": "Switch ↗", + "{0} 확인": "Checked {0}", + "조회 실패": "Refresh failed", + "확인 대기": "Waiting", + "저장된 값": "Saved value", + "\n마지막 확인 {0:MM.dd HH:mm}": "\nLast checked {0:MM.dd HH:mm}", + "이전 {0}%": "Prev. {0}%", + "초기화 미확인": "Reset unknown", + "전체 갱신으로 확인": "Refresh all to check", + "사용 중": "In use", + "{0} 사용 중": "{0}, in use", + "{0} 계정으로 전환": "Switch to {0}", + "{0} 계정 관리 메뉴": "Manage {0}", + "가장 늦은 초기화": "Latest reset", + "전체 주간 잔여": "Total weekly remaining", + "다음 초기화": "Next reset", + "{0}개 계정 · 계정당 100% 기준": "{0} accounts · 100% per account", + "저장된 값 · 전체 갱신 필요": "Saved values · Refresh all", + "개별 확인값 · 전체 갱신 필요": "Individual results · Refresh all", + "전체 합계 확인 필요": "Refresh to confirm the total", + "확인 필요": "Check needed", + "계정별 확인 상태를 살펴보세요.": "Check each account's status.", + "{0} · 이전 일정 {1:MM/dd HH:mm}": "{0} · Previous reset {1:MM/dd HH:mm}", + "{0} · 초기화 시각 미확인": "{0} · Reset time unknown", + "확인된 계정 중 마지막": "Latest confirmed reset", + "계정 구성 변경 · 전체 갱신 필요": "Accounts changed · Refresh all", + "전체 조회 취소 · 다시 갱신해 주세요.": "Refresh canceled · Try again", + "전체 조회 중단 · 확인 필요": "Refresh interrupted · Check needed", + "개별 확인 · 전체 합계 갱신 필요": "Individual check · Refresh total", + "오늘": "Today", + "{0} {1:HH:mm}에 전체 확인": "All checked {0} {1:HH:mm}", + "{0} {1:HH:mm}에 일부 확인": "Partly checked {0} {1:HH:mm}", + "{0}일 {1}시간 뒤": "In {0}d {1}h", + "{0}시간 {1}분 뒤": "In {0}h {1}m", + "{0}분 뒤": "In {0}m", + "조회 취소 · 이전 값": "Refresh canceled · Previous value", + "이번 전체 조회에서 미확인": "Not checked in this refresh", + "주간 사용량 미확인": "Weekly usage unknown", + "초기화 시각 미확인": "Reset time unknown", + "초기화 시점 지남 · 갱신 필요": "Reset time passed · Refresh needed", + "확인 완료": "Checked", + "주간 잔여 사용량": "Weekly remaining usage", + "조회 취소됨": "Canceled" +} diff --git a/src/Localization/errors.en.json b/src/Localization/errors.en.json new file mode 100644 index 0000000..a74e638 --- /dev/null +++ b/src/Localization/errors.en.json @@ -0,0 +1,65 @@ +{ + "다른 계정 관리 작업이 진행 중입니다. 잠시 후 다시 시도하세요.": "Another account operation is in progress. Try again shortly.", + "현재 사용 중인 계정입니다. 현재 계정 등록을 사용하세요.": "This account is already in use. Choose Register current account instead.", + "저장된 계정을 찾을 수 없습니다.": "The saved account could not be found.", + "현재 로그인한 계정을 먼저 등록하세요.": "Register the account you are currently signed in to first.", + "교체 중 다른 로그인이 발견되어 복구 기록을 보존했습니다.": "A different sign-in was detected while switching accounts. The recovery record has been kept.", + "로그인 파일이 없어 자동 복구를 멈췄습니다. 원래 계정으로 로그인한 뒤 복구하세요.": "Automatic recovery stopped because the sign-in file is missing. Sign in to the original account, then run recovery.", + "교체 대상과 다른 계정이 로그인되어 있습니다. 현재 로그인은 보존했고 자동 복구를 멈췄습니다.": "The signed-in account differs from the switch target. Your current sign-in has been kept, and automatic recovery has stopped.", + "현재 사용 중인 계정은 삭제할 수 없습니다.": "You cannot remove the account currently in use.", + "사용량 값이 올바르지 않습니다.": "The usage value is invalid.", + "최대 20개 계정까지 저장할 수 있습니다.": "You can save up to 20 accounts.", + "계정 이름은 제어 문자 없이 1~40자로 입력하세요.": "Enter an account name of 1–40 characters without control characters.", + "미완료 계정 교체를 먼저 복구하세요.": "Recover the unfinished account switch first.", + "중단된 사용량 조회를 먼저 복구하세요.": "Recover the interrupted usage check first.", + "로그인 정보가 작업 중 변경되어 교체를 멈췄습니다. 복구 후 다시 시도하세요.": "Sign-in details changed during the operation, so the account switch stopped. Run recovery, then try again.", + "관리형 Codex 설정이 있어 계정 교체를 지원하지 않습니다. 설정은 변경하지 않았습니다.": "Account switching is not supported with managed Codex settings. Your settings have not been changed.", + "Codex 관리 설정을 확인할 수 없어 계정 교체를 중단했습니다.": "The account switch stopped because managed Codex settings could not be checked.", + "Codex 사용자 폴더를 찾을 수 없습니다.": "The Codex user folder could not be found.", + "CODEX_API_KEY가 설정된 환경에서는 계정 교체를 지원하지 않습니다.": "Account switching is not supported when CODEX_API_KEY is set.", + "파일 방식 이외의 인증 저장 설정은 지원하지 않습니다. 설정은 변경하지 않았습니다.": "Only file-based credential storage is supported. Your settings have not been changed.", + "로그인 또는 워크스페이스 제한이 있는 환경은 자동 교체를 지원하지 않습니다.": "Automatic account replacement is not supported when sign-in or workspace restrictions are in place.", + "계정 · ": "Account · ", + "계정 · {0}": "Account · {0}", + "지원되는 ChatGPT 로그인 파일이 아닙니다. 공식 Codex 로그인으로 다시 등록하세요.": "This ChatGPT sign-in file is not supported. Sign in through the official Codex app and register the account again.", + "계정 보관함 또는 복구 기록을 읽을 수 없습니다. 원본 파일을 보존한 채 중단했습니다.": "The account vault or recovery record could not be read. The operation stopped without changing the original files.", + "Windows 사용자 SID를 확인할 수 없습니다.": "The Windows user SID could not be determined.", + "링크 또는 리파스 경로의 인증 파일은 지원하지 않습니다.": "Credential files accessed through links or reparse points are not supported.", + "계정 보관함 크기 제한을 초과했습니다.": "The account vault has exceeded its size limit.", + "이전 인증 교체의 임시 파일이 있습니다. 복구를 먼저 실행하세요.": "Temporary files remain from a previous credential replacement. Run recovery first.", + "Windows 계정 암호화 작업에 실패했습니다.": "Windows account encryption failed.", + "Codex 앱과 Codex CLI·IDE 작업을 모두 종료한 뒤 다시 시도하세요. 실행 중인 프로세스는 자동 종료하지 않습니다.": "Close the Codex app and all Codex CLI and IDE sessions, then try again. Running processes will not be closed automatically.", + "Codex 실행 경로가 없거나 앱이 업데이트되었습니다. 시작 메뉴에서 Codex를 열어 주세요.": "The Codex launch path is missing or the app has been updated. Open Codex from the Start menu.", + "Codex를 자동으로 열지 못했습니다. 시작 메뉴에서 열어 주세요.": "Codex could not be opened automatically. Open it from the Start menu.", + "이미 계정 로그인이 진행 중입니다.": "An account sign-in is already in progress.", + "설치된 Codex CLI를 찾지 못했습니다. Codex 앱을 먼저 실행해 주세요.": "The installed Codex CLI could not be found. Open the Codex app first.", + "Codex 로그인 프로세스를 안전하게 시작하지 못했습니다.": "The Codex sign-in process could not be started safely.", + "로그인이 완료되지 않았습니다. 브라우저에서 계정을 확인한 뒤 다시 시도하세요.": "Sign-in did not complete. Check the account in your browser, then try again.", + "로그인 결과의 형식이 올바르지 않습니다.": "The sign-in result has an invalid format.", + "Codex 로그인 처리에 실패했습니다. 계정 상태를 확인한 뒤 다시 시도하세요.": "Codex sign-in failed. Check the account status, then try again.", + "관리형 Codex 설정이 감지되어 격리 로그인을 중단했습니다. 이 버전은 개인용 파일 인증 환경을 지원합니다.": "Managed Codex settings were detected, so isolated sign-in stopped. This version supports personal accounts with file-based credentials.", + "Codex 관리 설정을 확인할 수 없어 로그인을 시작하지 않았습니다.": "Sign-in did not start because managed Codex settings could not be checked.", + "계정 저장소를 먼저 만들어 주세요.": "Create the account store first.", + "현재 Windows 사용자를 확인할 수 없습니다.": "The current Windows user could not be determined.", + "연결된 폴더나 파일에서는 계정 로그인을 수행할 수 없습니다.": "Account sign-in is not supported through linked folders or files.", + "로그인 프로세스 보호를 준비하지 못했습니다.": "Sign-in process protection could not be prepared.", + "로그인 프로세스 보호를 설정하지 못했습니다.": "Sign-in process protection could not be configured.", + "로그인 프로세스 보호를 연결하지 못했습니다.": "Sign-in process protection could not be attached.", + "조회 프로세스 그룹을 종료하지 못했습니다.": "The usage-check process group could not be stopped.", + "조회 프로세스 그룹의 종료 상태를 확인하지 못했습니다.": "The usage-check process group's shutdown status could not be confirmed.", + "조회 프로세스 그룹의 종료 시간이 초과되었습니다.": "The usage-check process group did not stop within the time limit.", + "로그인 임시 폴더의 범위를 확인하지 못했습니다.": "The temporary sign-in folder's location could not be verified.", + "로그인 임시 파일을 정리하지 못했습니다. 계정 저장소의 login- 임시 폴더를 확인해 주세요.": "Temporary sign-in files could not be removed. Check the login- temporary folders in the account store.", + "로그인 정보 복구를 먼저 완료해 주세요.": "Finish recovering the sign-in details first.", + "이전 임시 파일을 정리하지 못해 새 조회를 시작하지 못했습니다. 잠시 후 정리를 다시 시도하세요.": "A new usage check could not start because earlier temporary files could not be removed. Try cleanup again shortly.", + "현재 계정은 활성 사용량 연결로 조회해야 합니다.": "The current account must be checked through its active usage connection.", + "이전 조회 폴더가 남아 있습니다. 계정 저장소를 확인하세요.": "A folder from a previous usage check remains. Check the account store.", + "저장된 계정의 ChatGPT 로그인을 확인할 수 없습니다. 다시 로그인해 주세요.": "The saved account's ChatGPT sign-in could not be verified. Sign in again.", + "조회 중 대상 계정이 현재 로그인으로 변경되었습니다. 현재 로그인은 보존했으며 조회 복구가 필요합니다.": "The account being checked became the signed-in account during the check. Your current sign-in has been kept. Run usage-check recovery.", + "정리 경로 확인 필요": "Verify cleanup path", + "파일 접근 제한": "File access restricted", + "파일 사용 중": "File in use", + "파일 정리 오류": "File cleanup error", + "조회 프로세스 종료를 확인하지 못했습니다. 조회 복구를 실행해 주세요.": "The usage-check process could not be confirmed stopped. Run usage-check recovery.", + "Codex CLI를 찾지 못했습니다. Codex 앱을 먼저 실행해 주세요.": "The Codex CLI could not be found. Open the Codex app first." +} diff --git a/src/Localization/errors.ko.json b/src/Localization/errors.ko.json new file mode 100644 index 0000000..c85a580 --- /dev/null +++ b/src/Localization/errors.ko.json @@ -0,0 +1,31 @@ +{ + "The Codex helper account changed during the usage read. Refresh again.": "사용량 조회 중 Codex 보조 프로세스의 계정이 변경되었습니다. 다시 갱신해 주세요.", + "Codex app-server could not be started.": "Codex 사용량 조회 프로세스를 시작하지 못했습니다.", + "codex.exe was not found. Install or open Codex Desktop first.": "codex.exe를 찾지 못했습니다. Codex 데스크톱 앱을 설치하거나 먼저 실행해 주세요.", + "Could not register a Codex request.": "Codex 조회 요청을 등록하지 못했습니다.", + "Codex app-server disconnected.": "Codex 사용량 조회 연결이 끊어졌습니다.", + "Codex could not read account usage. Check the account login.": "Codex 계정 사용량을 읽지 못했습니다. 계정 로그인 상태를 확인해 주세요.", + "Codex returned an incomplete response.": "Codex가 불완전한 응답을 반환했습니다.", + "Codex helper shutdown is still in progress.": "Codex 보조 프로세스가 아직 종료 중입니다.", + "The previous Codex helper has not stopped.": "이전 Codex 보조 프로세스가 아직 종료되지 않았습니다.", + "Codex helper did not exit. Account switching is blocked.": "Codex 보조 프로세스가 종료되지 않아 계정 전환을 차단했습니다.", + "Codex usage is paused during account switching.": "계정 전환 중에는 Codex 사용량 조회를 일시 중지합니다.", + "Codex did not return a usage window.": "Codex가 사용량 기간 정보를 반환하지 않았습니다.", + "Codex did not return rate-limit data.": "Codex가 사용량 한도 정보를 반환하지 않았습니다.", + "Claude Code usage is temporarily unavailable.": "Claude Code 사용량을 일시적으로 확인할 수 없습니다.", + "Claude Code did not return a usable Fable limit.": "Claude Code가 유효한 Fable 한도 정보를 반환하지 않았습니다.", + "Claude Code login is required.": "Claude Code 로그인이 필요합니다.", + "Claude Code /usage exited with code {0}.": "Claude Code /usage가 종료 코드 {0}로 종료되었습니다.", + "Claude Code returned an invalid /usage response.": "Claude Code가 올바르지 않은 /usage 응답을 반환했습니다.", + "Claude Code could not retrieve /usage.": "Claude Code가 /usage 정보를 가져오지 못했습니다.", + "Claude Code /usage unexpectedly consumed a model turn.": "Claude Code /usage에서 예상치 못한 모델 호출이 발생하여 응답을 거부했습니다.", + "Claude Code /usage did not return a valid Fable limit.": "Claude Code /usage가 유효한 Fable 한도 정보를 반환하지 않았습니다.", + "Claude Code returned an unknown reset time zone.": "Claude Code가 알 수 없는 초기화 시간대를 반환했습니다.", + "Claude Code returned an invalid reset time zone.": "Claude Code가 올바르지 않은 초기화 시간대를 반환했습니다.", + "Claude Code returned an invalid reset time.": "Claude Code가 올바르지 않은 초기화 시각을 반환했습니다.", + "Claude Code returned a reset time in a daylight-saving gap.": "Claude Code가 서머타임 전환으로 존재하지 않는 초기화 시각을 반환했습니다.", + "Claude Code could not be started.": "Claude Code를 시작하지 못했습니다.", + "Claude Code returned too much /usage output.": "Claude Code /usage 응답이 허용 크기를 초과했습니다.", + "Claude Code /usage timed out.": "Claude Code /usage 응답 시간이 초과되었습니다.", + "Claude Code executable was not found.": "Claude Code 실행 파일을 찾지 못했습니다." +} diff --git a/src/Localization/widget.en.json b/src/Localization/widget.en.json new file mode 100644 index 0000000..bc60a4f --- /dev/null +++ b/src/Localization/widget.en.json @@ -0,0 +1,65 @@ +{ + "Codex 및 Claude 사용량": "Codex and Claude usage", + "Codex 및 Claude Fable 사용량 인디케이터": "Codex and Claude Fable usage indicator", + "Codex 사용량 · 계정 관리": "Codex usage · Accounts", + "Codex 및 Claude 사용량을 불러오는 중…": "Loading Codex and Claude usage…", + "Codex 계정 관리…": "Codex accounts…", + "새로고침": "Refresh", + "Claude 사용량 표시": "Show Claude usage", + "항상 위에 표시": "Always on top", + "현재 상태 복사": "Copy current status", + "닫기": "Close", + "현재 계정": "Current account", + "주간": "Weekly", + "5시간": "5-hour", + "전체 주간": "All models · weekly", + "확인 필요: ": "Check required: ", + "전체 계정 · 주간 잔여 {0}% / {1}%": "All accounts · Weekly remaining {0}% / {1}%", + "전체 계정 · 합계 갱신 필요": "All accounts · Refresh total", + "전체 조회 중 · {0}/{1}개 확인": "Refreshing all · {0}/{1} checked", + "개별 확인값 · 전체 합계 갱신 필요": "Individual check · Refresh the total", + "전체 확인": "All checked", + "마지막 조회": "Last checked", + "{0} {1:MM/dd HH:mm} 기준 · 계정당 100%": "{0} {1:MM/dd HH:mm} · 100% per account", + "저장된 값 · 계정 관리에서 전체 갱신해 주세요.": "Saved values · Refresh all in Accounts.", + "확인된 잔여 {0}% · {1}/{2}개": "Confirmed {0}% · {1}/{2} accounts", + "갱신 필요": "Refresh needed", + "이전 ": "Previous ", + "미확인": "Unknown", + " · 조회 실패": " · Check failed", + " · 조회 취소": " · Canceled", + " [사용 중]": " [Active]", + " 초기화 {0}": " Resets {0}", + " · {0:MM/dd HH:mm} 값": " · Saved {0:MM/dd HH:mm}", + "외 {0}개 · 계정 관리에서 모두 보기": "{0} more · View all in Accounts", + "업데이트 지연 · 마지막 성공 {0:MM/dd HH:mm}": "Update delayed · Last success {0:MM/dd HH:mm}", + "다음 시도 {0}": "Next attempt {0}", + "우클릭 → Codex 계정 관리 · 전체 갱신 / 계정 전환": "Right-click → Codex accounts · Refresh all / Switch account", + ": 정보 없음": ": No data", + "{0}% 남음": "{0}% remaining", + "{0}: {1} · 초기화: {2}": "{0}: {1} · Resets: {2}", + "정보 없음": "No data", + "드래그: 이동 · 더블클릭: 새로고침 · 우클릭: 메뉴": "Drag to move · Double-click to refresh · Right-click for menu", + "{0}: 정보 없음": "{0}: No data", + "{0}: {1}% 남음 ({2}% 사용)": "{0}: {1}% remaining ({2}% used)", + " 초기화: {0}": " Resets: {0}", + "업데이트 실패: {0}": "Update failed: {0}", + "업데이트 지연: 마지막 성공 {0:yyyy-MM-dd HH:mm}": "Update delayed: last success {0:yyyy-MM-dd HH:mm}", + " 다음 시도: {0}": " Next attempt: {0}", + "지금": "Now", + "{0}일 {1}시간 후": "In {0}d {1}h", + "{0}시간 {1}분 후": "In {0}h {1}m", + "{0}분 후": "In {0}m", + "Codex 응답 시간이 초과되었습니다.": "Codex request timed out.", + "Codex 실행 파일을 찾지 못했습니다.": "Codex executable was not found.", + "Codex 로그인이 필요합니다.": "Sign in to Codex.", + "Claude 응답 시간이 초과되었습니다.": "Claude request timed out.", + "Claude Code 로그인이 만료되었습니다.": "Your Claude Code login has expired.", + "Claude 사용량 조회가 지연되었습니다. 다음 시도: {0}": "Claude usage is temporarily unavailable. Next attempt: {0}", + "Claude Code 실행 파일을 찾지 못했습니다.": "Claude Code executable was not found.", + "Claude Code 로그인이 필요합니다.": "Sign in to Claude Code.", + "Codex 조회 프로세스의 ChatGPT 로그인을 확인할 수 없습니다.": "The Codex helper's ChatGPT login could not be verified.", + "현재 계정의 사용량을 조회하려면 Codex 앱을 열어 주세요.": "Open Codex Desktop to check the current account's usage.", + "현재 계정이 변경되었습니다. 목록을 갱신한 뒤 다시 조회하세요.": "The current account changed. Reload the list and try again.", + "조회 중 현재 계정이 변경되었습니다. 목록을 갱신한 뒤 다시 조회하세요.": "The current account changed during the query. Reload the list and try again." +} diff --git a/src/Program.cs b/src/Program.cs index 4d8edbd..f951594 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -34,6 +34,7 @@ private static int Main(string[] args) argument.Equals("--preview", StringComparison.OrdinalIgnoreCase)); ApplicationConfiguration.Initialize(); + if (!previewMode) UiText.SetLanguage(IndicatorSettingsStore.LoadLanguage()); Application.Run(new UsageIndicatorForm(previewMode, openAccounts)); GC.KeepAlive(singleInstance); return 0; @@ -74,6 +75,7 @@ internal sealed class UsageIndicatorForm : Form private ClaudeUsageResult? _claudeUsage; private string? _codexError; private string? _claudeError; + private Exception? _codexFailure, _claudeFailure; private bool _codexWasRunning; private bool _isRefreshing; private bool _isHovered; @@ -81,6 +83,7 @@ internal sealed class UsageIndicatorForm : Form private bool _keepOnTop = true; private bool _showClaude; private bool _positionInitialized; + private ToolStripMenuItem? _languageMenu; private readonly CodexAccountStore _accountStore = new(); private AccountManagerForm? _accountManager; private readonly CombinedUsageSnapshot _combinedUsage = new(); @@ -107,15 +110,16 @@ public UsageIndicatorForm(bool previewMode, bool openAccounts = false) ShowIcon = false; ShowInTaskbar = false; StartPosition = FormStartPosition.Manual; - Text = "Codex 및 Claude 사용량"; - AccessibleName = "Codex 및 Claude Fable 사용량 인디케이터"; + Text = UiText.T("Codex 및 Claude 사용량"); + AccessibleName = UiText.T("Codex 및 Claude Fable 사용량 인디케이터"); Opacity = 0; BuildContextMenu(); - _accountTray = new NotifyIcon { Icon = SystemIcons.Application, Text = "Codex 사용량 · 계정 관리", ContextMenuStrip = _contextMenu, Visible = !previewMode }; + _contextMenu.Opening += (_, _) => { if (_languageMenu is not null) _languageMenu.Enabled = CanChangeLanguage; }; + _accountTray = new NotifyIcon { Icon = SystemIcons.Application, Text = UiText.T("Codex 사용량 · 계정 관리"), ContextMenuStrip = _contextMenu, Visible = !previewMode }; _accountTray.DoubleClick += (_, _) => ShowAccountManager(); ApplyRoundedRegion(); - _toolTip.SetHoverText("Codex 및 Claude 사용량을 불러오는 중…"); + _toolTip.SetHoverText(UiText.T("Codex 및 Claude 사용량을 불러오는 중…")); _toolTip.TrackHover(this); _pollTimer.Tick += async (_, _) => await RefreshUsageAsync(); @@ -195,12 +199,14 @@ protected override CreateParams CreateParams private void BuildContextMenu() { - var accountsItem = new ToolStripMenuItem("Codex 계정 관리…"); + foreach (var item in _contextMenu.Items.Cast().ToArray()) item.Dispose(); + _contextMenu.Items.Clear(); + var accountsItem = new ToolStripMenuItem(UiText.T("Codex 계정 관리…")); accountsItem.Click += (_, _) => ShowAccountManager(); - var refreshItem = new ToolStripMenuItem("새로고침"); + var refreshItem = new ToolStripMenuItem(UiText.T("새로고침")); refreshItem.Click += async (_, _) => await RefreshUsageAsync(force: true); - var showClaudeItem = new ToolStripMenuItem("Claude 사용량 표시") + var showClaudeItem = new ToolStripMenuItem(UiText.T("Claude 사용량 표시")) { Checked = _showClaude, CheckOnClick = true @@ -208,9 +214,9 @@ private void BuildContextMenu() showClaudeItem.CheckedChanged += async (_, _) => await SetClaudeVisibilityAsync(showClaudeItem.Checked); - var topMostItem = new ToolStripMenuItem("항상 위에 표시") + var topMostItem = new ToolStripMenuItem(UiText.T("항상 위에 표시")) { - Checked = true, + Checked = _keepOnTop, CheckOnClick = true }; topMostItem.CheckedChanged += (_, _) => @@ -219,7 +225,7 @@ private void BuildContextMenu() if (IsHandleCreated) _ = NativeWindow.TrySetTopMost(Handle, _keepOnTop); }; - var copyItem = new ToolStripMenuItem("현재 상태 복사"); + var copyItem = new ToolStripMenuItem(UiText.T("현재 상태 복사")); copyItem.Click += (_, _) => { Clipboard.SetText(BuildClipboardText( @@ -230,20 +236,61 @@ private void BuildContextMenu() _showClaude)); }; - var exitItem = new ToolStripMenuItem("닫기"); + var exitItem = new ToolStripMenuItem(UiText.T("닫기")); exitItem.Click += (_, _) => Close(); + _languageMenu = new ToolStripMenuItem("Language / 언어") { Name = "LanguageMenu" }; + foreach (var (language, label) in new[] { ("ko", "한국어"), ("en", "English") }) + { + var choice = new ToolStripMenuItem(label) { Name = "Language-" + language, Checked = UiText.Language == language }; + choice.Click += (_, _) => BeginInvoke((Action)(() => ChangeLanguage(language))); + _languageMenu.DropDownItems.Add(choice); + } + _contextMenu.Items.AddRange([ refreshItem, showClaudeItem, topMostItem, copyItem, accountsItem, + _languageMenu, new ToolStripSeparator(), exitItem ]); } + private bool CanChangeLanguage => !_accountBusy && _accountManager is not { IsOperationInProgress: true } && + _accountManager is not { Enabled: false } && + !(_accountManager?.OwnedForms.Any(f => f.Visible) ?? false); + + internal bool ChangeLanguage(string language) + { + if (!CanChangeLanguage || !UiText.IsSupported(language)) return false; + if (UiText.Language == language) return true; + var previousManager = _accountManager; + Rectangle? bounds = previousManager is { Visible: true } manager ? manager.Bounds : null; + _accountManager?.Close(); _accountManager?.Dispose(); _accountManager = null; + UiText.SetLanguage(language); + if (!_previewMode) IndicatorSettingsStore.SaveLanguage(language); + Text = UiText.T("Codex 및 Claude 사용량"); + AccessibleName = UiText.T("Codex 및 Claude Fable 사용량 인디케이터"); + if (_accountTray is not null) _accountTray.Text = UiText.T("Codex 사용량 · 계정 관리"); + if (_codexFailure is not null) _codexError = FriendlyCodexError(_codexFailure); + if (_claudeFailure is not null) _claudeError = FriendlyClaudeError(_claudeFailure); + BuildContextMenu(); UpdateToolTip(); + if (bounds is { } previous) + { + _accountManager = previousManager!.CreateLocalizedCopy(); + var recreated = _accountManager; + recreated.StartPosition = FormStartPosition.Manual; + // Restore physical bounds after WinForms applies the monitor DPI, + // while still inside Load so no intermediate size reaches the screen. + recreated.Load += (_, _) => recreated.Bounds = previous; + _accountManager.Show(); + } + return true; + } + private async Task SetClaudeVisibilityAsync(bool showClaude) { _showClaude = showClaude; @@ -252,13 +299,13 @@ private async Task SetClaudeVisibilityAsync(bool showClaude) if (!showClaude) { _claudeUsage = null; - _claudeError = null; + _claudeError = null; _claudeFailure = null; UpdateToolTip(); Invalidate(); return; } - _claudeError = null; + _claudeError = null; _claudeFailure = null; await RefreshUsageAsync(force: true); } @@ -355,7 +402,7 @@ private async Task RefreshUsageAsync(bool force = false) if (_previewMode) { _codexSnapshot = new UsageSnapshot(38, DateTimeOffset.Now.AddDays(3), 10080, "codex"); - _codexError = null; + _codexError = null; _codexFailure = null; UpdateToolTip(); Invalidate(); return; @@ -379,7 +426,7 @@ private async Task RefreshUsageAsync(bool force = false) else { _claudeUsage = null; - _claudeError = null; + _claudeError = null; _claudeFailure = null; } await Task.WhenAll(refreshTasks); @@ -438,13 +485,13 @@ private async Task RefreshCodexAsync() if (generation != _accountGeneration || _accountBusy) return; if (identity is not null && identity != _accountStore.GetCurrentIdentity().Key) return; _codexSnapshot = snapshot; - _codexError = null; + _codexError = null; _codexFailure = null; if (identity is not null && snapshot is not null) _accountStore.SaveUsage(identity, snapshot); } catch (Exception ex) { if (generation != _accountGeneration || _accountBusy) return; - _codexError = FriendlyCodexError(ex); + _codexFailure = ex; _codexError = FriendlyCodexError(ex); } } @@ -452,12 +499,14 @@ private void ShowAccountManager() { if (_previewMode) return; if (_accountManager is null || _accountManager.IsDisposed) - _accountManager = new AccountManagerForm(_accountStore, SuspendAccountsAsync, ResumeAccounts, - queryUsage: QueryAccountUsageAsync, combined: _combinedUsage, usageChanged: UpdateToolTip); + _accountManager = CreateAccountManager(refreshAllOnOpen: true); _accountManager.Show(); _accountManager.Activate(); } + private AccountManagerForm CreateAccountManager(bool refreshAllOnOpen) => new(_accountStore, SuspendAccountsAsync, ResumeAccounts, + queryUsage: QueryAccountUsageAsync, combined: _combinedUsage, usageChanged: UpdateToolTip, refreshAllOnOpen: refreshAllOnOpen); + private async Task QueryAccountUsageAsync(SavedCodexAccount account, CancellationToken token) { if (!account.IsActive) @@ -482,7 +531,7 @@ private async Task QueryAccountUsageAsync(SavedCodexAccount account, Cancellatio _accountStore.GetCurrentIdentity().Key != identity) throw new InvalidOperationException("조회 중 현재 계정이 변경되었습니다. 목록을 갱신한 뒤 다시 조회하세요."); _accountStore.SaveUsage(identity, result.Usage); - _codexSnapshot = result.Usage; _codexError = null; + _codexSnapshot = result.Usage; _codexError = null; _codexFailure = null; UpdateToolTip(); Invalidate(); } @@ -499,7 +548,7 @@ private void ResumeAccounts() _accountBusy = _accountStore.HasPendingRecovery; _codexSnapshot = null; _helperIdentityKey = null; - _codexError = null; + _codexError = null; _codexFailure = null; _codexWasRunning = false; if (!_accountBusy) SyncCodexVisibility(); Invalidate(); @@ -511,12 +560,12 @@ private async Task RefreshClaudeAsync() { using var timeout = new CancellationTokenSource(ClaudeRefreshTimeout); _claudeUsage = await _claudeClient.GetUsageAsync(timeout.Token); - _claudeError = null; + _claudeError = null; _claudeFailure = null; } catch (Exception ex) { _claudeUsage = null; - _claudeError = FriendlyClaudeError(ex); + _claudeFailure = ex; _claudeError = FriendlyClaudeError(ex); } } @@ -661,7 +710,7 @@ private static string BuildDetailsText( { var builder = new StringBuilder(); builder.AppendLine("CODEX"); - AppendUsageWindow(builder, "주간", codex?.UsedPercent, codex?.ResetsAt); + AppendUsageWindow(builder, UiText.T("주간"), codex?.UsedPercent, codex?.ResetsAt); AppendRefreshError(builder, codexError); if (showClaude) @@ -671,12 +720,12 @@ private static string BuildDetailsText( builder.AppendLine("CLAUDE"); AppendUsageWindow( builder, - "5시간", + UiText.T("5시간"), claudeSnapshot?.FiveHour?.UsedPercent, claudeSnapshot?.FiveHour?.ResetsAt); AppendUsageWindow( builder, - "주간", + UiText.T("주간"), claudeSnapshot?.Weekly?.UsedPercent, claudeSnapshot?.Weekly?.ResetsAt); AppendUsageWindow( @@ -691,7 +740,7 @@ private static string BuildDetailsText( if (includeControls) { builder.AppendLine(); - builder.Append("드래그: 이동 · 더블클릭: 새로고침 · 우클릭: 메뉴"); + builder.Append(UiText.T("드래그: 이동 · 더블클릭: 새로고침 · 우클릭: 메뉴")); } return builder.ToString().TrimEnd(); @@ -705,21 +754,21 @@ private static void AppendUsageWindow( { if (usedPercent is null) { - builder.AppendLine($"{label}: 정보 없음"); + builder.AppendLine(UiText.F($"{label}: 정보 없음")); return; } var used = (int)Math.Round( Math.Clamp(usedPercent.Value, 0d, 100d), MidpointRounding.AwayFromZero); - builder.AppendLine($"{label}: {100 - used}% 남음 ({used}% 사용)"); - builder.AppendLine($" 초기화: {FormatResetTime(resetsAt)}"); + builder.AppendLine(UiText.F($"{label}: {100 - used}% 남음 ({used}% 사용)")); + builder.AppendLine(UiText.F($" 초기화: {FormatResetTime(resetsAt)}")); } private static void AppendRefreshError(StringBuilder builder, string? error) { if (error is null) return; - builder.AppendLine($"업데이트 실패: {error}"); + builder.AppendLine(UiText.F($"업데이트 실패: {UiText.Message(error)}")); } private static void AppendClaudeRefreshState( @@ -729,16 +778,16 @@ private static void AppendClaudeRefreshState( if (claude is not { IsStale: true }) return; builder.AppendLine( - $"업데이트 지연: 마지막 성공 {claude.LastUpdatedAt.ToLocalTime():yyyy-MM-dd HH:mm}"); + UiText.F($"업데이트 지연: 마지막 성공 {claude.LastUpdatedAt.ToLocalTime():yyyy-MM-dd HH:mm}")); if (claude.RetryAfter is { } retryAfter) { - builder.AppendLine($" 다음 시도: {FormatResetTime(retryAfter)}"); + builder.AppendLine(UiText.F($" 다음 시도: {FormatResetTime(retryAfter)}")); } } private static string FormatResetTime(DateTimeOffset? resetsAt) { - if (resetsAt is null) return "정보 없음"; + if (resetsAt is null) return UiText.T("정보 없음"); var localReset = resetsAt.Value.ToLocalTime(); var remaining = localReset - DateTimeOffset.Now; @@ -747,46 +796,46 @@ private static string FormatResetTime(DateTimeOffset? resetsAt) private static string FormatTimeRemaining(TimeSpan remaining) { - if (remaining <= TimeSpan.Zero) return "지금"; + if (remaining <= TimeSpan.Zero) return UiText.T("지금"); var days = (int)remaining.TotalDays; - if (days > 0) return $"{days}일 {remaining.Hours}시간 후"; - if (remaining.Hours > 0) return $"{remaining.Hours}시간 {remaining.Minutes}분 후"; - return $"{Math.Max(0, remaining.Minutes)}분 후"; + if (days > 0) return UiText.F($"{days}일 {remaining.Hours}시간 후"); + if (remaining.Hours > 0) return UiText.F($"{remaining.Hours}시간 {remaining.Minutes}분 후"); + return UiText.F($"{Math.Max(0, remaining.Minutes)}분 후"); } private static string FriendlyCodexError(Exception exception) { if (exception is TimeoutException or TaskCanceledException) - return "Codex 응답 시간이 초과되었습니다."; + return UiText.T("Codex 응답 시간이 초과되었습니다."); if (exception.Message.Contains("codex.exe", StringComparison.OrdinalIgnoreCase)) - return "Codex 실행 파일을 찾지 못했습니다."; + return UiText.T("Codex 실행 파일을 찾지 못했습니다."); if (exception.Message.Contains("not logged", StringComparison.OrdinalIgnoreCase)) - return "Codex 로그인이 필요합니다."; - return TruncateError(exception.Message); + return UiText.T("Codex 로그인이 필요합니다."); + return TruncateError(UiText.Message(exception.Message)); } internal static string FriendlyClaudeError(Exception exception) { if (exception is TimeoutException or TaskCanceledException) - return "Claude 응답 시간이 초과되었습니다."; + return UiText.T("Claude 응답 시간이 초과되었습니다."); if (exception is UnauthorizedAccessException) - return "Claude Code 로그인이 만료되었습니다."; + return UiText.T("Claude Code 로그인이 만료되었습니다."); if (exception is ClaudeUsageTemporarilyUnavailableException unavailable) { - return $"Claude 사용량 조회가 지연되었습니다. 다음 시도: {FormatResetTime(unavailable.RetryAfter)}"; + return UiText.F($"Claude 사용량 조회가 지연되었습니다. 다음 시도: {FormatResetTime(unavailable.RetryAfter)}"); } if (exception is FileNotFoundException || exception.Message.Contains("executable", StringComparison.OrdinalIgnoreCase)) { - return "Claude Code 실행 파일을 찾지 못했습니다."; + return UiText.T("Claude Code 실행 파일을 찾지 못했습니다."); } if (exception.Message.Contains("credentials", StringComparison.OrdinalIgnoreCase) || exception.Message.Contains("login", StringComparison.OrdinalIgnoreCase)) { - return "Claude Code 로그인이 필요합니다."; + return UiText.T("Claude Code 로그인이 필요합니다."); } - return TruncateError(exception.Message); + return TruncateError(UiText.Message(exception.Message)); } private static string TruncateError(string message) => message.Length > 120 @@ -845,7 +894,7 @@ private static GraphicsPath RoundedRectangle(RectangleF rectangle, float radius) } } -internal sealed record IndicatorSettings(int? X, int? Y, bool? ShowClaude); +internal sealed record IndicatorSettings(int? X, int? Y, bool? ShowClaude, string? Language = null); internal static class IndicatorSettingsStore { @@ -855,37 +904,50 @@ internal static class IndicatorSettingsStore private static readonly string SettingsPath = Path.Combine(SettingsDirectory, "settings.json"); - public static Point? LoadPosition() + public static Point? LoadPosition(string? path = null) { - var settings = LoadSettings(); + var settings = LoadSettings(path); return settings?.X is { } x && settings.Y is { } y ? new Point(x, y) : null; } - public static bool LoadShowClaude() => LoadSettings()?.ShowClaude ?? true; + public static bool LoadShowClaude(string? path = null) => LoadSettings(path)?.ShowClaude ?? true; + + public static string LoadLanguage(string? path = null) + { + var settings = LoadSettings(path); + return UiText.IsSupported(settings?.Language) ? settings!.Language! : settings is not null ? "ko" : UiText.SystemLanguage; + } + + public static void SaveLanguage(string language, string? path = null) + { + if (!UiText.IsSupported(language)) throw new ArgumentException("Unsupported UI language.", nameof(language)); + var current = LoadSettings(path); + SaveSettings(new IndicatorSettings(current?.X, current?.Y, current?.ShowClaude ?? true, language), path); + } - public static void SavePosition(Point position) + public static void SavePosition(Point position, string? path = null) { - var current = LoadSettings(); + var current = LoadSettings(path); SaveSettings(new IndicatorSettings( position.X, position.Y, - current?.ShowClaude ?? true)); + current?.ShowClaude ?? true, current?.Language ?? LoadLanguage(path)), path); } - public static void SaveShowClaude(bool showClaude) + public static void SaveShowClaude(bool showClaude, string? path = null) { - var current = LoadSettings(); - SaveSettings(new IndicatorSettings(current?.X, current?.Y, showClaude)); + var current = LoadSettings(path); + SaveSettings(new IndicatorSettings(current?.X, current?.Y, showClaude, current?.Language ?? LoadLanguage(path)), path); } - private static IndicatorSettings? LoadSettings() + private static IndicatorSettings? LoadSettings(string? path = null) { try { - return File.Exists(SettingsPath) - ? JsonSerializer.Deserialize(File.ReadAllText(SettingsPath)) + return File.Exists(path ?? SettingsPath) + ? JsonSerializer.Deserialize(File.ReadAllText(path ?? SettingsPath)) : null; } catch @@ -894,17 +956,18 @@ public static void SaveShowClaude(bool showClaude) } } - private static void SaveSettings(IndicatorSettings settings) + private static void SaveSettings(IndicatorSettings settings, string? path = null) { try { - Directory.CreateDirectory(SettingsDirectory); - var temporaryPath = SettingsPath + ".tmp"; + path ??= SettingsPath; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temporaryPath = path + ".tmp"; var json = JsonSerializer.Serialize( settings, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(temporaryPath, json); - File.Move(temporaryPath, SettingsPath, overwrite: true); + File.Move(temporaryPath, path, overwrite: true); } catch { diff --git a/src/UiText.cs b/src/UiText.cs new file mode 100644 index 0000000..7148a1a --- /dev/null +++ b/src/UiText.cs @@ -0,0 +1,95 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace WeeklyUsageIndicator; + +// Korean source templates are translation keys. Only explicit UI/message calls +// are translated: never walk controls or rewrite account aliases and input text. +internal static class UiText +{ + private static readonly Dictionary English = ReadCatalog("en"); + private static readonly Dictionary Korean = ReadCatalog("ko"); + private static readonly Regex Placeholder = new(@"\{(\d+)(?:,[^}:]+)?(?::[^}]+)?\}", RegexOptions.CultureInvariant); + private static readonly Lazy Patterns = new(BuildPatterns); + private static string _language = "ko"; + internal static string Language => _language; + internal static bool IsEnglish => _language == "en"; + internal static CultureInfo Culture => CultureInfo.GetCultureInfo(IsEnglish ? "en-US" : "ko-KR"); + internal static string SystemLanguage => CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == "ko" ? "ko" : "en"; + internal static bool IsSupported(string? language) => language is "ko" or "en"; + internal static void SetLanguage(string language) + { + if (!IsSupported(language)) throw new ArgumentException("Unsupported UI language.", nameof(language)); + _language = language; + } + internal static string T(string template, params object?[] args) + { + var map = IsEnglish ? English : Korean; + var translated = map.GetValueOrDefault(template, template); + return args.Length == 0 ? translated : string.Format(Culture, translated, args); + } + internal static string F(FormattableString text) => T(text.Format, text.GetArguments()); + + // Cached failure messages remain canonical in the account/query layer. + // Recognize complete known application templates, preserving captured data. + internal static string Message(string message) + { + var exact = T(message); + if (exact != message) return exact; + foreach (var pattern in Patterns.Value) + { + if (pattern.Language != Language) continue; + var match = pattern.Regex.Match(message); + if (!match.Success) continue; + var values = Enumerable.Range(0, pattern.ArgumentCount).Select(i => (object)match.Groups["p" + i].Value).ToArray(); + return string.Format(Culture, pattern.Target, values); + } + // Cleanup summaries carry one known category and an unchanged error code. + var separator = message.IndexOf(" · 0x", StringComparison.Ordinal); + if (separator > 0) return Message(message[..separator]) + message[separator..]; + return message; + } + internal static IReadOnlyDictionary Catalog(string language) => language == "en" ? English : Korean; + private static Dictionary ReadCatalog(string language) + { + var result = new Dictionary(StringComparer.Ordinal); + var assembly = typeof(UiText).Assembly; + foreach (var name in assembly.GetManifestResourceNames().Where(n => n.EndsWith("." + language + ".json", StringComparison.Ordinal)).Order()) + { + using var stream = assembly.GetManifestResourceStream(name)!; + foreach (var entry in JsonSerializer.Deserialize>(stream)!) + { + if (result.TryGetValue(entry.Key, out var previous) && previous != entry.Value) + throw new InvalidOperationException("Conflicting UI translation: " + entry.Key); + result[entry.Key] = entry.Value; + } + } + return result; + } + private static TranslationPattern[] BuildPatterns() + { + var result = new List(); + Add(English, "en"); Add(Korean, "ko"); + // Static translated errors may have been cached before the language changed. + foreach (var pair in English) if (!Korean.ContainsKey(pair.Value)) AddOne(pair.Value, pair.Key, "ko"); + foreach (var pair in Korean) if (!English.ContainsKey(pair.Value)) AddOne(pair.Value, pair.Key, "en"); + return result.ToArray(); + void Add(Dictionary map, string language) + { foreach (var pair in map) AddOne(pair.Key, pair.Value, language); } + void AddOne(string source, string target, string language) + { + var matches = Placeholder.Matches(source); var pattern = new StringBuilder(@"\A"); var start = 0; var count = 0; + foreach (Match match in matches) + { + pattern.Append(Regex.Escape(source[start..match.Index])); + var index = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); count = Math.Max(count, index + 1); + pattern.Append("(?.*?)"); start = match.Index + match.Length; + } + pattern.Append(Regex.Escape(source[start..])).Append(@"\z"); + result.Add(new(language, new Regex(pattern.ToString(), RegexOptions.Singleline | RegexOptions.CultureInvariant | RegexOptions.NonBacktracking), target, count)); + } + } + private sealed record TranslationPattern(string Language, Regex Regex, string Target, int ArgumentCount); +} diff --git a/src/UsageToolTip.cs b/src/UsageToolTip.cs index 70b49c2..8f806fd 100644 --- a/src/UsageToolTip.cs +++ b/src/UsageToolTip.cs @@ -3,8 +3,8 @@ namespace WeeklyUsageIndicator; // A non-activating hover window avoids native tooltip placement under the pointer. internal sealed class UsageToolTip : IDisposable { - private readonly Font _font = new("맑은 고딕", 9.5f); - private readonly Font _heading = new("맑은 고딕", 9.5f, FontStyle.Bold); + private readonly Font _font = AccountFonts.Create(9.5f); + private readonly Font _heading = AccountFonts.Create(9.5f, bold: true); private HoverWindow? _window; private readonly System.Windows.Forms.Timer _hoverDelay = new(); private Control? _hoverOwner; @@ -83,7 +83,7 @@ internal static Point PlaceOutside(Rectangle owner, Size size, Rectangle area, i return new(x, owner.Top >= area.Top + area.Height / 2 ? owner.Top - gap - size.Height : owner.Bottom + gap); } private static bool IsHeading(string line) => line.StartsWith("CODEX", StringComparison.Ordinal) || - line.StartsWith("CLAUDE", StringComparison.Ordinal) || line.StartsWith("전체 계정", StringComparison.Ordinal); + line.StartsWith("CLAUDE", StringComparison.Ordinal) || line.StartsWith("전체 계정", StringComparison.Ordinal) || line.StartsWith("All accounts", StringComparison.Ordinal); internal Size Measure(string text, float scale, IDeviceContext? context = null) { var padding = (int)(16 * scale); var width = (int)(490 * scale); var height = padding * 2; @@ -101,6 +101,7 @@ private int LineHeight(string line, int width, float scale, IDeviceContext? cont } internal void Render(Graphics graphics, Rectangle bounds, string text, float scale) { + graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; using var background = new SolidBrush(AccountUiTheme.Surface); using var border = new Pen(AccountUiTheme.Border); graphics.FillRectangle(background, bounds); graphics.DrawRectangle(border, bounds.X, bounds.Y, bounds.Width - 1, bounds.Height - 1); var padding = (int)(16 * scale); var width = bounds.Width - padding * 2; var y = bounds.Top + padding; diff --git a/src/UsageTooltipText.cs b/src/UsageTooltipText.cs index 5b34e20..cb2b2bd 100644 --- a/src/UsageTooltipText.cs +++ b/src/UsageTooltipText.cs @@ -8,57 +8,57 @@ internal static string Build(UsageSnapshot? codex, ClaudeUsageResult? claude, st string? claudeError, bool showClaude, CombinedUsageSnapshot? combined = null, string? activeLabel = null) { var now = DateTimeOffset.Now; var text = new StringBuilder(); - text.AppendLine("CODEX · " + (activeLabel is null ? "현재 계정" : Name(activeLabel))); - text.AppendLine(Window("주간", codex?.UsedPercent, codex?.ResetsAt, now)); - if (codex?.ShortWindow is { } shortWindow) text.AppendLine(Window("5시간", shortWindow.UsedPercent, shortWindow.ResetsAt, now)); - if (codexError is not null) text.AppendLine("확인 필요: " + codexError); + text.AppendLine("CODEX · " + (activeLabel is null ? UiText.T("현재 계정") : Name(activeLabel))); + text.AppendLine(Window(UiText.T("주간"), codex?.UsedPercent, codex?.ResetsAt, now)); + if (codex?.ShortWindow is { } shortWindow) text.AppendLine(Window(UiText.T("5시간"), shortWindow.UsedPercent, shortWindow.ResetsAt, now)); + if (codexError is not null) text.AppendLine(UiText.T("확인 필요: ") + UiText.Message(codexError)); if (combined is { Accounts.Count: > 0 }) { text.AppendLine(); text.AppendLine(combined.IsComplete(now) - ? $"전체 계정 · 주간 잔여 {combined.ConfirmedRemaining(now)}% / {combined.Accounts.Count * 100}%" - : "전체 계정 · 합계 갱신 필요"); - text.AppendLine(combined.IsBatch && combined.CompletedAt is null ? $"전체 조회 중 · {combined.ConfirmedCount(now)}/{combined.Accounts.Count}개 확인" : !combined.IsBatch && combined.CompletedAt is not null ? "개별 확인값 · 전체 합계 갱신 필요" : combined.CompletedAt is { } at - ? $"{(combined.IsComplete(now) ? "전체 확인" : "마지막 조회")} {at.ToLocalTime():MM/dd HH:mm} 기준 · 계정당 100%" - : "저장된 값 · 계정 관리에서 전체 갱신해 주세요."); + ? UiText.F($"전체 계정 · 주간 잔여 {combined.ConfirmedRemaining(now)}% / {combined.Accounts.Count * 100}%") + : UiText.T("전체 계정 · 합계 갱신 필요")); + text.AppendLine(combined.IsBatch && combined.CompletedAt is null ? UiText.F($"전체 조회 중 · {combined.ConfirmedCount(now)}/{combined.Accounts.Count}개 확인") : !combined.IsBatch && combined.CompletedAt is not null ? UiText.T("개별 확인값 · 전체 합계 갱신 필요") : combined.CompletedAt is { } at + ? UiText.F($"{(combined.IsComplete(now) ? UiText.T("전체 확인") : UiText.T("마지막 조회"))} {at.ToLocalTime():MM/dd HH:mm} 기준 · 계정당 100%") + : UiText.T("저장된 값 · 계정 관리에서 전체 갱신해 주세요.")); if (combined.IsBatch && !combined.IsComplete(now) && combined.ConfirmedCount(now) > 0) - text.AppendLine($"확인된 잔여 {combined.ConfirmedRemaining(now)}% · {combined.ConfirmedCount(now)}/{combined.Accounts.Count}개"); + text.AppendLine(UiText.F($"확인된 잔여 {combined.ConfirmedRemaining(now)}% · {combined.ConfirmedCount(now)}/{combined.Accounts.Count}개")); foreach (var row in combined.ByReset().Take(5)) { var a = row.Account; - var remaining = a.Usage?.ResetsAt <= now ? "갱신 필요" : row.HasWeeklyValue - ? $"{(row.State == CombinedReadState.Success ? "" : "이전 ")}{100 - a.Usage!.UsedPercent}%" : "미확인"; - var state = row.State == CombinedReadState.Failed ? " · 조회 실패" : row.State == CombinedReadState.Canceled ? " · 조회 취소" : ""; - text.AppendLine($"{Name(a.Label)}{(a.IsActive ? " [사용 중]" : "")} {remaining}{state}"); - text.AppendLine($" 초기화 {Reset(a.Usage?.ResetsAt, now)}" + - (row.State != CombinedReadState.Success && a.ObservedAt is { } savedAt ? $" · {savedAt.ToLocalTime():MM/dd HH:mm} 값" : "")); + var remaining = a.Usage?.ResetsAt <= now ? UiText.T("갱신 필요") : row.HasWeeklyValue + ? $"{(row.State == CombinedReadState.Success ? "" : UiText.T("이전 "))}{100 - a.Usage!.UsedPercent}%" : UiText.T("미확인"); + var state = row.State == CombinedReadState.Failed ? UiText.T(" · 조회 실패") : row.State == CombinedReadState.Canceled ? UiText.T(" · 조회 취소") : ""; + text.AppendLine($"{Name(a.Label)}{(a.IsActive ? UiText.T(" [사용 중]") : "")} {remaining}{state}"); + text.AppendLine(UiText.F($" 초기화 {Reset(a.Usage?.ResetsAt, now)}") + + (row.State != CombinedReadState.Success && a.ObservedAt is { } savedAt ? UiText.F($" · {savedAt.ToLocalTime():MM/dd HH:mm} 값") : "")); } - if (combined.Accounts.Count > 5) text.AppendLine($"외 {combined.Accounts.Count - 5}개 · 계정 관리에서 모두 보기"); + if (combined.Accounts.Count > 5) text.AppendLine(UiText.F($"외 {combined.Accounts.Count - 5}개 · 계정 관리에서 모두 보기")); } if (showClaude) { text.AppendLine(); text.AppendLine("CLAUDE"); var snapshot = claudeError is null ? claude?.Snapshot : null; text.AppendLine(Window("Fable", snapshot?.Fable?.UsedPercent, snapshot?.Fable?.ResetsAt, now)); - text.AppendLine(Window("5시간", snapshot?.FiveHour?.UsedPercent, snapshot?.FiveHour?.ResetsAt, now)); - text.AppendLine(Window("전체 주간", snapshot?.Weekly?.UsedPercent, snapshot?.Weekly?.ResetsAt, now)); - if (claudeError is not null) text.AppendLine("확인 필요: " + claudeError); + text.AppendLine(Window(UiText.T("5시간"), snapshot?.FiveHour?.UsedPercent, snapshot?.FiveHour?.ResetsAt, now)); + text.AppendLine(Window(UiText.T("전체 주간"), snapshot?.Weekly?.UsedPercent, snapshot?.Weekly?.ResetsAt, now)); + if (claudeError is not null) text.AppendLine(UiText.T("확인 필요: ") + UiText.Message(claudeError)); else if (claude is { IsStale: true }) { - text.AppendLine($"업데이트 지연 · 마지막 성공 {claude.LastUpdatedAt.ToLocalTime():MM/dd HH:mm}"); - if (claude.RetryAfter is { } retry) text.AppendLine($"다음 시도 {Reset(retry, now)}"); + text.AppendLine(UiText.F($"업데이트 지연 · 마지막 성공 {claude.LastUpdatedAt.ToLocalTime():MM/dd HH:mm}")); + if (claude.RetryAfter is { } retry) text.AppendLine(UiText.F($"다음 시도 {Reset(retry, now)}")); } } - text.AppendLine(); text.Append("우클릭 → Codex 계정 관리 · 전체 갱신 / 계정 전환"); + text.AppendLine(); text.Append(UiText.T("우클릭 → Codex 계정 관리 · 전체 갱신 / 계정 전환")); return text.ToString(); } private static string Window(string label, double? used, DateTimeOffset? reset, DateTimeOffset now) { - if (used is null) return label + ": 정보 없음"; - var value = reset <= now ? "갱신 필요" : $"{100 - (int)Math.Round(Math.Clamp(used.Value, 0, 100), MidpointRounding.AwayFromZero)}% 남음"; - return $"{label}: {value} · 초기화: {Reset(reset, now)}"; + if (used is null) return label + UiText.T(": 정보 없음"); + var value = reset <= now ? UiText.T("갱신 필요") : UiText.F($"{100 - (int)Math.Round(Math.Clamp(used.Value, 0, 100), MidpointRounding.AwayFromZero)}% 남음"); + return UiText.F($"{label}: {value} · 초기화: {Reset(reset, now)}"); } private static string Reset(DateTimeOffset? reset, DateTimeOffset now) => reset is { } at - ? $"{at.ToLocalTime():MM/dd HH:mm} ({CombinedUsagePanel.TimeUntil(at, now)})" : "정보 없음"; + ? $"{at.ToLocalTime():MM/dd HH:mm} ({CombinedUsagePanel.TimeUntil(at, now)})" : UiText.T("정보 없음"); private static string Name(string text) => text.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' '); } diff --git a/src/WeeklyUsageIndicator.csproj b/src/WeeklyUsageIndicator.csproj index 40180dd..45e38bc 100644 --- a/src/WeeklyUsageIndicator.csproj +++ b/src/WeeklyUsageIndicator.csproj @@ -9,10 +9,15 @@ WeeklyUsageIndicator WeeklyUsageIndicator app.manifest - 1.7.1 + 1.8.0 true none false true + + + + + diff --git a/tests/WeeklyUsageIndicator.Tests/AccountOpeningSmoke.cs b/tests/WeeklyUsageIndicator.Tests/AccountOpeningSmoke.cs new file mode 100644 index 0000000..a1dbff0 --- /dev/null +++ b/tests/WeeklyUsageIndicator.Tests/AccountOpeningSmoke.cs @@ -0,0 +1,136 @@ +using System.Diagnostics; +using System.Drawing; +using System.Text.Json; +using System.Windows.Forms; +using WeeklyUsageIndicator; + +// Observe the production window from its first native paint, not only a settled DrawToBitmap. +internal static class AccountOpeningSmoke +{ + internal static Task RunAsync(bool traceOnly = false) + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => + { + var taskRoot = Environment.GetEnvironmentVariable("GFS_ACCOUNT_TEST_ROOT") + ?? Path.Combine(Path.GetTempPath(), "gfs-agent", "account-opening-tests"); + var root = Path.Combine(taskRoot, "opening-" + Guid.NewGuid().ToString("N")); + var trace = new List(); + var failures = new List(); + try + { + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); Application.EnableVisualStyles(); + foreach (var cached in new[] { true, false }) + { + var home = Directory.CreateDirectory(Path.Combine(root, cached.ToString(), "home")).FullName; + var authPath = Path.Combine(home, "auth.json"); + var originalAuth = AccountUsageQueryTests.Auth("opening-a", "fixture"); + File.WriteAllBytes(authPath, originalAuth); + var store = new CodexAccountStore(Path.Combine(root, cached.ToString(), "vault"), home); + var main = store.RegisterCurrent("메인 계정"); + var file = Path.Combine(root, "import.json"); + File.WriteAllBytes(file, AccountUsageQueryTests.Auth("opening-b", "fixture")); var second = store.ImportLoginFile(file, "서브 계정"); + File.WriteAllBytes(file, AccountUsageQueryTests.Auth("opening-c", "fixture")); var third = store.ImportLoginFile(file, "서브 계정2"); + var accounts = new[] { main, second, third }; + var now = DateTimeOffset.Now; + UsageSnapshot Usage(string id) => new(37, now.AddDays(id == main.Id ? 6 : id == second.Id ? 4 : 2), 10080, "codex"); + async Task Save(SavedCodexAccount account, CancellationToken token) + { + if (account.IsActive) store.SaveUsage(store.GetCurrentIdentity().Key, Usage(account.Id)); + else await Task.Run(() => store.QueryInactiveUsage(account.Id, (_, _) => Task.FromResult( + new CodexAccountUsage(Usage(account.Id), "fixture@example.invalid", "pro", true)), token), token); + } + if (cached) foreach (var account in accounts) Save(account, CancellationToken.None).GetAwaiter().GetResult(); + var calls = 0; + var gates = Enumerable.Range(0, 6).Select(_ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)).ToArray(); + using var form = new AccountManagerForm(store, () => Task.CompletedTask, () => { }, queryUsage: async (account, token) => + { + var index = calls++; + await gates[index].Task.WaitAsync(token); + await Save(account, token); + }); + form.Text = "Codex 계정 관리 · 열림 검증 (가상 계정)"; + var clock = Stopwatch.StartNew(); + bool painted = false; + Dictionary? firstGeometry = null; + AccountTable Table() => form.Controls.Find("AccountList", true).OfType().Single(); + Dictionary Geometry() => Descendants(form) + .Where(c => c.Visible && (c.Name.StartsWith("AccountRow-", StringComparison.Ordinal) || c.Name is "AccountList" or "CombinedUsagePanel" or "RefreshAllUsageButton")) + .ToDictionary(c => c.Name, c => new Rectangle(form.PointToClient(c.PointToScreen(Point.Empty)), c.Size)); + void Record(string kind) => trace.Add(new { cached, kind, ms = clock.ElapsedMilliseconds, calls, form.DeviceDpi, + geometry = Geometry().ToDictionary(p => p.Key, p => p.Value.ToString()) }); + void Check(bool condition, string message) { if (!condition) failures.Add($"cached={cached}: {message}"); } + void Watch(Control control) + { + control.LocationChanged += (_, _) => { if (painted && control.Visible && control.Name.Length > 0) Record("move:" + control.Name); }; + control.SizeChanged += (_, _) => { if (painted && control.Visible && control.Name.Length > 0) Record("size:" + control.Name); }; + control.ControlAdded += (_, e) => { if (e.Control is { } child) Watch(child); }; + foreach (Control child in control.Controls) Watch(child); + } + Watch(form); + form.Paint += (_, _) => + { + if (painted) + { + if (form.IsOperationInProgress && firstGeometry is not null) + Check(Same(firstGeometry, Geometry()), "every painted loading frame must retain the initial geometry"); + return; + } + painted = true; firstGeometry = Geometry(); Record("first-paint"); + if (traceOnly) trace.Add(new { kind = "first-paint-stack", visible = form.Visible, stack = Environment.StackTrace }); + Check(firstGeometry.Keys.Count(k => k.StartsWith("AccountRow-", StringComparison.Ordinal)) == 3, + "the first visible paint must already contain all three account rows"); + }; + form.Shown += async (_, _) => + { + try + { + await Task.Delay(100); + var opening = Geometry(); Record("opening-waiting"); + Check(firstGeometry is not null && Same(firstGeometry, opening), "initial account positions must be ready before first paint"); + for (var i = 0; i < 3; i++) + { + gates[i].TrySetResult(); + await Until(() => calls >= i + 2 || !form.IsOperationInProgress); + await Task.Delay(75); Record("result-" + i); + if (i < 2) Check(Same(opening, Geometry()), "rows must not shuffle or resize after each partial response"); + } + await Until(() => !form.IsOperationInProgress); + Check(Table().Items.Count == 3 && !Table().VerticalScroll.Visible, "three rows fit without scroll after completion"); + var expectedOrder = form.CombinedUsage.ByReset().Select(r => "AccountRow-" + r.Account.Id); + var actualOrder = Geometry().Where(p => p.Key.StartsWith("AccountRow-", StringComparison.Ordinal)).OrderBy(p => p.Value.Top).Select(p => p.Key); + Check(expectedOrder.SequenceEqual(actualOrder), "the final rows use nearest reset order"); + var stable = Geometry(); + form.Reload(quiet: true); + await Task.Delay(75); Record("local-refresh"); + Check(Same(stable, Geometry()) && calls == 3, "local refresh must preserve geometry and not query"); + form.Hide(); form.Show(); await Task.Delay(75); + Check(Same(stable, Geometry()) && calls == 3, "reshow must preserve geometry and not query"); + Check(File.ReadAllBytes(authPath).SequenceEqual(originalAuth), "opening never changes active credentials"); + } + catch (Exception ex) { done.TrySetException(ex); } + finally { foreach (var gate in gates) gate.TrySetResult(); form.Close(); } + }; + Application.Run(form); + } + var capture = Environment.GetEnvironmentVariable("GFS_ACCOUNT_UI_CAPTURE"); + if (capture is not null) File.WriteAllText(Path.Combine(Path.GetDirectoryName(capture)!, "opening-trace.json"), + JsonSerializer.Serialize(new { failures, events = trace }, new JsonSerializerOptions { WriteIndented = true })); + Console.WriteLine($"Opening trace: {trace.Count} events; {failures.Count} violated expectations."); + if (!traceOnly && failures.Count > 0) throw new InvalidOperationException(string.Join(Environment.NewLine, failures)); + done.TrySetResult(); + } + catch (Exception ex) { done.TrySetException(ex); } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + }); + thread.SetApartmentState(ApartmentState.STA); thread.Start(); return done.Task; + } + private static IEnumerable Descendants(Control control) => control.Controls.Cast() + .SelectMany(child => new[] { child }.Concat(Descendants(child))); + private static bool Same(Dictionary a, Dictionary b) => a.Count == b.Count && a.All(p => b.TryGetValue(p.Key, out var r) && p.Value == r); + private static async Task Until(Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!condition()) { if (DateTime.UtcNow > deadline) throw new TimeoutException("Opening fixture timed out."); await Task.Delay(15); } + } +} diff --git a/tests/WeeklyUsageIndicator.Tests/AccountStylePreview.cs b/tests/WeeklyUsageIndicator.Tests/AccountStylePreview.cs new file mode 100644 index 0000000..abb7de6 --- /dev/null +++ b/tests/WeeklyUsageIndicator.Tests/AccountStylePreview.cs @@ -0,0 +1,107 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.Windows.Forms; +using WeeklyUsageIndicator; + +// Production forms with synthetic data, for visual review without touching live accounts. +internal static class AccountStylePreview +{ + internal static Task RunAsync(bool hold, bool standardDpi = false) + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => + { + var basePath = Environment.GetEnvironmentVariable("GFS_ACCOUNT_TEST_ROOT") + ?? throw new InvalidOperationException("Set GFS_ACCOUNT_TEST_ROOT to the task workspace."); + var root = Path.Combine(basePath, "style-" + Guid.NewGuid().ToString("N")); + try + { + Application.SetHighDpiMode(standardDpi ? HighDpiMode.DpiUnaware : HighDpiMode.PerMonitorV2); Application.EnableVisualStyles(); + var home = Directory.CreateDirectory(Path.Combine(root, "home")).FullName; + File.WriteAllBytes(Path.Combine(home, "auth.json"), AccountUsageQueryTests.Auth("style-a", "preview")); + var store = new CodexAccountStore(Path.Combine(root, "vault"), home); + var main = store.RegisterCurrent("메인 계정"); + var file = Path.Combine(root, "import.json"); + File.WriteAllBytes(file, AccountUsageQueryTests.Auth("style-b", "preview")); var second = store.ImportLoginFile(file, "서브 계정"); + File.WriteAllBytes(file, AccountUsageQueryTests.Auth("style-c", "preview")); var third = store.ImportLoginFile(file, "서브 계정2"); + var now = DateTimeOffset.Now; + var accounts = new[] { main, second, third }.Select((account, i) => account with + { + Usage = new UsageSnapshot(new[] { 37, 19, 76 }[i], now.AddDays(2 + i * 2), 10080, "codex"), + ObservedAt = now + }).ToArray(); + var snapshot = new CombinedUsageSnapshot(); + snapshot.Initialize(accounts, batch: true); + foreach (var account in accounts) snapshot.SetResult(account.Id, account, CombinedReadState.Success); + snapshot.CompletedAt = now; + using var form = new AccountManagerForm(store, () => Task.CompletedTask, () => { }, + refreshAllOnOpen: false, combined: snapshot, + queryUsage: (_, _) => throw new InvalidOperationException("디자인 미리보기에서는 실제 계정을 조회하지 않습니다.")); + form.Text += UiText.IsEnglish ? " · Design preview" : " · 디자인 미리보기"; + form.Shown += async (_, _) => + { + try + { + await Task.Delay(200); Capture(form, "manager.png"); + using (var widget = new UsageIndicatorForm(previewMode: true)) + { + typeof(UsageIndicatorForm).GetField("_showClaude", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.SetValue(widget, true); + typeof(UsageIndicatorForm).GetField("_claudeUsage", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.SetValue(widget, + new ClaudeUsageResult(new(new(10, now.AddHours(3)), new(20, now.AddDays(3)), new(40, now.AddDays(3))), now, null, false)); + widget.Show(); await Task.Delay(100); Capture(widget, "widget.png"); widget.Close(); + } + store.Rename(second.Id, "서브 계정 · 긴 한글 이름과 English 0123456789 확인"); + form.MenuAction("RefreshAccountsButton").PerformClick(); + await Task.Delay(100); Capture(form, "long-name.png"); + using (var dialog = new AccountNameDialog("메인 계정")) + { + dialog.Show(form); await Task.Delay(100); Capture(dialog, "rename.png"); dialog.Close(); + } + using (var dialog = new AccountNameDialog("", adding: true)) + { + dialog.Show(form); await Task.Delay(100); Capture(dialog, "add.png"); dialog.Close(); + } + using (var dialog = new AccountSwitchDialog("메인 계정", "서브 계정", () => throw new InvalidOperationException("Fixture writer"))) + { + dialog.Show(form); await Task.Delay(100); Capture(dialog, "switch.png"); dialog.Close(); + } + var output = Environment.GetEnvironmentVariable("GFS_ACCOUNT_UI_CAPTURE"); + if (!string.IsNullOrEmpty(output)) + { + using var tooltip = new UsageToolTip(); + var text = UsageTooltipText.Build(accounts[0].Usage, null, null, null, false, snapshot, accounts[0].Label); + using var screen = form.CreateGraphics(); + var scale = form.DeviceDpi / 96f; var size = tooltip.Measure(text, scale, screen); + using var bitmap = new Bitmap(size.Width, size.Height); bitmap.SetResolution(screen.DpiX, screen.DpiY); + using var graphics = Graphics.FromImage(bitmap); + tooltip.Render(graphics, new Rectangle(Point.Empty, size), text, scale); + bitmap.Save(Path.Combine(Path.GetDirectoryName(output)!, "tooltip.png"), ImageFormat.Png); + } + store.Rename(second.Id, "서브 계정"); + form.MenuAction("RefreshAccountsButton").PerformClick(); + if (!hold) form.Close(); + } + catch (Exception ex) { done.TrySetException(ex); form.Close(); } + }; + Application.Run(form); done.TrySetResult(); + } + catch (Exception ex) { done.TrySetException(ex); } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + }); + thread.SetApartmentState(ApartmentState.STA); thread.Start(); return done.Task; + } + private static void Capture(Form form, string name) + { + var output = Environment.GetEnvironmentVariable("GFS_ACCOUNT_UI_CAPTURE"); + if (string.IsNullOrEmpty(output)) return; + using var bitmap = new Bitmap(form.Width, form.Height); + form.DrawToBitmap(bitmap, new Rectangle(Point.Empty, bitmap.Size)); + bitmap.Save(Path.Combine(Path.GetDirectoryName(output)!, name), ImageFormat.Png); + IEnumerable Walk(Control c) => new[] { c }.Concat(c.Controls.Cast().SelectMany(Walk)); + foreach (var c in Walk(form).Where(c => c.Visible && c.Parent is not null && c.Parent is not ScrollableControl { AutoScroll: true })) + if (!c.Parent!.ClientRectangle.Contains(c.Bounds)) + throw new InvalidOperationException($"Native preview clips {c.Name} ({c.GetType().Name}) at {c.DeviceDpi} DPI."); + File.WriteAllText(Path.ChangeExtension(Path.Combine(Path.GetDirectoryName(output)!, name), ".json"), + System.Text.Json.JsonSerializer.Serialize(Walk(form).Select(c => new { c.Name, Type = c.GetType().Name, c.Text, Bounds = c.Bounds.ToString(), c.DeviceDpi, Font = c.Font.Size, Parent = c.Parent?.GetType().Name }))); + } +} diff --git a/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs b/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs index 840da24..0a19b57 100644 --- a/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs +++ b/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs @@ -30,6 +30,7 @@ internal static Task RunAsync() using var form = new AccountManagerForm(store, () => { suspended++; return Task.CompletedTask; }, () => resumed++, refreshAllOnOpen: false); form.Show(); Application.DoEvents(); + Check(form.Font.Name == "Pretendard", "the portable manager uses its bundled Korean font"); Capture(form, output, "-empty"); Button(form, "RegisterCurrentButton").PerformClick(); Check(store.IsEnabled && store.ListAccounts().Single().Label == "계정 1", "registration needs no name entry"); diff --git a/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs b/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs index d32948d..2370a75 100644 --- a/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs +++ b/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs @@ -47,8 +47,13 @@ internal static Task RunAsync() form.MenuAction("RefreshAccountsButton").PerformClick(); Check(calls == 0 && button.Enabled, "opening/selecting/reloading never makes a remote request"); button.PerformClick(); - Check(form.IsOperationInProgress && !button.Enabled && !list.Enabled && !form.SelectedSwitchButton!.Enabled, + var rowActions = list.Items.SelectMany(account => new[] { "SwitchAccount-" + account.Id, "ManageAccount-" + account.Id }) + .Select(name => Find