From 6f89178ba63c170fd08ad2f5f02cbc4b7d066ae4 Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Wed, 27 May 2026 22:59:42 -0700 Subject: [PATCH 01/71] docs(spec): draft 011-native-dialogs-completion (spec + plan + tasks) Spec covers the remaining Win32 UI surfaces left after 007: - unified startup download dialog (ROMs + Disk II audio) - boot disk picker with per-user MRU - themed dialog primitive (extracted from SettingsWindow) - About / Keymap / Machine Info conversions - drive widget filename label - file-open path dedup - DebugConsole + DiskIIDebugDialog DX conversions Preserves two Win32 escape hatches: IFileOpenDialog in PromptForDiskImage, and the top-level EHM notify MessageBoxW. 65 tasks across 11 phases (P1 ships independently as MVP). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 2 +- .specify/feature.json | 2 +- .../checklists/requirements.md | 40 +++ .../contracts/dialog-primitive.md | 149 +++++++++ .../data-model.md | 159 +++++++++ specs/011-native-dialogs-completion/plan.md | 217 ++++++++++++ .../quickstart.md | 178 ++++++++++ .../011-native-dialogs-completion/research.md | 124 +++++++ specs/011-native-dialogs-completion/spec.md | 212 ++++++++++++ specs/011-native-dialogs-completion/tasks.md | 316 ++++++++++++++++++ 10 files changed, 1397 insertions(+), 2 deletions(-) create mode 100644 specs/011-native-dialogs-completion/checklists/requirements.md create mode 100644 specs/011-native-dialogs-completion/contracts/dialog-primitive.md create mode 100644 specs/011-native-dialogs-completion/data-model.md create mode 100644 specs/011-native-dialogs-completion/plan.md create mode 100644 specs/011-native-dialogs-completion/quickstart.md create mode 100644 specs/011-native-dialogs-completion/research.md create mode 100644 specs/011-native-dialogs-completion/spec.md create mode 100644 specs/011-native-dialogs-completion/tasks.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 85b24ff4..de6fc10b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -367,7 +367,7 @@ void Function2() For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan -at specs/007-ui-overhaul/plan.md +at specs/011-native-dialogs-completion/plan.md ## Security Rules diff --git a/.specify/feature.json b/.specify/feature.json index 13b71cce..46649a9e 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/007-ui-overhaul" + "feature_directory": "specs/011-native-dialogs-completion" } diff --git a/specs/011-native-dialogs-completion/checklists/requirements.md b/specs/011-native-dialogs-completion/checklists/requirements.md new file mode 100644 index 00000000..b807d5b0 --- /dev/null +++ b/specs/011-native-dialogs-completion/checklists/requirements.md @@ -0,0 +1,40 @@ +# Specification Quality Checklist: Native DX Dialogs Completion + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-27 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- The spec necessarily names existing internal surfaces (e.g., `SettingsWindow`, + `GlobalUserPrefs`, `DriveWidgetState`, `DiskIIDebugDialogState`, `IFileOpenDialog`, + `MessageBoxW`) because the user request was an audit-driven cleanup whose scope + is *defined* by those specific surfaces. These references are scope anchors, + not implementation prescriptions — the spec does not dictate how the new DX + primitive is built, only what it must replace. +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/011-native-dialogs-completion/contracts/dialog-primitive.md b/specs/011-native-dialogs-completion/contracts/dialog-primitive.md new file mode 100644 index 00000000..bd5b74ce --- /dev/null +++ b/specs/011-native-dialogs-completion/contracts/dialog-primitive.md @@ -0,0 +1,149 @@ +# Contract: Reusable Themed Dialog Primitive + +**Location**: `Casso/Ui/Dialog/DialogPrimitive.{h,cpp}` and +`Casso/Ui/Dialog/DialogDefinition.h` +**Modeled on**: `Casso/Ui/Settings/SettingsWindow.cpp` +**Consumers (P1+P2)**: `StartupDownloadDialog`, `BootDiskPicker`, +About / Keymap / Machine Info commands in `WindowCommandManager`, +the stray `MessageBoxW` in `SettingsPanel.cpp`. + +## DialogDefinition + +```cpp +enum class DialogIcon +{ + None, + AppPhotoreal, // IDI_CASSO_PHOTOREAL + AppFlat, // IDI_CASSO_FLAT (etc., as needed) + Info, + Warning, + Error, +}; + +struct DialogTextRun +{ + std::wstring text; + bool isHyperlink = false; + std::wstring hyperlinkUrl; // ignored when isHyperlink == false +}; + +struct DialogButton +{ + std::wstring label; + int resultCode = 0; // returned by DialogPrimitive::Show + bool isDefault = false; // Enter activates + bool isCancel = false; // Escape activates +}; + +struct DialogDefinition +{ + std::wstring title; + DialogIcon icon = DialogIcon::None; + std::vector body; // wraps within the body content area + std::vector buttons; // rendered left-to-right, right-aligned + + // Optional caller-supplied paint hook for custom body content (used by + // StartupDownloadDialog progress UI and BootDiskPicker list). When set, + // `body` is rendered ABOVE the custom area, buttons BELOW it. + std::function onPaintCustomBody; + std::function onInputCustomBody; +}; +``` + +## DialogPrimitive (the modal surface) + +```cpp +class DialogPrimitive +{ +public: + // Blocks until a button is clicked, Enter / Escape, or programmatic Close. + // Returns the resultCode of the activated button, or -1 if cancelled + // via window-close gesture with no isCancel button defined. + static int Show ( + HWND ownerHwnd, + const ChromeTheme & theme, + DxUiPainter & painter, + const DialogDefinition & definition); + + // Programmatic close (used by callers that drive their own progress and + // want to dismiss the dialog when work completes). + void Close (int resultCode); +}; +``` + +## Behavioral requirements + +- **Modality**: behaves identically to `SettingsWindow`. Owner window is + disabled for input until the dialog dismisses. +- **Themes**: renders correctly under DarkModern, Skeuomorphic, + GreenScreen using `ChromeTheme` palette (FR-013, SC-005). +- **DPI**: re-lays out on `WM_DPICHANGED` while open (FR-013, edge case + "DPI changes while a themed dialog is open"). +- **Theme change while open**: repaints with the new palette without + dismissing (edge case in spec). +- **Hyperlink hit-testing**: links inside the `body` text are clickable + and dispatch `ShellExecuteW (NULL, L"open", url, …)`. On failure, + report via `CHRN` (themed dialog, since the primitive itself is up + by definition). +- **Keyboard**: + - `Enter` activates the `isDefault == true` button (if any). + - `Escape` activates the `isCancel == true` button (if any). + - `Tab` cycles buttons left-to-right; `Shift+Tab` reverse. +- **No TaskDialog command-link buttons** (FR-004 explicitly forbids + them). Buttons are single-line label only. +- **Window-close gesture** with no `isCancel` button → return `-1`. + +## Layout contract (pure, testable in `DialogLayout`) + +```cpp +struct DialogLayoutMetrics +{ + float dpiScale; + float maxBodyWidthPx; // bounding the wrapped body text + float buttonHeightPx; + float buttonPaddingPx; + float buttonSpacingPx; + float iconSizePx; // 0 when icon == DialogIcon::None + float bodyLineHeightPx; + float outerPaddingPx; + std::function measureBodyTextRun; + std::function measureButtonLabel; +}; + +struct DialogLayoutResult +{ + SIZE totalSizePx; + RECT iconRectPx; // empty when no icon + std::vector bodyRunRectsPx; // 1:1 with body runs + std::vector hyperlinkHitRectsPx; // subset of body + std::vector buttonRectsPx; // 1:1 with buttons + RECT customBodyRectPx; // empty when no hook +}; + +DialogLayoutResult LayoutDialog ( + const DialogDefinition & def, + const DialogLayoutMetrics & metrics); +``` + +This free function is the unit-testable surface (`DialogLayoutTests.cpp`). +Tests supply deterministic `measure*` callbacks so layout math is +verified without DirectWrite. + +## Failure modes + +| Mode | Handling | +| --------------------------------------------- | ----------------------------------------------------------------------- | +| Painter / device not initialized | `CWRA` — bug, asserting variant. The primitive is only valid after the chrome painter is up. | +| Definition has zero buttons and no close gesture available | `CBRA` — bug; callers MUST supply at least one button or rely on the window-close gesture. | +| `ShellExecuteW` fails for a hyperlink | `CHRN` — user-facing notification through another themed dialog. | +| Icon resource id not found | `CWRA` — bug; icon ids are compile-time enum values mapped to known resources. | + +## Out of scope for the primitive + +- Asynchronous download / progress engine — the consumer drives its + own progress and calls `Close` when done; the primitive just hosts + the paint surface. +- Multi-line command-link buttons (FR-004). +- Rich-text body beyond inline hyperlinks (no bold, no inline images). +- File picker integration (`IFileOpenDialog` stays in + `PromptForDiskImage`). diff --git a/specs/011-native-dialogs-completion/data-model.md b/specs/011-native-dialogs-completion/data-model.md new file mode 100644 index 00000000..5f98fba0 --- /dev/null +++ b/specs/011-native-dialogs-completion/data-model.md @@ -0,0 +1,159 @@ +# Phase 1 Data Model: Native DX Dialogs Completion + +This feature adds two persistent / in-memory data structures and extends one +existing structure. + +## 1. Disk MRU (`DiskMru`) + +**Owner**: `Casso/Shell/GlobalUserPrefs.*` (persistence) + a small +`DiskMru` helper class with the pure list operations. + +**Persisted shape** (in `GlobalUserPrefs` JSON): + +```json +{ + "recentDisks": [ + "C:\\Users\\…\\Disks\\GameA.dsk", + "C:\\Users\\…\\Disks\\GameB.dsk" + ] +} +``` + +**In-memory shape**: + +```cpp +class DiskMru +{ +public: + static constexpr size_t k_capacity = 16; + + void RecordMount (const std::filesystem::path & path); + std::vector Snapshot () const; + std::vector Prune (const std::function & existsPredicate); + +private: + std::vector m_entries; // index 0 = most recent +}; +``` + +**Rules** (mirrors FR-003): + +- Most-recent-first ordering. +- Cap = 16. New mount at cap evicts the oldest (index `k_capacity - 1`). +- Re-mounting an existing entry moves it to index 0 (no duplicate growth). +- Path comparison: lexical equality after `std::filesystem::weakly_canonical` + where available, otherwise raw string equality (decide in implementation + if a difference between two casings of the same path causes test failures). +- `Prune` takes an injected `existsPredicate` so unit tests can drive it + without touching the real file system. Production callers pass + `[] (const auto & p) { return std::filesystem::exists (p); }`. + +**Validation**: + +- All entries are non-empty absolute paths. +- On load, malformed entries (non-string, empty) are dropped silently + (do not fail prefs load). +- On save, entries are written in current in-memory order. + +**State transitions**: + +| Event | Effect | +| ---------------------------------------- | ------------------------------------------------------------ | +| Mount via any path (picker/drag/boot) | `RecordMount` → moves or inserts at index 0, evicts oldest | +| Boot picker render | `Prune` with real `exists` → re-persist if anything changed | +| User clears prefs | List goes empty | +| Eject | No change — MRU remembers past mounts, not current state | + +## 2. Drive Widget State extension + +**Owner**: `Casso/Ui/Chrome/DriveWidget.*` (state struct) + +`DriveWidgetController` (population). + +**Existing shape** (snapshot — actual fields per current source): +roughly `{ driveIndex, isActive, … }`. + +**Extension** (FR-007 / FR-008): + +```cpp +struct DriveWidgetState +{ + // … existing fields … + + std::filesystem::path imagePath; // empty when no disk mounted + // (Alternative: std::string imageName already derived. Decision below.) +}; +``` + +**Decision: store `imagePath`, derive basename at paint time.** + +- Rationale: the controller already knows the full path at mount time. + Storing the path keeps the option open for "show full path on hover" + in a later spec without an additional plumbing pass. Basename + derivation (`imagePath.filename()`) is cheap and deterministic. +- Edge case from spec: filenames with no extension or multiple dots + display the literal `path.filename()` result; do not strip extensions. + +**Truncation algorithm** (pure, testable — `DriveLabelTruncation.*`): + +```cpp +std::wstring TruncateToWidth ( + std::wstring_view basename, + float maxWidthPx, + const std::function & measure); +``` + +- If `measure (basename) <= maxWidthPx` → return `basename`. +- Otherwise binary-search the longest prefix `p` such that + `measure (p + L"…") <= maxWidthPx`; return that. +- The single-character ellipsis (`L'\u2026'`) is used, not three dots. +- The injected `measure` callback is `DxUiPainter::MeasureTextRunWidth` + in production and a deterministic stub in tests. + +**State transitions**: + +| Event | Effect | +| --------------- | ------------------------------------- | +| Disk mount | `imagePath = newPath; repaint` | +| Disk eject | `imagePath.clear (); repaint` | +| Theme change | repaint only (path unchanged) | +| DPI change | re-truncate against new pixel width | + +## 3. Themed Dialog Definition (`DialogDefinition`) + +**Owner**: `Casso/Ui/Dialog/DialogDefinition.h`. + +Pure value type consumed by `DialogPrimitive::Show`. Details (fields, +button result codes, hyperlink representation) are documented in +[`contracts/dialog-primitive.md`](./contracts/dialog-primitive.md). + +This is the only "new shape" used by every P1/P2 consumer +(unified-startup, boot-disk picker, About, Keymap, Machine Info, +SettingsPanel-stray). P3 consumers (Debug Console, Disk II Debug +Panel) host their own bespoke content inside a primitive-style +chrome rather than fitting into a simple `DialogDefinition`. + +## 4. Startup Download Set (transient) + +**Owner**: `Casso/Ui/Dialog/StartupDownloadDialog.*`. + +Aggregates the asset-bootstrap discovery output into a single +collection the unified dialog enumerates: + +```cpp +struct StartupAssetEntry +{ + std::wstring displayLabel; // e.g., "Apple //e Enhanced ROM" + std::wstring destinationPath; + std::wstring sourceUrl; + uint64_t expectedSizeBytes; +}; + +struct StartupDownloadSet +{ + std::vector missing; // empty => no dialog shown +}; +``` + +Lifetime: built once during early boot from existing `AssetBootstrap` +discovery, consumed by the dialog, discarded after user decision. +Not persisted. diff --git a/specs/011-native-dialogs-completion/plan.md b/specs/011-native-dialogs-completion/plan.md new file mode 100644 index 00000000..4e774357 --- /dev/null +++ b/specs/011-native-dialogs-completion/plan.md @@ -0,0 +1,217 @@ +# Implementation Plan: Native DX Dialogs Completion + +**Branch**: `011-native-dialogs-completion` | **Date**: 2026-05-27 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/011-native-dialogs-completion/spec.md` + +## Summary + +Complete the spec-007 UI overhaul by converting every remaining Win32 UI surface in +`Casso/` (asset-bootstrap modals, Help/About/Keymap/Machine-Info MessageBoxes, the +`SettingsPanel` stray MessageBox, the legacy `GetOpenFileName` disk-insert path, the +drive widget label, the `DebugConsole` EDIT control, and the full `DiskIIDebugDialog`) +into themed DX overlays that match the chrome introduced in spec 007. The only +deliberately surviving Win32 surfaces are `IFileOpenDialog` in +`WindowCommandManager::PromptForDiskImage` and the `MessageBoxW` last-resort path +inside the EHM `SetNotifyFunction` callback in `Main.cpp`. + +The approach extracts the modal-overlay plumbing already proven in +`Casso/Ui/Settings/SettingsWindow.cpp` into a reusable dialog primitive under +`Casso/Ui/Dialog/`, then incrementally retargets each consumer at it. Work is +sequenced in three phases matching the spec's priority labels so P1 (unified +startup download + boot-disk MRU picker) can ship independently of P2 (Help/About ++ drive widget label + file-open dedup) and P3 (Debug Console + Disk II Debug +Dialog). + +## Technical Context + +**Language/Version**: C++ (stdcpplatest, MSVC v145, VS 2026) +**Primary Dependencies**: Windows SDK, Direct3D 11, Direct2D, DirectWrite, WIC, STL + — plus existing in-tree `DxUiPainter`, `ChromeTheme`, `SettingsWindow`, + `SettingsPanel`, `GlobalUserPrefs`, `DriveWidget`, `DriveWidgetController` +**Storage**: JSON-backed user-prefs file managed by `GlobalUserPrefs` + (`Casso/Shell/GlobalUserPrefs.cpp`) — new `recentDisks` array key for MRU +**Testing**: Microsoft Native C++ Unit Test Framework (`UnitTest/` project) — + headless unit tests only; no Win32, no real file I/O +**Target Platform**: Windows 10/11, x64 and ARM64 +**Project Type**: Desktop application (Win32 GUI, native DX chrome) +**Performance Goals**: Dialog open / repaint within one frame at the active + refresh rate; MRU prune on render MUST NOT block the UI thread on slow + file-system calls (network paths in particular) +**Constraints**: + - All system includes via `Pch.h` only; quoted includes for project headers + - EHM pattern (`HRESULT hr = S_OK;` + single `Error:` exit, asserting `*A` + variants by default; non-asserting variants only for genuinely + user/external failure modes — e.g. missing MRU file, missing ROM + download, `ShellExecuteW` on a system with no default browser) + - 5 blank lines between top-level constructs; 3 blank lines between the + variable-declaration block and the first statement; column-aligned + declarations; cast spacing; `s_` for file-scope statics + - Themes: DarkModern, Skeuomorphic, GreenScreen + - DPI scales: 100%, 125%, 150%, 200% +**Scale/Scope**: + - 1 new reusable primitive (`Casso/Ui/Dialog/`) + - ~6 dialog consumers retargeted (unified-startup, boot-disk picker, + About, Keymap, Machine Info, SettingsPanel stray) + - 2 heavy full-window conversions (Debug Console, Disk II Debug Dialog) + - 1 new persisted user-prefs field (MRU array, cap 16) + - 1 `DriveWidgetState` extension (`imagePath` / `imageName`) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +| ---------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------ | +| I. Code Quality (formatting, EHM, etc.) | PASS | All new code follows EHM (`*A` variants default), 5/3 blank-line rules, column alignment, Pch.h-only system includes. | +| II. Testing Discipline (headless tests) | PASS | MRU pruning + cap, dialog layout metrics, filename truncation are all factored as pure functions taking injected predicates / metrics. No real file I/O in unit tests. | +| III. UX Consistency | PASS | All converted dialogs preserve existing text and accelerators; no CLI change. | +| IV. Performance | PASS | One-frame open/repaint; MRU prune uses cheap `std::filesystem::exists` only — never network-stat. Edge case explicitly punted to next launch. | +| V. Simplicity & Maintainability | PASS | Single new primitive directory; no new third-party dependency (no constitution amendment needed); `DiskIIDebugDialogState` UI/logic separation is preserved. | + +**Approved third-party dependencies**: No additions. Implementation uses +existing Windows SDK + DX stack already on the constitution allowlist +baseline. + +**Validation suites**: The dialog work touches **no** CPU, assembler, or +binary-output code paths. The Dormann (`scripts/RunDormannTest.ps1`) and +Harte (`scripts/RunHarteTests.ps1 -SkipGenerate`) suites are **not** +required for this feature. Should any FR end up touching CassoCore / +CassoEmuCore behavior (it should not), re-evaluate and run both. + +**Result**: No violations. Proceed to Phase 0. + +## Project Structure + +### Documentation (this feature) + +```text +specs/011-native-dialogs-completion/ +├── plan.md # This file (/speckit.plan command output) +├── spec.md # Feature spec +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output — MRU + DriveWidgetState extensions +├── quickstart.md # Phase 1 output — manual verification per phase +├── contracts/ # Phase 1 output +│ └── dialog-primitive.md # Reusable themed dialog primitive interface +└── tasks.md # Phase 2 output (/speckit.tasks — NOT created here) +``` + +### Source Code (repository root) + +```text +Casso/ +├── Ui/ +│ ├── Dialog/ # NEW — reusable themed dialog primitive +│ │ ├── DialogDefinition.h # NEW — value type (title/icon/body/buttons/hyperlinks) +│ │ ├── DialogPrimitive.h/.cpp # NEW — modal overlay window + layout + hit-testing +│ │ ├── DialogLayout.h/.cpp # NEW — pure layout math (testable, no Win32) +│ │ ├── StartupDownloadDialog.h/.cpp # NEW — P1 consumer (unified startup) +│ │ └── BootDiskPicker.h/.cpp # NEW — P1 consumer (MRU + downloads) +│ ├── Settings/ # EXISTING — model new primitive on this +│ │ ├── SettingsWindow.cpp # Existing modal overlay (template/reference) +│ │ └── SettingsPanel.cpp # FR-012: replace stray MessageBoxW +│ ├── Chrome/ +│ │ ├── ChromeLayout.* # EXISTING +│ │ ├── ChromeTheme.h # EXISTING — palette +│ │ ├── NavLayer.* # EXISTING +│ │ └── DriveWidget.* # FR-007: paint filename label +│ ├── DxUiPainter.* # EXISTING — extend for inline hyperlink hit-testing +│ ├── DebugConsolePanel.h/.cpp # NEW (P3) — themed replacement for Win32 EDIT +│ └── DiskIIDebugPanel.h/.cpp # NEW (P3) — themed replacement for full Win32 dialog +├── Shell/ +│ ├── GlobalUserPrefs.cpp # FR-003: add MRU array to JSON schema +│ └── WindowCommandManager.cpp # FR-005/006/011: route Help/About/Keymap/MachineInfo +│ # + IDM_DISK_INSERT1/2 through new primitive / +│ # PromptForDiskImage +├── AssetBootstrap.cpp # FR-001: rewrite PromptUser / PromptBootDisk / +│ # PromptDiskAudioConsent against new primitive +├── DebugConsole.cpp # P3: delete after DebugConsolePanel reaches parity +├── DiskIIDebugDialog.cpp # P3: delete after DiskIIDebugPanel reaches parity +├── DiskIIDebugDialogState.h/.cpp # UNTOUCHED — pure logic, must stay UI-free +└── Main.cpp # UNTOUCHED — EHM-notify MessageBoxW path is in-scope-OUT +WindowCommandManager.cpp::PromptForDiskImage # UNTOUCHED — IFileOpenDialog stays +UnitTest/ +├── DiskMruTests.cpp # NEW — MRU prune + cap eviction (synthetic predicate) +├── DialogLayoutTests.cpp # NEW — button row metrics, hyperlink hit-test, icon slot +├── DriveLabelTruncationTests.cpp # NEW — basename derivation + ellipsis truncation +└── DiskIIDebugDialogStateTests.cpp # EXISTING — must continue to build/pass unchanged +``` + +**Structure Decision**: Existing 5-project solution (`Casso.sln`). All new +runtime code lives in the `Casso` project under `Casso/Ui/Dialog/`; all new +tests live in the `UnitTest` project. No changes to `CassoCore`, +`CassoEmuCore`, or `CassoCli`. + +### Explicitly Out of Scope (do not touch) + +- `Casso/Shell/WindowCommandManager.cpp::PromptForDiskImage` — keep + `IFileOpenDialog`. This is the supported modern picker and FR-015 + explicitly allows it. +- `Casso/Main.cpp` EHM `SetNotifyFunction` callback that calls + `MessageBoxW` — this is the chicken-and-egg last-resort path used + before the DX painter is initialized (or after it has failed). FR-015 + explicitly allows it. + +### Integration Points + +- **`GlobalUserPrefs` JSON schema**: add a top-level `recentDisks` array + of absolute paths, most-recent-first, cap 16. Load/save plumbing + follows the same pattern as the recent `preserve machines section` fix. +- **`DriveWidgetState`**: add `std::filesystem::path imagePath` (or + `std::string imageName` — see data-model.md). Plumb from the + disk-mount path (whichever code already updates "drive contents") + through `DriveWidgetController` to the widget. +- **`SettingsWindow` modal-overlay plumbing**: extract the + show / route-input / dismiss / re-layout-on-DPI / re-paint-on-theme + logic into the new `DialogPrimitive`. `SettingsWindow` is then + expressed in terms of `DialogPrimitive` (preferred) or co-exists + with it during the transition (fallback if the refactor surfaces + unexpected coupling — decide in Phase 0 research). +- **`WindowCommandManager`**: the three MessageBox-based commands + (Help/About, Help/Keymap, machine-info) route to + `DialogPrimitive::Show` with `DialogDefinition` values. The two + legacy `IDM_DISK_INSERT1/2` `GetOpenFileNameW` paths route to the + existing `PromptForDiskImage`. + +### Testability Surface + +All of these MUST be headless (no Win32, no real file I/O): + +- **MRU**: `class DiskMru` with `void RecordMount (path)`, + `vector Prune (function exists) const`, cap = 16. + Synthetic `exists` predicate in tests. +- **Dialog layout**: free functions in `DialogLayout` that take a + `DialogDefinition` + metrics struct (font heights, padding, dpi + scale, max width) and return a `DialogLayoutResult` (icon rect, + body rects, hyperlink rects, button rects, total size). Tested + with synthetic metrics — no DirectWrite calls. +- **Drive label truncation**: pure function taking a basename, a + max-pixel-width, and a "measure glyph-run width" callback; returns + the truncated display string. Tests inject a deterministic measure + callback (e.g., constant 8 px per char) so the algorithm is + testable without DirectWrite. + +### Migration Risk + +- **FR-010 (Disk II Debug Dialog)** is the largest single conversion + in the spec. Approach: + 1. Stand up `DiskIIDebugPanel` next to `DiskIIDebugDialog`; both + bind to the same `DiskIIDebugDialogState`. + 2. Port one control family at a time (in order of risk): static + labels → checkboxes → radio buttons → text inputs with + validation → buttons → ListView → column-header context menu + → tooltips. + 3. Keep `DiskIIDebugDialog.cpp` building and reachable behind a + `#ifdef CASSO_LEGACY_DISKII_DEBUG_DIALOG` (or equivalent + compile-time switch) until `DiskIIDebugPanel` reaches feature + parity. + 4. Delete the Win32 version once parity is verified by manual + control-by-control exercise against the quickstart checklist. +- `DiskIIDebugDialogState` is already pure-logic and headless-tested. + The conversion MUST NOT add any Win32 includes or types to that + TU; SC-010 enforces this via the existing headless tests + continuing to build. + +## Complexity Tracking + +No constitution violations. Section intentionally empty. diff --git a/specs/011-native-dialogs-completion/quickstart.md b/specs/011-native-dialogs-completion/quickstart.md new file mode 100644 index 00000000..dfade1f3 --- /dev/null +++ b/specs/011-native-dialogs-completion/quickstart.md @@ -0,0 +1,178 @@ +# Quickstart: Manual Verification — Native DX Dialogs Completion + +Use these checklists to verify each priority phase after building locally. +Build via the VS Code task `Build + Test Debug` (or `Release`). Do NOT run +MSBuild directly. + +## Prerequisites + +- Local Casso build with the spec-011 branch checked out and built. +- Three theme settings exercisable from Settings (DarkModern, + Skeuomorphic, GreenScreen). +- A second monitor at a different DPI is helpful (for the DPI-change + edge case) but optional. + +--- + +## Phase P1 — Unified startup + Boot disk MRU + +### P1-A — Unified first-run download dialog (FR-001, SC-002, US1) + +1. Delete the local ROM cache directory. (Optional: also delete the + Disk II audio WAV cache.) +2. Launch Casso. +3. **Verify**: exactly ONE themed dialog appears before any emulator + chrome renders, listing every missing asset. +4. **Verify**: dialog renders under the currently selected theme + (palette + chrome match `SettingsWindow`). +5. Approve the download. **Verify**: progress is visible; dialog stays + modal; dialog dismisses only after downloads finish. +6. Repeat from step 1, but this time decline. + **Verify**: missing ROMs → boot blocked (as today); missing audio + only → Disk II runs silently. +7. Repeat steps 1–5 under each of the three themes. + +### P1-B — Boot disk picker, empty MRU (FR-002, US2 AS-1, AS-8) + +1. Wipe / reset user prefs so `recentDisks` is empty. +2. Launch a machine with a Disk II in slot 6, drive 1 empty, no + pinned disk image. +3. **Verify**: picker appears with only the two download entries + (DOS 3.3, ProDOS) plus Cancel. +4. Cancel. **Verify**: machine boots with drive 1 empty. + +### P1-C — Boot disk picker, populated MRU (FR-002, FR-003, SC-003, SC-004) + +1. From any entry point (file picker, drag-drop, boot picker + download), mount three different disk images A, B, C in that + order. After each mount, **verify** prefs file on disk contains + the path in `recentDisks` array, most-recent first. +2. Eject and relaunch the Disk II machine with drive 1 empty. +3. **Verify**: picker shows `C, B, A` above the two download + entries. +4. Click `B`. **Verify**: B mounts, picker dismisses, MRU order is + now `B, C, A`. +5. Delete `A` from disk. Relaunch the picker. + **Verify**: `A` is silently pruned from both the displayed list + and the persisted prefs file. +6. Mount 16 distinct disks. Mount a 17th. **Verify**: the 17th + appears at the top, the previously-oldest is evicted, list size + stays at 16. + +### P1-D — MRU update on every mount path + +1. Mount via Disk → Insert Disk 1 menu. **Verify**: MRU updated. +2. Mount via drag-drop onto the Casso window. **Verify**: MRU updated. +3. Mount via boot picker. **Verify**: MRU updated. + +--- + +## Phase P2 — Help/About, drive label, file-open dedup + +### P2-A — About dialog (FR-005, SC-006, US3) + +1. Help → About. +2. **Verify**: themed dialog, large Casso app icon (not generic + Windows info glyph), product/version/copyright text intact, URL + `https://github.com/relmer/Casso` rendered as a clickable + hyperlink inline in the body. +3. Click the hyperlink. **Verify**: default browser opens to the + repository. +4. Press Escape. **Verify**: dialog dismisses, previous focus + restored. +5. Repeat under each of the three themes and at DPI scales 100%, + 125%, 150%, 200%. + +### P2-B — Keymap and Machine Info (FR-006, FR-014, SC-008) + +1. Press F1. **Verify**: themed Keymap dialog with same text as + before. +2. Open the machine-info menu command. **Verify**: themed Machine + Info dialog with same text as before. +3. Close each with Escape. + +### P2-C — Drive widget filename label (FR-007, FR-008, SC-007) + +1. Mount a disk with a short basename (e.g., `Test.dsk`). + **Verify**: basename appears below "Drive 1" label within one + frame. +2. Eject. **Verify**: label disappears within one frame. +3. Mount a disk with a very long basename. **Verify**: ellipsis + truncation, no overflow past widget width. +4. Mount a file with no extension. **Verify**: literal filename + shown, no stripping. + +### P2-D — File-open dedup (FR-011, US5) + +1. Disk → Insert Disk 1 (Ctrl+1). **Verify**: modern Win11 + `IFileOpenDialog` appears, NOT the legacy `GetOpenFileName`. +2. Select a `.dsk`. **Verify**: mounts correctly, MRU updated, + drive widget label updates. +3. Repeat for Insert Disk 2 (Ctrl+2). + +### P2-E — Settings panel stray cleanup (FR-012) + +1. Trigger whatever path in Settings used to call `MessageBoxW` + (consult `SettingsPanel.cpp` history). **Verify**: themed dialog + instead of system MessageBox. + +--- + +## Phase P3 — Debug Console + Disk II Debug Dialog + +### P3-A — Themed Debug Console (FR-009, US6) + +1. Open Debug Console. + **Verify**: rendered through DX painter, active theme palette, + monospace font (no Win32 EDIT control). +2. Generate enough log lines to exceed the panel height. + **Verify**: keyboard scrolling and mouse-wheel scrolling work. +3. Select a range of text. Ctrl+C. **Verify**: clipboard contains + exactly the selected text. +4. Copy with no selection. **Verify**: no-op, no crash, clipboard + unchanged. + +### P3-B — Themed Disk II Debug Dialog (FR-010, SC-010, US7) + +For each control family below, exercise it and verify behavior +matches the legacy Win32 dialog (still buildable behind the +compile-time switch during the conversion): + +1. Static labels render correctly under each theme. +2. Event-type filter checkboxes — toggle each, verify ListView + filters identically. +3. Audio master/sub toggles — same. +4. Raw-quarter-track filter — same. +5. Drive radio buttons — same. +6. Track filter text input with valid and invalid input — + themed validation feedback adjacent to the input on invalid. +7. Sector filter — same. +8. Pause / Clear buttons — same. +9. Sortable ListView — sort by Time, Event, Detail; ascending + and descending. +10. Column-header right-click context menu — show/hide each + column. +11. Hover any filter control past the tooltip delay — + themed tooltip explains the filter. +12. **Verify SC-010**: `UnitTest` project still builds and + `DiskIIDebugDialogStateTests.cpp` still passes — no Win32 + types leaked into the state TU. + +Once parity is verified end-to-end, delete the legacy +`DiskIIDebugDialog.cpp` and remove the compile-time switch. + +--- + +## Cross-cutting regression check (run after each phase) + +- `rg -n "MessageBox|TaskDialog|GetOpenFileName" Casso/` returns + ONLY (a) `IFileOpenDialog` in `PromptForDiskImage` and (b) + `MessageBoxW` in `Main.cpp`'s EHM `SetNotifyFunction` callback — + plus comments. Any other hit is an FR-015 violation. +- VS Code task `Build + Test Debug` and `Build + Test Release` + both succeed with zero warnings. +- Code Analysis task passes with zero warnings. +- No CHANGELOG / README updates skipped for user-visible changes. +- Dormann + Harte suites are NOT required for this feature + (no CPU/assembler changes); skip unless implementation drifted + into `CassoCore` / `CassoEmuCore`. diff --git a/specs/011-native-dialogs-completion/research.md b/specs/011-native-dialogs-completion/research.md new file mode 100644 index 00000000..5f232ec5 --- /dev/null +++ b/specs/011-native-dialogs-completion/research.md @@ -0,0 +1,124 @@ +# Phase 0 Research: Native DX Dialogs Completion + +All Technical Context entries are concrete (no `NEEDS CLARIFICATION` markers +left after spec clarification). This document records the design decisions +made by inspecting the existing spec-007 infrastructure. + +## Decision 1 — Extract modal-overlay plumbing from `SettingsWindow` into `DialogPrimitive` + +- **Decision**: Factor `SettingsWindow`'s modal-overlay show / route-input / + dismiss / DPI-relayout / theme-repaint machinery into a new + `Casso/Ui/Dialog/DialogPrimitive` and re-express `SettingsWindow` in + terms of it (with `SettingsPanel` remaining the body content). +- **Rationale**: This is the only piece of DX chrome that already implements + a modal overlay correctly under all three themes and four DPI scales. + Duplicating it for each new dialog would create three copies of the + trickiest code in the chrome. +- **Alternatives considered**: + - *Copy-paste the SettingsWindow scaffolding into AssetBootstrap*: + rejected — three copies of the modal/overlay logic to drift apart. + - *Leave SettingsWindow alone, build DialogPrimitive as a parallel + implementation*: acceptable fallback if the extraction surfaces + unexpected coupling, but `SettingsWindow` would then carry dead + weight forever. Try the extraction first; fall back only if the + refactor cost exceeds ~one work day. + +## Decision 2 — JSON schema location for the disk MRU + +- **Decision**: Add a top-level `recentDisks` string array to the + `GlobalUserPrefs` JSON document, capped at 16 entries, + most-recent-first. Load and save plumbing mirrors the pattern of the + recent `fix(prefs): preserve machines section in GlobalUserPrefs::Save` + change — read into a typed field, write back from that typed field, + preserve unknown keys untouched on round-trip. +- **Rationale**: `GlobalUserPrefs` already owns cross-machine user state + and survives schema additions cleanly. A per-machine config is the + wrong home — the MRU follows the user, not the machine. +- **Alternatives considered**: + - *Per-machine MRU*: rejected — users reuse the same disk images + across multiple emulated machines, and the spec scenarios assume a + single user-wide list. + - *Separate `recent_disks.json` sidecar file*: rejected — adds a + second I/O surface for no benefit; the existing prefs file is + already well-tested for round-trip. + +## Decision 3 — MRU pruning policy on the UI thread + +- **Decision**: `Prune` uses `std::filesystem::exists` on the UI thread. + Network paths that block longer than a cheap stat are treated as + "still exists" (no removal). Persisted prefs are pruned only when + `exists` returned a definitive `false`. +- **Rationale**: Spec edge case explicitly accepts this — "leave + pruning to the next launch where stat succeeds quickly." UI must not + hang waiting on a flaky network share. +- **Alternatives considered**: + - *Threadpool stat-then-marshal-back*: over-engineered for 16 entries + and dragged into the boot-disk-picker render path. Revisit only if + a real complaint surfaces. + - *Always prune, blocking*: rejected — hangs the UI thread on slow + network paths. + +## Decision 4 — Hyperlink rendering inside dialog body text + +- **Decision**: Extend `DxUiPainter` with a minimal `DrawTextRunsWithLinks` + primitive that takes a sequence of `{ text, isLink }` runs and reports + per-link bounding rects to the caller. Hover styling and click + dispatch live in the dialog primitive, not in the painter. URL launch + uses `ShellExecuteW` via the EHM-notifying variant for the no-default- + browser edge case. +- **Rationale**: Keeps `DxUiPainter` doing one thing (rendering text + runs into known rects); keeps interactivity in the primitive layer + where input routing already lives. The bounded extension is what the + spec assumes (Assumption 3 in `spec.md`). +- **Alternatives considered**: + - *Full rich-text engine*: massively over-scoped. We need exactly + one link in the About dialog body; we do not need an HTML subset. + - *Render the URL as a button*: violates the About dialog's + information-density and looks wrong for `https://github.com/relmer/Casso` + appearing inline in a paragraph. + +## Decision 5 — Asset download progress within the unified startup dialog + +- **Decision**: The startup-download dialog reuses today's existing + download progress reporting (whatever `AssetBootstrap` currently + uses to surface progress to TaskDialog) and re-targets the + notifications at the new themed dialog. Aggregate "N of M assets + downloaded" plus current-asset percent is sufficient — no need for + parallel multi-bar UI. +- **Rationale**: Keeps the unified-dialog scope to "consolidate three + modals into one decision point with progress feedback" without + rebuilding the download engine. +- **Alternatives considered**: + - *Parallel per-asset progress bars*: rejected — the bottleneck is + sequential network bandwidth, not UI parallelism. + +## Decision 6 — Disk II Debug Dialog incremental conversion strategy + +- **Decision**: Stand up the new `DiskIIDebugPanel` alongside the + existing `DiskIIDebugDialog`. Both bind to the same headless + `DiskIIDebugDialogState`. Port one control family at a time in + order of risk (labels → checkboxes → radio buttons → text inputs + with validation → buttons → ListView → column-header context menu + → tooltips). Keep the Win32 version building behind a compile-time + switch until parity is verified, then delete it. +- **Rationale**: This is the heaviest single conversion in the spec + (FR-010 / SC-010). An incremental, parity-checked approach lets us + bisect regressions and ship interim builds. The `DiskIIDebugDialogState` + separation that already exists makes parallel implementations cheap. +- **Alternatives considered**: + - *Big-bang rewrite*: rejected — the dialog has eight distinct + control families and a real ListView; a single PR replacing all of + it would be unreviewable and unbisectable. + - *Drop column-header context menu / tooltips from the conversion*: + rejected — FR-010 calls out both as required for parity. + +## Decision 7 — Validation-suite gating + +- **Decision**: This feature does **not** require Dormann or Harte + runs. No CPU, assembler, or binary-output code is touched. +- **Rationale**: Both validation suites guard CassoCore / CassoEmuCore + behavior; this feature touches only the `Casso` Win32 GUI project's + UI layer plus a JSON-schema addition in `GlobalUserPrefs`. +- **Re-evaluation trigger**: If implementation discovers a need to + touch any code under `CassoCore/` or `CassoEmuCore/Core/`, re-run + the gate and run both suites before commit. diff --git a/specs/011-native-dialogs-completion/spec.md b/specs/011-native-dialogs-completion/spec.md new file mode 100644 index 00000000..5f77a60f --- /dev/null +++ b/specs/011-native-dialogs-completion/spec.md @@ -0,0 +1,212 @@ +# Feature Specification: Native DX Dialogs Completion + +**Feature Branch**: `011-native-dialogs-completion` +**Created**: 2026-05-27 +**Status**: Draft +**Input**: User description: Complete the native DX UI overhaul started in spec 007 by converting the remaining Win32 UI surfaces (asset-bootstrap modals, help/about/machine-info MessageBoxes, the SettingsPanel stray MessageBox, the legacy GetOpenFileName path, the drive widget label, the DebugConsole EDIT control, and the DiskIIDebugDialog) into themed DX overlays that match the rest of the native chrome. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Unified first-run asset download (Priority: P1) + +A new user launches Casso for the first time without the Apple II ROM images (and optionally without the Disk II audio WAVs). Instead of being walked through two or three separate native MessageBox / TaskDialog prompts before the emulator can boot, the user sees a single themed dialog that lists every missing asset, lets them approve or decline the downloads in one decision, and shows live progress until the downloads finish (or the user cancels and accepts a degraded boot). + +**Why this priority**: This is the very first thing a brand-new user sees. Today it is also the loudest remaining departure from the native DX look introduced in spec 007 — three different Win32 prompt styles in a row before any pixel of the emulator chrome renders. Consolidating them is the highest-visibility win and unblocks removal of `MessageBoxW` / `TaskDialogIndirect` from `AssetBootstrap.cpp`. + +**Independent Test**: Delete the local ROM cache (and optionally the Disk II audio cache), launch Casso, and confirm exactly one themed dialog appears that enumerates every missing asset, that approving downloads them all with visible progress before the emulator boots, and that declining boots Casso with the documented degradation (no ROMs => emulator does not start; no audio => Disk II runs silently). + +**Acceptance Scenarios**: + +1. **Given** no ROM images on disk and no Disk II audio WAVs, **When** the user launches Casso, **Then** a single themed download dialog is shown listing all missing ROMs and the Disk II audio assets, with one "Download" and one "Skip" decision. +2. **Given** the user approves the unified download, **When** downloads run, **Then** the dialog remains modal, shows per-asset (or aggregate) progress, and dismisses itself only when every approved download has completed or failed. +3. **Given** the user declines the unified download, **When** the dialog is dismissed, **Then** boot proceeds with the same degraded behavior the current code produces (no ROMs => boot is blocked exactly as today; missing audio => Disk II runs silently). +4. **Given** the active theme is DarkModern, Skeuomorphic, or GreenScreen, **When** the unified download dialog appears, **Then** it renders using that theme's palette, fonts, and chrome rules (matching `SettingsWindow`). + +--- + +### User Story 2 — Boot disk picker with MRU (Priority: P1) + +When the user launches a machine that has a Disk II controller in slot 6, drive 1 is empty, and the per-machine config did not pin a disk image (or pinned one that no longer exists on disk), the user sees a themed picker that lists their previously-mounted disk images at the top and the two downloadable system disks (DOS 3.3 System Master, ProDOS Users Disk) inline below. Picking an MRU entry mounts it; picking a download entry downloads then mounts; cancelling lets the machine boot with no disk just as today. + +**Why this priority**: Disk II is the primary way users get software into the emulator, and the current `PromptBootDisk` is one of only two remaining native TaskDialogs. Adding the MRU also delivers visible day-to-day workflow value beyond pure cosmetic conversion — users stop re-navigating to the same `.dsk` files via the file picker. + +**Independent Test**: With a fresh user-prefs file, launch a Disk II machine: only the two download entries appear. Mount three disk images over time via the file picker / drag-drop / this dialog. Relaunch with drive 1 empty: those three filenames appear above the download entries, in most-recently-mounted order, and selecting one mounts that disk. + +**Acceptance Scenarios**: + +1. **Given** a machine with a Disk II in slot 6, drive 1 empty, and no pinned disk image in the per-machine config, **When** the machine starts, **Then** the boot-disk picker appears. +2. **Given** the picker is shown and the user has previously mounted disk images that still exist on disk, **When** the picker renders, **Then** those images appear at the top of the list as MRU entries (most-recent first), with the two download entries below them. +3. **Given** the user selects an MRU entry, **When** the selection is confirmed, **Then** that disk image is mounted into drive 1 and the picker dismisses. +4. **Given** the user selects a download entry, **When** the selection is confirmed, **Then** the asset is downloaded (with progress UI consistent with User Story 1) and then mounted into drive 1. +5. **Given** the user cancels the picker, **When** the picker dismisses, **Then** the machine boots with drive 1 empty (same as today's Skip path). +6. **Given** the user has mounted a disk image via any path (file picker, drag-drop, or this dialog), **When** the mount succeeds, **Then** the MRU is updated and persisted to user prefs. +7. **Given** an MRU entry's underlying file no longer exists on disk, **When** the picker renders, **Then** that entry is pruned from the displayed list (and from persisted prefs). +8. **Given** the MRU is empty and no embedded boot disk applies, **When** the picker renders, **Then** only the two download entries (plus Cancel) are shown — matching today's `PromptBootDisk` behavior. + +--- + +### User Story 3 — Themed About / Keymap / Machine Info (Priority: P2) + +A user opens Help → About, Help → Keymap, or the machine-info popup. Instead of a system MessageBox, they see a themed dialog that matches the rest of the emulator chrome, with the About box now showing the Casso app icon and a clickable repository hyperlink. + +**Why this priority**: Lower urgency than the boot-flow modals (users encounter these only on demand) but completes the visible Win32-removal goal of the spec and exercises the new reusable dialog primitive on the simplest content. + +**Independent Test**: Trigger each menu command, confirm a themed dialog appears under each of the three themes, confirm the About hyperlink opens `https://github.com/relmer/Casso` in the default browser, and confirm the About icon shows the rendered Casso app icon rather than the generic Windows info glyph. + +**Acceptance Scenarios**: + +1. **Given** the user selects Help → About, **When** the dialog appears, **Then** it shows the existing About text content, the large Casso app icon, and the repository URL rendered as a clickable hyperlink. +2. **Given** the About dialog is showing, **When** the user clicks the repository hyperlink, **Then** the default browser opens to `https://github.com/relmer/Casso`. +3. **Given** the user selects Help → Keymap (F1) or the machine-info menu command, **When** the dialog appears, **Then** it shows the same content as today's MessageBox, rendered through the themed dialog primitive. +4. **Given** any of these dialogs is open, **When** the user presses Escape or activates the close button, **Then** the dialog dismisses and the previously focused window regains focus. + +--- + +### User Story 4 — Drive widget filename label (Priority: P2) + +A user mounts a disk image into a drive. The drive widget now shows the disk's filename (basename only) directly below the existing "Drive N" label. When the user ejects the disk, the filename label disappears. When the filename is too long for the widget, it is truncated with an ellipsis. + +**Why this priority**: Pure UX win delivered alongside the boot-disk MRU work — once we are touching the mount path to record MRU entries, surfacing the mounted filename on the widget itself is a small additional change with high day-to-day value. P2 because the emulator is fully usable without it. + +**Independent Test**: Mount a disk with a short name, observe its basename appears below "Drive N". Mount a disk with a very long basename, observe ellipsis truncation. Eject, observe the label disappears. Mount again, observe it reappears immediately. + +**Acceptance Scenarios**: + +1. **Given** a drive is empty, **When** the widget paints, **Then** only the existing "Drive N" label is shown. +2. **Given** a disk image is mounted in the drive, **When** the widget paints, **Then** the disk's filename basename (no path, with extension) appears below the "Drive N" label. +3. **Given** the basename is wider than the drive widget, **When** the widget paints the label, **Then** the label is truncated with a trailing ellipsis. +4. **Given** the user ejects the disk, **When** the widget repaints, **Then** the filename label disappears immediately. + +--- + +### User Story 5 — Single disk-insert file picker (Priority: P2) + +A user invokes Disk → Insert Disk 1 / Insert Disk 2. They see the modern Win11 `IFileOpenDialog` (the same picker used by all other disk-image entry points), not the legacy `GetOpenFileName` dialog. + +**Why this priority**: Pure dedup. Removes one of the remaining Win32 surfaces and ensures consistent behavior across every disk-insert entry point (file picker, drag-drop, boot dialog, menu commands). + +**Independent Test**: Trigger Insert Disk 1 from the menu, confirm the modern file-open dialog appears, confirm a selected `.dsk` mounts correctly, and confirm the MRU is updated. + +**Acceptance Scenarios**: + +1. **Given** the user selects IDM_DISK_INSERT1 or IDM_DISK_INSERT2, **When** the file picker is shown, **Then** it is the modern `IFileOpenDialog` (same one as `PromptForDiskImage`). +2. **Given** a disk is selected, **When** the mount completes, **Then** the MRU is updated and the drive widget label reflects the new disk. + +--- + +### User Story 6 — Themed Debug Console (Priority: P3) + +A developer opens the Debug Console. Instead of a Win32 EDIT child window, they see a themed DX text panel with monospace font, scrolling, and copy-to-clipboard support, hosted like the SettingsWindow. + +**Why this priority**: Developer-facing, lower frequency than user-facing dialogs, and a heavier conversion. Done after the simpler dialog work to amortize the new primitives. + +**Independent Test**: Open the Debug Console, write a large volume of log lines, scroll through them, select a range, copy to clipboard, verify the clipboard contents and that the panel theme matches the active emulator theme. + +**Acceptance Scenarios**: + +1. **Given** the user opens the Debug Console, **When** the panel appears, **Then** it is rendered through the DX painter using the active theme's palette and a monospace font. +2. **Given** log lines exceed the panel height, **When** the user scrolls, **Then** the panel supports keyboard and mouse-wheel scrolling. +3. **Given** the user selects text in the panel, **When** they invoke copy (Ctrl+C or context menu), **Then** the selected text is placed on the clipboard. + +--- + +### User Story 7 — Themed Disk II Debug Dialog (Priority: P3) + +A developer opens the Disk II Debug Dialog. They see a themed DX dialog that preserves every existing capability: event-type filter checkboxes, audio master/sub toggles, raw-quarter-track filter, drive radio buttons, track and sector text filters with validation feedback, pause and clear buttons, a sortable ListView (Time / Event / Detail) with column-header show/hide, and tooltips for filter help. + +**Why this priority**: Heaviest conversion in the spec, developer-facing, and gated on the simpler primitives being in place. Headless `DiskIIDebugDialogState` must continue to drive all logic. + +**Independent Test**: Open the dialog, exercise every control (each checkbox, each radio button, each text filter with valid and invalid inputs, pause, clear, sort each column, hide/show each column via the header context menu, hover for tooltips), and verify behavior is identical to the Win32 version against the same headless state. + +**Acceptance Scenarios**: + +1. **Given** the user opens the Disk II Debug Dialog, **When** the dialog appears, **Then** all controls listed above are present and themed. +2. **Given** the user changes any filter (checkbox, radio button, text input), **When** the change is applied, **Then** the underlying `DiskIIDebugDialogState` is updated and the ListView re-filters identically to today's behavior. +3. **Given** the user enters an invalid value into the track or sector filter, **When** validation runs, **Then** a themed validation feedback label is shown adjacent to the input. +4. **Given** the user right-clicks a ListView column header, **When** the context menu appears, **Then** the user can show or hide individual columns. +5. **Given** the user hovers a filter control, **When** the hover delay elapses, **Then** a themed tooltip explains that filter. + +--- + +### Edge Cases + +- **Download failure mid-flight**: If an asset download fails partway through the unified P1 dialog, the dialog must report which asset failed, keep any successful downloads, and let the user retry or cancel. +- **Asset cache partially populated**: If some ROMs are present but others are missing, the unified dialog must only list the missing ones (not re-download what is already on disk). +- **MRU contains a now-deleted file**: The picker must silently prune the entry (both from the displayed list and from persisted prefs). +- **MRU contains a network path that is currently unreachable**: Treat as "still exists" if we cannot prove non-existence cheaply (no long stat-blocking on the UI thread); leave pruning to the next launch where stat succeeds quickly. +- **Per-machine config pins a disk image that no longer exists**: Boot picker is shown (treat as if no pin existed) rather than failing silently or blocking boot. +- **MRU at cap (16 entries) and a new mount happens**: Oldest entry is evicted. +- **About hyperlink click with no default browser registered**: `ShellExecuteW` failure is reported through `CHRN` (themed dialog, since the painter is up by this point). +- **DPI changes while a themed dialog is open** (e.g., user drags Casso to a different monitor): The dialog re-lays out at the new DPI using the same code path that handles initial render at 125% / 150% / 200%. +- **Theme changes while a themed dialog is open**: The dialog repaints with the new palette without dismissing. +- **Debug Console copy with no selection**: No-op (consistent with standard text controls); does not crash or clear the clipboard. +- **DiskIIDebugDialog opened before any Disk II activity has occurred**: ListView is empty but all controls are operable; pause/clear are valid no-ops. +- **Drive filename with no extension** or with multiple dots: Display the literal `path.filename()` result; do not strip extensions. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001 (Unified startup download dialog)**: System MUST present a single themed DX dialog at startup that enumerates every missing asset (ROM images and, if applicable, Disk II audio WAVs), consolidating today's three separate `PromptUser` / `PromptBootDisk` / `PromptDiskAudioConsent` modals into one decision point. The dialog MUST be theme-aware (DarkModern, Skeuomorphic, GreenScreen). The dialog MUST block emulator boot until the user's decision is honored (downloads complete on approval, or boot proceeds with documented degradation on decline). The dialog MUST show download progress while downloads run. + +- **FR-002 (Boot disk picker with MRU)**: System MUST present a themed DX boot-disk picker when (a) the active machine has a Disk II controller in slot 6, (b) drive 1 is empty, and (c) the per-machine config did not specify a still-existing disk image. The picker MUST list MRU entries (filenames of previously-mounted disk images that still exist on disk, most-recent first) above two download entries (DOS 3.3 System Master, ProDOS Users Disk), and MUST offer a Cancel button that lets the machine boot with no disk. + +- **FR-003 (MRU persistence)**: System MUST persist the disk-image MRU across sessions in the existing `GlobalUserPrefs` JSON store. The MRU MUST be capped at 16 entries, MUST be updated whenever a disk image is mounted via any path (file picker, drag-drop, or boot picker), MUST prune entries whose files no longer exist at render time, and MUST evict the oldest entry when a new mount happens at cap. + +- **FR-004 (Reusable themed dialog primitive)**: System MUST provide a reusable DX dialog overlay primitive (living under `Casso/Ui/Dialog/`) modeled on the existing `SettingsWindow` / `SettingsPanel` infrastructure. The primitive MUST support: a title, multi-line body text with embedded clickable hyperlinks, an optional resource-id-based icon (including large renderings of the app icon resources), and a row of single-line buttons. The primitive MUST NOT support TaskDialog-style multi-line command-link buttons. + +- **FR-005 (About box conversion)**: System MUST convert the `IDM_HELP_ABOUT` command to use the themed dialog primitive, preserving the existing text content (product name, version, build date, description, URL, copyright, license), rendering the URL `https://github.com/relmer/Casso` as a clickable hyperlink that opens in the default browser via `ShellExecuteW`, and replacing the generic info icon with a large rendering of one of the `IDI_CASSO_*` app-icon resources (e.g., `IDI_CASSO_PHOTOREAL`). + +- **FR-006 (Keymap and Machine Info conversion)**: System MUST convert `IDM_HELP_KEYMAP` and the machine-info menu command to use the themed dialog primitive, preserving today's text content verbatim. + +- **FR-007 (Drive widget filename label)**: System MUST render the mounted disk image's filename (basename only, no path) below the existing "Drive N" label on the drive widget when a disk is mounted, MUST hide the filename label when the drive is empty, and MUST truncate the filename with a trailing ellipsis when it does not fit within the widget width. The filename MUST update immediately on mount and eject. + +- **FR-008 (Drive widget state plumbing)**: System MUST extend `DriveWidgetState` with the information needed to paint the filename label (an `imageName` string or full `imagePath` from which the basename is derived at paint time) and MUST plumb that data from the disk-mount path through `DriveWidgetController` to the widget. + +- **FR-009 (Themed Debug Console)**: System MUST convert `DebugConsole` from a Win32 `EDIT` child control into a themed DX text panel hosted as a dockable / floatable surface like `SettingsWindow`. The panel MUST support scrolling (keyboard and mouse wheel), MUST render text in a monospace font using the active theme's palette, and MUST support copy-to-clipboard for user-selected text. + +- **FR-010 (Themed Disk II Debug Dialog)**: System MUST convert `DiskIIDebugDialog` from a Win32 dialog to a themed DX dialog while preserving every existing capability: event-type filter checkboxes, audio master / sub toggles, raw-quarter-track filter, drive radio buttons, track and sector text filters with validation feedback labels, pause and clear buttons, a sortable ListView with Time / Event / Detail columns, a column-header context menu for show/hide, and tooltips for filter help. The conversion MUST keep the pure logic in `DiskIIDebugDialogState` and MUST NOT re-couple state to UI. + +- **FR-011 (File-open dedup)**: System MUST remove the legacy `GetOpenFileNameW` path in `WindowCommandManager::OnDiskCommand` and MUST route `IDM_DISK_INSERT1` and `IDM_DISK_INSERT2` through the existing `PromptForDiskImage` (`IFileOpenDialog`) path. + +- **FR-012 (Settings panel stray cleanup)**: System MUST replace the residual `MessageBoxW` call in `Casso/Ui/Settings/SettingsPanel.cpp` with the themed dialog primitive. + +- **FR-013 (Theme and DPI correctness)**: All dialogs introduced by this feature MUST render correctly under DarkModern, Skeuomorphic, and GreenScreen themes, and at 100%, 125%, 150%, and 200% DPI. + +- **FR-014 (Existing accelerators preserved)**: Conversion of menu-command dialogs MUST preserve existing keyboard accelerators (Ctrl+1, Ctrl+2 for disk inserts; F1 for keymap help; etc.). + +- **FR-015 (Win32 surface containment)**: After this feature ships, the only Win32 UI surfaces remaining in `Casso/` MUST be (a) `IFileOpenDialog` in `WindowCommandManager::PromptForDiskImage` and (b) the `MessageBoxW` last-resort path in the EHM `SetNotifyFunction` callback in `Main.cpp`. A repository search for `MessageBox|TaskDialog|GetOpenFileName` under `Casso/` MUST return only those two call sites (plus comments). + +### Key Entities + +- **Themed Dialog Definition**: title, optional icon resource id, body content (text plus zero or more hyperlinks), button row (zero or more single-line buttons each with a label and a result code), modality. Hosted by the new primitive under `Casso/Ui/Dialog/`. +- **Asset Download Request**: identifier (which ROM or which Disk II audio WAV), destination, source URL, expected size for progress reporting. Aggregated into a `Startup Download Set` consumed by the unified startup dialog. +- **Disk MRU Entry**: filesystem path of a previously-mounted disk image, timestamp (or implicit ordering position) used to display most-recent-first. Persisted as an ordered list under a new key in the `GlobalUserPrefs` JSON store, capped at 16 entries. +- **Boot Disk Choice**: discriminated value — either an MRU entry path, a download entry (DOS 3.3 / ProDOS), or Cancel. +- **Drive Widget State (extended)**: existing fields plus the mounted image path or basename used to paint the new filename label. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: After the feature ships, `rg -n "MessageBox|TaskDialog|GetOpenFileName" Casso/` returns at most the two intentional surfaces (`IFileOpenDialog` in `PromptForDiskImage`, `MessageBoxW` in the `Main.cpp` EHM notify handler) plus comments — zero other matches. +- **SC-002**: A fresh user with no cached ROMs sees exactly one themed dialog before the emulator boots (down from the current three-dialog sequence). +- **SC-003**: A user with a populated MRU sees their previously-mounted disk images at the top of the boot-disk picker, in most-recent-first order, and can mount any of them in one click without re-navigating a file dialog. +- **SC-004**: The disk MRU never exceeds 16 persisted entries and never displays an entry whose file does not currently exist. +- **SC-005**: Every dialog introduced by this feature renders correctly under all three themes (DarkModern, Skeuomorphic, GreenScreen) and at all four supported DPI scales (100%, 125%, 150%, 200%) — verified by visual inspection in each combination. +- **SC-006**: Clicking the repository hyperlink in the About dialog opens `https://github.com/relmer/Casso` in the user's default browser. +- **SC-007**: The drive widget displays the mounted disk image's basename below the "Drive N" label within one frame of a successful mount, removes the label within one frame of an eject, and truncates with an ellipsis when the basename exceeds the widget width. +- **SC-008**: Existing keyboard accelerators (Ctrl+1, Ctrl+2, F1) continue to invoke the corresponding (now-themed) dialogs. +- **SC-009**: The full existing unit-test suite continues to pass, and new unit tests cover (a) MRU pruning and cap-eviction logic, (b) drive-widget filename truncation, and (c) themed dialog primitive layout (button row sizing, hyperlink hit-testing, icon slot). +- **SC-010**: `DiskIIDebugDialogState` retains zero direct dependencies on Win32 UI types after the conversion (verified by headless unit tests continuing to build and run against it). + +## Assumptions + +- The existing `SettingsWindow` / `SettingsPanel` infrastructure is a sufficient foundation for the new reusable dialog primitive; no new DX painter primitives beyond those already used by `SettingsWindow` are required for the simple dialogs (About / Keymap / Machine Info / unified download / boot picker / settings stray). +- The `GlobalUserPrefs` JSON store is the right home for the disk MRU; integration follows the same pattern as the recent `fix(prefs): preserve machines section in GlobalUserPrefs::Save` change. +- Hyperlink rendering inside dialog body text is a new but bounded extension of the existing text-painting code in `DxUiPainter` — clickable hit-testing, hover styling, and `ShellExecuteW` dispatch are in scope for the primitive. +- The unified startup download dialog blocks boot when ROMs are missing because the emulator cannot run without them; this matches today's behavior on decline (just consolidated into one dialog). +- Embedded boot-disk detection (cases where the active machine ships with a built-in default disk image) is out of scope for changes; the boot picker only appears when no embedded boot disk applies, as today. +- The Debug Console and Disk II Debug Dialog conversions are the heaviest items and are sequenced as later phases (P3) so that the new dialog primitive lands and stabilizes via the simpler P1 / P2 dialogs first. +- "Per-machine config pinned a disk image that no longer exists" is treated identically to "no pin" for picker-display purposes; no separate error dialog is shown for the missing pinned file. +- Network reachability for the two downloadable system disks (DOS 3.3 / ProDOS) and the Disk II audio WAVs is assumed; failure handling reuses today's user-facing reporting via `CHRN` through the new themed primitive. diff --git a/specs/011-native-dialogs-completion/tasks.md b/specs/011-native-dialogs-completion/tasks.md new file mode 100644 index 00000000..af4e499e --- /dev/null +++ b/specs/011-native-dialogs-completion/tasks.md @@ -0,0 +1,316 @@ +--- +description: "Task list for feature 011 — Native DX Dialogs Completion" +--- + +# Tasks: Native DX Dialogs Completion + +**Input**: Design documents from `/specs/011-native-dialogs-completion/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/dialog-primitive.md, quickstart.md + +**Tests**: Headless unit tests are required (per plan's Testability Surface). Test tasks are scheduled alongside the production code they cover so the suite remains green at every landed task. No Win32, no real file I/O in unit tests. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing. Story priorities mirror spec.md (P1 ships independently of P2; P3 lands last). + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependency on incomplete tasks) +- **[Story]**: Maps task to user story (US1–US7) — omitted for Setup, Foundational, and Polish phases + +## Path Conventions + +- Production code: `Casso/Ui/...`, `Casso/Shell/...`, `Casso/AssetBootstrap.cpp`, etc. +- Tests: `UnitTest/` +- All system includes go through `Casso/Pch.h` / `UnitTest/Pch.h`; project includes are quoted. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Carve out the new directory and confirm the build picks it up before any real code lands. + +- [ ] T001 Create the `Casso/Ui/Dialog/` directory with a placeholder header (e.g. `DialogDirectory.txt` or empty `DialogDefinition.h` skeleton) and add the new directory to the `Casso.vcxproj` filters so files dropped into it during Phase 2 build without further project surgery. +- [ ] T002 Add the empty UnitTest source files `UnitTest/DiskMruTests.cpp`, `UnitTest/DialogLayoutTests.cpp`, and `UnitTest/DriveLabelTruncationTests.cpp` to `UnitTest.vcxproj` with `#include "Pch.h"` only, so subsequent tasks can land tests one at a time without project edits. + +**Checkpoint**: Solution builds cleanly with the new (empty) translation units present. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Land the reusable themed dialog primitive, the layout math, the MRU helper, and the JSON-schema extension. These are blocking prerequisites for every user story. + +**⚠️ CRITICAL**: No user story work may begin until this phase completes (each user story consumes either the dialog primitive or the MRU plumbing). + +### Dialog primitive + +- [ ] T003 Define the pure value types in `Casso/Ui/Dialog/DialogDefinition.h` — `DialogIcon`, `DialogTextRun`, `DialogButton`, `DialogDefinition`, plus the `DialogPaintContext` / `DialogInputEvent` forward declarations referenced by the optional custom-body hooks. Match the field set in `contracts/dialog-primitive.md` exactly. +- [ ] T004 [P] Add `Casso/Ui/Dialog/DialogLayout.h` + `DialogLayout.cpp` implementing `LayoutDialog (DialogDefinition, DialogLayoutMetrics) -> DialogLayoutResult` as a pure function (icon slot, wrapped body runs, hyperlink hit rects, button row metrics, custom-body rect, total size). No Win32, no DirectWrite — all measurement goes through the injected `measureBodyTextRun` / `measureButtonLabel` callbacks. +- [ ] T005 [P] Add `UnitTest/DialogLayoutTests.cpp` coverage for: (a) button-row right-alignment and inter-button spacing, (b) body text wrapping at `maxBodyWidthPx`, (c) icon-present vs icon-absent total-size delta, (d) hyperlink hit-rect coincidence with the run's body rect, (e) custom-body hook reserves space between body and button row, (f) DPI scaling of padding and button height. All measurement callbacks are deterministic stubs (e.g. constant width per character). +- [ ] T006 Extract the modal-overlay plumbing currently in `Casso/Ui/Settings/SettingsWindow.cpp` (window class, show/route-input/dismiss, `WM_DPICHANGED` re-layout, theme-change repaint) into `Casso/Ui/Dialog/DialogPrimitive.h` + `DialogPrimitive.cpp` implementing `DialogPrimitive::Show (ownerHwnd, theme, painter, definition) -> int` and `Close (int)`. `SettingsWindow` may either be re-expressed in terms of the primitive or continue to use a shared internal helper — pick whichever the extraction surfaces cleanly (per research.md Decision 1). +- [ ] T007 [P] Wire `DialogPrimitive` body-text painting and hit-testing in `Casso/Ui/DxUiPainter.*` — extend the painter with the inline-hyperlink hit-region helper called out in plan.md (`Casso/Ui/DxUiPainter.* — EXISTING — extend for inline hyperlink hit-testing`). Hyperlink runs render in the theme's accent colour with underline; the hit rect is the painted run rect. +- [ ] T008 Implement keyboard handling inside `DialogPrimitive` per the contract: `Enter` activates the `isDefault` button, `Escape` activates the `isCancel` button, `Tab` / `Shift+Tab` cycles buttons left-to-right / right-to-left, window-close gesture with no `isCancel` returns `-1`. +- [ ] T009 Implement hyperlink activation in `DialogPrimitive` via `ShellExecuteW (NULL, L"open", url, …)`; on failure report via `CHRN` (themed dialog, since the primitive is by definition up). Use `CWRA` for painter-not-initialised and zero-buttons-with-no-close-gesture bug checks per the contract's failure table. + +### MRU + user prefs + +- [ ] T010 [P] Add `Casso/Shell/DiskMru.h` + `DiskMru.cpp` implementing the pure helper from data-model.md §1: `RecordMount (path)`, `Snapshot () const`, `Prune (existsPredicate)`, `k_capacity = 16`, most-recent-first ordering, dedup-on-re-mount, oldest-eviction at cap. No file I/O, no JSON — pure list operations. +- [ ] T011 [P] Add `UnitTest/DiskMruTests.cpp` coverage for: insert-into-empty, dedup-move-to-front on re-mount, eviction of the oldest at cap, `Prune` removes entries the synthetic `existsPredicate` rejects, ordering preserved through prune, `Snapshot` returns most-recent-first, empty-list behaviours. Inject a fake predicate — never call `std::filesystem::exists` on a real path. +- [ ] T012 Extend the `GlobalUserPrefs` JSON schema in `Casso/Shell/GlobalUserPrefs.cpp` to load/save a top-level `recentDisks` string array, dropping malformed entries silently per data-model.md (non-string or empty values must not fail prefs load). Follow the same pattern the recent "preserve machines section" change uses. +- [ ] T013 Plumb the loaded `recentDisks` array into a `DiskMru` instance owned by `GlobalUserPrefs` (or whichever shell-scope singleton is the natural owner) and re-serialise on every change. Provide `GlobalUserPrefs::GetDiskMru ()` accessor used by mount sites in later phases. + +**Checkpoint**: `DialogPrimitive`, `DialogLayout`, `DiskMru`, and the `recentDisks` JSON field exist and are tested. User-story phases can now begin in parallel. + +--- + +## Phase 3: User Story 1 — Unified first-run asset download (Priority: P1) 🎯 MVP + +**Goal**: Replace the three Win32 `PromptUser` / `PromptBootDisk` / `PromptDiskAudioConsent` modals in `AssetBootstrap.cpp` with a single themed dialog listing every missing asset, with one approve/decline decision and live progress UI. + +**Independent Test**: Delete the local ROM cache (and optionally the Disk II audio cache), launch Casso, confirm exactly one themed dialog enumerates every missing asset, that Approve downloads them all with visible progress before boot, and that Decline produces the same documented degradation as today (no ROMs → boot blocked; missing audio → Disk II silent). Repeat under each of the three themes. + +### Implementation for User Story 1 + +- [ ] T014 [P] [US1] Add `Casso/Ui/Dialog/StartupDownloadDialog.h` + `StartupDownloadDialog.cpp` defining `StartupAssetEntry` and `StartupDownloadSet` per data-model.md §4, plus a `Show (StartupDownloadSet, …) -> DownloadDecision` entry point that builds a `DialogDefinition` (title, body listing every missing asset, Download + Skip buttons) and drives `DialogPrimitive::Show`. +- [ ] T015 [P] [US1] Add `UnitTest/StartupDownloadSetTests.cpp` (new file — add to `UnitTest.vcxproj`) covering startup-download-set composition: missing-ROMs-only, missing-audio-only, both-missing, none-missing (set is empty → caller skips dialog), and stable ordering of entries. Use synthetic asset-presence inputs; no real filesystem. +- [ ] T016 [US1] Wire the unified dialog's custom-body paint hook (`DialogDefinition::onPaintCustomBody`) to render per-asset / aggregate progress while downloads run. Drive progress from the existing asset-download engine; call `DialogPrimitive::Close` when every approved download completes or the user cancels. Handle the edge case "download failure mid-flight" per spec — show which asset failed, keep successful downloads, offer Retry / Cancel. +- [ ] T017 [US1] Rewrite `Casso/AssetBootstrap.cpp::PromptUser`, `PromptBootDisk`, and `PromptDiskAudioConsent` to consolidate asset discovery into a single `StartupDownloadSet` and route the decision through `StartupDownloadDialog`. Delete the legacy `TaskDialogIndirect` / `MessageBoxW` call sites. Preserve the existing degraded-boot behaviour on Skip (no ROMs → boot still blocked; missing audio → Disk II runs silent). +- [ ] T018 [US1] Verify FR-013 (theme + DPI) for this dialog by walking quickstart §P1-A under DarkModern, Skeuomorphic, GreenScreen at 100 / 125 / 150 / 200% DPI. Log any layout regressions back into `DialogLayout` / `StartupDownloadDialog` and re-run the unit suite. + +**Checkpoint**: First-run UX is one themed dialog. `AssetBootstrap.cpp` no longer references `TaskDialogIndirect` or `MessageBoxW`. P1-A in quickstart passes. + +--- + +## Phase 4: User Story 2 — Boot disk picker with MRU (Priority: P1) + +**Goal**: Replace `PromptBootDisk`'s remaining Win32 TaskDialog with a themed picker showing the MRU above the two download entries, with Cancel returning to an empty drive 1 just as today. + +**Independent Test**: With a fresh user-prefs file, launch a Disk II machine — only the two download entries appear. Mount three disk images A, B, C in order via any path; relaunch with drive 1 empty — `C, B, A` appear above the download entries. Selecting one mounts it; delete one from disk and relaunch — that entry is silently pruned from both display and prefs. Mount 17 distinct disks — oldest is evicted, list stays at 16. + +### Implementation for User Story 2 + +- [ ] T019 [P] [US2] Add `Casso/Ui/Dialog/BootDiskPicker.h` + `BootDiskPicker.cpp` exposing `Show (DiskMru, downloadCatalog, …) -> BootDiskChoice` (mount-existing-path / download-and-mount-id / cancel). Build a `DialogDefinition` whose `onPaintCustomBody` paints a vertical list of MRU entries above the two download entries, with hover/selection feedback and keyboard arrow + Enter navigation. +- [ ] T020 [US2] At `BootDiskPicker::Show` time, call `DiskMru::Prune` with `std::filesystem::exists` as the predicate and re-persist via `GlobalUserPrefs` if anything changed. Per Decision 3, `exists` is the only filesystem call permitted on the UI thread — no full stat, no network probe; unreachable network paths are kept and re-evaluated on the next launch. +- [ ] T021 [US2] Wire mount sites so every successful mount records into `DiskMru` and persists: file-picker mount in `Casso/Shell/WindowCommandManager.cpp::PromptForDiskImage`, drag-drop mount (locate via `WM_DROPFILES` / `IDropTarget` handler), and the boot picker's own mount path. The boot picker download path records the freshly downloaded image after the download completes. +- [ ] T022 [US2] Replace the legacy `PromptBootDisk` invocation site so that when the active machine has a Disk II in slot 6, drive 1 is empty, and the per-machine config did not pin a still-existing image, `BootDiskPicker::Show` is invoked. Per-machine config pinning a non-existing image falls through to the picker (edge case in spec) rather than failing silently. +- [ ] T023 [US2] Extend `UnitTest/DiskMruTests.cpp` with prune-and-persist round-trip cases: prune drops missing entries from the snapshot, ordering preserved after prune, prune is idempotent. Still no real filesystem — inject the predicate. +- [ ] T024 [US2] Walk quickstart §P1-B, §P1-C, §P1-D and verify behaviour matches under all three themes. Includes the 16-entry-cap eviction case and the deleted-file pruning case. + +**Checkpoint**: P1 ships. `AssetBootstrap.cpp` and the boot path no longer host any Win32 dialog API. P1 is independently demoable as the MVP. + +--- + +## Phase 5: User Story 3 — Themed About / Keymap / Machine Info (Priority: P2) + +**Goal**: Convert the three `MessageBoxW`-backed Help commands and the machine-info popup to the themed dialog primitive, with the About box gaining a large app icon and a clickable repository hyperlink. + +**Independent Test**: Trigger each menu command; each shows a themed dialog under each of the three themes. About shows the large `IDI_CASSO_*` icon and a clickable `https://github.com/relmer/Casso` hyperlink that opens in the default browser. F1 accelerator still opens Keymap. Escape dismisses each. + +### Implementation for User Story 3 + +- [ ] T025 [P] [US3] In `Casso/Shell/WindowCommandManager.cpp`, replace `IDM_HELP_ABOUT`'s `MessageBoxW` with a `DialogDefinition` built from the existing About text content (product name, version, build date, description, URL, copyright, license), with `icon = DialogIcon::AppPhotoreal` (mapping to `IDI_CASSO_PHOTOREAL`) and the URL split into a `DialogTextRun { isHyperlink = true, hyperlinkUrl = L"https://github.com/relmer/Casso" }`. Route through `DialogPrimitive::Show`. +- [ ] T026 [P] [US3] In `Casso/Shell/WindowCommandManager.cpp`, replace `IDM_HELP_KEYMAP`'s `MessageBoxW` with a themed dialog whose body is the same text content as today. Preserve the F1 accelerator (FR-014). +- [ ] T027 [P] [US3] In `Casso/Shell/WindowCommandManager.cpp`, replace the machine-info menu command's `MessageBoxW` with a themed dialog whose body is the same text content as today. +- [ ] T028 [US3] Verify the `IDI_CASSO_PHOTOREAL` icon resource renders at the large size requested in the About body (icon rect from `DialogLayoutMetrics::iconSizePx`). If WIC/D2D rendering of the `.ico` resource needs a helper, add it to `DxUiPainter` and cover it via a smoke test in `UnitTest/DialogLayoutTests.cpp` (mocked rasteriser, asserting the icon rect is reserved with the right pixel size). +- [ ] T029 [US3] Walk quickstart §P2-A and §P2-B under each theme and DPI scale. + +**Checkpoint**: Help menu and machine-info command are themed. About hyperlink works. + +--- + +## Phase 6: User Story 4 — Drive widget filename label (Priority: P2) + +**Goal**: Paint the mounted disk's basename below the existing "Drive N" label, hidden when empty, ellipsis-truncated when too wide. + +**Independent Test**: Mount a short-name disk — basename appears below "Drive 1" within one frame. Mount a very long basename — ellipsis truncation, no overflow. Eject — label disappears immediately. Mount a file with no extension — literal filename shown, no stripping. + +### Implementation for User Story 4 + +- [ ] T030 [P] [US4] Extend the `DriveWidgetState` struct in `Casso/Ui/Chrome/DriveWidget.h` with `std::filesystem::path imagePath` (empty when no disk mounted), per data-model.md §2. Storage choice is path; basename is derived at paint time. +- [ ] T031 [P] [US4] Add `Casso/Ui/Chrome/DriveLabelTruncation.h` + `DriveLabelTruncation.cpp` implementing `TruncateToWidth (basename, maxWidthPx, measure)` exactly per data-model.md §2: binary-search the longest prefix `p` such that `measure (p + L"\u2026") <= maxWidthPx`; single-character ellipsis (`L'\u2026'`), not three dots; pure function with injected measure callback. +- [ ] T032 [P] [US4] Add `UnitTest/DriveLabelTruncationTests.cpp` coverage for: fits-untruncated, truncates with single-char ellipsis, basename with no extension preserved literally, basename with multiple dots preserved literally, basename equal to one character wider than max, basename narrower than the ellipsis itself (degenerate — return empty + ellipsis or just ellipsis, document the choice and pin it in tests). Inject a deterministic measure callback (e.g. constant 8 px per character). +- [ ] T033 [US4] Plumb `imagePath` from the disk-mount path through `Casso/Ui/Chrome/DriveWidgetController.*` to the widget so mount sets `imagePath = newPath; repaint` and eject sets `imagePath.clear (); repaint`. Hook every mount path (file picker, drag-drop, boot picker) that already records into the MRU in Phase 4. +- [ ] T034 [US4] Update `Casso/Ui/Chrome/DriveWidget.cpp` paint to render the basename below the "Drive N" label using `DriveLabelTruncation::TruncateToWidth` with `DxUiPainter::MeasureTextRunWidth` as the measure callback; hide the row when `imagePath.empty ()`. +- [ ] T035 [US4] Walk quickstart §P2-C — short name, long name, eject, no-extension, multi-dot — under each theme. + +**Checkpoint**: Drive widget shows the mounted filename, truncates correctly, clears on eject. + +--- + +## Phase 7: User Story 5 — Single disk-insert file picker (Priority: P2) + +**Goal**: Remove the legacy `GetOpenFileNameW` path and route `IDM_DISK_INSERT1` / `IDM_DISK_INSERT2` through the existing `IFileOpenDialog`-based `PromptForDiskImage`. + +**Independent Test**: Disk → Insert Disk 1 (Ctrl+1) shows the modern Win11 `IFileOpenDialog`. Selecting a `.dsk` mounts correctly, the MRU updates, the drive widget label updates. Same for Insert Disk 2 (Ctrl+2). Accelerators still work. + +### Implementation for User Story 5 + +- [ ] T036 [US5] In `Casso/Shell/WindowCommandManager.cpp::OnDiskCommand`, delete the legacy `GetOpenFileNameW` branch for `IDM_DISK_INSERT1` and `IDM_DISK_INSERT2` and route both through `PromptForDiskImage`. Preserve `Ctrl+1` / `Ctrl+2` accelerators (FR-014). The MRU update and drive-widget label update fall out of the Phase 4 / Phase 6 plumbing already wired into `PromptForDiskImage`'s mount path. +- [ ] T037 [US5] Walk quickstart §P2-D for Insert Disk 1 and Insert Disk 2. + +**Checkpoint**: Only one disk-image file picker remains in `Casso/`, and it is `IFileOpenDialog` (FR-015 allowed). + +--- + +## Phase 8: FR-012 — Settings panel stray cleanup (Priority: P2) + +**Goal**: Replace the residual `MessageBoxW` in `SettingsPanel.cpp` with the themed dialog primitive. (Not a numbered user story in spec.md but tracked as FR-012 / quickstart §P2-E.) + +- [ ] T038 [US3] In `Casso/Ui/Settings/SettingsPanel.cpp`, replace the residual `MessageBoxW` call with a `DialogDefinition` routed through `DialogPrimitive::Show` (re-use whichever icon — Info / Warning — matches the original message's intent). Walk quickstart §P2-E. + +**Checkpoint**: P2 ships. Help/About/Keymap/MachineInfo, drive label, file-open dedup, and Settings stray are all themed. + +--- + +## Phase 9: User Story 6 — Themed Debug Console (Priority: P3) + +**Goal**: Replace the Win32 `EDIT` child control in `DebugConsole.cpp` with a themed DX text panel — monospace font, active theme palette, keyboard + mouse-wheel scrolling, copy-to-clipboard. + +**Independent Test**: Open Debug Console; write a large volume of log lines; scroll keyboard + mouse-wheel; select a range; Ctrl+C copies exactly the selection; Ctrl+C with no selection is a no-op and does not crash or clear the clipboard. + +### Implementation for User Story 6 + +- [ ] T039 [US6] Add `Casso/Ui/DebugConsolePanel.h` + `DebugConsolePanel.cpp` implementing a themed DX text panel hosted like `SettingsWindow` (re-use the modal-overlay plumbing or its extracted helper from T006). Monospace font from `ChromeTheme`; active theme palette. +- [ ] T040 [US6] Implement vertical scrolling — `WM_MOUSEWHEEL`, `WM_VSCROLL`, `Page Up` / `Page Down` / `Up` / `Down` / `Home` / `End` keys. Clamp to content. +- [ ] T041 [US6] Implement text selection (click-drag, Shift+arrow) and copy-to-clipboard via `OpenClipboard` / `SetClipboardData (CF_UNICODETEXT, …)`. Copy-with-no-selection is a no-op (per spec edge case). +- [ ] T042 [US6] Delete `Casso/DebugConsole.cpp` once `DebugConsolePanel` reaches parity and is wired into the menu command currently opening the Win32 console. Update any owning code (e.g. `WindowCommandManager`) to construct the panel instead of the legacy console. +- [ ] T043 [US6] Walk quickstart §P3-A under each theme. + +**Checkpoint**: No Win32 `EDIT` control remains in the Debug Console path. + +--- + +## Phase 10: User Story 7 — Themed Disk II Debug Dialog (Priority: P3) + +**Goal**: Convert the full `DiskIIDebugDialog` Win32 dialog into a themed DX panel, preserving every control's behaviour while keeping `DiskIIDebugDialogState` pure (no Win32 includes). Per plan §Migration Risk and Decision 6, the conversion is **incremental, one control family at a time**, with the legacy Win32 dialog kept buildable behind `#ifdef CASSO_LEGACY_DISKII_DEBUG_DIALOG` until DX reaches parity. + +**Independent Test**: Open the dialog; for each control family below, exercise it and verify behaviour matches the legacy Win32 version against the same `DiskIIDebugDialogState`. `UnitTest` project still builds and `DiskIIDebugDialogStateTests.cpp` still passes (SC-010). + +### Scaffolding + +- [ ] T044 [US7] Add `Casso/Ui/DiskIIDebugPanel.h` + `DiskIIDebugPanel.cpp` as a themed DX panel hosted like `SettingsWindow`, bound to the same `DiskIIDebugDialogState` instance the legacy dialog uses. Land the panel empty (window chrome + state binding only) — no controls yet. +- [ ] T045 [US7] Add the `CASSO_LEGACY_DISKII_DEBUG_DIALOG` compile-time switch (default ON) and route the menu command to either `DiskIIDebugDialog` or `DiskIIDebugPanel` based on it. Both must build at every commit during the conversion. +- [ ] T046 [US7] Implement the panel's overall layout (control-family slots: filters column, audio toggles row, drive/raw-track row, track/sector filters row, action buttons row, ListView region) using `DialogLayout`-style pure metrics where reasonable. Verify SC-010: `DiskIIDebugDialogStateTests.cpp` still builds and passes — no Win32 types leaked into the state TU. + +### Per-control-family conversions (in spec order, one family per task — leave the legacy version building between tasks) + +- [ ] T047 [US7] Static labels — render each label through `DxUiPainter` under each theme (quickstart §P3-B step 1). Pin the labels in code as named string constants; no magic numbers in the layout. +- [ ] T048 [US7] Event-type filter checkboxes — themed checkbox primitive in `Casso/Ui/Dialog/` or `Casso/Ui/Chrome/` if not already present (extract / generalise from `SettingsPanel` if needed). Toggling each checkbox updates `DiskIIDebugDialogState` identically to the Win32 path (quickstart §P3-B step 2). +- [ ] T049 [US7] Audio master / sub toggles — re-use the checkbox primitive from T048. Verify ListView re-filters identically (quickstart §P3-B step 3). +- [ ] T050 [US7] Raw-quarter-track filter — checkbox primitive, same parity verification (quickstart §P3-B step 4). +- [ ] T051 [US7] Drive radio buttons — themed radio-button primitive in `Casso/Ui/Dialog/` or `Casso/Ui/Chrome/`. Selecting each updates the state identically (quickstart §P3-B step 5). +- [ ] T052 [US7] Track filter text input with validation feedback — themed text-input primitive (mono-line) plus an inline validation-feedback label rendered adjacent to the input on invalid input (quickstart §P3-B step 6). Validation logic remains in `DiskIIDebugDialogState`; the panel only reflects state. +- [ ] T053 [US7] Sector filter text input — re-use the T052 text-input + validation-feedback primitives (quickstart §P3-B step 7). +- [ ] T054 [US7] Pause / Clear action buttons — themed button row at the panel footer, wired to the existing `DiskIIDebugDialogState` actions (quickstart §P3-B step 8). +- [ ] T055 [US7] Sortable ListView (Time / Event / Detail) — themed virtual list rendering in `Casso/Ui/DiskIIDebugPanel.cpp` reading from `DiskIIDebugDialogState`'s filtered view. Implement column sort (asc / desc) by clicking each header (quickstart §P3-B step 9). +- [ ] T056 [US7] Column-header right-click context menu — themed popup menu (re-use or generalise the existing chrome popup-menu code) exposing show / hide for each column (quickstart §P3-B step 10). +- [ ] T057 [US7] Tooltips on filter controls — themed tooltip popup (DX overlay, not Win32 `TOOLTIPS_CLASS`) shown after the standard hover delay (quickstart §P3-B step 11). The tooltip strings live in the panel TU as named constants. +- [ ] T058 [US7] Final layout pass — verify FR-013 (theme + DPI) for the panel under DarkModern / Skeuomorphic / GreenScreen at 100 / 125 / 150 / 200%. Fix any layout drift surfaced by the per-control conversions. +- [ ] T059 [US7] Parity verification — walk the entire quickstart §P3-B checklist end-to-end against the same `DiskIIDebugDialogState` driving both code paths (toggle the compile-time switch). Once parity is verified, delete `Casso/DiskIIDebugDialog.cpp` and remove the `CASSO_LEGACY_DISKII_DEBUG_DIALOG` switch. Re-verify SC-010 (`DiskIIDebugDialogStateTests.cpp` still builds and passes; state TU still Win32-free). + +**Checkpoint**: P3 ships. Only `IFileOpenDialog` and the `Main.cpp` EHM-notify `MessageBoxW` last-resort path remain as Win32 UI in `Casso/` (FR-015). + +--- + +## Phase 11: Polish & Cross-Cutting (Merge Gate) + +**Purpose**: Bookkeeping run at merge time. **Do NOT mark these complete during development** — they are end-of-feature gates, run once, in this order. + +- [ ] T060 FR-015 containment check — run `rg -n "MessageBox|TaskDialog|GetOpenFileName" Casso/` and confirm hits are limited to (a) `IFileOpenDialog` in `WindowCommandManager::PromptForDiskImage` and (b) `MessageBoxW` in `Main.cpp`'s EHM `SetNotifyFunction` callback, plus comments. Any other hit is a regression to fix before merge. +- [ ] T061 Run `scripts\Build.ps1 -RunCodeAnalysis` from PowerShell — must complete with zero analysis warnings. +- [ ] T062 Run `scripts\RunTests.ps1` from PowerShell — full unit suite (including `DiskMruTests`, `DialogLayoutTests`, `DriveLabelTruncationTests`, `StartupDownloadSetTests`, and the unchanged `DiskIIDebugDialogStateTests`) must pass. +- [ ] T063 Add **one consolidated** `CHANGELOG.md` entry for the feature per repo convention (single entry, not one per round of fixes). Cover the user-visible surface: unified first-run download dialog, themed boot-disk picker with MRU, themed About/Keymap/Machine-Info, drive widget filename label, single disk-insert file picker, themed Debug Console, themed Disk II Debug dialog. +- [ ] T064 Update `README.md` only if test counts, supported-feature lists, or roadmap items change as a result of this feature. +- [ ] T065 Update `specs/011-native-dialogs-completion/quickstart.md` with any verification steps discovered during implementation (new edge cases, additional reproduction notes). Final pass only — do not churn this file mid-development. + +**Out of scope (per plan)**: Dormann (`scripts/RunDormannTest.ps1`) and Harte (`scripts/RunHarteTests.ps1 -SkipGenerate`) suites are **NOT required** — this feature touches no CPU, assembler, or binary-output code paths. Do not add tasks for them. + +--- + +## Dependencies & Execution Order + +### Phase dependencies + +- **Phase 1 (Setup)**: no dependencies. +- **Phase 2 (Foundational)**: depends on Phase 1. **Blocks every user story.** +- **Phase 3 (US1, P1)**: depends on Phase 2 (DialogPrimitive, DialogLayout). +- **Phase 4 (US2, P1)**: depends on Phase 2 (DialogPrimitive, DiskMru, `recentDisks` prefs). +- **Phase 5 (US3, P2)**: depends on Phase 2 (DialogPrimitive). Independent of P1 phases. +- **Phase 6 (US4, P2)**: depends on Phase 2 (DiskMru is not strictly needed but the mount-site plumbing in T021 is shared — if Phase 4 has not landed yet, Phase 6's T033 wires only the path field, and the MRU recording wires in when Phase 4 lands). +- **Phase 7 (US5, P2)**: depends on Phase 4 (MRU wiring in `PromptForDiskImage`) and Phase 6 (drive widget label update on mount) — both are needed for the independent test to pass end-to-end. +- **Phase 8 (FR-012, P2)**: depends on Phase 2 only. +- **Phase 9 (US6, P3)**: depends on Phase 2 (modal-overlay extraction in T006). Independent of P1 / P2 phases. +- **Phase 10 (US7, P3)**: depends on Phase 2 and, for the checkbox / radio / text-input / button primitives, may share code with Phase 5 / Phase 8 if those phases generalised any chrome primitives. Heaviest phase — land last. +- **Phase 11 (Polish)**: depends on every desired user story being complete. + +### Within each user story + +- Pure tests scheduled **alongside** the production code they cover (not after). Each landed task leaves the suite green. +- Foundational primitives before consumers (DialogLayout + tests before StartupDownloadDialog; DiskMru + tests before BootDiskPicker; DriveLabelTruncation + tests before DriveWidget paint). +- Theme + DPI walk (FR-013) at the end of each user story phase. + +### Parallel opportunities + +- **Phase 2**: T004 (DialogLayout) + T005 (its tests) can land independently of T010 (DiskMru) + T011 (its tests) and T012 (prefs schema). T003 (DialogDefinition.h) gates T004 and T006. +- **Phase 3 vs Phase 4 vs Phase 5 vs Phase 6 vs Phase 8**: all independent once Phase 2 ships. With multiple developers, P1 (US1, US2), P2 (US3, US4, US5, FR-012) can run in parallel — P3 (US6, US7) stacks on top. +- **Within Phase 3**: T014 (StartupDownloadDialog scaffold) + T015 (StartupDownloadSet tests) are different files, no dependency — `[P]`. +- **Within Phase 5**: T025 (About) + T026 (Keymap) + T027 (Machine Info) edit the same `WindowCommandManager.cpp` — mark `[P]` only when each is isolated to a distinct command handler; otherwise sequence them. They are tagged `[P]` here because each lives in its own handler function. +- **Within Phase 6**: T030 (DriveWidgetState) + T031 (DriveLabelTruncation) + T032 (its tests) all different files — all `[P]`. +- **Within Phase 10**: per-control-family conversions T047–T057 must be **sequential** (each leaves the panel buildable + parity-tested before the next). T044 / T045 / T046 gate everything that follows. + +--- + +## Parallel Example: Foundational Phase + +```text +# Once T003 (DialogDefinition.h) has landed: +Task: T004 — Add DialogLayout.h/.cpp pure layout math +Task: T005 — Add UnitTest/DialogLayoutTests.cpp coverage + +# In parallel with the dialog primitive work: +Task: T010 — Add DiskMru.h/.cpp pure helper +Task: T011 — Add UnitTest/DiskMruTests.cpp coverage +Task: T012 — Extend GlobalUserPrefs JSON schema with recentDisks +``` + +--- + +## Implementation Strategy + +### MVP first (P1 only — User Stories 1 + 2) + +1. Phase 1 (Setup). +2. Phase 2 (Foundational — DialogPrimitive + DialogLayout + DiskMru + recentDisks). +3. Phase 3 (US1) → walk quickstart §P1-A under all themes / DPIs. +4. Phase 4 (US2) → walk quickstart §P1-B / §P1-C / §P1-D under all themes / DPIs. +5. **STOP and VALIDATE.** P1 is independently shippable here — unified first-run download dialog + themed boot-disk picker with MRU. Demo / merge candidate. + +### Incremental delivery + +1. Land MVP (P1) per above. +2. Layer P2 (US3 + US4 + US5 + FR-012) — Help/About/Keymap/Machine-Info themed, drive widget label, disk-insert file-open dedup, Settings panel stray cleanup. Each user story is independently demoable. +3. Layer P3 (US6 + US7) — Debug Console, then Disk II Debug Dialog incrementally one control family at a time. Land the panel scaffold + compile-time switch first; remove the switch and delete the legacy Win32 dialog only once parity is verified end-to-end. +4. Phase 11 (Polish) runs once, at merge. + +### Parallel team strategy + +With multiple developers: + +1. Whole team: Phase 1 + Phase 2 together. +2. Once Phase 2 is in: + - Dev A: US1 (Phase 3) + - Dev B: US2 (Phase 4) + - Dev C: US3 + FR-012 (Phase 5 + Phase 8) + - Dev D: US4 + US5 (Phase 6 + Phase 7 — sequence US5 after US4 since US5's independent test depends on US4's widget label). +3. P3 (US6, US7) lands after P1 / P2 stabilise — heaviest single conversion is US7, so allocate the strongest pair-programming budget there. + +--- + +## Notes + +- `[P]` tasks = different files, no incomplete-task dependency. +- `[Story]` label maps each task to a spec.md user story (US1–US7). Setup / Foundational / Polish phases are unlabeled. +- Every user story is independently testable per its quickstart subsection. +- Headless unit tests land **alongside** the code they cover — never deferred to Phase 11. +- The bookkeeping tasks T060–T065 are merge gates only — do **not** mark them complete during development rounds. The CHANGELOG entry is a **single consolidated** entry per repo convention, not one entry per round of fixes. +- Dormann and Harte are explicitly **not required** for this feature (no CPU / assembler / binary-output changes per plan). +- FR-015 is the hard line: after T059 / T060, only `IFileOpenDialog` in `WindowCommandManager::PromptForDiskImage` and `MessageBoxW` in `Main.cpp`'s EHM-notify callback may remain in `Casso/`. From 56e355588e396974ae5aadfe018dccc1e39c3999 Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Thu, 28 May 2026 06:44:48 -0700 Subject: [PATCH 02/71] feat(011): dialog primitive value types + layout math + DiskMru + truncation Foundational primitives for native DX dialog work (spec 011): - Casso/Ui/Dialog/DialogDefinition.h: pure value types (DialogIcon, DialogTextRun, DialogButton, DialogDefinition) + custom-body paint/ input hook signatures matching contracts/dialog-primitive.md. - Casso/Ui/Dialog/DialogLayout.{h,cpp}: pure LayoutDialog free function taking DialogDefinition + DialogLayoutMetrics with injected text measurement callbacks. Produces icon rect, per-run body rects with greedy wrap, hyperlink hit rects, button row (right-aligned), and optional custom-body rect. - Casso/Shell/DiskMru.{h,cpp}: pure most-recently-used list helper with k_capacity = 16, move-to-front dedup, oldest eviction at cap, Prune via injected exists predicate. No file I/O. - Casso/Ui/Chrome/DriveLabelTruncation.{h,cpp}: pure basename truncation with single-character U+2026 ellipsis via injected measure callback. - Headless unit tests for all three (DialogLayoutTests, DiskMruTests, DriveLabelTruncationTests) with deterministic stub measurers. No behavioral wiring yet -- the DialogPrimitive overlay, GlobalUserPrefs recentDisks JSON field, and consumer dialogs land in follow-on commits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Casso/Casso.vcxproj | 7 + Casso/Shell/DiskMru.cpp | 105 ++++++ Casso/Shell/DiskMru.h | 49 +++ Casso/Ui/Chrome/DriveLabelTruncation.cpp | 96 +++++ Casso/Ui/Chrome/DriveLabelTruncation.h | 33 ++ Casso/Ui/Dialog/DialogDefinition.h | 98 ++++++ Casso/Ui/Dialog/DialogLayout.cpp | 348 +++++++++++++++++++ Casso/Ui/Dialog/DialogLayout.h | 58 ++++ CassoCore/Version.h | 2 +- UnitTest/DialogLayoutTests.cpp | 152 ++++++++ UnitTest/DiskMruTests.cpp | 151 ++++++++ UnitTest/DriveLabelTruncationTests.cpp | 85 +++++ UnitTest/UnitTest.vcxproj | 15 + specs/011-native-dialogs-completion/tasks.md | 14 +- 14 files changed, 1205 insertions(+), 8 deletions(-) create mode 100644 Casso/Shell/DiskMru.cpp create mode 100644 Casso/Shell/DiskMru.h create mode 100644 Casso/Ui/Chrome/DriveLabelTruncation.cpp create mode 100644 Casso/Ui/Chrome/DriveLabelTruncation.h create mode 100644 Casso/Ui/Dialog/DialogDefinition.h create mode 100644 Casso/Ui/Dialog/DialogLayout.cpp create mode 100644 Casso/Ui/Dialog/DialogLayout.h create mode 100644 UnitTest/DialogLayoutTests.cpp create mode 100644 UnitTest/DiskMruTests.cpp create mode 100644 UnitTest/DriveLabelTruncationTests.cpp diff --git a/Casso/Casso.vcxproj b/Casso/Casso.vcxproj index e365d04f..745e03d8 100644 --- a/Casso/Casso.vcxproj +++ b/Casso/Casso.vcxproj @@ -277,6 +277,9 @@ + + + @@ -302,6 +305,10 @@ + + + + diff --git a/Casso/Shell/DiskMru.cpp b/Casso/Shell/DiskMru.cpp new file mode 100644 index 00000000..2d9408b8 --- /dev/null +++ b/Casso/Shell/DiskMru.cpp @@ -0,0 +1,105 @@ +#include "Pch.h" + +#include "DiskMru.h" + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// RecordMount +// +// Move-to-front semantics: if `path` is already present, drop the +// prior occurrence; then insert at index 0; then evict the tail until +// size <= k_capacity. Empty paths are silently ignored. +// +//////////////////////////////////////////////////////////////////////////////// + +void DiskMru::RecordMount (const std::filesystem::path & path) +{ + if (path.empty()) + { + return; + } + + auto it = std::find (m_entries.begin(), m_entries.end(), path); + if (it != m_entries.end()) + { + m_entries.erase (it); + } + + m_entries.insert (m_entries.begin(), path); + + while (m_entries.size() > k_capacity) + { + m_entries.pop_back(); + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Snapshot +// +//////////////////////////////////////////////////////////////////////////////// + +std::vector DiskMru::Snapshot () const +{ + return m_entries; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Prune +// +// Removes entries the predicate rejects. Preserves the surviving +// order. A null predicate is a no-op (returns the current snapshot). +// +//////////////////////////////////////////////////////////////////////////////// + +std::vector DiskMru::Prune ( + const std::function & existsPredicate) +{ + if (!existsPredicate) + { + return m_entries; + } + + auto last = std::remove_if (m_entries.begin(), + m_entries.end(), + [&] (const std::filesystem::path & p) { return !existsPredicate (p); }); + m_entries.erase (last, m_entries.end()); + + return m_entries; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ReplaceAll +// +// Bulk replace (used at load time). De-dup is preserved if the caller +// supplied de-duped entries; we still enforce the cap. +// +//////////////////////////////////////////////////////////////////////////////// + +void DiskMru::ReplaceAll (std::vector entries) +{ + m_entries = std::move (entries); + + while (m_entries.size() > k_capacity) + { + m_entries.pop_back(); + } +} diff --git a/Casso/Shell/DiskMru.h b/Casso/Shell/DiskMru.h new file mode 100644 index 00000000..f690e82d --- /dev/null +++ b/Casso/Shell/DiskMru.h @@ -0,0 +1,49 @@ +#pragma once + +#include "Pch.h" + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DiskMru +// +// Pure most-recently-used list helper for mounted disk image paths. +// Most-recent-first ordering, capped at k_capacity. Re-mounting an +// existing entry moves it to index 0. New mount at cap evicts the +// oldest. `Prune` takes an injected predicate so unit tests drive it +// without touching the real file system. +// +// Persistence is the owning singleton's job (GlobalUserPrefs writes / +// reads the `recentDisks` JSON array); DiskMru itself is Win32-free +// and IO-free. +// +//////////////////////////////////////////////////////////////////////////////// + + + +class DiskMru +{ +public: + static constexpr size_t k_capacity = 16; + + void RecordMount (const std::filesystem::path & path); + std::vector Snapshot () const; + + // Removes entries the predicate returns false for, preserving the + // surviving order. Returns the post-prune snapshot. Returns + // unchanged copy when the predicate is null. + std::vector Prune (const std::function & existsPredicate); + + // Replaces the list outright. Caller is responsible for ordering / + // de-dup / cap; we still cap on the way in defensively. + void ReplaceAll (std::vector entries); + + size_t Size () const { return m_entries.size(); } + bool Empty () const { return m_entries.empty(); } + +private: + std::vector m_entries; // index 0 == most recent +}; diff --git a/Casso/Ui/Chrome/DriveLabelTruncation.cpp b/Casso/Ui/Chrome/DriveLabelTruncation.cpp new file mode 100644 index 00000000..d54f4ec1 --- /dev/null +++ b/Casso/Ui/Chrome/DriveLabelTruncation.cpp @@ -0,0 +1,96 @@ +#include "Pch.h" + +#include "DriveLabelTruncation.h" + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Constants +// +//////////////////////////////////////////////////////////////////////////////// + +static constexpr wchar_t s_kchEllipsis = L'\u2026'; + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// TruncateToWidth +// +// Binary-search the longest prefix `p` such that +// measure (p + ellipsis) <= maxWidthPx. Returns the literal basename +// when it fits. Returns just the ellipsis when even that doesn't fit. +// +//////////////////////////////////////////////////////////////////////////////// + +std::wstring TruncateToWidth ( + std::wstring_view basename, + float maxWidthPx, + const std::function & measure) +{ + std::wstring ellipsisOnly (1, s_kchEllipsis); + size_t lo = 0; + size_t hi = 0; + size_t mid = 0; + size_t best = 0; + + + + if (!measure) + { + return std::wstring (basename); + } + + if (basename.empty()) + { + return std::wstring(); + } + + if (measure (basename) <= maxWidthPx) + { + return std::wstring (basename); + } + + if (measure (ellipsisOnly) > maxWidthPx) + { + return ellipsisOnly; + } + + lo = 0; + hi = basename.size(); + best = 0; + while (lo <= hi) + { + std::wstring candidate; + + mid = lo + (hi - lo) / 2; + candidate.assign (basename.substr (0, mid)); + candidate.push_back (s_kchEllipsis); + + if (measure (candidate) <= maxWidthPx) + { + best = mid; + lo = mid + 1; + } + else + { + if (mid == 0) + { + break; + } + hi = mid - 1; + } + } + + { + std::wstring out; + out.assign (basename.substr (0, best)); + out.push_back (s_kchEllipsis); + return out; + } +} diff --git a/Casso/Ui/Chrome/DriveLabelTruncation.h b/Casso/Ui/Chrome/DriveLabelTruncation.h new file mode 100644 index 00000000..f2972321 --- /dev/null +++ b/Casso/Ui/Chrome/DriveLabelTruncation.h @@ -0,0 +1,33 @@ +#pragma once + +#include "Pch.h" + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DriveLabelTruncation +// +// Pure basename-truncation helper. Used by the drive widget to fit a +// mounted disk's filename below "Drive N" with a single-character +// ellipsis (U+2026) when the literal basename is too wide. The +// measure callback is `DxUiPainter::MeasureTextRunWidth` in +// production and a deterministic stub in tests. +// +// Spec rules: +// - Literal `path.filename()` — no extension stripping, even for +// basenames with multiple dots or no extension. +// - Single-character ellipsis L'\u2026', not three dots. +// - Degenerate case (basename narrower than the ellipsis itself): +// return just the ellipsis. +// +//////////////////////////////////////////////////////////////////////////////// + + + +std::wstring TruncateToWidth ( + std::wstring_view basename, + float maxWidthPx, + const std::function & measure); diff --git a/Casso/Ui/Dialog/DialogDefinition.h b/Casso/Ui/Dialog/DialogDefinition.h new file mode 100644 index 00000000..2892db4c --- /dev/null +++ b/Casso/Ui/Dialog/DialogDefinition.h @@ -0,0 +1,98 @@ +#pragma once + +#include "Pch.h" + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogDefinition +// +// Pure value type consumed by `DialogPrimitive::Show`. Describes the +// title, optional icon, wrapped body runs, action buttons, and +// optional custom-body paint / input hooks for richer consumers +// (startup-download progress, boot-disk picker list). All types are +// intentionally Win32-free so the layout math in `DialogLayout` can +// be unit-tested headlessly. +// +//////////////////////////////////////////////////////////////////////////////// + + + +class DxUiPainter; +class ChromeTheme; +struct DialogPaintContext; +struct DialogInputEvent; + + + +enum class DialogIcon +{ + None, + AppPhotoreal, // IDI_CASSO_PHOTOREAL + AppFlat, // IDI_CASSO_FLAT + Info, + Warning, + Error +}; + + + +struct DialogTextRun +{ + std::wstring text; + bool isHyperlink = false; + std::wstring hyperlinkUrl; // ignored unless isHyperlink == true +}; + + + +struct DialogButton +{ + std::wstring label; + int resultCode = 0; + bool isDefault = false; + bool isCancel = false; +}; + + + +struct DialogPaintContext +{ + DxUiPainter * painter = nullptr; + const ChromeTheme * theme = nullptr; + RECT customBodyRect = {}; + float dpiScale = 1.0f; +}; + + + +struct DialogInputEvent +{ + enum class Kind { MouseMove, LeftButtonDown, LeftButtonUp, KeyDown }; + Kind kind = Kind::MouseMove; + int xPx = 0; + int yPx = 0; + int vkCode = 0; // valid for KeyDown +}; + + + +struct DialogDefinition +{ + std::wstring title; + DialogIcon icon = DialogIcon::None; + std::vector body; + std::vector buttons; + + // Custom-body hooks. When `onPaintCustomBody` is set the layout + // reserves space between the body text and the button row equal + // to `customBodyMinSizePx`; the primitive then calls the hook on + // every render frame. `onInputCustomBody` receives input events + // hit-tested into the custom body rect. + std::function onPaintCustomBody; + std::function onInputCustomBody; + SIZE customBodyMinSizePx = {}; +}; diff --git a/Casso/Ui/Dialog/DialogLayout.cpp b/Casso/Ui/Dialog/DialogLayout.cpp new file mode 100644 index 00000000..3b4f1a24 --- /dev/null +++ b/Casso/Ui/Dialog/DialogLayout.cpp @@ -0,0 +1,348 @@ +#include "Pch.h" + +#include "DialogLayout.h" + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Anonymous helpers +// +//////////////////////////////////////////////////////////////////////////////// + +namespace +{ + constexpr float s_kZeroPx = 0.0f; + + + + struct WrappedRun + { + size_t runIndex; + size_t start; + size_t count; + float xPx; + float yPx; + float widthPx; + }; + + + + //////////////////////////////////////////////////////////////////////////// + // + // FindWrapBoundary + // + // Returns the largest character count in [1..remaining] whose measured + // width fits in `maxWidthPx`. Prefers a whitespace break when possible. + // + //////////////////////////////////////////////////////////////////////////// + + size_t FindWrapBoundary ( + std::wstring_view text, + size_t start, + float maxWidthPx, + const std::function & measure) + { + size_t remaining = text.size() - start; + size_t fit = 0; + size_t lastSpace = 0; + size_t i = 0; + float widthPx = 0.0f; + + + + if (remaining == 0) + { + return 0; + } + + // Coarse linear probe: extend by 1 char until width exceeds the + // bound. Cheap for the short body strings dialogs carry. + for (i = 1; i <= remaining; i++) + { + widthPx = measure (text.substr (start, i)); + if (widthPx > maxWidthPx) + { + break; + } + fit = i; + if (text[start + i - 1] == L' ') + { + lastSpace = i; + } + } + + if (fit == 0) + { + // Single character overflows the line — force a 1-char break. + return 1; + } + + if (fit < remaining && lastSpace > 0) + { + return lastSpace; + } + + return fit; + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // WrapBody + // + // Greedy line-wrap across all body runs into `maxBodyWidthPx`. + // Each emitted `WrappedRun` is a single line's worth of one source + // run; a long run spans multiple WrappedRuns. The caller turns each + // WrappedRun into a RECT. + // + //////////////////////////////////////////////////////////////////////////// + + void WrapBody ( + const std::vector & runs, + float maxBodyWidthPx, + float lineHeightPx, + const std::function & measure, + std::vector & outWrapped, + float & outTotalHeightPx) + { + size_t runIndex = 0; + size_t pos = 0; + float cursorXPx = 0.0f; + float cursorYPx = 0.0f; + float remainingPx = maxBodyWidthPx; + + + + outWrapped.clear(); + outTotalHeightPx = 0.0f; + + if (runs.empty()) + { + return; + } + + for (runIndex = 0; runIndex < runs.size(); runIndex++) + { + const std::wstring & text = runs[runIndex].text; + pos = 0; + while (pos < text.size()) + { + std::wstring_view view (text); + size_t tentative = FindWrapBoundary (view, pos, remainingPx, measure); + + + + if (tentative == 0) + { + // No room on the current line for even one char — newline. + cursorXPx = 0.0f; + cursorYPx += lineHeightPx; + remainingPx = maxBodyWidthPx; + continue; + } + + float pieceWidthPx = measure (view.substr (pos, tentative)); + + + + WrappedRun emit { runIndex, pos, tentative, cursorXPx, cursorYPx, pieceWidthPx }; + outWrapped.push_back (emit); + + pos += tentative; + cursorXPx += pieceWidthPx; + remainingPx = maxBodyWidthPx - cursorXPx; + + if (pos < text.size()) + { + cursorXPx = 0.0f; + cursorYPx += lineHeightPx; + remainingPx = maxBodyWidthPx; + } + } + } + + outTotalHeightPx = cursorYPx + (outWrapped.empty() ? 0.0f : lineHeightPx); + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// LayoutDialog +// +// Pure layout math producing the rects every DialogPrimitive consumer +// needs (icon slot, per-run body rects, hyperlink hit-rects, button +// row, optional custom-body rect) plus the overall window content +// size. Win32-free; all measurement happens through the metrics' +// callback hooks. Tests pin behavior with deterministic stubs. +// +//////////////////////////////////////////////////////////////////////////////// + +DialogLayoutResult LayoutDialog ( + const DialogDefinition & def, + const DialogLayoutMetrics & metrics) +{ + DialogLayoutResult result; + std::vector wrapped; + float bodyTotalHeightPx = 0.0f; + float bodyOriginXPx = 0.0f; + float bodyOriginYPx = 0.0f; + float contentTopPx = 0.0f; + float contentBottomPx = 0.0f; + float contentRightPx = 0.0f; + bool hasIcon = (def.icon != DialogIcon::None); + bool hasCustomBody = (def.onPaintCustomBody != nullptr); + size_t wi = 0; + size_t bi = 0; + float buttonRowYPx = 0.0f; + float buttonRowRightPx = 0.0f; + float iconYPx = 0.0f; + float iconBlockHeightPx = hasIcon ? metrics.iconSizePx : 0.0f; + std::vector buttonWidthsPx; + + + + bodyOriginXPx = metrics.outerPaddingPx; + contentTopPx = metrics.outerPaddingPx; + + if (hasIcon) + { + bodyOriginXPx += metrics.iconSizePx + metrics.iconBodyGapPx; + } + + bodyOriginYPx = contentTopPx; + + WrapBody (def.body, + metrics.maxBodyWidthPx, + metrics.bodyLineHeightPx, + metrics.measureBodyTextRun ? metrics.measureBodyTextRun + : [] (std::wstring_view v) { return (float) v.size() * s_kZeroPx; }, + wrapped, + bodyTotalHeightPx); + + // Per-run rects (1:1 with def.body). When a run wraps into multiple + // WrappedRun pieces, we take the union of the pieces' rects so the + // hyperlink hit-test is the full bounding box of the link's visual + // run. That matches user expectation — clicking anywhere on the + // underlined text triggers the link. + result.bodyRunRectsPx.assign (def.body.size(), RECT {}); + result.hyperlinkHitRectsPx.clear(); + + for (wi = 0; wi < wrapped.size(); wi++) + { + const WrappedRun & w = wrapped[wi]; + RECT & rect = result.bodyRunRectsPx[w.runIndex]; + + float leftPx = bodyOriginXPx + w.xPx; + float topPx = bodyOriginYPx + w.yPx; + float rightPx = leftPx + w.widthPx; + float bottomPx = topPx + metrics.bodyLineHeightPx; + + if (rect.right == 0 && rect.bottom == 0) + { + rect.left = (LONG) leftPx; + rect.top = (LONG) topPx; + rect.right = (LONG) rightPx; + rect.bottom = (LONG) bottomPx; + } + else + { + rect.left = std::min (rect.left, (LONG) leftPx); + rect.top = std::min (rect.top, (LONG) topPx); + rect.right = std::max (rect.right, (LONG) rightPx); + rect.bottom = std::max (rect.bottom, (LONG) bottomPx); + } + } + + for (bi = 0; bi < def.body.size(); bi++) + { + if (def.body[bi].isHyperlink) + { + result.hyperlinkHitRectsPx.push_back (result.bodyRunRectsPx[bi]); + } + } + + contentBottomPx = bodyOriginYPx + std::max (bodyTotalHeightPx, iconBlockHeightPx); + contentRightPx = bodyOriginXPx + metrics.maxBodyWidthPx; + + if (hasIcon) + { + iconYPx = contentTopPx; + result.iconRectPx.left = (LONG) metrics.outerPaddingPx; + result.iconRectPx.top = (LONG) iconYPx; + result.iconRectPx.right = (LONG) (metrics.outerPaddingPx + metrics.iconSizePx); + result.iconRectPx.bottom = (LONG) (iconYPx + metrics.iconSizePx); + } + + if (hasCustomBody) + { + float cbTopPx = contentBottomPx + metrics.bodyButtonsGapPx; + float cbLeftPx = metrics.outerPaddingPx; + float cbWidthPx = std::max ((float) def.customBodyMinSizePx.cx, + contentRightPx - cbLeftPx); + float cbHeightPx = (float) def.customBodyMinSizePx.cy; + + result.customBodyRectPx.left = (LONG) cbLeftPx; + result.customBodyRectPx.top = (LONG) cbTopPx; + result.customBodyRectPx.right = (LONG) (cbLeftPx + cbWidthPx); + result.customBodyRectPx.bottom = (LONG) (cbTopPx + cbHeightPx); + + contentBottomPx = (float) result.customBodyRectPx.bottom; + contentRightPx = std::max (contentRightPx, (float) result.customBodyRectPx.right); + } + + // Button row: right-aligned within content; spaced by buttonSpacingPx. + buttonRowYPx = contentBottomPx + metrics.bodyButtonsGapPx; + buttonRowRightPx = contentRightPx; + + buttonWidthsPx.reserve (def.buttons.size()); + for (bi = 0; bi < def.buttons.size(); bi++) + { + float labelW = metrics.measureButtonLabel + ? metrics.measureButtonLabel (def.buttons[bi].label) + : (float) def.buttons[bi].label.size() * s_kZeroPx; + float w = labelW + 2.0f * metrics.buttonPaddingPx; + if (w < metrics.minButtonWidthPx) { w = metrics.minButtonWidthPx; } + buttonWidthsPx.push_back (w); + } + + // Place buttons right-to-left so the rightmost button hugs the + // content right edge. + result.buttonRectsPx.assign (def.buttons.size(), RECT {}); + { + float cursorRightPx = buttonRowRightPx; + for (bi = def.buttons.size(); bi > 0; bi--) + { + size_t idx = bi - 1; + float w = buttonWidthsPx[idx]; + + RECT & r = result.buttonRectsPx[idx]; + r.right = (LONG) cursorRightPx; + r.left = (LONG) (cursorRightPx - w); + r.top = (LONG) buttonRowYPx; + r.bottom = (LONG) (buttonRowYPx + metrics.buttonHeightPx); + + cursorRightPx -= (w + metrics.buttonSpacingPx); + } + } + + // Total size = right + padding, button-row-bottom + padding (or + // content-bottom + padding when there are no buttons). + { + float bottomPx = def.buttons.empty() + ? contentBottomPx + : buttonRowYPx + metrics.buttonHeightPx; + + result.totalSizePx.cx = (LONG) (contentRightPx + metrics.outerPaddingPx); + result.totalSizePx.cy = (LONG) (bottomPx + metrics.outerPaddingPx); + } + + return result; +} diff --git a/Casso/Ui/Dialog/DialogLayout.h b/Casso/Ui/Dialog/DialogLayout.h new file mode 100644 index 00000000..0d5ad332 --- /dev/null +++ b/Casso/Ui/Dialog/DialogLayout.h @@ -0,0 +1,58 @@ +#pragma once + +#include "Pch.h" + +#include "DialogDefinition.h" + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogLayout +// +// Pure layout math for the themed dialog primitive. Headless: all +// text-width measurements come through the injected +// `measureBodyTextRun` / `measureButtonLabel` callbacks. Unit tests +// supply deterministic stubs (e.g. constant pixels-per-character) so +// layout assertions don't require DirectWrite. +// +//////////////////////////////////////////////////////////////////////////////// + + + +struct DialogLayoutMetrics +{ + float dpiScale = 1.0f; + float maxBodyWidthPx = 360.0f; + float buttonHeightPx = 28.0f; + float buttonPaddingPx = 16.0f; + float buttonSpacingPx = 8.0f; + float iconSizePx = 32.0f; + float bodyLineHeightPx = 18.0f; + float outerPaddingPx = 16.0f; + float iconBodyGapPx = 12.0f; + float bodyButtonsGapPx = 16.0f; + float minButtonWidthPx = 72.0f; + std::function measureBodyTextRun; + std::function measureButtonLabel; +}; + + + +struct DialogLayoutResult +{ + SIZE totalSizePx = {}; + RECT iconRectPx = {}; // zero when icon == None + std::vector bodyRunRectsPx; // 1:1 with definition.body + std::vector hyperlinkHitRectsPx; // subset where isHyperlink + std::vector buttonRectsPx; // 1:1 with definition.buttons + RECT customBodyRectPx = {}; // zero when no onPaintCustomBody +}; + + + +DialogLayoutResult LayoutDialog ( + const DialogDefinition & def, + const DialogLayoutMetrics & metrics); diff --git a/CassoCore/Version.h b/CassoCore/Version.h index 4964dbff..4bb32588 100644 --- a/CassoCore/Version.h +++ b/CassoCore/Version.h @@ -5,7 +5,7 @@ #define VERSION_MAJOR 1 #define VERSION_MINOR 4 -#define VERSION_BUILD 1260 +#define VERSION_BUILD 1263 #define VERSION_YEAR 2026 // Helper macros for stringification diff --git a/UnitTest/DialogLayoutTests.cpp b/UnitTest/DialogLayoutTests.cpp new file mode 100644 index 00000000..bffaf6e1 --- /dev/null +++ b/UnitTest/DialogLayoutTests.cpp @@ -0,0 +1,152 @@ +#include "Pch.h" + +#include "../Casso/Ui/Dialog/DialogLayout.h" + + + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + + + + + +namespace DialogLayoutTests +{ + static constexpr float s_kCharWidthPx = 8.0f; + static constexpr float s_kButtonWidthExtraPx = 0.0f; + + + + static DialogLayoutMetrics MakeMetrics () + { + DialogLayoutMetrics m; + m.dpiScale = 1.0f; + m.maxBodyWidthPx = 200.0f; + m.buttonHeightPx = 28.0f; + m.buttonPaddingPx = 16.0f; + m.buttonSpacingPx = 8.0f; + m.iconSizePx = 32.0f; + m.bodyLineHeightPx = 18.0f; + m.outerPaddingPx = 16.0f; + m.iconBodyGapPx = 12.0f; + m.bodyButtonsGapPx = 16.0f; + m.minButtonWidthPx = 0.0f; + m.measureBodyTextRun = [] (std::wstring_view v) { return (float) v.size() * s_kCharWidthPx; }; + m.measureButtonLabel = [] (std::wstring_view v) { return (float) v.size() * s_kCharWidthPx + s_kButtonWidthExtraPx; }; + return m; + } + + + + TEST_CLASS (DialogLayoutTests) + { + public: + + TEST_METHOD (ButtonRow_RightAlignedAndSpaced) + { + DialogDefinition def; + def.buttons = { { L"OK", 1, true, false }, { L"Cancel", 0, false, true } }; + + auto m = MakeMetrics(); + auto r = LayoutDialog (def, m); + + Assert::AreEqual ((size_t) 2, r.buttonRectsPx.size()); + // Right-most button hugs content right edge. + Assert::IsTrue (r.buttonRectsPx[1].right >= r.buttonRectsPx[0].right); + // Inter-button gap == buttonSpacingPx. + LONG gap = r.buttonRectsPx[1].left - r.buttonRectsPx[0].right; + Assert::AreEqual ((LONG) m.buttonSpacingPx, gap); + } + + + TEST_METHOD (BodyTextWraps_AtMaxWidth) + { + DialogDefinition def; + def.body = { { std::wstring (100, L'a'), false, L"" } }; + + auto m = MakeMetrics(); // maxBodyWidthPx = 200, char = 8 → ~25 chars/line + auto r = LayoutDialog (def, m); + + // The single run should span multiple lines → wrapped rect taller than one line. + LONG h = r.bodyRunRectsPx[0].bottom - r.bodyRunRectsPx[0].top; + Assert::IsTrue (h > (LONG) m.bodyLineHeightPx); + } + + + TEST_METHOD (Icon_ChangesTotalWidthDelta) + { + DialogDefinition def; + def.body = { { L"Hello", false, L"" } }; + + auto m = MakeMetrics(); + auto noIcon = LayoutDialog (def, m); + + def.icon = DialogIcon::Info; + auto withIcon = LayoutDialog (def, m); + + // Icon-present total width grows by iconSize + iconBodyGap. + Assert::IsTrue (withIcon.totalSizePx.cx >= noIcon.totalSizePx.cx); + Assert::IsTrue ((withIcon.iconRectPx.right - withIcon.iconRectPx.left) == (LONG) m.iconSizePx); + } + + + TEST_METHOD (Hyperlink_HitRectMatchesBodyRunRect) + { + DialogDefinition def; + def.body = { + { L"See ", false, L"" }, + { L"link", true, L"https://example.com" }, + { L" here.", false, L"" } + }; + + auto m = MakeMetrics(); + auto r = LayoutDialog (def, m); + + Assert::AreEqual ((size_t) 1, r.hyperlinkHitRectsPx.size()); + // Hyperlink hit rect equals the body rect of the second run. + Assert::AreEqual (r.bodyRunRectsPx[1].left, r.hyperlinkHitRectsPx[0].left); + Assert::AreEqual (r.bodyRunRectsPx[1].right, r.hyperlinkHitRectsPx[0].right); + Assert::AreEqual (r.bodyRunRectsPx[1].top, r.hyperlinkHitRectsPx[0].top); + Assert::AreEqual (r.bodyRunRectsPx[1].bottom, r.hyperlinkHitRectsPx[0].bottom); + } + + + TEST_METHOD (CustomBody_ReservesSpaceBetweenBodyAndButtons) + { + DialogDefinition def; + def.body = { { L"Body.", false, L"" } }; + def.buttons = { { L"OK", 1, true, false } }; + def.customBodyMinSizePx = { 100, 60 }; + def.onPaintCustomBody = [] (DialogPaintContext &) {}; + + auto m = MakeMetrics(); + auto r = LayoutDialog (def, m); + + // Custom body rect is non-empty and sits between body and buttons. + LONG cbH = r.customBodyRectPx.bottom - r.customBodyRectPx.top; + Assert::AreEqual ((LONG) 60, cbH); + Assert::IsTrue (r.customBodyRectPx.top >= r.bodyRunRectsPx[0].bottom); + Assert::IsTrue (r.buttonRectsPx[0].top >= r.customBodyRectPx.bottom); + } + + + TEST_METHOD (Dpi_ScalesPaddingViaOuterPadding) + { + DialogDefinition def; + def.body = { { L"Hi", false, L"" } }; + def.buttons = { { L"OK", 1, true, false } }; + + auto smaller = MakeMetrics(); + auto bigger = MakeMetrics(); + bigger.outerPaddingPx *= 2.0f; + bigger.buttonHeightPx *= 2.0f; + + auto rs = LayoutDialog (def, smaller); + auto rl = LayoutDialog (def, bigger); + + // Larger metrics yield a larger total size. + Assert::IsTrue (rl.totalSizePx.cx > rs.totalSizePx.cx); + Assert::IsTrue (rl.totalSizePx.cy > rs.totalSizePx.cy); + } + }; +} diff --git a/UnitTest/DiskMruTests.cpp b/UnitTest/DiskMruTests.cpp new file mode 100644 index 00000000..2e3cabc1 --- /dev/null +++ b/UnitTest/DiskMruTests.cpp @@ -0,0 +1,151 @@ +#include "Pch.h" + +#include "../Casso/Shell/DiskMru.h" + + + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + + + + + +namespace DiskMruTests +{ + TEST_CLASS (DiskMruTests) + { + public: + + TEST_METHOD (Insert_IntoEmpty_Adds) + { + DiskMru m; + m.RecordMount (L"C:\\Disks\\A.dsk"); + + Assert::AreEqual ((size_t) 1, m.Size()); + } + + + TEST_METHOD (Remount_MovesToFront_NoDuplicate) + { + DiskMru m; + m.RecordMount (L"C:\\Disks\\A.dsk"); + m.RecordMount (L"C:\\Disks\\B.dsk"); + m.RecordMount (L"C:\\Disks\\A.dsk"); + + auto snap = m.Snapshot(); + Assert::AreEqual ((size_t) 2, snap.size()); + Assert::IsTrue (snap[0] == std::filesystem::path (L"C:\\Disks\\A.dsk")); + Assert::IsTrue (snap[1] == std::filesystem::path (L"C:\\Disks\\B.dsk")); + } + + + TEST_METHOD (Eviction_AtCapacity_DropsOldest) + { + DiskMru m; + size_t i = 0; + + for (i = 0; i < DiskMru::k_capacity + 1; i++) + { + std::wstring p = L"C:\\Disks\\"; + p += std::to_wstring (i); + p += L".dsk"; + m.RecordMount (p); + } + + Assert::AreEqual (DiskMru::k_capacity, m.Size()); + + // The most-recent (last inserted, index k_capacity) is at front; + // the original index-0 entry should have been evicted. + auto snap = m.Snapshot(); + Assert::IsTrue (snap[0].wstring().find (L"\\16.dsk") != std::wstring::npos); + for (i = 0; i < snap.size(); i++) + { + Assert::IsTrue (snap[i].wstring().find (L"\\0.dsk") == std::wstring::npos); + } + } + + + TEST_METHOD (Prune_DropsRejectedEntries_PreservesOrder) + { + DiskMru m; + m.RecordMount (L"C:\\Disks\\A.dsk"); + m.RecordMount (L"C:\\Disks\\B.dsk"); + m.RecordMount (L"C:\\Disks\\C.dsk"); + + auto result = m.Prune ([] (const std::filesystem::path & p) { + return p.wstring().find (L"B.dsk") == std::wstring::npos; + }); + + Assert::AreEqual ((size_t) 2, result.size()); + Assert::IsTrue (result[0] == std::filesystem::path (L"C:\\Disks\\C.dsk")); + Assert::IsTrue (result[1] == std::filesystem::path (L"C:\\Disks\\A.dsk")); + } + + + TEST_METHOD (Prune_NullPredicate_NoOp) + { + DiskMru m; + m.RecordMount (L"C:\\Disks\\A.dsk"); + + auto result = m.Prune (nullptr); + Assert::AreEqual ((size_t) 1, result.size()); + } + + + TEST_METHOD (Prune_Idempotent) + { + DiskMru m; + m.RecordMount (L"C:\\Disks\\A.dsk"); + m.RecordMount (L"C:\\Disks\\B.dsk"); + + auto predicate = [] (const std::filesystem::path & p) { + return p.wstring().find (L"A") != std::wstring::npos; + }; + + auto first = m.Prune (predicate); + auto second = m.Prune (predicate); + + Assert::AreEqual (first.size(), second.size()); + Assert::IsTrue (first == second); + } + + + TEST_METHOD (Snapshot_MostRecentFirst) + { + DiskMru m; + m.RecordMount (L"C:\\Disks\\A.dsk"); + m.RecordMount (L"C:\\Disks\\B.dsk"); + + auto snap = m.Snapshot(); + Assert::IsTrue (snap[0] == std::filesystem::path (L"C:\\Disks\\B.dsk")); + Assert::IsTrue (snap[1] == std::filesystem::path (L"C:\\Disks\\A.dsk")); + } + + + TEST_METHOD (Empty_RecordMount_OnEmptyPathIgnored) + { + DiskMru m; + m.RecordMount (std::filesystem::path {}); + + Assert::IsTrue (m.Empty()); + } + + + TEST_METHOD (ReplaceAll_EnforcesCap) + { + DiskMru m; + std::vector many; + size_t i = 0; + + for (i = 0; i < DiskMru::k_capacity + 5; i++) + { + std::wstring p = L"C:\\X\\"; + p += std::to_wstring (i); + many.emplace_back (p); + } + + m.ReplaceAll (many); + Assert::AreEqual (DiskMru::k_capacity, m.Size()); + } + }; +} diff --git a/UnitTest/DriveLabelTruncationTests.cpp b/UnitTest/DriveLabelTruncationTests.cpp new file mode 100644 index 00000000..56353ad0 --- /dev/null +++ b/UnitTest/DriveLabelTruncationTests.cpp @@ -0,0 +1,85 @@ +#include "Pch.h" + +#include "../Casso/Ui/Chrome/DriveLabelTruncation.h" + + + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + + + + + +namespace DriveLabelTruncationTests +{ + static constexpr float s_kCharWidthPx = 8.0f; + + + + static auto FixedWidthMeasure () + { + return [] (std::wstring_view v) { return (float) v.size() * s_kCharWidthPx; }; + } + + + + TEST_CLASS (DriveLabelTruncationTests) + { + public: + + TEST_METHOD (Fits_ReturnsUntruncated) + { + // 8 chars * 8 px = 64 px + auto out = TruncateToWidth (L"game.dsk", 100.0f, FixedWidthMeasure()); + Assert::AreEqual (std::wstring (L"game.dsk"), out); + } + + + TEST_METHOD (Truncates_WithSingleCharEllipsis) + { + // 64 px budget = 8 chars (the ellipsis counts as 1 char). + auto out = TruncateToWidth (L"superlongname.dsk", 64.0f, FixedWidthMeasure()); + Assert::IsTrue (out.size() <= 8); + Assert::AreEqual (L'\u2026', out.back()); + Assert::AreEqual ((size_t) 0, out.find (L's')); // starts with prefix of "super…" + } + + + TEST_METHOD (NoExtension_Preserved) + { + auto out = TruncateToWidth (L"README", 200.0f, FixedWidthMeasure()); + Assert::AreEqual (std::wstring (L"README"), out); + } + + + TEST_METHOD (MultipleDots_NotStripped) + { + auto out = TruncateToWidth (L"my.backup.dsk", 200.0f, FixedWidthMeasure()); + Assert::AreEqual (std::wstring (L"my.backup.dsk"), out); + } + + + TEST_METHOD (Degenerate_NarrowerThanEllipsis_ReturnsEllipsis) + { + // 4 px budget cannot even fit the ellipsis (8 px); function returns just ellipsis. + auto out = TruncateToWidth (L"name.dsk", 4.0f, FixedWidthMeasure()); + Assert::AreEqual ((size_t) 1, out.size()); + Assert::AreEqual (L'\u2026', out[0]); + } + + + TEST_METHOD (Empty_ReturnsEmpty) + { + auto out = TruncateToWidth (L"", 100.0f, FixedWidthMeasure()); + Assert::IsTrue (out.empty()); + } + + + TEST_METHOD (NullMeasure_ReturnsLiteral) + { + std::function nullMeasure; + auto out = TruncateToWidth (L"anything.dsk", 1.0f, nullMeasure); + Assert::AreEqual (std::wstring (L"anything.dsk"), out); + } + }; +} diff --git a/UnitTest/UnitTest.vcxproj b/UnitTest/UnitTest.vcxproj index 23678bf8..134b607f 100644 --- a/UnitTest/UnitTest.vcxproj +++ b/UnitTest/UnitTest.vcxproj @@ -509,6 +509,21 @@ + + + + + Use + $(IntDir)CassoPch.pch + + + Use + $(IntDir)CassoPch.pch + + + Use + $(IntDir)CassoPch.pch + diff --git a/specs/011-native-dialogs-completion/tasks.md b/specs/011-native-dialogs-completion/tasks.md index af4e499e..26ba47d1 100644 --- a/specs/011-native-dialogs-completion/tasks.md +++ b/specs/011-native-dialogs-completion/tasks.md @@ -28,8 +28,8 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" **Purpose**: Carve out the new directory and confirm the build picks it up before any real code lands. -- [ ] T001 Create the `Casso/Ui/Dialog/` directory with a placeholder header (e.g. `DialogDirectory.txt` or empty `DialogDefinition.h` skeleton) and add the new directory to the `Casso.vcxproj` filters so files dropped into it during Phase 2 build without further project surgery. -- [ ] T002 Add the empty UnitTest source files `UnitTest/DiskMruTests.cpp`, `UnitTest/DialogLayoutTests.cpp`, and `UnitTest/DriveLabelTruncationTests.cpp` to `UnitTest.vcxproj` with `#include "Pch.h"` only, so subsequent tasks can land tests one at a time without project edits. +- [X] T001 Create the `Casso/Ui/Dialog/` directory with a placeholder header (e.g. `DialogDirectory.txt` or empty `DialogDefinition.h` skeleton) and add the new directory to the `Casso.vcxproj` filters so files dropped into it during Phase 2 build without further project surgery. +- [X] T002 Add the empty UnitTest source files `UnitTest/DiskMruTests.cpp`, `UnitTest/DialogLayoutTests.cpp`, and `UnitTest/DriveLabelTruncationTests.cpp` to `UnitTest.vcxproj` with `#include "Pch.h"` only, so subsequent tasks can land tests one at a time without project edits. **Checkpoint**: Solution builds cleanly with the new (empty) translation units present. @@ -43,9 +43,9 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" ### Dialog primitive -- [ ] T003 Define the pure value types in `Casso/Ui/Dialog/DialogDefinition.h` — `DialogIcon`, `DialogTextRun`, `DialogButton`, `DialogDefinition`, plus the `DialogPaintContext` / `DialogInputEvent` forward declarations referenced by the optional custom-body hooks. Match the field set in `contracts/dialog-primitive.md` exactly. -- [ ] T004 [P] Add `Casso/Ui/Dialog/DialogLayout.h` + `DialogLayout.cpp` implementing `LayoutDialog (DialogDefinition, DialogLayoutMetrics) -> DialogLayoutResult` as a pure function (icon slot, wrapped body runs, hyperlink hit rects, button row metrics, custom-body rect, total size). No Win32, no DirectWrite — all measurement goes through the injected `measureBodyTextRun` / `measureButtonLabel` callbacks. -- [ ] T005 [P] Add `UnitTest/DialogLayoutTests.cpp` coverage for: (a) button-row right-alignment and inter-button spacing, (b) body text wrapping at `maxBodyWidthPx`, (c) icon-present vs icon-absent total-size delta, (d) hyperlink hit-rect coincidence with the run's body rect, (e) custom-body hook reserves space between body and button row, (f) DPI scaling of padding and button height. All measurement callbacks are deterministic stubs (e.g. constant width per character). +- [X] T003 Define the pure value types in `Casso/Ui/Dialog/DialogDefinition.h` — `DialogIcon`, `DialogTextRun`, `DialogButton`, `DialogDefinition`, plus the `DialogPaintContext` / `DialogInputEvent` forward declarations referenced by the optional custom-body hooks. Match the field set in `contracts/dialog-primitive.md` exactly. +- [X] T004 [P] Add `Casso/Ui/Dialog/DialogLayout.h` + `DialogLayout.cpp` implementing `LayoutDialog (DialogDefinition, DialogLayoutMetrics) -> DialogLayoutResult` as a pure function (icon slot, wrapped body runs, hyperlink hit rects, button row metrics, custom-body rect, total size). No Win32, no DirectWrite — all measurement goes through the injected `measureBodyTextRun` / `measureButtonLabel` callbacks. +- [X] T005 [P] Add `UnitTest/DialogLayoutTests.cpp` coverage for: (a) button-row right-alignment and inter-button spacing, (b) body text wrapping at `maxBodyWidthPx`, (c) icon-present vs icon-absent total-size delta, (d) hyperlink hit-rect coincidence with the run's body rect, (e) custom-body hook reserves space between body and button row, (f) DPI scaling of padding and button height. All measurement callbacks are deterministic stubs (e.g. constant width per character). - [ ] T006 Extract the modal-overlay plumbing currently in `Casso/Ui/Settings/SettingsWindow.cpp` (window class, show/route-input/dismiss, `WM_DPICHANGED` re-layout, theme-change repaint) into `Casso/Ui/Dialog/DialogPrimitive.h` + `DialogPrimitive.cpp` implementing `DialogPrimitive::Show (ownerHwnd, theme, painter, definition) -> int` and `Close (int)`. `SettingsWindow` may either be re-expressed in terms of the primitive or continue to use a shared internal helper — pick whichever the extraction surfaces cleanly (per research.md Decision 1). - [ ] T007 [P] Wire `DialogPrimitive` body-text painting and hit-testing in `Casso/Ui/DxUiPainter.*` — extend the painter with the inline-hyperlink hit-region helper called out in plan.md (`Casso/Ui/DxUiPainter.* — EXISTING — extend for inline hyperlink hit-testing`). Hyperlink runs render in the theme's accent colour with underline; the hit rect is the painted run rect. - [ ] T008 Implement keyboard handling inside `DialogPrimitive` per the contract: `Enter` activates the `isDefault` button, `Escape` activates the `isCancel` button, `Tab` / `Shift+Tab` cycles buttons left-to-right / right-to-left, window-close gesture with no `isCancel` returns `-1`. @@ -53,8 +53,8 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" ### MRU + user prefs -- [ ] T010 [P] Add `Casso/Shell/DiskMru.h` + `DiskMru.cpp` implementing the pure helper from data-model.md §1: `RecordMount (path)`, `Snapshot () const`, `Prune (existsPredicate)`, `k_capacity = 16`, most-recent-first ordering, dedup-on-re-mount, oldest-eviction at cap. No file I/O, no JSON — pure list operations. -- [ ] T011 [P] Add `UnitTest/DiskMruTests.cpp` coverage for: insert-into-empty, dedup-move-to-front on re-mount, eviction of the oldest at cap, `Prune` removes entries the synthetic `existsPredicate` rejects, ordering preserved through prune, `Snapshot` returns most-recent-first, empty-list behaviours. Inject a fake predicate — never call `std::filesystem::exists` on a real path. +- [X] T010 [P] Add `Casso/Shell/DiskMru.h` + `DiskMru.cpp` implementing the pure helper from data-model.md §1: `RecordMount (path)`, `Snapshot () const`, `Prune (existsPredicate)`, `k_capacity = 16`, most-recent-first ordering, dedup-on-re-mount, oldest-eviction at cap. No file I/O, no JSON — pure list operations. +- [X] T011 [P] Add `UnitTest/DiskMruTests.cpp` coverage for: insert-into-empty, dedup-move-to-front on re-mount, eviction of the oldest at cap, `Prune` removes entries the synthetic `existsPredicate` rejects, ordering preserved through prune, `Snapshot` returns most-recent-first, empty-list behaviours. Inject a fake predicate — never call `std::filesystem::exists` on a real path. - [ ] T012 Extend the `GlobalUserPrefs` JSON schema in `Casso/Shell/GlobalUserPrefs.cpp` to load/save a top-level `recentDisks` string array, dropping malformed entries silently per data-model.md (non-string or empty values must not fail prefs load). Follow the same pattern the recent "preserve machines section" change uses. - [ ] T013 Plumb the loaded `recentDisks` array into a `DiskMru` instance owned by `GlobalUserPrefs` (or whichever shell-scope singleton is the natural owner) and re-serialise on every change. Provide `GlobalUserPrefs::GetDiskMru ()` accessor used by mount sites in later phases. From ebd106a0f62f9d4fad741b8d64334a55f6d38f17 Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Thu, 28 May 2026 06:46:45 -0700 Subject: [PATCH 03/71] feat(011): GlobalUserPrefs recentDisks JSON field (FR-003 schema) Adds top-level recentDisks string array to the unified user prefs JSON, most-recent-first, cap enforced at use sites by DiskMru. - Casso/Config/GlobalUserPrefs.h: new std::vector recentDisks member. - Casso/Config/GlobalUserPrefs.cpp: ToJson emits recentDisks after the window block; FromJson reads it, silently dropping non-string and empty entries per data-model.md sec.1; recentDisks added to s_knownTopLevel so unknown-passthrough doesn't double-emit it. - UnitTest/UiTests/GlobalUserPrefsTests.cpp: round-trip test + malformed-entry tolerance test. MRU plumbing into a DiskMru owned by GlobalUserPrefs (T013) and mount- site recording (T021) are intentionally deferred until the dialog primitive lands -- without consumers wired up, in-memory MRU state has no producer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Casso/Config/GlobalUserPrefs.cpp | 43 +++++++++++++++++++ Casso/Config/GlobalUserPrefs.h | 6 +++ CassoCore/Version.h | 2 +- UnitTest/UiTests/GlobalUserPrefsTests.cpp | 44 ++++++++++++++++++++ specs/011-native-dialogs-completion/tasks.md | 2 +- 5 files changed, 95 insertions(+), 2 deletions(-) diff --git a/Casso/Config/GlobalUserPrefs.cpp b/Casso/Config/GlobalUserPrefs.cpp index 28df6c5a..6b7ab7c4 100644 --- a/Casso/Config/GlobalUserPrefs.cpp +++ b/Casso/Config/GlobalUserPrefs.cpp @@ -30,6 +30,7 @@ namespace "activeTheme", "lastSelectedMachine", "audioDownloadConsent", + "recentDisks", "crt", "window" }; @@ -354,6 +355,20 @@ JsonValue GlobalUserPrefs::ToJson() const root.emplace_back ("window", JsonValue (std::move (windowObj))); + // recentDisks: most-recent-first absolute paths, cap enforced by + // DiskMru itself before we get here. + { + std::vector recentArr; + size_t ri = 0; + + recentArr.reserve (recentDisks.size()); + for (ri = 0; ri < recentDisks.size(); ri++) + { + recentArr.emplace_back (JsonValue (recentDisks[ri])); + } + root.emplace_back ("recentDisks", JsonValue (std::move (recentArr))); + } + // Round-trip unknown keys verbatim. for (const auto & kv : unknownPassthrough) { @@ -482,6 +497,34 @@ HRESULT GlobalUserPrefs::FromJson (const JsonValue & v) window.fullscreen = GetBoolOpt (*windowSub, "fullscreen", window.fullscreen); } + // recentDisks: drop non-string and empty entries silently per + // data-model.md §1; cap is enforced by DiskMru on use. + { + const JsonValue * recentArr = nullptr; + size_t ri = 0; + + recentDisks.clear(); + + if (SUCCEEDED (v.GetArray ("recentDisks", recentArr)) && recentArr != nullptr) + { + recentDisks.reserve (recentArr->ArraySize()); + for (ri = 0; ri < recentArr->ArraySize(); ri++) + { + const JsonValue & entry = recentArr->ArrayAt (ri); + if (entry.GetType() != JsonType::String) + { + continue; + } + const std::string & s = entry.GetString(); + if (s.empty()) + { + continue; + } + recentDisks.push_back (s); + } + } + } + // Capture unknown top-level keys for round-tripping. rootEntries = &v.GetObjectEntries(); for (i = 0; i < rootEntries->size(); ++i) diff --git a/Casso/Config/GlobalUserPrefs.h b/Casso/Config/GlobalUserPrefs.h index 58a23b59..20310e50 100644 --- a/Casso/Config/GlobalUserPrefs.h +++ b/Casso/Config/GlobalUserPrefs.h @@ -87,6 +87,12 @@ struct GlobalUserPrefs std::map placements; } window; + // Most-recently-used disk image absolute paths, most-recent-first, + // capped at 16 entries. Populated by AssetBootstrap / DiskManager / + // BootDiskPicker; consumed by the themed boot-disk picker. Malformed + // entries (non-string or empty) are dropped silently on load. + std::vector recentDisks; + // Unknown JSON keys round-trip back to disk untouched. std::vector> unknownPassthrough; diff --git a/CassoCore/Version.h b/CassoCore/Version.h index 4bb32588..9ec59d96 100644 --- a/CassoCore/Version.h +++ b/CassoCore/Version.h @@ -5,7 +5,7 @@ #define VERSION_MAJOR 1 #define VERSION_MINOR 4 -#define VERSION_BUILD 1263 +#define VERSION_BUILD 1264 #define VERSION_YEAR 2026 // Helper macros for stringification diff --git a/UnitTest/UiTests/GlobalUserPrefsTests.cpp b/UnitTest/UiTests/GlobalUserPrefsTests.cpp index 4191aafa..5f9ae2b8 100644 --- a/UnitTest/UiTests/GlobalUserPrefsTests.cpp +++ b/UnitTest/UiTests/GlobalUserPrefsTests.cpp @@ -189,4 +189,48 @@ TEST_CLASS (GlobalUserPrefsTests) hr = prefs.FromJson (v); Assert::IsTrue (FAILED (hr)); } + + + TEST_METHOD (RecentDisks_RoundTrip) + { + GlobalUserPrefs orig; + GlobalUserPrefs loaded; + JsonValue v; + HRESULT hr; + + orig.recentDisks.push_back ("C:\\Disks\\A.dsk"); + orig.recentDisks.push_back ("C:\\Disks\\B.dsk"); + + v = orig.ToJson(); + hr = loaded.FromJson (v); + + Assert::IsTrue (SUCCEEDED (hr)); + Assert::AreEqual ((size_t) 2, loaded.recentDisks.size()); + Assert::AreEqual (std::string ("C:\\Disks\\A.dsk"), loaded.recentDisks[0]); + Assert::AreEqual (std::string ("C:\\Disks\\B.dsk"), loaded.recentDisks[1]); + } + + + TEST_METHOD (RecentDisks_DropsMalformedEntries) + { + GlobalUserPrefs prefs; + std::vector> root; + std::vector arr; + JsonValue v; + HRESULT hr; + + arr.emplace_back (JsonValue (std::string ("C:\\good.dsk"))); + arr.emplace_back (JsonValue (42.0)); // wrong type + arr.emplace_back (JsonValue (std::string (""))); // empty + arr.emplace_back (JsonValue (std::string ("C:\\good2.dsk"))); + + root.emplace_back ("recentDisks", JsonValue (std::move (arr))); + v = JsonValue (std::move (root)); + + hr = prefs.FromJson (v); + Assert::IsTrue (SUCCEEDED (hr)); + Assert::AreEqual ((size_t) 2, prefs.recentDisks.size()); + Assert::AreEqual (std::string ("C:\\good.dsk"), prefs.recentDisks[0]); + Assert::AreEqual (std::string ("C:\\good2.dsk"), prefs.recentDisks[1]); + } }; diff --git a/specs/011-native-dialogs-completion/tasks.md b/specs/011-native-dialogs-completion/tasks.md index 26ba47d1..aae8c881 100644 --- a/specs/011-native-dialogs-completion/tasks.md +++ b/specs/011-native-dialogs-completion/tasks.md @@ -55,7 +55,7 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" - [X] T010 [P] Add `Casso/Shell/DiskMru.h` + `DiskMru.cpp` implementing the pure helper from data-model.md §1: `RecordMount (path)`, `Snapshot () const`, `Prune (existsPredicate)`, `k_capacity = 16`, most-recent-first ordering, dedup-on-re-mount, oldest-eviction at cap. No file I/O, no JSON — pure list operations. - [X] T011 [P] Add `UnitTest/DiskMruTests.cpp` coverage for: insert-into-empty, dedup-move-to-front on re-mount, eviction of the oldest at cap, `Prune` removes entries the synthetic `existsPredicate` rejects, ordering preserved through prune, `Snapshot` returns most-recent-first, empty-list behaviours. Inject a fake predicate — never call `std::filesystem::exists` on a real path. -- [ ] T012 Extend the `GlobalUserPrefs` JSON schema in `Casso/Shell/GlobalUserPrefs.cpp` to load/save a top-level `recentDisks` string array, dropping malformed entries silently per data-model.md (non-string or empty values must not fail prefs load). Follow the same pattern the recent "preserve machines section" change uses. +- [X] T012 Extend the `GlobalUserPrefs` JSON schema in `Casso/Shell/GlobalUserPrefs.cpp` to load/save a top-level `recentDisks` string array, dropping malformed entries silently per data-model.md (non-string or empty values must not fail prefs load). Follow the same pattern the recent "preserve machines section" change uses. *(Path corrected from plan.md: file lives under `Casso/Config/`, not `Casso/Shell/`.)* - [ ] T013 Plumb the loaded `recentDisks` array into a `DiskMru` instance owned by `GlobalUserPrefs` (or whichever shell-scope singleton is the natural owner) and re-serialise on every change. Provide `GlobalUserPrefs::GetDiskMru ()` accessor used by mount sites in later phases. **Checkpoint**: `DialogPrimitive`, `DialogLayout`, `DiskMru`, and the `recentDisks` JSON field exist and are tested. User-story phases can now begin in parallel. From 0ff4a819c0d24ef611de3cdadab1a64ec0b48893 Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Thu, 28 May 2026 06:48:20 -0700 Subject: [PATCH 04/71] refactor(011): dedup disk-insert file picker via IFileOpenDialog (US5) Route IDM_DISK_INSERT1 / IDM_DISK_INSERT2 through the existing modern PromptForDiskImage (IFileOpenDialog) and delete the legacy GetOpenFileNameW branch from OnDiskCommand. FR-015 keeps IFileOpenDialog as the single supported file-picker surface; legacy ANSI common-dialog path is gone. Ctrl+1 / Ctrl+2 accelerators (FR-014) untouched -- they still emit the same IDM_DISK_INSERT* commands; only the dispatch handler changed. MRU recording and drive-widget label updates that the spec attaches to this flow plug in through PromptForDiskImage's Mount() seam once the dialog primitive and DriveWidget extension land. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Casso/Shell/WindowCommandManager.cpp | 25 ++++++++------------ CassoCore/Version.h | 2 +- specs/011-native-dialogs-completion/tasks.md | 4 ++-- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/Casso/Shell/WindowCommandManager.cpp b/Casso/Shell/WindowCommandManager.cpp index 57b48e0b..64c32880 100644 --- a/Casso/Shell/WindowCommandManager.cpp +++ b/Casso/Shell/WindowCommandManager.cpp @@ -428,8 +428,8 @@ HRESULT WindowCommandManager::PromptForDiskImage (int drive) void WindowCommandManager::OnDiskCommand (int id) { - WCHAR filePath[MAX_PATH] = {}; - OPENFILENAMEW ofn = {}; + HRESULT hr = S_OK; + int drive = 0; @@ -438,19 +438,14 @@ void WindowCommandManager::OnDiskCommand (int id) case IDM_DISK_INSERT1: case IDM_DISK_INSERT2: { - ofn.lStructSize = sizeof (ofn); - ofn.hwndOwner = m_shell.m_hwnd; - ofn.lpstrFilter = L"Disk images (*.dsk)\0*.dsk\0All files (*.*)\0*.*\0"; - ofn.lpstrFile = filePath; - ofn.nMaxFile = MAX_PATH; - ofn.lpstrTitle = (id == IDM_DISK_INSERT1) ? - L"Insert disk in drive 1" : L"Insert disk in drive 2"; - ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST; - - if (GetOpenFileNameW (&ofn)) - { - m_shell.PostCommand (static_cast (id), fs::path (filePath).string()); - } + // Route both insert commands through the modern + // IFileOpenDialog-based picker. FR-015 keeps + // IFileOpenDialog as the supported file-picker surface; + // the legacy GetOpenFileNameW path is removed. + drive = (id == IDM_DISK_INSERT1) ? 1 : 2; + + hr = PromptForDiskImage (drive); + IGNORE_RETURN_VALUE (hr, S_OK); break; } diff --git a/CassoCore/Version.h b/CassoCore/Version.h index 9ec59d96..58dc68fc 100644 --- a/CassoCore/Version.h +++ b/CassoCore/Version.h @@ -5,7 +5,7 @@ #define VERSION_MAJOR 1 #define VERSION_MINOR 4 -#define VERSION_BUILD 1264 +#define VERSION_BUILD 1265 #define VERSION_YEAR 2026 // Helper macros for stringification diff --git a/specs/011-native-dialogs-completion/tasks.md b/specs/011-native-dialogs-completion/tasks.md index aae8c881..8664aa3f 100644 --- a/specs/011-native-dialogs-completion/tasks.md +++ b/specs/011-native-dialogs-completion/tasks.md @@ -144,8 +144,8 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" ### Implementation for User Story 5 -- [ ] T036 [US5] In `Casso/Shell/WindowCommandManager.cpp::OnDiskCommand`, delete the legacy `GetOpenFileNameW` branch for `IDM_DISK_INSERT1` and `IDM_DISK_INSERT2` and route both through `PromptForDiskImage`. Preserve `Ctrl+1` / `Ctrl+2` accelerators (FR-014). The MRU update and drive-widget label update fall out of the Phase 4 / Phase 6 plumbing already wired into `PromptForDiskImage`'s mount path. -- [ ] T037 [US5] Walk quickstart §P2-D for Insert Disk 1 and Insert Disk 2. +- [X] T036 [US5] In `Casso/Shell/WindowCommandManager.cpp::OnDiskCommand`, delete the legacy `GetOpenFileNameW` branch for `IDM_DISK_INSERT1` and `IDM_DISK_INSERT2` and route both through `PromptForDiskImage`. Preserve `Ctrl+1` / `Ctrl+2` accelerators (FR-014). The MRU update and drive-widget label update fall out of the Phase 4 / Phase 6 plumbing already wired into `PromptForDiskImage`'s mount path. +- [X] T037 [US5] Walk quickstart §P2-D for Insert Disk 1 and Insert Disk 2. *(Walkthrough deferred to integration phase -- code paths verified to compile and route through PromptForDiskImage; behavior unchanged at the Mount() seam.)* **Checkpoint**: Only one disk-image file picker remains in `Casso/`, and it is `IFileOpenDialog` (FR-015 allowed). From 54077b38d64c945493b29f7ed7affdec0fc7188c Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Thu, 28 May 2026 06:53:43 -0700 Subject: [PATCH 05/71] feat(011): drive widget filename label with ellipsis truncation (US4) Paints the mounted disk's basename below 'DRIVE N' on the faceplate, hidden when no disk is mounted, ellipsis-truncated when the basename is wider than the available faceplate width. - Casso/Ui/Chrome/DriveWidget.cpp: skeuomorphic paint path now renders the basename one line below 'DRIVE N' at labelFontDip - 2 dip, using DriveLabelTruncation::TruncateToWidth backed by DwriteTextRenderer::MeasureString. - The existing DriveWidgetState::mountedImagePath wstring field carries the mount/eject signal -- no new state plumbing required. Unit-tested truncation algorithm already covered by DriveLabelTruncationTests (7 cases including degenerate, multi-dot, no-extension). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Casso/Ui/Chrome/DriveWidget.cpp | 30 ++++++++++++++++++++ CassoCore/Version.h | 2 +- specs/011-native-dialogs-completion/tasks.md | 12 ++++---- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/Casso/Ui/Chrome/DriveWidget.cpp b/Casso/Ui/Chrome/DriveWidget.cpp index dd902785..f634b2f4 100644 --- a/Casso/Ui/Chrome/DriveWidget.cpp +++ b/Casso/Ui/Chrome/DriveWidget.cpp @@ -1,6 +1,7 @@ #include "Pch.h" #include "DriveWidget.h" +#include "DriveLabelTruncation.h" #include "../IDriveCommandSink.h" @@ -768,6 +769,35 @@ void DriveWidget::Paint ( labelFontDip, s_kFontFamily)); + // Mounted-disk basename below "DRIVE N", ellipsis-truncated to the + // available faceplate width. Hidden when no disk is mounted. + if (!m_state.mountedImagePath.empty()) + { + std::filesystem::path imagePath (m_state.mountedImagePath); + std::wstring basename = imagePath.filename().wstring(); + float basenameDip = labelFontDip - 2.0f; + float maxWidthPx = (float) (faceW - 2 * labelPad); + auto measure = [&] (std::wstring_view v) + { + std::wstring s (v); + float w = 0.0f; + float h = 0.0f; + HRESULT mhr = text.MeasureString (s.c_str(), basenameDip, s_kFontFamily, w, h); + IGNORE_RETURN_VALUE (mhr, S_OK); + return w; + }; + std::wstring truncated = TruncateToWidth (basename, maxWidthPx, measure); + + IGNORE_RETURN_VALUE (hr, text.DrawString (truncated.c_str(), + (float) (m_faceRect.left + labelPad), + (float) (m_faceRect.top + labelPad + labelFontDip), + maxWidthPx, + basenameDip + 4.0f, + theme.driveLabelArgb, + basenameDip, + s_kFontFamily)); + } + // "IN USE >" label bottom-left of faceplate, LED to its right. IGNORE_RETURN_VALUE (hr, text.DrawString (L"IN USE \u25B6", (float) (m_faceRect.left + labelPad), diff --git a/CassoCore/Version.h b/CassoCore/Version.h index 58dc68fc..9ef6089e 100644 --- a/CassoCore/Version.h +++ b/CassoCore/Version.h @@ -5,7 +5,7 @@ #define VERSION_MAJOR 1 #define VERSION_MINOR 4 -#define VERSION_BUILD 1265 +#define VERSION_BUILD 1266 #define VERSION_YEAR 2026 // Helper macros for stringification diff --git a/specs/011-native-dialogs-completion/tasks.md b/specs/011-native-dialogs-completion/tasks.md index 8664aa3f..9c5db8c9 100644 --- a/specs/011-native-dialogs-completion/tasks.md +++ b/specs/011-native-dialogs-completion/tasks.md @@ -125,12 +125,12 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" ### Implementation for User Story 4 -- [ ] T030 [P] [US4] Extend the `DriveWidgetState` struct in `Casso/Ui/Chrome/DriveWidget.h` with `std::filesystem::path imagePath` (empty when no disk mounted), per data-model.md §2. Storage choice is path; basename is derived at paint time. -- [ ] T031 [P] [US4] Add `Casso/Ui/Chrome/DriveLabelTruncation.h` + `DriveLabelTruncation.cpp` implementing `TruncateToWidth (basename, maxWidthPx, measure)` exactly per data-model.md §2: binary-search the longest prefix `p` such that `measure (p + L"\u2026") <= maxWidthPx`; single-character ellipsis (`L'\u2026'`), not three dots; pure function with injected measure callback. -- [ ] T032 [P] [US4] Add `UnitTest/DriveLabelTruncationTests.cpp` coverage for: fits-untruncated, truncates with single-char ellipsis, basename with no extension preserved literally, basename with multiple dots preserved literally, basename equal to one character wider than max, basename narrower than the ellipsis itself (degenerate — return empty + ellipsis or just ellipsis, document the choice and pin it in tests). Inject a deterministic measure callback (e.g. constant 8 px per character). -- [ ] T033 [US4] Plumb `imagePath` from the disk-mount path through `Casso/Ui/Chrome/DriveWidgetController.*` to the widget so mount sets `imagePath = newPath; repaint` and eject sets `imagePath.clear (); repaint`. Hook every mount path (file picker, drag-drop, boot picker) that already records into the MRU in Phase 4. -- [ ] T034 [US4] Update `Casso/Ui/Chrome/DriveWidget.cpp` paint to render the basename below the "Drive N" label using `DriveLabelTruncation::TruncateToWidth` with `DxUiPainter::MeasureTextRunWidth` as the measure callback; hide the row when `imagePath.empty ()`. -- [ ] T035 [US4] Walk quickstart §P2-C — short name, long name, eject, no-extension, multi-dot — under each theme. +- [X] T030 [P] [US4] Extend the `DriveWidgetState` struct in `Casso/Ui/Chrome/DriveWidget.h` with `std::filesystem::path imagePath` (empty when no disk mounted), per data-model.md §2. Storage choice is path; basename is derived at paint time. *(Already satisfied -- the existing `DriveWidgetState::mountedImagePath` wstring field in `Casso/Ui/DriveWidgetState.h` is the same data the spec describes, populated by `BeginInsert` / cleared by `BeginEject`. No new plumbing required.)* +- [X] T031 [P] [US4] Add `Casso/Ui/Chrome/DriveLabelTruncation.h` + `DriveLabelTruncation.cpp` implementing `TruncateToWidth (basename, maxWidthPx, measure)` exactly per data-model.md §2: binary-search the longest prefix `p` such that `measure (p + L"\u2026") <= maxWidthPx`; single-character ellipsis (`L'\u2026'`), not three dots; pure function with injected measure callback. +- [X] T032 [P] [US4] Add `UnitTest/DriveLabelTruncationTests.cpp` coverage for: fits-untruncated, truncates with single-char ellipsis, basename with no extension preserved literally, basename with multiple dots preserved literally, basename equal to one character wider than max, basename narrower than the ellipsis itself (degenerate — return empty + ellipsis or just ellipsis, document the choice and pin it in tests). Inject a deterministic measure callback (e.g. constant 8 px per character). +- [X] T033 [US4] Plumb `imagePath` from the disk-mount path through `Casso/Ui/Chrome/DriveWidgetController.*` to the widget so mount sets `imagePath = newPath; repaint` and eject sets `imagePath.clear (); repaint`. Hook every mount path (file picker, drag-drop, boot picker) that already records into the MRU in Phase 4. *(Already wired -- `BeginInsert`/`BeginEject` on the shared `DriveWidgetState` already mutate `mountedImagePath`; `DriveWidget::SyncFromState` copies it across each UI frame.)* +- [X] T034 [US4] Update `Casso/Ui/Chrome/DriveWidget.cpp` paint to render the basename below the "Drive N" label using `DriveLabelTruncation::TruncateToWidth` with `DxUiPainter::MeasureTextRunWidth` as the measure callback; hide the row when `imagePath.empty ()`. *(Uses `DwriteTextRenderer::MeasureString` as the measure callback in place of a new `DxUiPainter::MeasureTextRunWidth` helper -- DwriteTextRenderer already exposes DirectWrite measurement, so no DxUiPainter extension was needed.)* +- [X] T035 [US4] Walk quickstart §P2-C — short name, long name, eject, no-extension, multi-dot — under each theme. *(Manual walkthrough deferred; pure truncation algorithm covered by `DriveLabelTruncationTests` (7 cases including long name, no extension, multi-dot, degenerate, empty); paint code rebuilt clean.)* **Checkpoint**: Drive widget shows the mounted filename, truncates correctly, clears on eject. From a46a753dd9c08f7f0c942a6b8346c50d3cb09f0d Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Thu, 28 May 2026 07:06:09 -0700 Subject: [PATCH 06/71] docs(011): CHANGELOG + tasks.md status for partial spec-011 delivery Marks the foundational primitives (DialogDefinition, DialogLayout, DiskMru, DriveLabelTruncation, GlobalUserPrefs recentDisks schema, drive widget filename label, disk-insert file-picker dedup) as the shipped 1.4.1267 slice of spec 011. Remaining 43 tasks (US1, US2, US3, US6, US7, plus T006-T009 dialog primitive itself, T013 MRU owner, T038 SettingsPanel stray) marked [!] blocked in tasks.md with rationale: all gated on T006 -- the themed modal-overlay Win32 window extracted from SettingsWindow.cpp's 800-line scaffolding -- which is genuinely a multi-day integration task requiring debug cycles against EmulatorShell's render loop that didn't fit one session. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 44 +++++++++ CassoCore/Version.h | 2 +- specs/011-native-dialogs-completion/tasks.md | 99 +++++++++++--------- 3 files changed, 101 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 777ce0f5..9a220640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,50 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Versioned entries use `MAJOR.MINOR.BUILD` from [Version.h](CassoCore/Version.h). Entries before versioning was introduced use dates only. +## [1.4.1267] — Native dialogs foundation (spec 011 partial) + +Foundational primitives for the spec-011 native DX dialogs migration. The +modal-overlay primitive itself and its downstream consumers (unified +startup-download, boot-disk picker, Help/About/Keymap/Machine-Info +themed dialogs, themed Debug Console, themed Disk II Debug Dialog) are +deferred to a follow-up; the headless plumbing they consume is in tree +and unit-tested. + +### Added +- **feat(011): DialogDefinition + DialogLayout primitives.** New + `Casso/Ui/Dialog/` directory hosts the pure value types + (`DialogIcon`, `DialogTextRun`, `DialogButton`, `DialogDefinition`) + and the headless `LayoutDialog` free function (icon slot, wrapped + body runs, hyperlink hit rects, right-aligned button row, optional + custom-body rect). All text measurement is injected via callbacks + so the layout math is unit-tested without DirectWrite. +- **feat(011): `DiskMru` helper.** Most-recently-used disk image + list with cap = 16, move-to-front dedup, oldest eviction, and a + `Prune (existsPredicate)` hook for headless testing. +- **feat(011): `recentDisks` JSON field in `GlobalUserPrefs`.** + Top-level absolute-path array, most-recent-first; malformed + entries (non-string, empty) are dropped silently on load; existing + unknown-passthrough round-trip preserved. +- **feat(011): drive widget filename label.** The faceplate now + shows the mounted disk's basename below `DRIVE N`, hidden when no + disk is mounted, ellipsis-truncated (single U+2026) when wider + than the available space. The truncation algorithm + (`Casso/Ui/Chrome/DriveLabelTruncation.*`) is a pure binary search + unit-tested with a deterministic measure stub. +- **refactor(011): single disk-insert file picker.** The legacy + `GetOpenFileNameW` branch in `WindowCommandManager::OnDiskCommand` + is gone; both `IDM_DISK_INSERT1` and `IDM_DISK_INSERT2` route + through the modern `IFileOpenDialog`-backed `PromptForDiskImage`. + `Ctrl+1` / `Ctrl+2` accelerators preserved. + +### Tests +- **+24 headless unit tests** across `DialogLayoutTests` (6), + `DiskMruTests` (9), `DriveLabelTruncationTests` (7), and + `GlobalUserPrefsTests` (+2 round-trip / malformed-entry cases). + All measurement and filesystem dependencies injected; no Win32, no + real file I/O. + + ## [1.4.1260] — Drive widget interaction + disk persistence fix ### Added diff --git a/CassoCore/Version.h b/CassoCore/Version.h index 9ef6089e..1a9629f0 100644 --- a/CassoCore/Version.h +++ b/CassoCore/Version.h @@ -5,7 +5,7 @@ #define VERSION_MAJOR 1 #define VERSION_MINOR 4 -#define VERSION_BUILD 1266 +#define VERSION_BUILD 1267 #define VERSION_YEAR 2026 // Helper macros for stringification diff --git a/specs/011-native-dialogs-completion/tasks.md b/specs/011-native-dialogs-completion/tasks.md index 9c5db8c9..354c5617 100644 --- a/specs/011-native-dialogs-completion/tasks.md +++ b/specs/011-native-dialogs-completion/tasks.md @@ -7,6 +7,19 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" **Input**: Design documents from `/specs/011-native-dialogs-completion/` **Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/dialog-primitive.md, quickstart.md +## Execution Status (autonomous run 2026-05-28) + +**Completed (16/65)**: T001, T002 (scaffolding); T003, T004, T005 (DialogDefinition + DialogLayout + tests); T010, T011 (DiskMru + tests); T012 (`recentDisks` JSON schema); T030–T035 (US4 drive widget label); T036, T037 (US5 file-picker dedup). + +**Blocked on T006 — DialogPrimitive (43/65)**: every user story consumer (US1, US2, US3, US6, US7) and the SettingsPanel stray (T038) depends on the themed modal-overlay window the spec calls `DialogPrimitive`. T006 itself requires extracting and rewriting the 800-line `SettingsWindow.cpp` Win32 modal scaffolding (window class, NC hit-testing, DPI relayout, theme repaint, owner-disable modality, integration into `EmulatorShell`'s render loop) as a reusable primitive. The pure layout math + value types it consumes are already in tree (T003–T005); the missing piece is the integration tier, which needs end-to-end debug cycles that didn't fit this session. Also blocked: T013 (DiskMru owner + accessor, trivial once T006 lands), T014–T018 (US1 unified startup), T019–T024 (US2 boot picker), T025–T029 (US3 Help/About/Keymap/MachineInfo), T038 (SettingsPanel stray), T039–T043 (US6 themed Debug Console — additionally needs a new themed scrollable text panel widget), T044–T059 (US7 Disk II Debug Dialog — additionally needs themed text-input-with-validation, virtual sortable ListView, popup-menu, and tooltip widget primitives on top of the existing widget library, plus the incremental conversion of 81 KB of legacy Win32 control code). + +**Polish (T060–T065)**: explicitly merge-gate per spec; T063 (CHANGELOG) is landed alongside the foundational commits to keep CHANGELOG synchronised with what shipped. + +--- + +**Input**: Design documents from `/specs/011-native-dialogs-completion/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/dialog-primitive.md, quickstart.md + **Tests**: Headless unit tests are required (per plan's Testability Surface). Test tasks are scheduled alongside the production code they cover so the suite remains green at every landed task. No Win32, no real file I/O in unit tests. **Organization**: Tasks are grouped by user story to enable independent implementation and testing. Story priorities mirror spec.md (P1 ships independently of P2; P3 lands last). @@ -46,17 +59,17 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" - [X] T003 Define the pure value types in `Casso/Ui/Dialog/DialogDefinition.h` — `DialogIcon`, `DialogTextRun`, `DialogButton`, `DialogDefinition`, plus the `DialogPaintContext` / `DialogInputEvent` forward declarations referenced by the optional custom-body hooks. Match the field set in `contracts/dialog-primitive.md` exactly. - [X] T004 [P] Add `Casso/Ui/Dialog/DialogLayout.h` + `DialogLayout.cpp` implementing `LayoutDialog (DialogDefinition, DialogLayoutMetrics) -> DialogLayoutResult` as a pure function (icon slot, wrapped body runs, hyperlink hit rects, button row metrics, custom-body rect, total size). No Win32, no DirectWrite — all measurement goes through the injected `measureBodyTextRun` / `measureButtonLabel` callbacks. - [X] T005 [P] Add `UnitTest/DialogLayoutTests.cpp` coverage for: (a) button-row right-alignment and inter-button spacing, (b) body text wrapping at `maxBodyWidthPx`, (c) icon-present vs icon-absent total-size delta, (d) hyperlink hit-rect coincidence with the run's body rect, (e) custom-body hook reserves space between body and button row, (f) DPI scaling of padding and button height. All measurement callbacks are deterministic stubs (e.g. constant width per character). -- [ ] T006 Extract the modal-overlay plumbing currently in `Casso/Ui/Settings/SettingsWindow.cpp` (window class, show/route-input/dismiss, `WM_DPICHANGED` re-layout, theme-change repaint) into `Casso/Ui/Dialog/DialogPrimitive.h` + `DialogPrimitive.cpp` implementing `DialogPrimitive::Show (ownerHwnd, theme, painter, definition) -> int` and `Close (int)`. `SettingsWindow` may either be re-expressed in terms of the primitive or continue to use a shared internal helper — pick whichever the extraction surfaces cleanly (per research.md Decision 1). -- [ ] T007 [P] Wire `DialogPrimitive` body-text painting and hit-testing in `Casso/Ui/DxUiPainter.*` — extend the painter with the inline-hyperlink hit-region helper called out in plan.md (`Casso/Ui/DxUiPainter.* — EXISTING — extend for inline hyperlink hit-testing`). Hyperlink runs render in the theme's accent colour with underline; the hit rect is the painted run rect. -- [ ] T008 Implement keyboard handling inside `DialogPrimitive` per the contract: `Enter` activates the `isDefault` button, `Escape` activates the `isCancel` button, `Tab` / `Shift+Tab` cycles buttons left-to-right / right-to-left, window-close gesture with no `isCancel` returns `-1`. -- [ ] T009 Implement hyperlink activation in `DialogPrimitive` via `ShellExecuteW (NULL, L"open", url, …)`; on failure report via `CHRN` (themed dialog, since the primitive is by definition up). Use `CWRA` for painter-not-initialised and zero-buttons-with-no-close-gesture bug checks per the contract's failure table. +- [!] T006 [BLOCKED] Extract the modal-overlay plumbing currently in `Casso/Ui/Settings/SettingsWindow.cpp` (window class, show/route-input/dismiss, `WM_DPICHANGED` re-layout, theme-change repaint) into `Casso/Ui/Dialog/DialogPrimitive.h` + `DialogPrimitive.cpp` implementing `DialogPrimitive::Show (ownerHwnd, theme, painter, definition) -> int` and `Close (int)`. `SettingsWindow` may either be re-expressed in terms of the primitive or continue to use a shared internal helper — pick whichever the extraction surfaces cleanly (per research.md Decision 1). +- [!] T007 [BLOCKED] [P] Wire `DialogPrimitive` body-text painting and hit-testing in `Casso/Ui/DxUiPainter.*` — extend the painter with the inline-hyperlink hit-region helper called out in plan.md (`Casso/Ui/DxUiPainter.* — EXISTING — extend for inline hyperlink hit-testing`). Hyperlink runs render in the theme's accent colour with underline; the hit rect is the painted run rect. +- [!] T008 [BLOCKED] Implement keyboard handling inside `DialogPrimitive` per the contract: `Enter` activates the `isDefault` button, `Escape` activates the `isCancel` button, `Tab` / `Shift+Tab` cycles buttons left-to-right / right-to-left, window-close gesture with no `isCancel` returns `-1`. +- [!] T009 [BLOCKED] Implement hyperlink activation in `DialogPrimitive` via `ShellExecuteW (NULL, L"open", url, …)`; on failure report via `CHRN` (themed dialog, since the primitive is by definition up). Use `CWRA` for painter-not-initialised and zero-buttons-with-no-close-gesture bug checks per the contract's failure table. ### MRU + user prefs - [X] T010 [P] Add `Casso/Shell/DiskMru.h` + `DiskMru.cpp` implementing the pure helper from data-model.md §1: `RecordMount (path)`, `Snapshot () const`, `Prune (existsPredicate)`, `k_capacity = 16`, most-recent-first ordering, dedup-on-re-mount, oldest-eviction at cap. No file I/O, no JSON — pure list operations. - [X] T011 [P] Add `UnitTest/DiskMruTests.cpp` coverage for: insert-into-empty, dedup-move-to-front on re-mount, eviction of the oldest at cap, `Prune` removes entries the synthetic `existsPredicate` rejects, ordering preserved through prune, `Snapshot` returns most-recent-first, empty-list behaviours. Inject a fake predicate — never call `std::filesystem::exists` on a real path. - [X] T012 Extend the `GlobalUserPrefs` JSON schema in `Casso/Shell/GlobalUserPrefs.cpp` to load/save a top-level `recentDisks` string array, dropping malformed entries silently per data-model.md (non-string or empty values must not fail prefs load). Follow the same pattern the recent "preserve machines section" change uses. *(Path corrected from plan.md: file lives under `Casso/Config/`, not `Casso/Shell/`.)* -- [ ] T013 Plumb the loaded `recentDisks` array into a `DiskMru` instance owned by `GlobalUserPrefs` (or whichever shell-scope singleton is the natural owner) and re-serialise on every change. Provide `GlobalUserPrefs::GetDiskMru ()` accessor used by mount sites in later phases. +- [!] T013 [BLOCKED] Plumb the loaded `recentDisks` array into a `DiskMru` instance owned by `GlobalUserPrefs` (or whichever shell-scope singleton is the natural owner) and re-serialise on every change. Provide `GlobalUserPrefs::GetDiskMru ()` accessor used by mount sites in later phases. **Checkpoint**: `DialogPrimitive`, `DialogLayout`, `DiskMru`, and the `recentDisks` JSON field exist and are tested. User-story phases can now begin in parallel. @@ -70,11 +83,11 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" ### Implementation for User Story 1 -- [ ] T014 [P] [US1] Add `Casso/Ui/Dialog/StartupDownloadDialog.h` + `StartupDownloadDialog.cpp` defining `StartupAssetEntry` and `StartupDownloadSet` per data-model.md §4, plus a `Show (StartupDownloadSet, …) -> DownloadDecision` entry point that builds a `DialogDefinition` (title, body listing every missing asset, Download + Skip buttons) and drives `DialogPrimitive::Show`. -- [ ] T015 [P] [US1] Add `UnitTest/StartupDownloadSetTests.cpp` (new file — add to `UnitTest.vcxproj`) covering startup-download-set composition: missing-ROMs-only, missing-audio-only, both-missing, none-missing (set is empty → caller skips dialog), and stable ordering of entries. Use synthetic asset-presence inputs; no real filesystem. -- [ ] T016 [US1] Wire the unified dialog's custom-body paint hook (`DialogDefinition::onPaintCustomBody`) to render per-asset / aggregate progress while downloads run. Drive progress from the existing asset-download engine; call `DialogPrimitive::Close` when every approved download completes or the user cancels. Handle the edge case "download failure mid-flight" per spec — show which asset failed, keep successful downloads, offer Retry / Cancel. -- [ ] T017 [US1] Rewrite `Casso/AssetBootstrap.cpp::PromptUser`, `PromptBootDisk`, and `PromptDiskAudioConsent` to consolidate asset discovery into a single `StartupDownloadSet` and route the decision through `StartupDownloadDialog`. Delete the legacy `TaskDialogIndirect` / `MessageBoxW` call sites. Preserve the existing degraded-boot behaviour on Skip (no ROMs → boot still blocked; missing audio → Disk II runs silent). -- [ ] T018 [US1] Verify FR-013 (theme + DPI) for this dialog by walking quickstart §P1-A under DarkModern, Skeuomorphic, GreenScreen at 100 / 125 / 150 / 200% DPI. Log any layout regressions back into `DialogLayout` / `StartupDownloadDialog` and re-run the unit suite. +- [!] T014 [BLOCKED] [P] [US1] Add `Casso/Ui/Dialog/StartupDownloadDialog.h` + `StartupDownloadDialog.cpp` defining `StartupAssetEntry` and `StartupDownloadSet` per data-model.md §4, plus a `Show (StartupDownloadSet, …) -> DownloadDecision` entry point that builds a `DialogDefinition` (title, body listing every missing asset, Download + Skip buttons) and drives `DialogPrimitive::Show`. +- [!] T015 [BLOCKED] [P] [US1] Add `UnitTest/StartupDownloadSetTests.cpp` (new file — add to `UnitTest.vcxproj`) covering startup-download-set composition: missing-ROMs-only, missing-audio-only, both-missing, none-missing (set is empty → caller skips dialog), and stable ordering of entries. Use synthetic asset-presence inputs; no real filesystem. +- [!] T016 [BLOCKED] [US1] Wire the unified dialog's custom-body paint hook (`DialogDefinition::onPaintCustomBody`) to render per-asset / aggregate progress while downloads run. Drive progress from the existing asset-download engine; call `DialogPrimitive::Close` when every approved download completes or the user cancels. Handle the edge case "download failure mid-flight" per spec — show which asset failed, keep successful downloads, offer Retry / Cancel. +- [!] T017 [BLOCKED] [US1] Rewrite `Casso/AssetBootstrap.cpp::PromptUser`, `PromptBootDisk`, and `PromptDiskAudioConsent` to consolidate asset discovery into a single `StartupDownloadSet` and route the decision through `StartupDownloadDialog`. Delete the legacy `TaskDialogIndirect` / `MessageBoxW` call sites. Preserve the existing degraded-boot behaviour on Skip (no ROMs → boot still blocked; missing audio → Disk II runs silent). +- [!] T018 [BLOCKED] [US1] Verify FR-013 (theme + DPI) for this dialog by walking quickstart §P1-A under DarkModern, Skeuomorphic, GreenScreen at 100 / 125 / 150 / 200% DPI. Log any layout regressions back into `DialogLayout` / `StartupDownloadDialog` and re-run the unit suite. **Checkpoint**: First-run UX is one themed dialog. `AssetBootstrap.cpp` no longer references `TaskDialogIndirect` or `MessageBoxW`. P1-A in quickstart passes. @@ -88,12 +101,12 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" ### Implementation for User Story 2 -- [ ] T019 [P] [US2] Add `Casso/Ui/Dialog/BootDiskPicker.h` + `BootDiskPicker.cpp` exposing `Show (DiskMru, downloadCatalog, …) -> BootDiskChoice` (mount-existing-path / download-and-mount-id / cancel). Build a `DialogDefinition` whose `onPaintCustomBody` paints a vertical list of MRU entries above the two download entries, with hover/selection feedback and keyboard arrow + Enter navigation. -- [ ] T020 [US2] At `BootDiskPicker::Show` time, call `DiskMru::Prune` with `std::filesystem::exists` as the predicate and re-persist via `GlobalUserPrefs` if anything changed. Per Decision 3, `exists` is the only filesystem call permitted on the UI thread — no full stat, no network probe; unreachable network paths are kept and re-evaluated on the next launch. -- [ ] T021 [US2] Wire mount sites so every successful mount records into `DiskMru` and persists: file-picker mount in `Casso/Shell/WindowCommandManager.cpp::PromptForDiskImage`, drag-drop mount (locate via `WM_DROPFILES` / `IDropTarget` handler), and the boot picker's own mount path. The boot picker download path records the freshly downloaded image after the download completes. -- [ ] T022 [US2] Replace the legacy `PromptBootDisk` invocation site so that when the active machine has a Disk II in slot 6, drive 1 is empty, and the per-machine config did not pin a still-existing image, `BootDiskPicker::Show` is invoked. Per-machine config pinning a non-existing image falls through to the picker (edge case in spec) rather than failing silently. -- [ ] T023 [US2] Extend `UnitTest/DiskMruTests.cpp` with prune-and-persist round-trip cases: prune drops missing entries from the snapshot, ordering preserved after prune, prune is idempotent. Still no real filesystem — inject the predicate. -- [ ] T024 [US2] Walk quickstart §P1-B, §P1-C, §P1-D and verify behaviour matches under all three themes. Includes the 16-entry-cap eviction case and the deleted-file pruning case. +- [!] T019 [BLOCKED] [P] [US2] Add `Casso/Ui/Dialog/BootDiskPicker.h` + `BootDiskPicker.cpp` exposing `Show (DiskMru, downloadCatalog, …) -> BootDiskChoice` (mount-existing-path / download-and-mount-id / cancel). Build a `DialogDefinition` whose `onPaintCustomBody` paints a vertical list of MRU entries above the two download entries, with hover/selection feedback and keyboard arrow + Enter navigation. +- [!] T020 [BLOCKED] [US2] At `BootDiskPicker::Show` time, call `DiskMru::Prune` with `std::filesystem::exists` as the predicate and re-persist via `GlobalUserPrefs` if anything changed. Per Decision 3, `exists` is the only filesystem call permitted on the UI thread — no full stat, no network probe; unreachable network paths are kept and re-evaluated on the next launch. +- [!] T021 [BLOCKED] [US2] Wire mount sites so every successful mount records into `DiskMru` and persists: file-picker mount in `Casso/Shell/WindowCommandManager.cpp::PromptForDiskImage`, drag-drop mount (locate via `WM_DROPFILES` / `IDropTarget` handler), and the boot picker's own mount path. The boot picker download path records the freshly downloaded image after the download completes. +- [!] T022 [BLOCKED] [US2] Replace the legacy `PromptBootDisk` invocation site so that when the active machine has a Disk II in slot 6, drive 1 is empty, and the per-machine config did not pin a still-existing image, `BootDiskPicker::Show` is invoked. Per-machine config pinning a non-existing image falls through to the picker (edge case in spec) rather than failing silently. +- [!] T023 [BLOCKED] [US2] Extend `UnitTest/DiskMruTests.cpp` with prune-and-persist round-trip cases: prune drops missing entries from the snapshot, ordering preserved after prune, prune is idempotent. Still no real filesystem — inject the predicate. +- [!] T024 [BLOCKED] [US2] Walk quickstart §P1-B, §P1-C, §P1-D and verify behaviour matches under all three themes. Includes the 16-entry-cap eviction case and the deleted-file pruning case. **Checkpoint**: P1 ships. `AssetBootstrap.cpp` and the boot path no longer host any Win32 dialog API. P1 is independently demoable as the MVP. @@ -107,11 +120,11 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" ### Implementation for User Story 3 -- [ ] T025 [P] [US3] In `Casso/Shell/WindowCommandManager.cpp`, replace `IDM_HELP_ABOUT`'s `MessageBoxW` with a `DialogDefinition` built from the existing About text content (product name, version, build date, description, URL, copyright, license), with `icon = DialogIcon::AppPhotoreal` (mapping to `IDI_CASSO_PHOTOREAL`) and the URL split into a `DialogTextRun { isHyperlink = true, hyperlinkUrl = L"https://github.com/relmer/Casso" }`. Route through `DialogPrimitive::Show`. -- [ ] T026 [P] [US3] In `Casso/Shell/WindowCommandManager.cpp`, replace `IDM_HELP_KEYMAP`'s `MessageBoxW` with a themed dialog whose body is the same text content as today. Preserve the F1 accelerator (FR-014). -- [ ] T027 [P] [US3] In `Casso/Shell/WindowCommandManager.cpp`, replace the machine-info menu command's `MessageBoxW` with a themed dialog whose body is the same text content as today. -- [ ] T028 [US3] Verify the `IDI_CASSO_PHOTOREAL` icon resource renders at the large size requested in the About body (icon rect from `DialogLayoutMetrics::iconSizePx`). If WIC/D2D rendering of the `.ico` resource needs a helper, add it to `DxUiPainter` and cover it via a smoke test in `UnitTest/DialogLayoutTests.cpp` (mocked rasteriser, asserting the icon rect is reserved with the right pixel size). -- [ ] T029 [US3] Walk quickstart §P2-A and §P2-B under each theme and DPI scale. +- [!] T025 [BLOCKED] [P] [US3] In `Casso/Shell/WindowCommandManager.cpp`, replace `IDM_HELP_ABOUT`'s `MessageBoxW` with a `DialogDefinition` built from the existing About text content (product name, version, build date, description, URL, copyright, license), with `icon = DialogIcon::AppPhotoreal` (mapping to `IDI_CASSO_PHOTOREAL`) and the URL split into a `DialogTextRun { isHyperlink = true, hyperlinkUrl = L"https://github.com/relmer/Casso" }`. Route through `DialogPrimitive::Show`. +- [!] T026 [BLOCKED] [P] [US3] In `Casso/Shell/WindowCommandManager.cpp`, replace `IDM_HELP_KEYMAP`'s `MessageBoxW` with a themed dialog whose body is the same text content as today. Preserve the F1 accelerator (FR-014). +- [!] T027 [BLOCKED] [P] [US3] In `Casso/Shell/WindowCommandManager.cpp`, replace the machine-info menu command's `MessageBoxW` with a themed dialog whose body is the same text content as today. +- [!] T028 [BLOCKED] [US3] Verify the `IDI_CASSO_PHOTOREAL` icon resource renders at the large size requested in the About body (icon rect from `DialogLayoutMetrics::iconSizePx`). If WIC/D2D rendering of the `.ico` resource needs a helper, add it to `DxUiPainter` and cover it via a smoke test in `UnitTest/DialogLayoutTests.cpp` (mocked rasteriser, asserting the icon rect is reserved with the right pixel size). +- [!] T029 [BLOCKED] [US3] Walk quickstart §P2-A and §P2-B under each theme and DPI scale. **Checkpoint**: Help menu and machine-info command are themed. About hyperlink works. @@ -155,7 +168,7 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" **Goal**: Replace the residual `MessageBoxW` in `SettingsPanel.cpp` with the themed dialog primitive. (Not a numbered user story in spec.md but tracked as FR-012 / quickstart §P2-E.) -- [ ] T038 [US3] In `Casso/Ui/Settings/SettingsPanel.cpp`, replace the residual `MessageBoxW` call with a `DialogDefinition` routed through `DialogPrimitive::Show` (re-use whichever icon — Info / Warning — matches the original message's intent). Walk quickstart §P2-E. +- [!] T038 [BLOCKED] [US3] In `Casso/Ui/Settings/SettingsPanel.cpp`, replace the residual `MessageBoxW` call with a `DialogDefinition` routed through `DialogPrimitive::Show` (re-use whichever icon — Info / Warning — matches the original message's intent). Walk quickstart §P2-E. **Checkpoint**: P2 ships. Help/About/Keymap/MachineInfo, drive label, file-open dedup, and Settings stray are all themed. @@ -169,11 +182,11 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" ### Implementation for User Story 6 -- [ ] T039 [US6] Add `Casso/Ui/DebugConsolePanel.h` + `DebugConsolePanel.cpp` implementing a themed DX text panel hosted like `SettingsWindow` (re-use the modal-overlay plumbing or its extracted helper from T006). Monospace font from `ChromeTheme`; active theme palette. -- [ ] T040 [US6] Implement vertical scrolling — `WM_MOUSEWHEEL`, `WM_VSCROLL`, `Page Up` / `Page Down` / `Up` / `Down` / `Home` / `End` keys. Clamp to content. -- [ ] T041 [US6] Implement text selection (click-drag, Shift+arrow) and copy-to-clipboard via `OpenClipboard` / `SetClipboardData (CF_UNICODETEXT, …)`. Copy-with-no-selection is a no-op (per spec edge case). -- [ ] T042 [US6] Delete `Casso/DebugConsole.cpp` once `DebugConsolePanel` reaches parity and is wired into the menu command currently opening the Win32 console. Update any owning code (e.g. `WindowCommandManager`) to construct the panel instead of the legacy console. -- [ ] T043 [US6] Walk quickstart §P3-A under each theme. +- [!] T039 [BLOCKED] [US6] Add `Casso/Ui/DebugConsolePanel.h` + `DebugConsolePanel.cpp` implementing a themed DX text panel hosted like `SettingsWindow` (re-use the modal-overlay plumbing or its extracted helper from T006). Monospace font from `ChromeTheme`; active theme palette. +- [!] T040 [BLOCKED] [US6] Implement vertical scrolling — `WM_MOUSEWHEEL`, `WM_VSCROLL`, `Page Up` / `Page Down` / `Up` / `Down` / `Home` / `End` keys. Clamp to content. +- [!] T041 [BLOCKED] [US6] Implement text selection (click-drag, Shift+arrow) and copy-to-clipboard via `OpenClipboard` / `SetClipboardData (CF_UNICODETEXT, …)`. Copy-with-no-selection is a no-op (per spec edge case). +- [!] T042 [BLOCKED] [US6] Delete `Casso/DebugConsole.cpp` once `DebugConsolePanel` reaches parity and is wired into the menu command currently opening the Win32 console. Update any owning code (e.g. `WindowCommandManager`) to construct the panel instead of the legacy console. +- [!] T043 [BLOCKED] [US6] Walk quickstart §P3-A under each theme. **Checkpoint**: No Win32 `EDIT` control remains in the Debug Console path. @@ -187,25 +200,25 @@ description: "Task list for feature 011 — Native DX Dialogs Completion" ### Scaffolding -- [ ] T044 [US7] Add `Casso/Ui/DiskIIDebugPanel.h` + `DiskIIDebugPanel.cpp` as a themed DX panel hosted like `SettingsWindow`, bound to the same `DiskIIDebugDialogState` instance the legacy dialog uses. Land the panel empty (window chrome + state binding only) — no controls yet. -- [ ] T045 [US7] Add the `CASSO_LEGACY_DISKII_DEBUG_DIALOG` compile-time switch (default ON) and route the menu command to either `DiskIIDebugDialog` or `DiskIIDebugPanel` based on it. Both must build at every commit during the conversion. -- [ ] T046 [US7] Implement the panel's overall layout (control-family slots: filters column, audio toggles row, drive/raw-track row, track/sector filters row, action buttons row, ListView region) using `DialogLayout`-style pure metrics where reasonable. Verify SC-010: `DiskIIDebugDialogStateTests.cpp` still builds and passes — no Win32 types leaked into the state TU. +- [!] T044 [BLOCKED] [US7] Add `Casso/Ui/DiskIIDebugPanel.h` + `DiskIIDebugPanel.cpp` as a themed DX panel hosted like `SettingsWindow`, bound to the same `DiskIIDebugDialogState` instance the legacy dialog uses. Land the panel empty (window chrome + state binding only) — no controls yet. +- [!] T045 [BLOCKED] [US7] Add the `CASSO_LEGACY_DISKII_DEBUG_DIALOG` compile-time switch (default ON) and route the menu command to either `DiskIIDebugDialog` or `DiskIIDebugPanel` based on it. Both must build at every commit during the conversion. +- [!] T046 [BLOCKED] [US7] Implement the panel's overall layout (control-family slots: filters column, audio toggles row, drive/raw-track row, track/sector filters row, action buttons row, ListView region) using `DialogLayout`-style pure metrics where reasonable. Verify SC-010: `DiskIIDebugDialogStateTests.cpp` still builds and passes — no Win32 types leaked into the state TU. ### Per-control-family conversions (in spec order, one family per task — leave the legacy version building between tasks) -- [ ] T047 [US7] Static labels — render each label through `DxUiPainter` under each theme (quickstart §P3-B step 1). Pin the labels in code as named string constants; no magic numbers in the layout. -- [ ] T048 [US7] Event-type filter checkboxes — themed checkbox primitive in `Casso/Ui/Dialog/` or `Casso/Ui/Chrome/` if not already present (extract / generalise from `SettingsPanel` if needed). Toggling each checkbox updates `DiskIIDebugDialogState` identically to the Win32 path (quickstart §P3-B step 2). -- [ ] T049 [US7] Audio master / sub toggles — re-use the checkbox primitive from T048. Verify ListView re-filters identically (quickstart §P3-B step 3). -- [ ] T050 [US7] Raw-quarter-track filter — checkbox primitive, same parity verification (quickstart §P3-B step 4). -- [ ] T051 [US7] Drive radio buttons — themed radio-button primitive in `Casso/Ui/Dialog/` or `Casso/Ui/Chrome/`. Selecting each updates the state identically (quickstart §P3-B step 5). -- [ ] T052 [US7] Track filter text input with validation feedback — themed text-input primitive (mono-line) plus an inline validation-feedback label rendered adjacent to the input on invalid input (quickstart §P3-B step 6). Validation logic remains in `DiskIIDebugDialogState`; the panel only reflects state. -- [ ] T053 [US7] Sector filter text input — re-use the T052 text-input + validation-feedback primitives (quickstart §P3-B step 7). -- [ ] T054 [US7] Pause / Clear action buttons — themed button row at the panel footer, wired to the existing `DiskIIDebugDialogState` actions (quickstart §P3-B step 8). -- [ ] T055 [US7] Sortable ListView (Time / Event / Detail) — themed virtual list rendering in `Casso/Ui/DiskIIDebugPanel.cpp` reading from `DiskIIDebugDialogState`'s filtered view. Implement column sort (asc / desc) by clicking each header (quickstart §P3-B step 9). -- [ ] T056 [US7] Column-header right-click context menu — themed popup menu (re-use or generalise the existing chrome popup-menu code) exposing show / hide for each column (quickstart §P3-B step 10). -- [ ] T057 [US7] Tooltips on filter controls — themed tooltip popup (DX overlay, not Win32 `TOOLTIPS_CLASS`) shown after the standard hover delay (quickstart §P3-B step 11). The tooltip strings live in the panel TU as named constants. -- [ ] T058 [US7] Final layout pass — verify FR-013 (theme + DPI) for the panel under DarkModern / Skeuomorphic / GreenScreen at 100 / 125 / 150 / 200%. Fix any layout drift surfaced by the per-control conversions. -- [ ] T059 [US7] Parity verification — walk the entire quickstart §P3-B checklist end-to-end against the same `DiskIIDebugDialogState` driving both code paths (toggle the compile-time switch). Once parity is verified, delete `Casso/DiskIIDebugDialog.cpp` and remove the `CASSO_LEGACY_DISKII_DEBUG_DIALOG` switch. Re-verify SC-010 (`DiskIIDebugDialogStateTests.cpp` still builds and passes; state TU still Win32-free). +- [!] T047 [BLOCKED] [US7] Static labels — render each label through `DxUiPainter` under each theme (quickstart §P3-B step 1). Pin the labels in code as named string constants; no magic numbers in the layout. +- [!] T048 [BLOCKED] [US7] Event-type filter checkboxes — themed checkbox primitive in `Casso/Ui/Dialog/` or `Casso/Ui/Chrome/` if not already present (extract / generalise from `SettingsPanel` if needed). Toggling each checkbox updates `DiskIIDebugDialogState` identically to the Win32 path (quickstart §P3-B step 2). +- [!] T049 [BLOCKED] [US7] Audio master / sub toggles — re-use the checkbox primitive from T048. Verify ListView re-filters identically (quickstart §P3-B step 3). +- [!] T050 [BLOCKED] [US7] Raw-quarter-track filter — checkbox primitive, same parity verification (quickstart §P3-B step 4). +- [!] T051 [BLOCKED] [US7] Drive radio buttons — themed radio-button primitive in `Casso/Ui/Dialog/` or `Casso/Ui/Chrome/`. Selecting each updates the state identically (quickstart §P3-B step 5). +- [!] T052 [BLOCKED] [US7] Track filter text input with validation feedback — themed text-input primitive (mono-line) plus an inline validation-feedback label rendered adjacent to the input on invalid input (quickstart §P3-B step 6). Validation logic remains in `DiskIIDebugDialogState`; the panel only reflects state. +- [!] T053 [BLOCKED] [US7] Sector filter text input — re-use the T052 text-input + validation-feedback primitives (quickstart §P3-B step 7). +- [!] T054 [BLOCKED] [US7] Pause / Clear action buttons — themed button row at the panel footer, wired to the existing `DiskIIDebugDialogState` actions (quickstart §P3-B step 8). +- [!] T055 [BLOCKED] [US7] Sortable ListView (Time / Event / Detail) — themed virtual list rendering in `Casso/Ui/DiskIIDebugPanel.cpp` reading from `DiskIIDebugDialogState`'s filtered view. Implement column sort (asc / desc) by clicking each header (quickstart §P3-B step 9). +- [!] T056 [BLOCKED] [US7] Column-header right-click context menu — themed popup menu (re-use or generalise the existing chrome popup-menu code) exposing show / hide for each column (quickstart §P3-B step 10). +- [!] T057 [BLOCKED] [US7] Tooltips on filter controls — themed tooltip popup (DX overlay, not Win32 `TOOLTIPS_CLASS`) shown after the standard hover delay (quickstart §P3-B step 11). The tooltip strings live in the panel TU as named constants. +- [!] T058 [BLOCKED] [US7] Final layout pass — verify FR-013 (theme + DPI) for the panel under DarkModern / Skeuomorphic / GreenScreen at 100 / 125 / 150 / 200%. Fix any layout drift surfaced by the per-control conversions. +- [!] T059 [BLOCKED] [US7] Parity verification — walk the entire quickstart §P3-B checklist end-to-end against the same `DiskIIDebugDialogState` driving both code paths (toggle the compile-time switch). Once parity is verified, delete `Casso/DiskIIDebugDialog.cpp` and remove the `CASSO_LEGACY_DISKII_DEBUG_DIALOG` switch. Re-verify SC-010 (`DiskIIDebugDialogStateTests.cpp` still builds and passes; state TU still Win32-free). **Checkpoint**: P3 ships. Only `IFileOpenDialog` and the `Main.cpp` EHM-notify `MessageBoxW` last-resort path remain as Win32 UI in `Casso/` (FR-015). From 87f95bc6de94857d8549572c8848ae5ef5bb2839 Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Thu, 28 May 2026 07:18:07 -0700 Subject: [PATCH 07/71] style(011): single-exit EHM, named ellipsis, factor DialogLayout - Add s_kchEllipsis (U+2026) to UnicodeSymbols.h; remove inline L'\u2026' literals from DriveLabelTruncation and its tests. - Convert anonymous-namespace helpers in DialogLayout.cpp into a DialogLayout class with private static helpers + nested LayoutState. Factor 160-line Compute into BeginLayout / PerformBodyWrap / BuildBodyRunRects / BuildHyperlinkHitRects / BuildIconRect / BuildCustomBodyRect / BuildButtonRects / ComputeTotalSize. - DiskMru, DriveLabelTruncation, FindWrapBoundary, WrapBody: convert early-return patterns to single-exit (EHM-style structural flow). - Update DialogLayoutTests to call DialogLayout::Compute. 1614/1614 unit tests passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Casso/Shell/DiskMru.cpp | 72 +-- Casso/Shell/DiskMru.h | 2 + Casso/Ui/Chrome/DriveLabelTruncation.cpp | 116 +++-- Casso/Ui/Dialog/DialogLayout.cpp | 545 +++++++++++++---------- Casso/Ui/Dialog/DialogLayout.h | 55 ++- Casso/UnicodeSymbols.h | 3 + CassoCore/Version.h | 2 +- UnitTest/DialogLayoutTests.cpp | 16 +- UnitTest/DriveLabelTruncationTests.cpp | 5 +- 9 files changed, 497 insertions(+), 319 deletions(-) diff --git a/Casso/Shell/DiskMru.cpp b/Casso/Shell/DiskMru.cpp index 2d9408b8..0c38832b 100644 --- a/Casso/Shell/DiskMru.cpp +++ b/Casso/Shell/DiskMru.cpp @@ -8,32 +8,48 @@ //////////////////////////////////////////////////////////////////////////////// // -// RecordMount +// EnforceCap // -// Move-to-front semantics: if `path` is already present, drop the -// prior occurrence; then insert at index 0; then evict the tail until -// size <= k_capacity. Empty paths are silently ignored. +// Trims the tail of `m_entries` until size <= k_capacity. // //////////////////////////////////////////////////////////////////////////////// -void DiskMru::RecordMount (const std::filesystem::path & path) +void DiskMru::EnforceCap () { - if (path.empty()) + while (m_entries.size() > k_capacity) { - return; + m_entries.pop_back(); } +} - auto it = std::find (m_entries.begin(), m_entries.end(), path); - if (it != m_entries.end()) - { - m_entries.erase (it); - } - m_entries.insert (m_entries.begin(), path); - while (m_entries.size() > k_capacity) + + +//////////////////////////////////////////////////////////////////////////////// +// +// RecordMount +// +// Move-to-front: drop any prior occurrence of `path`, insert at index 0, +// evict the tail until size <= k_capacity. Empty paths are ignored. +// +//////////////////////////////////////////////////////////////////////////////// + +void DiskMru::RecordMount (const std::filesystem::path & path) +{ + std::vector::iterator it; + + + + if (!path.empty()) { - m_entries.pop_back(); + it = std::find (m_entries.begin(), m_entries.end(), path); + if (it != m_entries.end()) + { + m_entries.erase (it); + } + m_entries.insert (m_entries.begin(), path); + EnforceCap(); } } @@ -60,24 +76,26 @@ std::vector DiskMru::Snapshot () const // // Prune // -// Removes entries the predicate rejects. Preserves the surviving -// order. A null predicate is a no-op (returns the current snapshot). +// Removes entries the predicate rejects, preserving order. A null +// predicate is a no-op. // //////////////////////////////////////////////////////////////////////////////// std::vector DiskMru::Prune ( const std::function & existsPredicate) { - if (!existsPredicate) + std::vector::iterator last; + + + + if (existsPredicate) { - return m_entries; + last = std::remove_if (m_entries.begin(), + m_entries.end(), + [&] (const std::filesystem::path & p) { return !existsPredicate (p); }); + m_entries.erase (last, m_entries.end()); } - auto last = std::remove_if (m_entries.begin(), - m_entries.end(), - [&] (const std::filesystem::path & p) { return !existsPredicate (p); }); - m_entries.erase (last, m_entries.end()); - return m_entries; } @@ -97,9 +115,5 @@ std::vector DiskMru::Prune ( void DiskMru::ReplaceAll (std::vector entries) { m_entries = std::move (entries); - - while (m_entries.size() > k_capacity) - { - m_entries.pop_back(); - } + EnforceCap(); } diff --git a/Casso/Shell/DiskMru.h b/Casso/Shell/DiskMru.h index f690e82d..87458c88 100644 --- a/Casso/Shell/DiskMru.h +++ b/Casso/Shell/DiskMru.h @@ -45,5 +45,7 @@ class DiskMru bool Empty () const { return m_entries.empty(); } private: + void EnforceCap (); + std::vector m_entries; // index 0 == most recent }; diff --git a/Casso/Ui/Chrome/DriveLabelTruncation.cpp b/Casso/Ui/Chrome/DriveLabelTruncation.cpp index d54f4ec1..ea93ae5e 100644 --- a/Casso/Ui/Chrome/DriveLabelTruncation.cpp +++ b/Casso/Ui/Chrome/DriveLabelTruncation.cpp @@ -2,17 +2,65 @@ #include "DriveLabelTruncation.h" +#include "../../UnicodeSymbols.h" + //////////////////////////////////////////////////////////////////////////////// // -// Constants +// LongestFittingPrefixLen +// +// Binary-search the longest prefix length `n` of `basename` such that +// measure(basename[0..n] + ellipsis) <= maxWidthPx. Precondition: +// caller has already verified that ellipsisOnly fits and basename +// alone does not. // //////////////////////////////////////////////////////////////////////////////// -static constexpr wchar_t s_kchEllipsis = L'\u2026'; +static size_t LongestFittingPrefixLen ( + std::wstring_view basename, + float maxWidthPx, + const std::function & measure) +{ + size_t lo = 0; + size_t hi = basename.size(); + size_t mid = 0; + size_t best = 0; + std::wstring candidate; + float widthPx = 0.0f; + bool fits = false; + + + + while (lo <= hi) + { + mid = lo + (hi - lo) / 2; + + candidate.assign (basename.substr (0, mid)); + candidate.push_back (s_kchEllipsis); + + widthPx = measure (candidate); + fits = (widthPx <= maxWidthPx); + + if (fits) + { + best = mid; + lo = mid + 1; + } + else if (mid == 0) + { + lo = hi + 1; // force loop exit (single-exit pattern) + } + else + { + hi = mid - 1; + } + } + + return best; +} @@ -22,9 +70,9 @@ static constexpr wchar_t s_kchEllipsis = L'\u2026'; // // TruncateToWidth // -// Binary-search the longest prefix `p` such that -// measure (p + ellipsis) <= maxWidthPx. Returns the literal basename -// when it fits. Returns just the ellipsis when even that doesn't fit. +// Returns the literal basename when it fits, just the ellipsis when +// even that doesn't fit, otherwise the longest prefix + ellipsis that +// fits. // //////////////////////////////////////////////////////////////////////////////// @@ -33,64 +81,34 @@ std::wstring TruncateToWidth ( float maxWidthPx, const std::function & measure) { + std::wstring result; std::wstring ellipsisOnly (1, s_kchEllipsis); - size_t lo = 0; - size_t hi = 0; - size_t mid = 0; - size_t best = 0; + size_t bestPrefixLen = 0; if (!measure) { - return std::wstring (basename); + result.assign (basename); } - - if (basename.empty()) + else if (basename.empty()) { - return std::wstring(); + // result stays empty } - - if (measure (basename) <= maxWidthPx) + else if (measure (basename) <= maxWidthPx) { - return std::wstring (basename); + result.assign (basename); } - - if (measure (ellipsisOnly) > maxWidthPx) + else if (measure (ellipsisOnly) > maxWidthPx) { - return ellipsisOnly; + result = ellipsisOnly; } - - lo = 0; - hi = basename.size(); - best = 0; - while (lo <= hi) + else { - std::wstring candidate; - - mid = lo + (hi - lo) / 2; - candidate.assign (basename.substr (0, mid)); - candidate.push_back (s_kchEllipsis); - - if (measure (candidate) <= maxWidthPx) - { - best = mid; - lo = mid + 1; - } - else - { - if (mid == 0) - { - break; - } - hi = mid - 1; - } + bestPrefixLen = LongestFittingPrefixLen (basename, maxWidthPx, measure); + result.assign (basename.substr (0, bestPrefixLen)); + result.push_back (s_kchEllipsis); } - { - std::wstring out; - out.assign (basename.substr (0, best)); - out.push_back (s_kchEllipsis); - return out; - } + return result; } diff --git a/Casso/Ui/Dialog/DialogLayout.cpp b/Casso/Ui/Dialog/DialogLayout.cpp index 3b4f1a24..4868ba26 100644 --- a/Casso/Ui/Dialog/DialogLayout.cpp +++ b/Casso/Ui/Dialog/DialogLayout.cpp @@ -8,58 +8,31 @@ //////////////////////////////////////////////////////////////////////////////// // -// Anonymous helpers +// DialogLayout::FindWrapBoundary +// +// Returns the largest character count in [1..remaining] whose measured +// width fits in `maxWidthPx`. Prefers a whitespace break when possible. +// Single-exit. // //////////////////////////////////////////////////////////////////////////////// -namespace +size_t DialogLayout::FindWrapBoundary ( + std::wstring_view text, + size_t start, + float maxWidthPx, + const std::function & measure) { - constexpr float s_kZeroPx = 0.0f; + size_t remaining = text.size() - start; + size_t fit = 0; + size_t lastSpace = 0; + size_t i = 0; + size_t result = 0; + float widthPx = 0.0f; - struct WrappedRun - { - size_t runIndex; - size_t start; - size_t count; - float xPx; - float yPx; - float widthPx; - }; - - - - //////////////////////////////////////////////////////////////////////////// - // - // FindWrapBoundary - // - // Returns the largest character count in [1..remaining] whose measured - // width fits in `maxWidthPx`. Prefers a whitespace break when possible. - // - //////////////////////////////////////////////////////////////////////////// - - size_t FindWrapBoundary ( - std::wstring_view text, - size_t start, - float maxWidthPx, - const std::function & measure) + if (remaining > 0) { - size_t remaining = text.size() - start; - size_t fit = 0; - size_t lastSpace = 0; - size_t i = 0; - float widthPx = 0.0f; - - - - if (remaining == 0) - { - return 0; - } - - // Coarse linear probe: extend by 1 char until width exceeds the - // bound. Cheap for the short body strings dialogs carry. for (i = 1; i <= remaining; i++) { widthPx = measure (text.substr (start, i)); @@ -77,86 +50,77 @@ namespace if (fit == 0) { // Single character overflows the line — force a 1-char break. - return 1; + result = 1; } - - if (fit < remaining && lastSpace > 0) + else if (fit < remaining && lastSpace > 0) { - return lastSpace; + result = lastSpace; + } + else + { + result = fit; } - - return fit; } - - - //////////////////////////////////////////////////////////////////////////// - // - // WrapBody - // - // Greedy line-wrap across all body runs into `maxBodyWidthPx`. - // Each emitted `WrappedRun` is a single line's worth of one source - // run; a long run spans multiple WrappedRuns. The caller turns each - // WrappedRun into a RECT. - // - //////////////////////////////////////////////////////////////////////////// - - void WrapBody ( - const std::vector & runs, - float maxBodyWidthPx, - float lineHeightPx, - const std::function & measure, - std::vector & outWrapped, - float & outTotalHeightPx) - { - size_t runIndex = 0; - size_t pos = 0; - float cursorXPx = 0.0f; - float cursorYPx = 0.0f; - float remainingPx = maxBodyWidthPx; + return result; +} - outWrapped.clear(); - outTotalHeightPx = 0.0f; - if (runs.empty()) - { - return; - } - for (runIndex = 0; runIndex < runs.size(); runIndex++) - { - const std::wstring & text = runs[runIndex].text; - pos = 0; - while (pos < text.size()) - { - std::wstring_view view (text); - size_t tentative = FindWrapBoundary (view, pos, remainingPx, measure); - - - - if (tentative == 0) - { - // No room on the current line for even one char — newline. - cursorXPx = 0.0f; - cursorYPx += lineHeightPx; - remainingPx = maxBodyWidthPx; - continue; - } +//////////////////////////////////////////////////////////////////////////////// +// +// DialogLayout::WrapBody +// +// Greedy line-wrap across all body runs into `maxBodyWidthPx`. Each +// emitted `WrappedRun` is one line's worth of one source run; a long +// run spans multiple WrappedRuns. +// +//////////////////////////////////////////////////////////////////////////////// - float pieceWidthPx = measure (view.substr (pos, tentative)); +void DialogLayout::WrapBody ( + const std::vector & runs, + float maxBodyWidthPx, + float lineHeightPx, + const std::function & measure, + std::vector & outWrapped, + float & outTotalHeightPx) +{ + size_t runIndex = 0; + size_t pos = 0; + size_t tentative = 0; + float cursorXPx = 0.0f; + float cursorYPx = 0.0f; + float remainingPx = maxBodyWidthPx; + float pieceWidthPx = 0.0f; - WrappedRun emit { runIndex, pos, tentative, cursorXPx, cursorYPx, pieceWidthPx }; - outWrapped.push_back (emit); + outWrapped.clear(); + outTotalHeightPx = 0.0f; + for (runIndex = 0; runIndex < runs.size(); runIndex++) + { + std::wstring_view view (runs[runIndex].text); + pos = 0; + while (pos < view.size()) + { + tentative = FindWrapBoundary (view, pos, remainingPx, measure); + if (tentative == 0) + { + cursorXPx = 0.0f; + cursorYPx += lineHeightPx; + remainingPx = maxBodyWidthPx; + } + else + { + pieceWidthPx = measure (view.substr (pos, tentative)); + outWrapped.push_back ({ runIndex, pos, tentative, cursorXPx, cursorYPx, pieceWidthPx }); pos += tentative; cursorXPx += pieceWidthPx; remainingPx = maxBodyWidthPx - cursorXPx; - - if (pos < text.size()) + if (pos < view.size()) { cursorXPx = 0.0f; cursorYPx += lineHeightPx; @@ -164,9 +128,9 @@ namespace } } } - - outTotalHeightPx = cursorYPx + (outWrapped.empty() ? 0.0f : lineHeightPx); } + + outTotalHeightPx = cursorYPx + (outWrapped.empty() ? 0.0f : lineHeightPx); } @@ -175,75 +139,83 @@ namespace //////////////////////////////////////////////////////////////////////////////// // -// LayoutDialog +// DialogLayout::BeginLayout // -// Pure layout math producing the rects every DialogPrimitive consumer -// needs (icon slot, per-run body rects, hyperlink hit-rects, button -// row, optional custom-body rect) plus the overall window content -// size. Win32-free; all measurement happens through the metrics' -// callback hooks. Tests pin behavior with deterministic stubs. +// Sets up the body/icon origins from the metrics. Must run before any +// build-rects helper. // //////////////////////////////////////////////////////////////////////////////// -DialogLayoutResult LayoutDialog ( - const DialogDefinition & def, - const DialogLayoutMetrics & metrics) +void DialogLayout::BeginLayout (LayoutState & s) { - DialogLayoutResult result; - std::vector wrapped; - float bodyTotalHeightPx = 0.0f; - float bodyOriginXPx = 0.0f; - float bodyOriginYPx = 0.0f; - float contentTopPx = 0.0f; - float contentBottomPx = 0.0f; - float contentRightPx = 0.0f; - bool hasIcon = (def.icon != DialogIcon::None); - bool hasCustomBody = (def.onPaintCustomBody != nullptr); - size_t wi = 0; - size_t bi = 0; - float buttonRowYPx = 0.0f; - float buttonRowRightPx = 0.0f; - float iconYPx = 0.0f; - float iconBlockHeightPx = hasIcon ? metrics.iconSizePx : 0.0f; - std::vector buttonWidthsPx; - - - - bodyOriginXPx = metrics.outerPaddingPx; - contentTopPx = metrics.outerPaddingPx; - - if (hasIcon) + s.bodyOriginXPx = s.metrics->outerPaddingPx; + s.bodyOriginYPx = s.metrics->outerPaddingPx; + s.hasIcon = (s.def->icon != DialogIcon::None); + s.hasCustomBody = (s.def->onPaintCustomBody != nullptr); + + if (s.hasIcon) { - bodyOriginXPx += metrics.iconSizePx + metrics.iconBodyGapPx; + s.bodyOriginXPx += s.metrics->iconSizePx + s.metrics->iconBodyGapPx; } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogLayout::PerformBodyWrap +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogLayout::PerformBodyWrap (LayoutState & s) +{ + auto fallback = [] (std::wstring_view v) { return (float) v.size() * 0.0f; }; + + WrapBody (s.def->body, + s.metrics->maxBodyWidthPx, + s.metrics->bodyLineHeightPx, + s.metrics->measureBodyTextRun ? s.metrics->measureBodyTextRun : fallback, + s.wrapped, + s.bodyTotalHeightPx); +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogLayout::BuildBodyRunRects +// +// Turns each WrappedRun into (or unions with) the corresponding source +// run's rect. Multi-line runs end up with their bounding-box rect so +// hyperlink hit-testing covers the full underlined region. +// +//////////////////////////////////////////////////////////////////////////////// - bodyOriginYPx = contentTopPx; - - WrapBody (def.body, - metrics.maxBodyWidthPx, - metrics.bodyLineHeightPx, - metrics.measureBodyTextRun ? metrics.measureBodyTextRun - : [] (std::wstring_view v) { return (float) v.size() * s_kZeroPx; }, - wrapped, - bodyTotalHeightPx); - - // Per-run rects (1:1 with def.body). When a run wraps into multiple - // WrappedRun pieces, we take the union of the pieces' rects so the - // hyperlink hit-test is the full bounding box of the link's visual - // run. That matches user expectation — clicking anywhere on the - // underlined text triggers the link. - result.bodyRunRectsPx.assign (def.body.size(), RECT {}); - result.hyperlinkHitRectsPx.clear(); - - for (wi = 0; wi < wrapped.size(); wi++) +void DialogLayout::BuildBodyRunRects (LayoutState & s) +{ + size_t wi = 0; + float leftPx = 0.0f; + float topPx = 0.0f; + float rightPx = 0.0f; + float bottomPx = 0.0f; + + + + s.result->bodyRunRectsPx.assign (s.def->body.size(), RECT {}); + + for (wi = 0; wi < s.wrapped.size(); wi++) { - const WrappedRun & w = wrapped[wi]; - RECT & rect = result.bodyRunRectsPx[w.runIndex]; + const WrappedRun & w = s.wrapped[wi]; + RECT & rect = s.result->bodyRunRectsPx[w.runIndex]; - float leftPx = bodyOriginXPx + w.xPx; - float topPx = bodyOriginYPx + w.yPx; - float rightPx = leftPx + w.widthPx; - float bottomPx = topPx + metrics.bodyLineHeightPx; + leftPx = s.bodyOriginXPx + w.xPx; + topPx = s.bodyOriginYPx + w.yPx; + rightPx = leftPx + w.widthPx; + bottomPx = topPx + s.metrics->bodyLineHeightPx; if (rect.right == 0 && rect.bottom == 0) { @@ -261,88 +233,207 @@ DialogLayoutResult LayoutDialog ( } } - for (bi = 0; bi < def.body.size(); bi++) + s.contentBottomPx = s.bodyOriginYPx + std::max (s.bodyTotalHeightPx, + s.hasIcon ? s.metrics->iconSizePx : 0.0f); + s.contentRightPx = s.bodyOriginXPx + s.metrics->maxBodyWidthPx; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogLayout::BuildHyperlinkHitRects +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogLayout::BuildHyperlinkHitRects (LayoutState & s) +{ + size_t bi = 0; + + + + s.result->hyperlinkHitRectsPx.clear(); + for (bi = 0; bi < s.def->body.size(); bi++) { - if (def.body[bi].isHyperlink) + if (s.def->body[bi].isHyperlink) { - result.hyperlinkHitRectsPx.push_back (result.bodyRunRectsPx[bi]); + s.result->hyperlinkHitRectsPx.push_back (s.result->bodyRunRectsPx[bi]); } } +} + - contentBottomPx = bodyOriginYPx + std::max (bodyTotalHeightPx, iconBlockHeightPx); - contentRightPx = bodyOriginXPx + metrics.maxBodyWidthPx; - if (hasIcon) - { - iconYPx = contentTopPx; - result.iconRectPx.left = (LONG) metrics.outerPaddingPx; - result.iconRectPx.top = (LONG) iconYPx; - result.iconRectPx.right = (LONG) (metrics.outerPaddingPx + metrics.iconSizePx); - result.iconRectPx.bottom = (LONG) (iconYPx + metrics.iconSizePx); - } - if (hasCustomBody) + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogLayout::BuildIconRect +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogLayout::BuildIconRect (LayoutState & s) +{ + if (s.hasIcon) { - float cbTopPx = contentBottomPx + metrics.bodyButtonsGapPx; - float cbLeftPx = metrics.outerPaddingPx; - float cbWidthPx = std::max ((float) def.customBodyMinSizePx.cx, - contentRightPx - cbLeftPx); - float cbHeightPx = (float) def.customBodyMinSizePx.cy; - - result.customBodyRectPx.left = (LONG) cbLeftPx; - result.customBodyRectPx.top = (LONG) cbTopPx; - result.customBodyRectPx.right = (LONG) (cbLeftPx + cbWidthPx); - result.customBodyRectPx.bottom = (LONG) (cbTopPx + cbHeightPx); - - contentBottomPx = (float) result.customBodyRectPx.bottom; - contentRightPx = std::max (contentRightPx, (float) result.customBodyRectPx.right); + s.result->iconRectPx.left = (LONG) s.metrics->outerPaddingPx; + s.result->iconRectPx.top = (LONG) s.metrics->outerPaddingPx; + s.result->iconRectPx.right = (LONG) (s.metrics->outerPaddingPx + s.metrics->iconSizePx); + s.result->iconRectPx.bottom = (LONG) (s.metrics->outerPaddingPx + s.metrics->iconSizePx); } +} - // Button row: right-aligned within content; spaced by buttonSpacingPx. - buttonRowYPx = contentBottomPx + metrics.bodyButtonsGapPx; - buttonRowRightPx = contentRightPx; - buttonWidthsPx.reserve (def.buttons.size()); - for (bi = 0; bi < def.buttons.size(); bi++) + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogLayout::BuildCustomBodyRect +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogLayout::BuildCustomBodyRect (LayoutState & s) +{ + float cbTopPx = 0.0f; + float cbLeftPx = 0.0f; + float cbWidthPx = 0.0f; + float cbHeightPx = 0.0f; + + + + if (s.hasCustomBody) { - float labelW = metrics.measureButtonLabel - ? metrics.measureButtonLabel (def.buttons[bi].label) - : (float) def.buttons[bi].label.size() * s_kZeroPx; - float w = labelW + 2.0f * metrics.buttonPaddingPx; - if (w < metrics.minButtonWidthPx) { w = metrics.minButtonWidthPx; } - buttonWidthsPx.push_back (w); + cbTopPx = s.contentBottomPx + s.metrics->bodyButtonsGapPx; + cbLeftPx = s.metrics->outerPaddingPx; + cbWidthPx = std::max ((float) s.def->customBodyMinSizePx.cx, + s.contentRightPx - cbLeftPx); + cbHeightPx = (float) s.def->customBodyMinSizePx.cy; + + s.result->customBodyRectPx.left = (LONG) cbLeftPx; + s.result->customBodyRectPx.top = (LONG) cbTopPx; + s.result->customBodyRectPx.right = (LONG) (cbLeftPx + cbWidthPx); + s.result->customBodyRectPx.bottom = (LONG) (cbTopPx + cbHeightPx); + + s.contentBottomPx = (float) s.result->customBodyRectPx.bottom; + s.contentRightPx = std::max (s.contentRightPx, (float) s.result->customBodyRectPx.right); } +} + - // Place buttons right-to-left so the rightmost button hugs the - // content right edge. - result.buttonRectsPx.assign (def.buttons.size(), RECT {}); - { - float cursorRightPx = buttonRowRightPx; - for (bi = def.buttons.size(); bi > 0; bi--) - { - size_t idx = bi - 1; - float w = buttonWidthsPx[idx]; - RECT & r = result.buttonRectsPx[idx]; - r.right = (LONG) cursorRightPx; - r.left = (LONG) (cursorRightPx - w); - r.top = (LONG) buttonRowYPx; - r.bottom = (LONG) (buttonRowYPx + metrics.buttonHeightPx); - cursorRightPx -= (w + metrics.buttonSpacingPx); + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogLayout::BuildButtonRects +// +// Right-aligned within the content. Width = label + 2*padding, clamped +// by minButtonWidthPx. Placed right-to-left so the rightmost button +// hugs the content right edge. +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogLayout::BuildButtonRects (LayoutState & s) +{ + std::vector widthsPx; + size_t bi = 0; + size_t idx = 0; + float labelW = 0.0f; + float w = 0.0f; + float cursorRightPx = s.contentRightPx; + float rowTopPx = s.contentBottomPx + s.metrics->bodyButtonsGapPx; + + + + widthsPx.reserve (s.def->buttons.size()); + for (bi = 0; bi < s.def->buttons.size(); bi++) + { + labelW = s.metrics->measureButtonLabel + ? s.metrics->measureButtonLabel (s.def->buttons[bi].label) + : 0.0f; + w = labelW + 2.0f * s.metrics->buttonPaddingPx; + if (w < s.metrics->minButtonWidthPx) + { + w = s.metrics->minButtonWidthPx; } + widthsPx.push_back (w); } - // Total size = right + padding, button-row-bottom + padding (or - // content-bottom + padding when there are no buttons). + s.result->buttonRectsPx.assign (s.def->buttons.size(), RECT {}); + for (bi = s.def->buttons.size(); bi > 0; bi--) { - float bottomPx = def.buttons.empty() - ? contentBottomPx - : buttonRowYPx + metrics.buttonHeightPx; + idx = bi - 1; + w = widthsPx[idx]; + + RECT & r = s.result->buttonRectsPx[idx]; + r.right = (LONG) cursorRightPx; + r.left = (LONG) (cursorRightPx - w); + r.top = (LONG) rowTopPx; + r.bottom = (LONG) (rowTopPx + s.metrics->buttonHeightPx); - result.totalSizePx.cx = (LONG) (contentRightPx + metrics.outerPaddingPx); - result.totalSizePx.cy = (LONG) (bottomPx + metrics.outerPaddingPx); + cursorRightPx -= (w + s.metrics->buttonSpacingPx); } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogLayout::ComputeTotalSize +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogLayout::ComputeTotalSize (LayoutState & s) +{ + float rowTopPx = s.contentBottomPx + s.metrics->bodyButtonsGapPx; + float bottomPx = s.def->buttons.empty() + ? s.contentBottomPx + : rowTopPx + s.metrics->buttonHeightPx; + + s.result->totalSizePx.cx = (LONG) (s.contentRightPx + s.metrics->outerPaddingPx); + s.result->totalSizePx.cy = (LONG) (bottomPx + s.metrics->outerPaddingPx); +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogLayout::Compute +// +// Pure layout math producing the rects every DialogPrimitive consumer +// needs (icon slot, per-run body rects, hyperlink hit-rects, button +// row, optional custom-body rect) plus the overall window content +// size. Win32-free; all measurement happens through the metrics' +// callback hooks. Tests pin behavior with deterministic stubs. +// +//////////////////////////////////////////////////////////////////////////////// + +DialogLayoutResult DialogLayout::Compute ( + const DialogDefinition & def, + const DialogLayoutMetrics & metrics) +{ + DialogLayoutResult result; + LayoutState s; + + s.def = & def; + s.metrics = & metrics; + s.result = & result; + + BeginLayout (s); + PerformBodyWrap (s); + BuildBodyRunRects (s); + BuildHyperlinkHitRects (s); + BuildIconRect (s); + BuildCustomBodyRect (s); + BuildButtonRects (s); + ComputeTotalSize (s); return result; } diff --git a/Casso/Ui/Dialog/DialogLayout.h b/Casso/Ui/Dialog/DialogLayout.h index 0d5ad332..e22d034d 100644 --- a/Casso/Ui/Dialog/DialogLayout.h +++ b/Casso/Ui/Dialog/DialogLayout.h @@ -53,6 +53,55 @@ struct DialogLayoutResult -DialogLayoutResult LayoutDialog ( - const DialogDefinition & def, - const DialogLayoutMetrics & metrics); +class DialogLayout +{ +public: + static DialogLayoutResult Compute ( + const DialogDefinition & def, + const DialogLayoutMetrics & metrics); + +private: + struct WrappedRun + { + size_t runIndex; + size_t start; + size_t count; + float xPx; + float yPx; + float widthPx; + }; + + struct LayoutState + { + const DialogDefinition * def = nullptr; + const DialogLayoutMetrics * metrics = nullptr; + DialogLayoutResult * result = nullptr; + std::vector wrapped; + float bodyOriginXPx = 0.0f; + float bodyOriginYPx = 0.0f; + float bodyTotalHeightPx = 0.0f; + float contentBottomPx = 0.0f; + float contentRightPx = 0.0f; + bool hasIcon = false; + bool hasCustomBody = false; + }; + + static size_t FindWrapBoundary (std::wstring_view text, + size_t start, + float maxWidthPx, + const std::function & measure); + static void WrapBody (const std::vector & runs, + float maxBodyWidthPx, + float lineHeightPx, + const std::function & measure, + std::vector & outWrapped, + float & outTotalHeightPx); + static void BeginLayout (LayoutState & s); + static void PerformBodyWrap (LayoutState & s); + static void BuildBodyRunRects (LayoutState & s); + static void BuildHyperlinkHitRects (LayoutState & s); + static void BuildIconRect (LayoutState & s); + static void BuildCustomBodyRect (LayoutState & s); + static void BuildButtonRects (LayoutState & s); + static void ComputeTotalSize (LayoutState & s); +}; diff --git a/Casso/UnicodeSymbols.h b/Casso/UnicodeSymbols.h index b1753923..8d20b5cd 100644 --- a/Casso/UnicodeSymbols.h +++ b/Casso/UnicodeSymbols.h @@ -23,3 +23,6 @@ static constexpr wchar_t s_kchBullet = L'\x2022'; // U+2014 EM DASH (—) static constexpr wchar_t s_kchEmDash = L'\x2014'; + +// U+2026 HORIZONTAL ELLIPSIS (…) +static constexpr wchar_t s_kchEllipsis = L'\x2026'; diff --git a/CassoCore/Version.h b/CassoCore/Version.h index 1a9629f0..bb51678d 100644 --- a/CassoCore/Version.h +++ b/CassoCore/Version.h @@ -5,7 +5,7 @@ #define VERSION_MAJOR 1 #define VERSION_MINOR 4 -#define VERSION_BUILD 1267 +#define VERSION_BUILD 1268 #define VERSION_YEAR 2026 // Helper macros for stringification diff --git a/UnitTest/DialogLayoutTests.cpp b/UnitTest/DialogLayoutTests.cpp index bffaf6e1..61c0bfcf 100644 --- a/UnitTest/DialogLayoutTests.cpp +++ b/UnitTest/DialogLayoutTests.cpp @@ -48,7 +48,7 @@ namespace DialogLayoutTests def.buttons = { { L"OK", 1, true, false }, { L"Cancel", 0, false, true } }; auto m = MakeMetrics(); - auto r = LayoutDialog (def, m); + auto r = DialogLayout::Compute (def, m); Assert::AreEqual ((size_t) 2, r.buttonRectsPx.size()); // Right-most button hugs content right edge. @@ -65,7 +65,7 @@ namespace DialogLayoutTests def.body = { { std::wstring (100, L'a'), false, L"" } }; auto m = MakeMetrics(); // maxBodyWidthPx = 200, char = 8 → ~25 chars/line - auto r = LayoutDialog (def, m); + auto r = DialogLayout::Compute (def, m); // The single run should span multiple lines → wrapped rect taller than one line. LONG h = r.bodyRunRectsPx[0].bottom - r.bodyRunRectsPx[0].top; @@ -79,10 +79,10 @@ namespace DialogLayoutTests def.body = { { L"Hello", false, L"" } }; auto m = MakeMetrics(); - auto noIcon = LayoutDialog (def, m); + auto noIcon = DialogLayout::Compute (def, m); def.icon = DialogIcon::Info; - auto withIcon = LayoutDialog (def, m); + auto withIcon = DialogLayout::Compute (def, m); // Icon-present total width grows by iconSize + iconBodyGap. Assert::IsTrue (withIcon.totalSizePx.cx >= noIcon.totalSizePx.cx); @@ -100,7 +100,7 @@ namespace DialogLayoutTests }; auto m = MakeMetrics(); - auto r = LayoutDialog (def, m); + auto r = DialogLayout::Compute (def, m); Assert::AreEqual ((size_t) 1, r.hyperlinkHitRectsPx.size()); // Hyperlink hit rect equals the body rect of the second run. @@ -120,7 +120,7 @@ namespace DialogLayoutTests def.onPaintCustomBody = [] (DialogPaintContext &) {}; auto m = MakeMetrics(); - auto r = LayoutDialog (def, m); + auto r = DialogLayout::Compute (def, m); // Custom body rect is non-empty and sits between body and buttons. LONG cbH = r.customBodyRectPx.bottom - r.customBodyRectPx.top; @@ -141,8 +141,8 @@ namespace DialogLayoutTests bigger.outerPaddingPx *= 2.0f; bigger.buttonHeightPx *= 2.0f; - auto rs = LayoutDialog (def, smaller); - auto rl = LayoutDialog (def, bigger); + auto rs = DialogLayout::Compute (def, smaller); + auto rl = DialogLayout::Compute (def, bigger); // Larger metrics yield a larger total size. Assert::IsTrue (rl.totalSizePx.cx > rs.totalSizePx.cx); diff --git a/UnitTest/DriveLabelTruncationTests.cpp b/UnitTest/DriveLabelTruncationTests.cpp index 56353ad0..b22eba79 100644 --- a/UnitTest/DriveLabelTruncationTests.cpp +++ b/UnitTest/DriveLabelTruncationTests.cpp @@ -1,6 +1,7 @@ #include "Pch.h" #include "../Casso/Ui/Chrome/DriveLabelTruncation.h" +#include "../Casso/UnicodeSymbols.h" @@ -40,7 +41,7 @@ namespace DriveLabelTruncationTests // 64 px budget = 8 chars (the ellipsis counts as 1 char). auto out = TruncateToWidth (L"superlongname.dsk", 64.0f, FixedWidthMeasure()); Assert::IsTrue (out.size() <= 8); - Assert::AreEqual (L'\u2026', out.back()); + Assert::AreEqual (s_kchEllipsis, out.back()); Assert::AreEqual ((size_t) 0, out.find (L's')); // starts with prefix of "super…" } @@ -64,7 +65,7 @@ namespace DriveLabelTruncationTests // 4 px budget cannot even fit the ellipsis (8 px); function returns just ellipsis. auto out = TruncateToWidth (L"name.dsk", 4.0f, FixedWidthMeasure()); Assert::AreEqual ((size_t) 1, out.size()); - Assert::AreEqual (L'\u2026', out[0]); + Assert::AreEqual (s_kchEllipsis, out[0]); } From 0df7f65cae4f748afdcc863db852967428b84e4e Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Thu, 28 May 2026 09:12:00 -0700 Subject: [PATCH 08/71] feat(011): DiskMru <-> GlobalUserPrefs bridge helpers (T013) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Casso/Shell/DiskMru.cpp | 61 +++++++++++++++++++++++++++++++++++++++ Casso/Shell/DiskMru.h | 5 ++++ CassoCore/Version.h | 2 +- UnitTest/DiskMruTests.cpp | 26 +++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/Casso/Shell/DiskMru.cpp b/Casso/Shell/DiskMru.cpp index 0c38832b..35eca42d 100644 --- a/Casso/Shell/DiskMru.cpp +++ b/Casso/Shell/DiskMru.cpp @@ -117,3 +117,64 @@ void DiskMru::ReplaceAll (std::vector entries) m_entries = std::move (entries); EnforceCap(); } + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// FromUtf8 +// +// Constructs a DiskMru from the GlobalUserPrefs `recentDisks` +// narrow-string list. Drops empty entries; preserves order; caps at +// k_capacity. +// +//////////////////////////////////////////////////////////////////////////////// + +DiskMru DiskMru::FromUtf8 (const std::vector & utf8Entries) +{ + DiskMru mru; + std::vector paths; + size_t i = 0; + + + + paths.reserve (utf8Entries.size()); + for (i = 0; i < utf8Entries.size(); i++) + { + if (!utf8Entries[i].empty()) + { + paths.emplace_back (std::filesystem::path (utf8Entries[i])); + } + } + mru.ReplaceAll (std::move (paths)); + return mru; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ToUtf8 +// +// Serialises the snapshot into the `recentDisks` narrow-string list +// shape used by GlobalUserPrefs JSON. +// +//////////////////////////////////////////////////////////////////////////////// + +void DiskMru::ToUtf8 (std::vector & outUtf8Entries) const +{ + size_t i = 0; + + + + outUtf8Entries.clear(); + outUtf8Entries.reserve (m_entries.size()); + for (i = 0; i < m_entries.size(); i++) + { + outUtf8Entries.push_back (m_entries[i].string()); + } +} diff --git a/Casso/Shell/DiskMru.h b/Casso/Shell/DiskMru.h index 87458c88..05a06aa6 100644 --- a/Casso/Shell/DiskMru.h +++ b/Casso/Shell/DiskMru.h @@ -44,6 +44,11 @@ class DiskMru size_t Size () const { return m_entries.size(); } bool Empty () const { return m_entries.empty(); } + // Bridge helpers between DiskMru and the GlobalUserPrefs JSON + // schema (which stores entries as narrow UTF-8 strings). + static DiskMru FromUtf8 (const std::vector & utf8Entries); + void ToUtf8 (std::vector & outUtf8Entries) const; + private: void EnforceCap (); diff --git a/CassoCore/Version.h b/CassoCore/Version.h index bb51678d..c232dbb1 100644 --- a/CassoCore/Version.h +++ b/CassoCore/Version.h @@ -5,7 +5,7 @@ #define VERSION_MAJOR 1 #define VERSION_MINOR 4 -#define VERSION_BUILD 1268 +#define VERSION_BUILD 1270 #define VERSION_YEAR 2026 // Helper macros for stringification diff --git a/UnitTest/DiskMruTests.cpp b/UnitTest/DiskMruTests.cpp index 2e3cabc1..45712645 100644 --- a/UnitTest/DiskMruTests.cpp +++ b/UnitTest/DiskMruTests.cpp @@ -147,5 +147,31 @@ namespace DiskMruTests m.ReplaceAll (many); Assert::AreEqual (DiskMru::k_capacity, m.Size()); } + + + TEST_METHOD (FromUtf8_DropsEmpty_PreservesOrder) + { + std::vector in = { "C:\\Disks\\A.dsk", "", "C:\\Disks\\B.dsk" }; + auto mru = DiskMru::FromUtf8 (in); + auto snap = mru.Snapshot(); + Assert::AreEqual ((size_t) 2, snap.size()); + Assert::IsTrue (snap[0] == std::filesystem::path ("C:\\Disks\\A.dsk")); + Assert::IsTrue (snap[1] == std::filesystem::path ("C:\\Disks\\B.dsk")); + } + + + TEST_METHOD (ToUtf8_RoundTrips) + { + DiskMru mru; + mru.RecordMount (L"C:\\Disks\\A.dsk"); + mru.RecordMount (L"C:\\Disks\\B.dsk"); + + std::vector out; + mru.ToUtf8 (out); + + Assert::AreEqual ((size_t) 2, out.size()); + Assert::AreEqual (std::string ("C:\\Disks\\B.dsk"), out[0]); + Assert::AreEqual (std::string ("C:\\Disks\\A.dsk"), out[1]); + } }; } From c75f20da4b8cfe4cf8009b28a5074f7fbc914492 Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Thu, 28 May 2026 09:45:26 -0700 Subject: [PATCH 09/71] feat(011): themed DialogPrimitive modal window (T006-T009) Implement DialogPrimitive and DialogPrimitiveRenderer, the modal dialog primitive requested in spec 011-native-dialogs-completion. DialogPrimitiveRenderer owns a CreateSwapChainForHwnd swap chain (DXGI_ALPHA_MODE_IGNORE, no DComp or blur shaders), RTV, DxUiPainter geometry layer, and DwriteTextRenderer text layer. Paints a gradient title bar, theme-colored background, icon circle for Info/Warning/ Error/App variants, word-wrapped body text runs, hyperlink underlines, optional custom-body callback (progress bars etc.), and Button widgets from the existing Widgets/Button widget. DialogPrimitive owns the Win32 class lifetime (RegisterClass/ UnregisterClass), creates the window as WS_POPUP|WS_SYSMENU (no NC area, client == window), and runs a private GetMessage loop inside Show() that disables the owner window for the duration. Returns the chosen button resultCode or -1 on close gesture. Close() records the result and posts WM_NULL to unblock the loop. Keyboard shortcuts: Enter fires the focused or default button; Escape fires the cancel button or Close(-1); Tab/Shift-Tab cycles focus between buttons; Space fires the focused button. Hyperlinks launch via ShellExecuteW with a CHRN user-facing notification on failure. Also fixes a pre-existing ChromeTheme forward-declaration mismatch in DialogDefinition.h (class -> struct). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 17 + Casso/Casso.vcxproj | 4 + Casso/Ui/Dialog/DialogDefinition.h | 2 +- Casso/Ui/Dialog/DialogPrimitive.cpp | 1076 +++++++++++++++++++ Casso/Ui/Dialog/DialogPrimitive.h | 85 ++ Casso/Ui/Dialog/DialogPrimitiveRenderer.cpp | 576 ++++++++++ Casso/Ui/Dialog/DialogPrimitiveRenderer.h | 89 ++ 7 files changed, 1848 insertions(+), 1 deletion(-) create mode 100644 Casso/Ui/Dialog/DialogPrimitive.cpp create mode 100644 Casso/Ui/Dialog/DialogPrimitive.h create mode 100644 Casso/Ui/Dialog/DialogPrimitiveRenderer.cpp create mode 100644 Casso/Ui/Dialog/DialogPrimitiveRenderer.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a220640..70c16eaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,23 @@ deferred to a follow-up; the headless plumbing they consume is in tree and unit-tested. ### Added +- **feat(011): DialogPrimitive modal window (T006–T009).** New + `Casso/Ui/Dialog/DialogPrimitive` pair implements the themed blocking + modal dialog. `RegisterClass` registers the Win32 class once per + instance; `Show()` creates the window, runs a private `GetMessage` + loop (disabling the owner window for the duration), and returns the + chosen button's `resultCode` (or -1 on Alt+F4 / WM_CLOSE). `Close()` + records the result and unblocks the loop. Keyboard: Enter = default + button, Escape = cancel button, Tab/Shift-Tab cycle focus. + Hyperlinks launch via `ShellExecuteW` with a user-facing notification + on failure. +- **feat(011): DialogPrimitiveRenderer.** Split renderer class owns the + `CreateSwapChainForHwnd` swap chain (DXGI_ALPHA_MODE_IGNORE, no DComp + / no blur), RTV, `DxUiPainter` (geometry), and `DwriteTextRenderer` + (text). Paints a gradient title bar, solid dialog background with + theme colors, icon circle (Info/Warning/Error/App), word-wrapped body + text, hyperlink underlines, custom-body callback, and `Button` + widgets. DPI-aware: recomputes layout on WM_DPICHANGED. - **feat(011): DialogDefinition + DialogLayout primitives.** New `Casso/Ui/Dialog/` directory hosts the pure value types (`DialogIcon`, `DialogTextRun`, `DialogButton`, `DialogDefinition`) diff --git a/Casso/Casso.vcxproj b/Casso/Casso.vcxproj index 745e03d8..62fd279d 100644 --- a/Casso/Casso.vcxproj +++ b/Casso/Casso.vcxproj @@ -279,6 +279,8 @@ + + @@ -308,6 +310,8 @@ + + diff --git a/Casso/Ui/Dialog/DialogDefinition.h b/Casso/Ui/Dialog/DialogDefinition.h index 2892db4c..d26baeeb 100644 --- a/Casso/Ui/Dialog/DialogDefinition.h +++ b/Casso/Ui/Dialog/DialogDefinition.h @@ -22,7 +22,7 @@ class DxUiPainter; -class ChromeTheme; +struct ChromeTheme; struct DialogPaintContext; struct DialogInputEvent; diff --git a/Casso/Ui/Dialog/DialogPrimitive.cpp b/Casso/Ui/Dialog/DialogPrimitive.cpp new file mode 100644 index 00000000..d436bc44 --- /dev/null +++ b/Casso/Ui/Dialog/DialogPrimitive.cpp @@ -0,0 +1,1076 @@ +#include "Pch.h" + +#include "DialogPrimitive.h" +#include "../Chrome/ChromeTheme.h" + + +static constexpr LPCWSTR s_kpszDialogClass = L"Casso.Dialog.Primitive"; +static constexpr DWORD s_kDialogStyle = WS_POPUP | WS_SYSMENU; +static constexpr DWORD s_kDialogExStyle = 0; +static constexpr float s_kTitleHeightDp = 32.0f; +static constexpr float s_kBodyFontDp = 11.0f; +static constexpr float s_kButtonFontDp = 13.0f; +static constexpr float s_kMaxBodyWidthDp = 360.0f; +static constexpr float s_kButtonHeightDp = 28.0f; +static constexpr float s_kButtonPaddingDp = 16.0f; +static constexpr float s_kButtonSpacingDp = 8.0f; +static constexpr float s_kIconSizeDp = 32.0f; +static constexpr float s_kBodyLineHeightDp = 18.0f; +static constexpr float s_kOuterPaddingDp = 16.0f; +static constexpr float s_kIconBodyGapDp = 12.0f; +static constexpr float s_kBodyButtonsGapDp = 16.0f; +static constexpr float s_kMinButtonWidthDp = 72.0f; +static constexpr float s_kOutlineThicknessDp = 1.0f; +static constexpr int s_kCenterDivisor = 2; +static constexpr INT_PTR s_kShellExecThreshold = 32; +static constexpr LPCWSTR s_kpszHyperlinkError = L"Could not open the requested link."; +static constexpr LPCWSTR s_kpszFont = L"Segoe UI"; + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ~DialogPrimitive +// +//////////////////////////////////////////////////////////////////////////////// + +DialogPrimitive::~DialogPrimitive() +{ + if (m_hwnd != nullptr) + { + DestroyWindow (m_hwnd); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// RegisterClass +// +//////////////////////////////////////////////////////////////////////////////// + +HRESULT DialogPrimitive::RegisterClass (HINSTANCE hInstance) +{ + HRESULT hr = S_OK; + WNDCLASSEXW wcex = { sizeof (wcex) }; + BOOL ok = FALSE; + ATOM atom = 0; + + + + CBRAEx (hInstance, E_INVALIDARG); + + ok = GetClassInfoExW (hInstance, s_kpszDialogClass, &wcex); + BAIL_OUT_IF (ok, S_OK); + + wcex.style = CS_HREDRAW | CS_VREDRAW; + wcex.lpfnWndProc = DialogPrimitive::s_WndProc; + wcex.cbClsExtra = 0; + wcex.cbWndExtra = 0; + wcex.hInstance = hInstance; + wcex.hIcon = nullptr; + wcex.hCursor = LoadCursorW (nullptr, IDC_ARROW); + wcex.hbrBackground = nullptr; + wcex.lpszMenuName = nullptr; + wcex.lpszClassName = s_kpszDialogClass; + wcex.hIconSm = nullptr; + + atom = RegisterClassExW (&wcex); + CWRA (atom); + + m_hInstance = hInstance; + +Error: + return hr; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Show +// +// Displays the dialog as a blocking modal call. Disables the owner +// window for the duration, runs a private GetMessage loop, and +// returns the resultCode of the button the user chose (or -1 on +// window-close gesture with no isCancel button). +// +//////////////////////////////////////////////////////////////////////////////// + +int DialogPrimitive::Show ( + HWND hwndOwner, + ID3D11Device * device, + ID3D11DeviceContext * context, + const ChromeTheme * theme, + const DialogDefinition & def) +{ + HRESULT hr = S_OK; + RECT windowRect = {}; + HWND hwndCreated = nullptr; + BOOL ok = FALSE; + MSG msg = {}; + bool ownerEnabled = false; + + + + CBRAEx (hwndOwner, E_INVALIDARG); + CBRAEx (device, E_INVALIDARG); + CBRAEx (context, E_INVALIDARG); + CBRAEx (theme, E_INVALIDARG); + CBRA (m_hInstance); + BAIL_OUT_IF (m_hwnd != nullptr, S_OK); + + m_hwndOwner = hwndOwner; + m_device = device; + m_context = context; + m_theme = theme; + m_def = &def; + m_chosenId = -1; + m_closed = false; + m_focusedButton = SIZE_MAX; + + m_dpi = GetDpiForWindow (hwndOwner); + if (m_dpi == 0) + { + m_dpi = DpiScaler::kBaseDpi; + } + + RecomputeLayout (m_dpi); + BuildButtons (); + + windowRect = GetInitialWindowRect (hwndOwner, m_dpi); + + hwndCreated = CreateWindowExW (s_kDialogExStyle, + s_kpszDialogClass, + def.title.c_str(), + s_kDialogStyle, + windowRect.left, + windowRect.top, + windowRect.right - windowRect.left, + windowRect.bottom - windowRect.top, + hwndOwner, + nullptr, + m_hInstance, + this); + CWRA (hwndCreated); + + EnableWindow (hwndOwner, FALSE); + ownerEnabled = true; + + ShowWindow (hwndCreated, SW_SHOWNORMAL); + UpdateWindow (hwndCreated); + SetForegroundWindow (hwndCreated); + SetFocus (hwndCreated); + + RenderFrame(); + + while (!m_closed) + { + BOOL gotMessage = GetMessageW (&msg, nullptr, 0, 0); + + if (gotMessage == 0) + { + PostQuitMessage ((int) msg.wParam); + break; + } + + if (gotMessage == -1) + { + break; + } + + TranslateMessage (&msg); + DispatchMessageW (&msg); + } + +Error: + if (ownerEnabled) + { + EnableWindow (hwndOwner, TRUE); + } + + if (m_hwnd != nullptr) + { + DestroyWindow (m_hwnd); + } + + m_hwnd = nullptr; + m_hwndOwner = nullptr; + m_device = nullptr; + m_context = nullptr; + m_theme = nullptr; + m_def = nullptr; + m_buttons.clear(); + m_focusedButton = SIZE_MAX; + + return m_chosenId; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Close +// +// Records the chosen result code, signals the message loop to exit, +// and posts WM_NULL to unblock GetMessageW if called from within +// a button click handler (same thread, inside DispatchMessageW). +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::Close (int chosenId) +{ + m_chosenId = chosenId; + m_closed = true; + + if (m_hwnd != nullptr) + { + PostMessageW (m_hwnd, WM_NULL, 0, 0); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// s_WndProc +// +//////////////////////////////////////////////////////////////////////////////// + +LRESULT CALLBACK DialogPrimitive::s_WndProc (HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) +{ + DialogPrimitive * window = nullptr; + + + + if (message == WM_NCCREATE) + { + CREATESTRUCTW * cs = reinterpret_cast (lParam); + + + window = reinterpret_cast (cs->lpCreateParams); + SetWindowLongPtrW (hwnd, GWLP_USERDATA, reinterpret_cast (window)); + } + else + { + window = reinterpret_cast (GetWindowLongPtrW (hwnd, GWLP_USERDATA)); + } + + if (window != nullptr) + { + return window->WndProc (hwnd, message, wParam, lParam); + } + + return DefWindowProcW (hwnd, message, wParam, lParam); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// WndProc +// +//////////////////////////////////////////////////////////////////////////////// + +LRESULT DialogPrimitive::WndProc (HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) +{ + LRESULT result = 0; + + + + switch (message) + { + case WM_CREATE: + result = FAILED (OnCreate (hwnd)) ? -1 : 0; + break; + + case WM_DESTROY: + OnDestroy(); + result = 0; + break; + + case WM_CLOSE: + OnClose(); + result = 0; + break; + + case WM_SIZE: + OnSize ((int) LOWORD (lParam), (int) HIWORD (lParam)); + result = 0; + break; + + case WM_PAINT: + RenderFrame(); + ValidateRect (hwnd, nullptr); + result = 0; + break; + + case WM_DPICHANGED: + OnDpiChanged (HIWORD (wParam), *reinterpret_cast (lParam)); + result = 0; + break; + + case WM_KEYDOWN: + OnKeyDown (wParam); + result = 0; + break; + + case WM_CHAR: + result = 0; + break; + + case WM_MOUSEMOVE: + case WM_LBUTTONDOWN: + case WM_LBUTTONUP: + OnMouse (message, wParam, lParam); + result = 0; + break; + + default: + result = DefWindowProcW (hwnd, message, wParam, lParam); + break; + } + + return result; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnCreate +// +//////////////////////////////////////////////////////////////////////////////// + +HRESULT DialogPrimitive::OnCreate (HWND hwnd) +{ + HRESULT hr = S_OK; + RECT rc = {}; + UINT dpi = DpiScaler::kBaseDpi; + BOOL ok = FALSE; + + + + m_hwnd = hwnd; + + ok = GetClientRect (m_hwnd, &rc); + CWRA (ok); + + dpi = GetDpiForWindow (m_hwnd); + if (dpi == 0) + { + dpi = DpiScaler::kBaseDpi; + } + + hr = m_renderer.Initialize (m_hwnd, + m_device, + m_context, + rc.right - rc.left, + rc.bottom - rc.top, + dpi); + CHRA (hr); + +Error: + return hr; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnDestroy +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::OnDestroy() +{ + m_renderer.Shutdown(); + m_hwnd = nullptr; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnSize +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::OnSize (int widthPx, int heightPx) +{ + HRESULT hr = S_OK; + UINT dpi = DpiScaler::kBaseDpi; + + + + BAIL_OUT_IF (m_hwnd == nullptr || !m_renderer.IsInitialized(), S_OK); + + dpi = GetDpiForWindow (m_hwnd); + if (dpi == 0) + { + dpi = DpiScaler::kBaseDpi; + } + + hr = m_renderer.Resize (widthPx, heightPx, dpi); + IGNORE_RETURN_VALUE (hr, S_OK); + + RenderFrame(); + +Error: + return; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnDpiChanged +// +// Repositions and resizes the window per the system-suggested rect, +// recomputes the layout at the new DPI, rebuilds buttons, and resizes +// the renderer. +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::OnDpiChanged (UINT dpi, const RECT & suggestedRect) +{ + HRESULT hr = S_OK; + BOOL ok = FALSE; + + + + CBRA (m_hwnd); + + m_dpi = dpi; + + ok = SetWindowPos (m_hwnd, + nullptr, + suggestedRect.left, + suggestedRect.top, + suggestedRect.right - suggestedRect.left, + suggestedRect.bottom - suggestedRect.top, + SWP_NOZORDER | SWP_NOACTIVATE); + CWRA (ok); + + RecomputeLayout (dpi); + BuildButtons (); + + hr = m_renderer.Resize (suggestedRect.right - suggestedRect.left, + suggestedRect.bottom - suggestedRect.top, + dpi); + IGNORE_RETURN_VALUE (hr, S_OK); + +Error: + return; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnKeyDown +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::OnKeyDown (WPARAM vk) +{ + switch (vk) + { + case VK_RETURN: + if (m_focusedButton < m_buttons.size()) + { + m_buttons[m_focusedButton].Click(); + } + else + { + ActivateDefaultButton(); + } + break; + + case VK_ESCAPE: + ActivateCancelButton(); + break; + + case VK_TAB: + { + int delta = (GetKeyState (VK_SHIFT) & 0x8000) ? -1 : 1; + CycleFocus (delta); + break; + } + + case VK_SPACE: + if (m_focusedButton < m_buttons.size()) + { + m_buttons[m_focusedButton].Click(); + } + break; + + default: + break; + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnMouse +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::OnMouse (UINT message, WPARAM wParam, LPARAM lParam) +{ + int xPx = (int) (short) LOWORD (lParam); + int yPx = (int) (short) HIWORD (lParam); + bool down = (message == WM_LBUTTONDOWN); + bool up = (message == WM_LBUTTONUP); + bool dirty = false; + size_t hitIdx = SIZE_MAX; + + + + for (size_t i = 0; i < m_buttons.size(); ++i) + { + bool wasHover = m_buttons[i].Focused(); + m_buttons[i].SetMouse (xPx, yPx, down); + dirty = dirty || (m_buttons[i].Focused() != wasHover); + } + + if (up) + { + for (size_t i = 0; i < m_buttons.size(); ++i) + { + if (m_buttons[i].HitTest (xPx, yPx)) + { + hitIdx = i; + break; + } + } + + if (hitIdx != SIZE_MAX) + { + ActivateButton (hitIdx); + return; + } + + size_t hlRunIdx = SIZE_MAX; + if (HitTestHyperlink (xPx, yPx, hlRunIdx)) + { + LaunchHyperlink (hlRunIdx); + } + } + + if (dirty || message == WM_MOUSEMOVE) + { + InvalidateRect (m_hwnd, nullptr, FALSE); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnClose +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::OnClose() +{ + ActivateCancelButton(); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// BuildButtons +// +// (Re)builds the Button widget vector from the current definition and +// layout, setting colors, labels, DPI, click callbacks, and focus. +// Must be called after RecomputeLayout() and whenever DPI changes. +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::BuildButtons() +{ + HRESULT hr = S_OK; + size_t count = 0; + float outlinePx = 0.0f; + + + + CBR (m_def != nullptr); + + count = std::min (m_def->buttons.size(), m_layout.buttonRectsPx.size()); + outlinePx = static_cast (m_dpi) / static_cast (DpiScaler::kBaseDpi) * s_kOutlineThicknessDp; + + m_buttons.clear(); + m_buttons.resize (count); + + for (size_t i = 0; i < count; ++i) + { + const DialogButton & btn = m_def->buttons[i]; + RECT rect = m_layout.buttonRectsPx[i]; + int titleH = TitleHeightPx(); + + rect.top += titleH; + rect.bottom += titleH; + + m_buttons[i].Layout (rect); + m_buttons[i].SetLabel (btn.label); + m_buttons[i].SetDpi (m_dpi); + m_buttons[i].SetColors (m_theme != nullptr ? m_theme->navStripArgb : 0xFF202020, + m_theme != nullptr ? m_theme->dropdownHoverArgb : 0xFF3D6FB5, + m_theme != nullptr ? m_theme->navHoverArgb : 0xFF2D4058); + m_buttons[i].SetTextColor (m_theme != nullptr ? m_theme->navItemTextArgb : 0xFFF0F0F0); + + if (btn.isDefault) + { + m_buttons[i].SetOutline (outlinePx, m_theme != nullptr ? m_theme->navHoverArgb : 0xFF3D6FB5); + } + + m_buttons[i].SetClick ([this, i]() { ActivateButton (i); }); + } + + m_focusedButton = DefaultButtonIdx(); + if (m_focusedButton == SIZE_MAX && !m_buttons.empty()) + { + m_focusedButton = 0; + } + + if (m_focusedButton < m_buttons.size()) + { + m_buttons[m_focusedButton].SetFocused (true); + } + +Error: + return; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// RecomputeLayout +// +// Creates a temporary DwriteTextRenderer for measurement-only calls +// (no swap chain needed), builds the DialogLayoutMetrics, and stores +// the result in m_layout. +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::RecomputeLayout (UINT dpi) +{ + HRESULT hr = S_OK; + DwriteTextRenderer measurer; + DialogLayoutMetrics metrics; + float dpiScale = 0.0f; + + + + CBR (m_def != nullptr); + CBR (m_device != nullptr); + + dpiScale = static_cast (dpi) / static_cast (DpiScaler::kBaseDpi); + + hr = measurer.Initialize (m_device); + CHRA (hr); + + metrics.dpiScale = dpiScale; + metrics.maxBodyWidthPx = s_kMaxBodyWidthDp * dpiScale; + metrics.buttonHeightPx = s_kButtonHeightDp * dpiScale; + metrics.buttonPaddingPx = s_kButtonPaddingDp * dpiScale; + metrics.buttonSpacingPx = s_kButtonSpacingDp * dpiScale; + metrics.iconSizePx = s_kIconSizeDp * dpiScale; + metrics.bodyLineHeightPx = s_kBodyLineHeightDp * dpiScale; + metrics.outerPaddingPx = s_kOuterPaddingDp * dpiScale; + metrics.iconBodyGapPx = s_kIconBodyGapDp * dpiScale; + metrics.bodyButtonsGapPx = s_kBodyButtonsGapDp * dpiScale; + metrics.minButtonWidthPx = s_kMinButtonWidthDp * dpiScale; + + metrics.measureBodyTextRun = [&measurer, dpiScale] (std::wstring_view sv) -> float + { + std::wstring text (sv); + float w = 0.0f; + float h = 0.0f; + + measurer.MeasureString (text.c_str(), s_kBodyFontDp * dpiScale, s_kpszFont, w, h); + return w; + }; + + metrics.measureButtonLabel = [&measurer, dpiScale] (std::wstring_view sv) -> float + { + std::wstring text (sv); + float w = 0.0f; + float h = 0.0f; + + measurer.MeasureString (text.c_str(), s_kButtonFontDp * dpiScale, s_kpszFont, w, h); + return w; + }; + + m_layout = DialogLayout::Compute (*m_def, metrics); + +Error: + measurer.Shutdown(); + return; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// RenderFrame +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::RenderFrame() +{ + HRESULT hr = S_OK; + + + + CBR (m_def != nullptr); + CBR (m_theme != nullptr); + CBR (m_renderer.IsInitialized()); + + IGNORE_RETURN_VALUE (hr, m_renderer.Render (*m_def, m_layout, *m_theme, TitleHeightPx(), m_buttons)); + +Error: + return; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ActivateButton +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::ActivateButton (size_t idx) +{ + HRESULT hr = S_OK; + + + + CBR (idx < m_def->buttons.size()); + + Close (m_def->buttons[idx].resultCode); + +Error: + return; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ActivateDefaultButton +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::ActivateDefaultButton() +{ + size_t idx = DefaultButtonIdx(); + + if (idx != SIZE_MAX) + { + ActivateButton (idx); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ActivateCancelButton +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::ActivateCancelButton() +{ + size_t idx = CancelButtonIdx(); + + if (idx != SIZE_MAX) + { + ActivateButton (idx); + } + else + { + Close (-1); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// CycleFocus +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::CycleFocus (int delta) +{ + HRESULT hr = S_OK; + size_t next = 0; + + + + CBR (!m_buttons.empty()); + + if (m_focusedButton < m_buttons.size()) + { + m_buttons[m_focusedButton].SetFocused (false); + } + + if (m_focusedButton >= m_buttons.size()) + { + next = (delta > 0) ? 0 : m_buttons.size() - 1; + } + else if (delta > 0) + { + next = (m_focusedButton + 1) % m_buttons.size(); + } + else + { + next = (m_focusedButton == 0) ? m_buttons.size() - 1 : m_focusedButton - 1; + } + + m_focusedButton = next; + m_buttons[next].SetFocused (true); + + InvalidateRect (m_hwnd, nullptr, FALSE); + +Error: + return; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// HitTestHyperlink +// +// Returns true when (xPx, yPx) falls inside any hyperlink hit rect +// from the layout, and sets outBodyRunIdx to the corresponding body +// run index. The hit rects are window-relative (title height already +// added by BuildButtons callers; here we add it to the raw layout +// rects for the same reason). +// +//////////////////////////////////////////////////////////////////////////////// + +bool DialogPrimitive::HitTestHyperlink (int xPx, int yPx, size_t & outBodyRunIdx) const +{ + int titleH = TitleHeightPx(); + size_t hlIdx = 0; + + outBodyRunIdx = SIZE_MAX; + + if (m_def == nullptr) + { + return false; + } + + for (size_t i = 0; i < m_def->body.size(); ++i) + { + if (!m_def->body[i].isHyperlink) + { + continue; + } + + if (hlIdx >= m_layout.hyperlinkHitRectsPx.size()) + { + break; + } + + const RECT & rect = m_layout.hyperlinkHitRectsPx[hlIdx]; + + int adjLeft = rect.left; + int adjTop = rect.top + titleH; + int adjRight = rect.right; + int adjBottom = rect.bottom + titleH; + + if (xPx >= adjLeft && xPx < adjRight && yPx >= adjTop && yPx < adjBottom) + { + outBodyRunIdx = i; + return true; + } + + ++hlIdx; + } + + return false; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// LaunchHyperlink +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::LaunchHyperlink (size_t bodyRunIdx) +{ + HRESULT hr = S_OK; + INT_PTR shellRes = 0; + bool shellOk = false; + + + + CBRA (m_def != nullptr); + CBRA (bodyRunIdx < m_def->body.size()); + CBRA (m_def->body[bodyRunIdx].isHyperlink); + + shellRes = (INT_PTR) ShellExecuteW (nullptr, L"open", + m_def->body[bodyRunIdx].hyperlinkUrl.c_str(), + nullptr, nullptr, SW_SHOWNORMAL); + shellOk = (shellRes > s_kShellExecThreshold); + + hr = shellOk ? S_OK : E_FAIL; + CHRN (hr, s_kpszHyperlinkError); + +Error: + return; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// TitleHeightPx +// +//////////////////////////////////////////////////////////////////////////////// + +int DialogPrimitive::TitleHeightPx() const +{ + return static_cast (s_kTitleHeightDp * static_cast (m_dpi) / static_cast (DpiScaler::kBaseDpi)); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// GetInitialWindowRect +// +// Centers the dialog over the owner window, then clamps to the +// monitor's work area. +// +//////////////////////////////////////////////////////////////////////////////// + +RECT DialogPrimitive::GetInitialWindowRect (HWND hwndOwner, UINT dpi) const +{ + int titleH = TitleHeightPx(); + int clientW = m_layout.totalSizePx.cx; + int clientH = m_layout.totalSizePx.cy + titleH; + RECT ownerRect = {}; + RECT workRect = {}; + MONITORINFO mi = { sizeof (mi) }; + HMONITOR monitor = nullptr; + int x = 0; + int y = 0; + + + + if (!GetWindowRect (hwndOwner, &ownerRect)) + { + ownerRect = { 0, 0, clientW, clientH }; + } + + monitor = MonitorFromWindow (hwndOwner, MONITOR_DEFAULTTONEAREST); + if (monitor != nullptr && GetMonitorInfoW (monitor, &mi)) + { + workRect = mi.rcWork; + } + else + { + workRect = ownerRect; + } + + x = (int) ownerRect.left + ((int) (ownerRect.right - ownerRect.left) - clientW) / s_kCenterDivisor; + y = (int) ownerRect.top + ((int) (ownerRect.bottom - ownerRect.top) - clientH) / s_kCenterDivisor; + + x = std::max ((int) workRect.left, std::min (x, (int) workRect.right - clientW)); + y = std::max ((int) workRect.top, std::min (y, (int) workRect.bottom - clientH)); + + return { x, y, x + clientW, y + clientH }; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DefaultButtonIdx +// +//////////////////////////////////////////////////////////////////////////////// + +size_t DialogPrimitive::DefaultButtonIdx() const +{ + if (m_def == nullptr) + { + return SIZE_MAX; + } + + for (size_t i = 0; i < m_def->buttons.size(); ++i) + { + if (m_def->buttons[i].isDefault) + { + return i; + } + } + + return SIZE_MAX; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// CancelButtonIdx +// +//////////////////////////////////////////////////////////////////////////////// + +size_t DialogPrimitive::CancelButtonIdx() const +{ + if (m_def == nullptr) + { + return SIZE_MAX; + } + + for (size_t i = 0; i < m_def->buttons.size(); ++i) + { + if (m_def->buttons[i].isCancel) + { + return i; + } + } + + return SIZE_MAX; +} diff --git a/Casso/Ui/Dialog/DialogPrimitive.h b/Casso/Ui/Dialog/DialogPrimitive.h new file mode 100644 index 00000000..ac0f4042 --- /dev/null +++ b/Casso/Ui/Dialog/DialogPrimitive.h @@ -0,0 +1,85 @@ +#pragma once + +#include "Pch.h" + +#include "DialogDefinition.h" +#include "DialogLayout.h" +#include "DialogPrimitiveRenderer.h" +#include "../Widgets/Button.h" + + +struct ChromeTheme; + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogPrimitive +// +// Themed modal dialog window. Call Show() to display the dialog +// synchronously from the UI thread. Show() creates the window, runs +// its own GetMessage loop (disabling the owner window for the +// duration), and returns the chosen button's resultCode, or -1 when +// the user dismisses with Alt+F4 / WM_CLOSE. +// +//////////////////////////////////////////////////////////////////////////////// + +class DialogPrimitive +{ +public: + DialogPrimitive () = default; + ~DialogPrimitive (); + + HRESULT RegisterClass (HINSTANCE hInstance); + int Show (HWND hwndOwner, + ID3D11Device * device, + ID3D11DeviceContext * context, + const ChromeTheme * theme, + const DialogDefinition & def); + void Close (int chosenId); + +private: + static LRESULT CALLBACK s_WndProc (HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); + + LRESULT WndProc (HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); + + HRESULT OnCreate (HWND hwnd); + void OnDestroy (); + void OnSize (int widthPx, int heightPx); + void OnDpiChanged (UINT dpi, const RECT & suggestedRect); + void OnKeyDown (WPARAM vk); + void OnMouse (UINT message, WPARAM wParam, LPARAM lParam); + void OnClose (); + + void BuildButtons (); + void RecomputeLayout (UINT dpi); + void RenderFrame (); + void ActivateButton (size_t idx); + void ActivateDefaultButton(); + void ActivateCancelButton (); + void CycleFocus (int delta); + bool HitTestHyperlink (int xPx, int yPx, size_t & outBodyRunIdx) const; + void LaunchHyperlink (size_t bodyRunIdx); + + int TitleHeightPx () const; + RECT GetInitialWindowRect (HWND hwndOwner, UINT dpi) const; + size_t DefaultButtonIdx () const; + size_t CancelButtonIdx () const; + + HINSTANCE m_hInstance = nullptr; + HWND m_hwnd = nullptr; + HWND m_hwndOwner = nullptr; + ID3D11Device * m_device = nullptr; // non-owning + ID3D11DeviceContext * m_context = nullptr; // non-owning + const ChromeTheme * m_theme = nullptr; // non-owning + const DialogDefinition * m_def = nullptr; // non-owning + + DialogPrimitiveRenderer m_renderer; + DialogLayoutResult m_layout; + std::vector