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/CHANGELOG.md b/CHANGELOG.md index fb8b0a5c..8f191812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,309 @@ 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.5.1395] — Native dialogs migration (spec 011) + +Themed DX-based modal dialogs now replace every Win32 `MessageBoxW` / +`TaskDialogIndirect` consumer in the app (except the pre-shell EHM +notification fallback in `Main.cpp`). The bootstrap-time prompts, the +in-app Help/About/Keymap/Machine Info dialogs, and the SettingsPanel +ROM-error notification all paint through the new `DialogPrimitive` and +honour the active chrome theme. The `IFileOpenDialog`-based disk picker +is preserved as the lone deliberate Win32 surface. + +### 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). Keyboard: + Enter = default button, Escape = cancel button, Tab/Shift-Tab cycle + focus. Hyperlinks launch via `ShellExecuteW`. +- **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 colours, icon circle (Info / Warning / Error / App), word-wrapped + body text, hyperlink underlines, optional custom-body callback, and + `Button` widgets. DPI-aware: recomputes layout on WM_DPICHANGED. +- **feat(011): DialogDefinition + DialogLayout primitives.** Pure value + types and headless `LayoutDialog` free function; all measurement is + injected so the math is unit-tested without DirectWrite. +- **feat(011): StandaloneDialog wrapper.** Bootstrap-friendly helper + that spins up a transient `D3D11CreateDevice (HARDWARE)` + one-shot + `DialogPrimitive` for callers that fire before `EmulatorShell` exists + (e.g. `AssetBootstrap`'s missing-asset prompts). The caller passes + `GlobalUserPrefs::activeTheme` so startup dialogs honour the user's + persisted `ChromeTheme` choice (`Skeuomorphic` / `DarkModern` / + `RetroTerminal`) instead of always painting Skeuomorphic. +- **feat(011): themed startup prompts.** `AssetBootstrap::PromptUser` + (missing-asset download approval — now with clickable URL hyperlink), + `PromptBootDisk` (DOS 3.3 / ProDOS / Skip), and + `PromptDiskAudioConsent` (download / skip with GPL-3 disclosure) all + paint through `StandaloneDialog`. The legacy `TaskDialogIndirect` + + `MessageBoxW` fallback paths are removed. +- **feat(011): themed in-app dialogs.** `IDM_MACHINE_INFO`, + `IDM_HELP_KEYMAP`, `IDM_HELP_ABOUT` (with the photoreal Cassowary app + icon + clickable GitHub URL) and the SettingsPanel ROM-download + failure notification all route through `EmulatorShell::ShowModalDialog`. +- **feat(011): `DiskMru` helper + `recentDisks` persistence.** + Most-recently-used disk image list (cap = 16, move-to-front dedup, + oldest eviction) round-trips through the new `recentDisks` JSON + array in `GlobalUserPrefs`. Every successful `EmulatorShell::Mount` + pushes the image path onto the MRU and persists. +- **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. Truncation algorithm is a pure binary search, + unit-tested with a deterministic measure stub. +- **refactor(011): single disk-insert file picker.** Legacy + `GetOpenFileNameW` branch removed; both `IDM_DISK_INSERT*` route + through the modern `IFileOpenDialog`-backed `PromptForDiskImage`. +- **feat(011): boot-disk MRU picker (US2).** When no disk is configured + at startup, a themed picker lists every still-present recent disk + image as a clickable row (basename + hover highlight) above + `Download…` / `Skip` footer buttons. Selecting a row mounts that + image; `Download…` falls through to the asset bootstrap; `Skip` / + Esc boots without a disk. Existence-prunes the MRU on show so + deleted files don't reappear. +- **feat(011): DialogPrimitive custom-body input.** `DialogDefinition` + gained an `onInputCustomBody` hook (`std::optional` return = + close-request); `DialogPrimitive` dispatches mouse / keyboard events + through it before its own handling. `DialogPaintContext` now carries + the `DwriteTextRenderer` pointer so custom-body paint callbacks can + render text in addition to geometry. +- **feat(011): Win11 dark-mode pass on debug dialogs (US6/US7).** + `DebugConsole` and `DiskIIDebugDialog` apply + `DWMWA_USE_IMMERSIVE_DARK_MODE`, dark control brushes, the + `DarkMode_Explorer` window theme, and `WM_CTLCOLORSTATIC` overrides so + the developer-only dialogs match the rest of the Win11 dark chrome. + ListView header (`ItemsView` theme) + row colors picked up too; the + Disk II Debug dialog still flags invalid track/sector input in red. +- **chore(011): link uxtheme.lib.** Required for `SetWindowTheme` calls + used by the dark-mode pass; added to all six `AdditionalDependencies` + entries in `Casso.vcxproj`. +- **chore(011): named Unicode constants.** New `s_kchAlmostEqual` + (U+2248) in `UnicodeSymbols.h`; all dialog body strings consume + named constants rather than inline `\xNNNN` escapes. +- **feat(011): DX Disk II Debug Panel (US7, T044-T055, T059).** Brand + new `Disk2DebugPanel` replaces the legacy Win32 `DiskIIDebugDialog` + (-3073 lines). Hosts itself in the shared `ChromedPanelWindow` + chrome shell with the active theme; lays out filter checkboxes, + audio toggles, drive radios, themed track / sector text inputs with + validation feedback, pause / clear buttons, and a sortable virtual + event ListView. Adds shared widget primitives `Checkbox`, `Radio`, + `TextInput` (cursor + selection + clipboard + keyboard nav), and + the `RequiredRowsForHeightPx` helper on `ListView`. All 18 + `IDiskIIEventSink` + `IDriveAudioEventSink` overrides preserved so + the panel slots into the existing EmulatorShell wiring with no + contract change. +- **feat(011): DX Debug Console Panel (US6, T039-T043).** New + `DebugConsolePanel` replaces the legacy `DebugConsole` EDIT-control + window. Themed monospace log body inside the shared chrome shell, + mouse-wheel + PgUp/PgDn/Home/End/arrow scrolling, Ctrl+C copies the + full buffer to the clipboard. Thread-safe `Log` / `LogConfig` + contract preserved verbatim; existing call sites needed no change. +- **feat(011): Disk2DebugPanel column toggle + filter tooltips + (T056–T058).** Right-clicking a `ListView` column header now opens a + themed `PopupMenu` listing every column with its current visibility as + a check; selecting an entry hides or shows the column and re-runs + layout. Hovering any filter control surfaces a themed `Tooltip` (DX + overlay, no Win32 `TOOLTIPS_CLASS`) explaining the control after the + standard dwell delay. Layout pass walked under Skeuomorphic, + DarkModern, and RetroTerminal — every widget family renders without + overlap and the ListView header shows all six columns. +- **chore(011): shared `PopupMenu` widget.** + `Casso/Ui/Widgets/PopupMenu` provides a reusable themed popup with + check glyph, keyboard navigation (arrow keys + Enter / Escape), and + host-rectangle clamping so panels can host context menus without + pulling in Win32 menu APIs. +- **chore(011): chrome shell extracted.** `ChromedPanelWindow` and + `IChromedPanelContent` factored out from the dialog primitives so + both new panels (and any future child window) share NC chrome, + title bar, sys buttons, DPI handling, and input routing without + copy-paste. + +- **feat(011): DebugConsolePanel text selection (T041).** Click-drag + selects a character range; Shift+arrows / Shift+Home/End extend the + selection (Shift+Ctrl+Home/End jump to the buffer extremes), plain + caret-move keys collapse it. Ctrl+A selects the whole buffer; Ctrl+C + copies the current selection to the clipboard as `CF_UNICODETEXT` + (CR/LF between lines), or is a no-op when the selection is empty. + Selection highlight paints under the text using the active theme's + nav-hover colour and tracks the viewport while dragging past the + body edges. +- **feat(011): Disk2DebugPanel per-column sortable header (T055).** + Clicking any ListView header now sorts by that column; clicking the + active column flips ascending / descending. All six columns + (Wall / Uptime / Cycle / Drive / Event / Detail) participate, with + a numeric-aware comparator for the comma-grouped cycle string and + the projection's `EventLabel` for the event column. A ▲ / ▼ glyph + paints in the active sort header. `ListView::HitTestHeaderColumn` + is the new shared hit-test helper. + +### Fixed +- **fix(disk2debug): Z-pattern Tab order, list keyboard nav, selection + preservation.** The Disk II debug panel's Tab focus now follows a + Z-pattern top-to-bottom / left-to-right (Motor through DriveSel, + Audio master + sub-checks, Drive radio, Track/Sector edits, raw QT + checkbox, Pause, Clear) instead of starting on Pause. Past stop 18 + the Tab cycle continues through dynamic per-visible-column stops: + each visible column gets a header stop (Space sorts) and a divider + stop (Left/Right resizes by 8dp); the last dynamic stop is the list + body, where Up/Down/Home/End/PageUp/PageDown move the selected row. + Selection is now persisted by event identity (index into `m_events`) + and remapped via `lower_bound` on every filter/sort/clear rebuild, + snapping to the nearest still-visible neighbour when the selected + event falls outside the current filter. `OnKey` now routes input to + the focused widget only (the old broadcast-to-all path swallowed + arrows for the wrong widgets) and `PushListViewRows` now skips + stale `m_filteredIndices` entries defensively to harden against any + index/deque desync that would have tripped a `vector subscript out + of range` assertion in ARM64 Debug. +- **fix(011): SettingsPanel + dialog body share one themed background.** + The settings popup hardcoded a dark-navy panel fill while the + themed dialog body used `dropdownBgArgb`, so the two surfaces drew + visibly different colours when stacked. New `panelBgArgb` / + `panelEdgeArgb` entries on `ChromeTheme` are now consumed by both + the settings popup and `DialogPrimitiveRenderer`, and pick up the + active theme (Skeuomorphic / Dark / RetroTerminal) instead of the + former blue-only constants. +- **fix(011): mounted disks now follow the user across machine switches.** + Switching machines (e.g. //e → ][+) tore down the old Disk II + controller and brought up a fresh empty one, leaving the previously + mounted image orphaned in `DiskImageStore`. The new machine's boot + ROM then seeked to track 0, found no data, and spun forever. The + switch now snapshots slot-6 source paths before teardown and + re-mounts them on the new controller, matching the user's physical + mental model (the disk stays in the drive). Per-machine saved-disk + prefs are still consulted when no disk is mounted at switch time. + Carry is also guarded by `HasSlot6Controller()` so destinations + without a Disk II (future non-Apple families, or //e → IIgs where + the 3.5" SmartPort lives in slot 5) cleanly drop incompatible media + rather than silently losing it. +- **fix(011): Disk II debug panel polish.** A handful of cosmetic / + ergonomic issues in the new themed Disk II debug panel: + - Tooltips now scale with DPI (font, padding, border, anchor gap) + and measure real text width instead of estimating from a magic + character-width constant. + - Filter checkboxes, radio buttons, and the "Raw qt" toggle were + too narrow at default fonts, wrapping their labels; widened the + per-control slot widths in the layout helper. + - The `Drive` column header in the event list was clipping to + `Driv\ne`; bumped `kColDriveWidth` to fit the bold header glyphs. + - Non-modal panels (Disk II debug, Debug console) no longer pin + themselves above the main window — the new + `IChromedPanelContent::IsNonModal()` hook passes `nullptr` as + the parent HWND so the user can park them behind Casso. + - General-purpose `Button` widgets now use a dedicated themed + palette (`buttonIdle/Hover/Pressed/BorderArgb`) instead of + inheriting the transparent chrome min/max/close colours, and + paint a default 1dip border so a button actually looks like a + button. Chrome titlebar buttons are unaffected (they paint + themselves). + - Renamed the event list's `Wall` column header to `Time`. + - Tooltips are now fully opaque (background alpha bumped from 0xF0 + to 0xFF) so text underneath no longer bleeds through. The Disk II + debug panel additionally flushes both `DxUiPainter` and + `DwriteTextRenderer` between underlying widgets and the + tooltip/column-menu overlays, so opaque overlay backgrounds + actually composite *above* widget text (both renderers batch, so + submission order alone wasn't enough — text from underlying widgets + would otherwise paint on top of the overlay's geometry). + - **Disk II debug panel: scrollable event list + scrollbar.** The + panel's `ListView` now keeps its full filtered history rather than + truncating to whatever fits in the slot, and paints a vertical + scrollbar at the right edge whenever the row count exceeds the + visible capacity. Mouse-wheel / trackpad scrolling is wired + through `IChromedPanelContent::OnMouseWheel`. Sticky-tail behaviour + keeps the latest events in view when parked at the bottom; once the + user scrolls back, new events accumulate without yanking the view. + - **Disk II debug panel: keyboard focus + Tab navigation.** Tab / + Shift+Tab cycle focus through the 19 focusable stops (Pause, Clear, + 8 event-type checkboxes, audio master + 4 sub-checks, raw-Qt check, + drive radio group, track edit, sector edit). Mouse-clicks on any + widget also acquire focus. The existing per-widget focus rings + finally light up; Enter / Space activate buttons, Space toggles + checkboxes, arrows cycle radios — all without touching the mouse. + - Non-modal panels (Disk II debug, Debug console) now get a taskbar + button via `WS_EX_APPWINDOW` and drop `WS_EX_TOOLWINDOW`, so they + re-appear in Alt+Tab and can be raised even when fully occluded + by Casso. + - Event list column headers are now user-resizable: faint vertical + separators mark each column edge, the cursor switches to + `IDC_SIZEWE` over the right-edge handle, and click-drag adjusts + the column width via `ListView::SetColumnOverrideWidthPx`. + Plumbed through a new `IChromedPanelContent::OnSetCursor` hook + that `ChromedPanelWindow::WndProc` routes from `WM_SETCURSOR`. + - "Invalid" feedback under the track / sector filters now restores + the rejected-token detail (e.g. `Invalid track: foo, 99`) instead + of the bare `Invalid` placeholder, slicing the original spans out + of the edit's text via `TrackSectorPredicate::RejectedSpans()`. + - The drive-filter radio row gets a `Drive:` label so the three + radios aren't anonymous. New layout slot + `Label m_driveFilterLabel`. + - `All` is now the default selected drive filter again: the bug was + that `ConfigureWidgets` called `SetSelected(0)` before + `LayoutWidgets` had populated `SetOptions`, so the out-of-range + clamp silently fell back to -1. `LayoutWidgets` now re-applies + `SetSelected (m_filter.driveFilter)` after `SetOptions`. + - Right-aligned column headers no longer underlap the sort triangle. + `ListView::Paint` reserves the sort-glyph width plus a small gap + on the right edge of the title's draw rectangle on the sorted + column, so right-aligned titles shift left and the triangle has + its own clear lane. + - `TextInput` no longer wraps when text exceeds the visible width. + `DwriteTextRenderer::DrawString` gained an optional `wrap` flag + that toggles `DWRITE_WORD_WRAPPING_NO_WRAP`, and the renderer + exposes `PushClipRect`/`PopClipRect` so a widget can clip text + to its inner rect. `TextInput::Paint` measures the caret pixel + position, slides a per-widget `m_scrollPx` offset so the caret + stays in view (and trailing edge doesn't leave dead space), and + pushes a clip rect across the inner area. `CaretFromX` adjusts + by `m_scrollPx` so mouse clicks still hit the right character + once the text has scrolled. + - `ListView` scrollbar thumb is now draggable with live scrolling. + New `HitTestScrollbarThumb`/`HitTestScrollbarTrack` and + `BeginThumbDrag`/`UpdateThumbDrag`/`EndThumbDrag` on the widget; + the Disk II debug panel routes `WM_LBUTTONDOWN` / `WM_MOUSEMOVE` + / `WM_LBUTTONUP` through them (with capture) ahead of other + hit-tests. Track clicks above / below the thumb page-scroll by + one visible-row capacity. + - Disk II debug panel: the event-type and audio-event checkbox + rows now have leading `Disk events:` / `Audio events:` labels + (same width on both rows, so the checkbox columns line up + vertically). The audio master checkbox is relabelled `All` + (the row label carries the "audio" word now), and the four + sub-checkboxes are now disabled when `All` is unchecked. + +### Deferred +- None — all spec 011 tasks shipped. + +### Removed +- **chore(debug): remove DX Debug Console panel.** The themed Debug + Console (Help → Debug, Ctrl+D) shipped earlier in this spec turned + out to carry no signal worth keeping (DEBUGMSG output is already + visible in the VS debugger and machine-config dumps duplicate what + the Hardware Info dialog shows). Removed `DebugConsolePanel.{h,cpp}`, + `IDM_HELP_DEBUG`, the Ctrl+D accelerator, the Help menu entry, the + keymap dialog line, and the per-frame render hook. + +### Fixed (post-merge polish) +- **fix(disk2debug): allow reopening after the close box.** Clicking + the title-bar X destroyed the panel's HWND but left the owning + `unique_ptr` in `EmulatorShell` non-null with a stale handle. The + next menu / Ctrl+Shift+D click hit the cached pointer's `Show()` + on a dead HWND and silently did nothing. The open path now treats + `Hwnd() == nullptr` the same as `panel == nullptr` and reconstructs. + +### 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. Plus `Disk2DebugPanelLayoutTests` (10) covering the new + layout slots. Total suite: 1653/1653 passing. + ## [1.5.1289] — Copy-protected games boot This release celebrates a milestone: Casso now boots original, @@ -65,7 +368,6 @@ software, and bumps Casso to **1.5**. a `$C0Ex` access occurs rather than the end-of-instruction rollup. - ## [1.4.1260] — Drive widget interaction + disk persistence fix ### Changed diff --git a/Casso/AssetBootstrap.cpp b/Casso/AssetBootstrap.cpp index b96041ad..a1ffaeff 100644 --- a/Casso/AssetBootstrap.cpp +++ b/Casso/AssetBootstrap.cpp @@ -10,6 +10,12 @@ #include "External/StbVorbisWrapper.h" #include "resource.h" #include "Ui/ThemeManager.h" +#include "Ui/Chrome/ChromeTheme.h" +#include "Ui/DxUiPainter.h" +#include "Ui/DwriteTextRenderer.h" +#include "Ui/Dialog/StandaloneDialog.h" +#include "Ui/Dialog/StartupDownloadDialog.h" +#include "Ui/Widgets/ListView.h" #include "UnicodeSymbols.h" #pragma comment(lib, "winhttp.lib") @@ -129,6 +135,19 @@ static constexpr BootDiskSpec s_kProDOSDisk = +static std::wstring MachineDisplayName (std::string_view machineId) +{ + if (machineId == "Apple2") return L"Apple ]["; + if (machineId == "Apple2Plus") return L"Apple ][+"; + if (machineId == "Apple2e") return L"Apple //e"; + if (machineId == "Apple2eEnhanced") return L"Apple //e Enhanced"; + return std::wstring (machineId.begin (), machineId.end ()); +} + + + + + //////////////////////////////////////////////////////////////////////////////// // // EmbeddedConfig @@ -894,13 +913,15 @@ static const RomSpec * FindRomSpec (string_view machineName, string_view cassoNa //////////////////////////////////////////////////////////////////////////////// static HRESULT DownloadHttp ( - HINTERNET hSession, - LPCWSTR host, - LPCWSTR urlPath, - size_t expectedSize, - string_view displayName, - vector & outBytes, - string & outError) + HINTERNET hSession, + LPCWSTR host, + LPCWSTR urlPath, + size_t expectedSize, + string_view displayName, + vector & outBytes, + string & outError, + std::atomic * progressBytes = nullptr, + std::atomic * cancelRequested = nullptr) { HRESULT hr = S_OK; HINTERNET hConnect = nullptr; @@ -917,6 +938,11 @@ static HRESULT DownloadHttp ( outBytes.clear(); outBytes.reserve (expectedSize); + if (progressBytes != nullptr) + { + progressBytes->store (0, std::memory_order_relaxed); + } + for (LPCWSTR p = host; *p; p++) { narrowHost.push_back (static_cast (*p & 0x7F)); @@ -963,6 +989,13 @@ static HRESULT DownloadHttp ( { vector chunk; + if (cancelRequested != nullptr && cancelRequested->load (std::memory_order_relaxed)) + { + outError = format ("{} cancelled", displayName); + hr = E_ABORT; + goto Error; + } + bytesAvail = 0; fOk = WinHttpQueryDataAvailable (hRequest, &bytesAvail); CBRF (fOk, @@ -985,6 +1018,11 @@ static HRESULT DownloadHttp ( } outBytes.insert (outBytes.end(), chunk.begin(), chunk.begin() + bytesRead); + + if (progressBytes != nullptr) + { + progressBytes->store ((std::uint64_t) outBytes.size(), std::memory_order_relaxed); + } } // A non-zero `expectedSize` is treated as an integrity check @@ -1059,41 +1097,153 @@ static HRESULT DownloadOne ( // //////////////////////////////////////////////////////////////////////////////// -static bool PromptUser (HWND hwndParent, const vector & missing) +static constexpr int s_kRomRowHeightDp = 26; +static constexpr int s_kRomRowGapDp = 2; +static constexpr int s_kRomBodyWidthDp = 520; +static constexpr int s_kRomNameColumnDp = 180; +static constexpr int s_kRomColumnGapDp = 16; +static constexpr float s_kRomFontDp = 13.0f; +static constexpr float s_kRomTextPaddingDp = 6.0f; + +static bool PromptUser (HINSTANCE hInstance, HWND hwndParent, std::string_view themeName, const vector & missing) { - wstring message; - wstring title; - int response = 0; + struct RomRow + { + wstring name; + wstring description; + }; + DialogDefinition def = {}; + wstring title; + wstring intro; + vector rows; + int response = 0; + int rowCount = (int) missing.size(); + UINT sysDpi = (hwndParent != nullptr) ? GetDpiForWindow (hwndParent) + : GetDpiForSystem(); + float dpiScale = (sysDpi > 0) ? ((float) sysDpi / 96.0f) : 1.0f; + int totalH = 0; - message = L"Casso needs the following Apple ROM image(s):\n\n"; + rows.reserve (missing.size()); for (const RomSpec * spec : missing) { - message += L" "; - message += s_kchBullet; - message += L' '; - message += AsciiToWide (spec->cassoName); - message += L" "; - message += s_kchEmDash; - message += L" "; - message += AsciiToWide (spec->description); - message += L'\n'; + RomRow row; + + row.name = AsciiToWide (spec->cassoName); + row.description = AsciiToWide (spec->description); + rows.push_back (std::move (row)); } - message += L"\nThese files are not bundled with Casso but are available from the " - L"AppleWin open-source emulator project (https://github.com/AppleWin/AppleWin)." - L"\n\nWould you like to download them now? "; + intro = L"Casso needs the Apple //e ROMs listed below to boot. "; + intro += L"They're not bundled but can be downloaded from AppleWin at "; title = L"Casso "; title += s_kchEmDash; title += L" Download ROM Images"; - response = MessageBoxW (hwndParent, - message.c_str(), - title.c_str(), - MB_YESNO | MB_ICONQUESTION); + totalH = (int) ((float) ((rowCount + 1) * s_kRomRowHeightDp + + rowCount * s_kRomRowGapDp) + * dpiScale); + + def.title = title; + def.icon = DialogIcon::AppFlat; + def.iconSizeOverrideDp = 64.0f; + def.body.push_back ({ intro, false, L"" }); + def.body.push_back ({ L"https://github.com/AppleWin/AppleWin", + true, L"https://github.com/AppleWin/AppleWin" }); + + def.customBodyMinSizePx.cx = (int) ((float) s_kRomBodyWidthDp * dpiScale); + def.customBodyMinSizePx.cy = totalH; + + def.onPaintCustomBody = [rows] (DialogPaintContext & ctx) + { + HRESULT hr = S_OK; + float x = 0.0f; + float y = 0.0f; + float rowH = (float) s_kRomRowHeightDp * ctx.dpiScale; + float gap = (float) s_kRomRowGapDp * ctx.dpiScale; + float fontPx = s_kRomFontDp * ctx.dpiScale; + float nameW = (float) s_kRomNameColumnDp * ctx.dpiScale; + float pad = s_kRomTextPaddingDp * ctx.dpiScale; + float colGap = (float) s_kRomColumnGapDp * ctx.dpiScale; + float descX = 0.0f; + float descW = 0.0f; + uint32_t bg = 0; + uint32_t fg = 0; + uint32_t band = 0; + size_t i = 0; + + + + if (ctx.painter == nullptr || ctx.text == nullptr || ctx.theme == nullptr) + { + return; + } + + x = (float) ctx.customBodyRect.left; + y = (float) ctx.customBodyRect.top; + bg = ctx.theme->dropdownBgArgb; + fg = ctx.theme->dropdownItemTextArgb; + band = ctx.theme->navStripArgb; + descX = x + nameW + colGap; + descW = (float) (ctx.customBodyRect.right - (LONG) descX); + + { + float fullW = (float) (ctx.customBodyRect.right - ctx.customBodyRect.left); + uint32_t headerBg = ctx.theme->navHoverArgb; + uint32_t headerFg = ctx.theme->titleTextArgb; + + ctx.painter->FillRect (x, y, fullW, rowH, headerBg); + + IGNORE_RETURN_VALUE (hr, ctx.text->DrawString (L"ROM file", + x + pad, y, + nameW, rowH, + headerFg, fontPx, L"Segoe UI", + DwriteTextRenderer::HAlign::Left, + DwriteTextRenderer::VAlign::Center, + DWRITE_FONT_WEIGHT_BOLD)); + + IGNORE_RETURN_VALUE (hr, ctx.text->DrawString (L"Description", + descX, y, + descW, rowH, + headerFg, fontPx, L"Segoe UI", + DwriteTextRenderer::HAlign::Left, + DwriteTextRenderer::VAlign::Center, + DWRITE_FONT_WEIGHT_BOLD)); + + y += rowH + gap; + } + + for (i = 0; i < rows.size(); i++) + { + float ry = y + (float) i * (rowH + gap); + uint32_t rowBg = ((i & 1u) == 0u) ? band : bg; + float fullW = (float) (ctx.customBodyRect.right - ctx.customBodyRect.left); + + ctx.painter->FillRect (x, ry, fullW, rowH, rowBg); + + IGNORE_RETURN_VALUE (hr, ctx.text->DrawString (rows[i].name.c_str(), + x + pad, ry, + nameW, rowH, + fg, fontPx, L"Segoe UI", + DwriteTextRenderer::HAlign::Left, + DwriteTextRenderer::VAlign::Center)); + + IGNORE_RETURN_VALUE (hr, ctx.text->DrawString (rows[i].description.c_str(), + descX, ry, + descW, rowH, + fg, fontPx, L"Segoe UI", + DwriteTextRenderer::HAlign::Left, + DwriteTextRenderer::VAlign::Center)); + } + }; + + def.buttons.push_back ({ L"Download", IDYES, true, false }); + def.buttons.push_back ({ L"Cancel", IDNO, false, true }); + + response = ShowStandaloneDialog (hInstance, hwndParent, themeName, def); return response == IDYES; } @@ -1314,6 +1464,7 @@ HRESULT AssetBootstrap::CheckAndFetchRoms ( HWND hwndParent, const vector & searchPaths, const fs::path & assetBaseDir, + std::string_view themeName, string & outError) { HRESULT hr = S_OK; @@ -1360,7 +1511,7 @@ HRESULT AssetBootstrap::CheckAndFetchRoms ( BAIL_OUT_IF (missing.empty(), S_OK); - userOk = PromptUser (hwndParent, missing); + userOk = PromptUser (hInstance, hwndParent, themeName, missing); BAIL_OUT_IF (!userOk, S_FALSE); hSession = WinHttpOpen (s_kpszUserAgent, @@ -1446,103 +1597,69 @@ static wstring GetEmbeddedDisplayName (HINSTANCE hInstance, const wstring & mach //////////////////////////////////////////////////////////////////////////////// // -// PromptBootDisk +// DownloadStockBootDisk // -// Three-button TaskDialog: DOS 3.3 / ProDOS / Cancel. Returns the -// chosen disk spec, or nullptr if the user cancelled. Falls back to -// a Yes/No/Cancel MessageBox if comctl32 v6's TaskDialogIndirect -// isn't available. +// Pure download helper used by PromptBootDiskMru's "Download..." rows. +// Fetches `spec` from the Asimov mirror, writes it under `diskDir`, +// and returns the absolute path in `outDiskPath`. No UI; the caller +// owns the prompt and progress reporting. // //////////////////////////////////////////////////////////////////////////////// -static constexpr int s_kIdDos33 = 1001; -static constexpr int s_kIdProDOS = 1002; -static constexpr int s_kIdSkip = IDCANCEL; - - -//////////////////////////////////////////////////////////////////////////////// -// -// PromptBootDisk -// -//////////////////////////////////////////////////////////////////////////////// - -static const BootDiskSpec * PromptBootDisk (HWND hwndParent, const wstring & displayName) +static HRESULT DownloadStockBootDisk ( + const BootDiskSpec & spec, + const fs::path & diskDir, + wstring & outDiskPath, + string & outError) { - HRESULT hr = S_OK; - int chosen = 0; - wstring body; - wstring title; - TASKDIALOGCONFIG cfg = { sizeof (TASKDIALOGCONFIG) }; - TASKDIALOG_BUTTON buttons[2] = {}; - const BootDiskSpec * result = nullptr; - - - - body = L"The "; - body += displayName; - body += L" has a Disk ][ controller in slot 6 but no disk in drive 1, " - L"and will spin forever waiting for one. A system master disk " - L"is available from the Asimov archive " - L"(https://www.apple.asimov.net).\n\n" - L"Alternatives:\n" - L" "; - body += s_kchBullet; - body += L" Skip and use Disk > Insert Drive 1... (Ctrl+1) to " - L"mount your own .dsk.\n" - L" "; - body += s_kchBullet; - body += L" Skip and press Ctrl+Reset once the drive starts " - L"spinning to drop to BASIC.\n\n" - L"Which disk would you like to download? "; + HRESULT hr = S_OK; + HINTERNET hSession = nullptr; + fs::path destPath; + vector payload; + error_code ec; - title = L"Casso "; - title += s_kchEmDash; - title += L" Boot Disk"; - buttons[0].nButtonID = s_kIdDos33; - buttons[0].pszButtonText = L"DOS 3.3 System Master\n" - L"Boots Applesoft BASIC; type CATALOG to list files."; - buttons[1].nButtonID = s_kIdProDOS; - buttons[1].pszButtonText = L"ProDOS Users Disk\n" - L"Boots ProDOS 8 with the BASIC.SYSTEM shell."; - - cfg.hwndParent = hwndParent; - cfg.dwFlags = TDF_USE_COMMAND_LINKS | TDF_ALLOW_DIALOG_CANCELLATION; - cfg.pszWindowTitle = title.c_str(); - cfg.pszMainIcon = TD_INFORMATION_ICON; - cfg.pszMainInstruction = L"No boot disk mounted"; - cfg.pszContent = body.c_str(); - cfg.cButtons = ARRAYSIZE (buttons); - cfg.pButtons = buttons; - cfg.dwCommonButtons = TDCBF_CANCEL_BUTTON; - cfg.nDefaultButton = s_kIdDos33; - - hr = TaskDialogIndirect (&cfg, &chosen, nullptr, nullptr); + outDiskPath.clear(); - if (FAILED (hr)) - { - // TaskDialog unavailable for some reason — fall back to a - // simpler MessageBox prompt: Yes=DOS3.3, No=ProDOS, Cancel=Skip. - chosen = MessageBoxW (hwndParent, - (body + L"\n\nYes = DOS 3.3, No = ProDOS, Cancel = Skip").c_str(), - title.c_str(), - MB_YESNOCANCEL | MB_ICONQUESTION); - - if (chosen == IDYES) chosen = s_kIdDos33; - else if (chosen == IDNO) chosen = s_kIdProDOS; - else chosen = s_kIdSkip; - } + fs::create_directories (diskDir, ec); + destPath = diskDir / spec.cassoName; - if (chosen == s_kIdDos33) + if (fs::exists (destPath, ec)) { - result = &s_kDos33Disk; + outDiskPath = destPath.wstring(); + BAIL_OUT_IF (true, S_OK); } - else if (chosen == s_kIdProDOS) + + hSession = WinHttpOpen (s_kpszUserAgent, + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + CBRF (hSession != nullptr, + outError = "Cannot initialize WinHTTP session"); + + hr = DownloadHttp (hSession, + s_kpszAsimovHost, + spec.asimovUrlPath, + spec.expectedSize, + spec.shortLabel, + payload, + outError); + CHR (hr); + + hr = WriteFileBytes (destPath, payload); + CHRF (hr, + outError = format ("Cannot write {}", destPath.string())); + + outDiskPath = destPath.wstring(); + +Error: + if (hSession != nullptr) { - result = &s_kProDOSDisk; + WinHttpCloseHandle (hSession); } - return result; + return hr; } @@ -1551,34 +1668,57 @@ static const BootDiskSpec * PromptBootDisk (HWND hwndParent, const wstring & dis //////////////////////////////////////////////////////////////////////////////// // -// OfferBootDiskDownload +// PromptBootDiskMru // -// When invoked for a machine with a Disk ][ controller and no disk -// has been resolved yet, prompts the user to download a stock Apple -// master disk (DOS 3.3 / ProDOS) from the Asimov mirror, drops it -// into `diskDir`, and returns its absolute path in `outDiskPath`. -// Returns S_FALSE (and `outDiskPath` empty) when: -// * the machine config has no Disk ][ controller, or -// * the user declined the download. +// Themed dialog that lists the user's recent disk images plus stock +// "Download" rows for DOS 3.3 / ProDOS. Always shown when the machine +// has a Disk ][ controller and no boot disk has been resolved yet, +// even when the MRU is empty (the download rows give a fresh install +// somewhere to go). Picking a row mounts that image (downloading on +// demand for the stock rows); the Skip button leaves the slot empty. +// +// On return: +// outDiskPath = path to mount, or empty if the user skipped / the +// machine has no Disk ][ controller. // //////////////////////////////////////////////////////////////////////////////// -HRESULT AssetBootstrap::OfferBootDiskDownload ( - HINSTANCE hInstance, - const wstring & machineName, - HWND hwndParent, - const fs::path & diskDir, - wstring & outDiskPath, - string & outError) +static constexpr int s_kBootMruBodyWidthDp = 520; + +HRESULT AssetBootstrap::PromptBootDiskMru ( + HINSTANCE hInstance, + HWND hwndParent, + const wstring & machineName, + const vector & mruEntries, + const fs::path & diskDir, + std::string_view themeName, + wstring & outDiskPath, + bool & outUserClosed, + string & outError) { - HRESULT hr = S_OK; - bool hasDisk = false; - const BootDiskSpec * choice = nullptr; - HINTERNET hSession = nullptr; - fs::path destPath; - vector payload; - error_code ec; + struct DownloadRow { const BootDiskSpec * spec; wstring label; }; + static constexpr int s_kCloseBoxResult = -1000; + + HRESULT hr = S_OK; + bool hasDisk = false; + DialogDefinition def = {}; + wstring title; + wstring intro; + wstring displayName; + DownloadRow downloads[] = + { + { &s_kDos33Disk, L"DOS 3.3" }, + { &s_kProDOSDisk, L"ProDOS" } + }; + int chosen = IDCANCEL; + int mruCount = (int) mruEntries.size(); + int downloadCount = (int) std::size (downloads); + int rowCount = mruCount + downloadCount; + UINT sysDpi = (hwndParent != nullptr) ? GetDpiForWindow (hwndParent) + : GetDpiForSystem(); + ListView list; + error_code ec; outDiskPath.clear(); @@ -1586,51 +1726,130 @@ HRESULT AssetBootstrap::OfferBootDiskDownload ( hr = HasDiskController (hInstance, machineName, hasDisk, outError); CHR (hr); - BAIL_OUT_IF (!hasDisk, S_FALSE); + BAIL_OUT_IF (!hasDisk, S_OK); - choice = PromptBootDisk (hwndParent, GetEmbeddedDisplayName (hInstance, machineName)); - BAIL_OUT_IF (choice == nullptr, S_FALSE); + displayName = GetEmbeddedDisplayName (hInstance, machineName); - fs::create_directories (diskDir, ec); - destPath = diskDir / choice->cassoName; + title = L"Casso "; + title += s_kchEmDash; + title += L" Boot Disk"; - // If the user already has the disk on disk (e.g. left over from a - // prior session), skip the download. - if (fs::exists (destPath, ec)) + if (mruCount > 0) { - outDiskPath = destPath.wstring(); - BAIL_OUT_IF (true, S_OK); + intro = L"Choose a recent disk for "; + intro += displayName; + intro += L", or download a stock master from the Asimov archive."; + } + else + { + intro = displayName; + intro += L" has a Disk ][ controller but no boot disk. Pick a " + L"stock master from the Asimov archive to get started."; } - hSession = WinHttpOpen (s_kpszUserAgent, - WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, - WINHTTP_NO_PROXY_NAME, - WINHTTP_NO_PROXY_BYPASS, - 0); - CBRF (hSession != nullptr, - outError = "Cannot initialize WinHTTP session"); + // Populate the list. Mru rows show " | "; + // download rows show " | Asimov archive (Download)". + { + std::vector cols; + std::vector> rows; - hr = DownloadHttp (hSession, - s_kpszAsimovHost, - choice->asimovUrlPath, - choice->expectedSize, - choice->shortLabel, - payload, - outError); - CHR (hr); + cols.push_back ({ L"Disk image", 0, false, DwriteTextRenderer::HAlign::Left }); + cols.push_back ({ L"Location", 0, false, DwriteTextRenderer::HAlign::Left }); - hr = WriteFileBytes (destPath, payload); - CHRF (hr, - outError = format ("Cannot write {}", destPath.string())); + rows.reserve ((size_t) rowCount); - outDiskPath = destPath.wstring(); + for (const auto & p : mruEntries) + { + ListView::Cell name { p.filename().wstring(), false }; + ListView::Cell loc { p.parent_path().wstring(), true }; + rows.push_back ({ std::move (name), std::move (loc) }); + } -Error: - if (hSession != nullptr) + for (const DownloadRow & dr : downloads) + { + fs::path wantPath = diskDir / string (dr.spec->cassoName); + bool present = fs::exists (wantPath, ec); + ListView::Cell name { dr.label, false }; + ListView::Cell loc { present ? wantPath.parent_path().wstring() + : L"Asimov archive (Download)", + true }; + rows.push_back ({ std::move (name), std::move (loc) }); + } + + list.SetDpi (sysDpi); + list.SetShowHeader (true); + list.SetColumns (std::move (cols)); + list.SetRows (std::move (rows)); + } + + def.title = title; + def.icon = DialogIcon::AppFlat; + def.iconSizeOverrideDp = 64.0f; + def.body.push_back ({ intro, false, L"" }); + def.customBodyMinSizePx.cx = MulDiv (s_kBootMruBodyWidthDp, (int) sysDpi, 96); + def.customBodyMinSizePx.cy = list.RequiredHeightPx(); + + def.onMeasureCustomBody = [&list, sysDpi] (DwriteTextRenderer & text, float /*dpiScale*/) -> SIZE { - WinHttpCloseHandle (hSession); + list.SetDpi (sysDpi); + list.MeasureColumnsPx (text); + SIZE sz {}; + sz.cx = list.TotalMeasuredWidthPx(); + sz.cy = list.RequiredHeightPx(); + return sz; + }; + + def.onPaintCustomBody = [&list] (DialogPaintContext & ctx) + { + if (ctx.painter == nullptr || ctx.text == nullptr) + { + return; + } + + list.SetTheme (ctx.theme); + list.SetRect (ctx.customBodyRect); + list.Paint (*ctx.painter, *ctx.text); + }; + + def.onInputCustomBody = [&list] (const DialogInputEvent & ev) -> std::optional + { + int idx = list.HitTestRow (ev.xPx, ev.yPx); + + if (ev.kind == DialogInputEvent::Kind::MouseMove) + { + list.SetHoveredRow (idx); + return std::nullopt; + } + + if (ev.kind == DialogInputEvent::Kind::LeftButtonUp && idx >= 0) + { + return idx; + } + + return std::nullopt; + }; + + def.buttons.push_back ({ L"Skip", IDCANCEL, true, true }); + def.closeBoxResult = s_kCloseBoxResult; + + chosen = ShowStandaloneDialog (hInstance, hwndParent, themeName, def); + + if (chosen == s_kCloseBoxResult) + { + outUserClosed = true; + } + else if (chosen >= 0 && chosen < mruCount) + { + outDiskPath = mruEntries[(size_t) chosen].wstring(); + } + else if (chosen >= mruCount && chosen < rowCount) + { + const BootDiskSpec & spec = *downloads[chosen - mruCount].spec; + hr = DownloadStockBootDisk (spec, diskDir, outDiskPath, outError); + CHR (hr); } +Error: return hr; } @@ -1652,11 +1871,13 @@ HRESULT AssetBootstrap::OfferBootDiskDownload ( //////////////////////////////////////////////////////////////////////////////// HRESULT AssetBootstrap::FetchAndDecodeOgg ( - HINTERNET hSession, - LPCWSTR urlPath, - uint32_t targetSampleRate, - vector & outPcm, - string & outError) + HINTERNET hSession, + LPCWSTR urlPath, + uint32_t targetSampleRate, + vector & outPcm, + string & outError, + std::atomic * progressBytes, + std::atomic * cancelRequested) { HRESULT hr = S_OK; vector oggBytes; @@ -1699,7 +1920,9 @@ HRESULT AssetBootstrap::FetchAndDecodeOgg ( 0, // 0 == any size acceptable narrowName, oggBytes, - outError); + outError, + progressBytes, + cancelRequested); CHR (hr); hr = StbVorbisWrapper::DecodeOggToInterleavedShort ( @@ -1904,43 +2127,36 @@ static constexpr int s_kIdDiskAudioSkip = IDCANCEL; // //////////////////////////////////////////////////////////////////////////////// -static int PromptDiskAudioConsent (HWND hwndParent) +static int PromptDiskAudioConsent (HINSTANCE hInstance, HWND hwndParent, std::string_view themeName) { - int chosen = s_kIdDiskAudioSkip; - TASKDIALOGCONFIG tdc = {}; - TASKDIALOG_BUTTON buttons[2] = {}; - HRESULT hr = S_OK; - int result = 0; - - LPCWSTR content = - L"Casso can download a small set of Disk II drive-noise samples " - L"(\x2248 100 KB) from the OpenEmulator project to power the in-emulator " - L"drive-audio feature. The samples will be cached on this machine.\n\n" - L"The samples are licensed under GPL-3; please review their license " - L"before redistributing them."; - - buttons[0].nButtonID = s_kIdDiskAudioDownload; - buttons[0].pszButtonText = L"Download\nFetch the samples and cache them locally."; - buttons[1].nButtonID = s_kIdDiskAudioSkip; - buttons[1].pszButtonText = L"Skip\nLaunch without drive audio."; - - tdc.cbSize = sizeof (tdc); - tdc.hwndParent = hwndParent; - tdc.hInstance = nullptr; - tdc.dwFlags = TDF_USE_COMMAND_LINKS | TDF_ALLOW_DIALOG_CANCELLATION; - tdc.pszWindowTitle = L"Casso \x2014 Drive audio samples"; - tdc.pszMainIcon = TD_INFORMATION_ICON; - tdc.pszMainInstruction = L"Download Disk II audio samples?"; - tdc.pszContent = content; - tdc.cButtons = ARRAYSIZE (buttons); - tdc.pButtons = buttons; - tdc.nDefaultButton = s_kIdDiskAudioDownload; - - hr = TaskDialogIndirect (&tdc, &result, nullptr, nullptr); + int chosen = s_kIdDiskAudioSkip; + DialogDefinition def = {}; + wstring title; + wstring content; - if (SUCCEEDED (hr)) + + + content = L"Casso can download a small set of Disk II drive-noise samples ("; + content += s_kchAlmostEqual; + content += L" 100 KB) from the OpenEmulator project to power the in-emulator " + L"drive-audio feature. The samples will be cached on this machine.\n\n" + L"The samples are licensed under GPL-3; please review their license " + L"before redistributing them."; + + title = L"Casso "; + title += s_kchEmDash; + title += L" Drive audio samples"; + + def.title = title; + def.icon = DialogIcon::Info; + def.body.push_back ({ content, false, L"" }); + def.buttons.push_back ({ L"Download", s_kIdDiskAudioDownload, true, false }); + def.buttons.push_back ({ L"Skip", s_kIdDiskAudioSkip, false, true }); + + chosen = ShowStandaloneDialog (hInstance, hwndParent, themeName, def); + if (chosen != s_kIdDiskAudioDownload && chosen != s_kIdDiskAudioSkip) { - chosen = result; + chosen = s_kIdDiskAudioSkip; } return chosen; @@ -2046,7 +2262,7 @@ HRESULT AssetBootstrap::CheckAndFetchDiskAudio ( else { // "ask" or any unknown value -- prompt now. - consent = PromptDiskAudioConsent (hwndParent); + consent = PromptDiskAudioConsent (hInstance, hwndParent, prefs.activeTheme); prefs.audioDownloadConsent = (consent == s_kIdDiskAudioDownload) ? std::string ("allow") : std::string ("decline"); @@ -2132,3 +2348,398 @@ HRESULT AssetBootstrap::CheckAndFetchDiskAudio ( return hr; } + + +//////////////////////////////////////////////////////////////////////////////// +// +// RunStartupDownloader +// +// Unified entry point: scans for every missing ROM and every missing +// Disk II drive-audio WAV, then presents a single themed dialog that +// downloads them on a worker thread with live per-asset progress. +// Replaces the legacy PromptUser (ROMs) + PromptDiskAudioConsent +// (audio) flows with one transparent experience. +// +//////////////////////////////////////////////////////////////////////////////// + +HRESULT AssetBootstrap::RunStartupDownloader ( + HINSTANCE hInstance, + const wstring & machineName, + HWND hwndParent, + const vector & searchPaths, + const fs::path & assetBaseDir, + bool considerDiskAudio, + bool offerBootDisk, + const fs::path & diskDir, + GlobalUserPrefs & prefs, + wstring & outBootDiskPath, + string & outError) +{ + HRESULT hr = S_OK; + StartupDownloadSet set; + StartupDownloadResult result = StartupDownloadResult::NothingToDo; + vector romFiles; + string narrowMachine; + fs::path devicesDir = assetBaseDir / "Devices" / "DiskII"; + bool audioIncluded = false; + error_code ec; + + UNREFERENCED_PARAMETER (hInstance); + + outBootDiskPath.clear(); + + narrowMachine.reserve (machineName.size ()); + + for (wchar_t wch : machineName) + { + narrowMachine.push_back (static_cast (wch & 0x7F)); + } + + hr = GetRequiredRoms (hInstance, machineName, romFiles, outError); + CHR (hr); + + for (const string & romFile : romFiles) + { + const RomSpec * spec = FindRomSpec (narrowMachine, romFile); + fs::path relPath; + fs::path found; + StartupAssetEntry entry; + + CBRF (spec != nullptr, + outError = format ("ROM '{}' is missing and Casso has no download " + "URL for it. Place the file under {} and try again.", + romFile, assetBaseDir.string ())); + + relPath = fs::path (string (spec->localRelDir)) / spec->cassoName; + found = PathResolver::FindFile (searchPaths, relPath); + + if (!found.empty ()) + { + continue; + } + + entry.kind = StartupAssetKind::Rom; + entry.groupLabel = MachineDisplayName (narrowMachine) + L" ROMs"; + entry.displayName = AsciiToWide (spec->description); + entry.kindLabel = L"ROM"; + entry.source = L"AppleWin (GitHub)"; + entry.selectable = false; + entry.selected = true; + entry.destPaths.push_back (assetBaseDir / string (spec->localRelDir) / spec->cassoName); + entry.expectedBytes = (std::uint64_t) spec->expectedSize; + entry.downloadFn = [spec, destPath = entry.destPaths.front()] ( + std::atomic & bytesDone, + std::atomic & cancel, + std::string & err) -> HRESULT + { + HRESULT hr = S_OK; + HINTERNET hSes = nullptr; + vector payload; + error_code ecLocal; + wstring wPath = wstring (s_kpszUrlPrefix) + AsciiToWide (spec->appleWinName); + + hSes = WinHttpOpen (s_kpszUserAgent, + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + CBRF (hSes != nullptr, err = "Cannot initialize WinHTTP session"); + + hr = DownloadHttp (hSes, + s_kpszAppleWinHost, + wPath.c_str (), + spec->expectedSize, + spec->cassoName, + payload, + err, + &bytesDone, + &cancel); + CHR (hr); + + fs::create_directories (destPath.parent_path (), ecLocal); + hr = WriteFileBytes (destPath, payload); + CHRF (hr, err = format ("Cannot write {}", destPath.string ())); + + Error: + if (hSes != nullptr) + { + WinHttpCloseHandle (hSes); + } + return hr; + }; + + set.entries.push_back (std::move (entry)); + } + + if (considerDiskAudio && prefs.audioDownloadConsent != "decline") + { + for (string_view mechanism : s_kDiskAudioMechanisms) + { + StartupAssetEntry entry; + string mechStr (mechanism); + wstring mechW (mechanism.begin (), mechanism.end ()); + size_t missingCount = 0; + + for (const DiskAudioSpec & spec : s_kDiskAudioCatalog) + { + fs::path mechDir = devicesDir / string (spec.mechanism); + fs::path wavPath = mechDir / string (spec.wavBasename); + + if (spec.mechanism != mechanism) + { + continue; + } + + if (fs::exists (wavPath, ec)) + { + continue; + } + + entry.destPaths.push_back (wavPath); + missingCount++; + } + + if (missingCount == 0) + { + continue; + } + + entry.kind = StartupAssetKind::DriveAudio; + entry.groupLabel = L"Disk ][ audio"; + entry.kindLabel = L"Drive audio"; + entry.source = L"OpenEmulator (GitHub)"; + entry.expectedBytes = 0; + entry.selectable = true; + entry.selected = true; + entry.displayName = mechW + L" mechanism"; + entry.downloadFn = [devicesDir, mechStr] ( + std::atomic & bytesDone, + std::atomic & cancel, + std::string & err) -> HRESULT + { + HRESULT hr = S_OK; + HINTERNET hSes = nullptr; + error_code ecLocal; + uint64_t cumulative = 0; + + hSes = WinHttpOpen (s_kpszUserAgent, + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + CBRF (hSes != nullptr, err = "Cannot initialize WinHTTP session"); + + for (const DiskAudioSpec & spec : s_kDiskAudioCatalog) + { + fs::path mechDir = devicesDir / string (spec.mechanism); + fs::path wavPath = mechDir / string (spec.wavBasename); + vector pcm; + wstring urlPath; + std::atomic perFileBytes{0}; + + if (spec.mechanism != mechStr) + { + continue; + } + + if (fs::exists (wavPath, ecLocal)) + { + continue; + } + + if (cancel.load (std::memory_order_relaxed)) + { + hr = E_ABORT; + goto Error; + } + + urlPath = s_kpszOpenEmulatorPathFmt; + urlPath += wstring (spec.mechanism.begin (), spec.mechanism.end ()); + urlPath += L"/"; + + for (char ch : spec.oggBasename) + { + if (ch == ' ') + { + urlPath += L"%20"; + } + else + { + urlPath += static_cast (static_cast (ch)); + } + } + + hr = AssetBootstrap::FetchAndDecodeOgg (hSes, + urlPath.c_str (), + 44100, + pcm, + err, + &perFileBytes, + &cancel); + + if (hr == E_ABORT || cancel.load (std::memory_order_relaxed)) + { + hr = E_ABORT; + goto Error; + } + + if (FAILED (hr)) + { + DEBUGMSG (L"Drive audio: skipping %S (%s)\n", + spec.oggBasename.data (), + wstring (err.begin (), err.end ()).c_str ()); + err.clear (); + hr = S_OK; + continue; + } + + fs::create_directories (mechDir, ecLocal); + + hr = AssetBootstrap::WritePcmAsWav (wavPath, pcm, 44100, err); + + if (FAILED (hr)) + { + DEBUGMSG (L"Drive audio: write failed for %S (%s)\n", + spec.wavBasename.data (), + wstring (err.begin (), err.end ()).c_str ()); + err.clear (); + hr = S_OK; + continue; + } + + cumulative += perFileBytes.load (std::memory_order_relaxed); + bytesDone.store (cumulative, std::memory_order_relaxed); + } + + Error: + if (hSes != nullptr) + { + WinHttpCloseHandle (hSes); + } + return hr; + }; + + set.entries.push_back (std::move (entry)); + audioIncluded = true; + } + } + + if (offerBootDisk) + { + struct DiskChoice { const BootDiskSpec * spec; bool defaultSelected; }; + DiskChoice choices[] = + { + { &s_kDos33Disk, true }, + { &s_kProDOSDisk, false } + }; + + for (const DiskChoice & dc : choices) + { + fs::path wantPath = diskDir / string (dc.spec->cassoName); + + if (fs::exists (wantPath, ec)) + { + continue; + } + + const BootDiskSpec * spec = dc.spec; + StartupAssetEntry entry; + + entry.kind = StartupAssetKind::BootDisk; + entry.groupLabel = L"Boot disks"; + entry.displayName = AsciiToWide (string (spec->shortLabel)); + entry.kindLabel = L"Boot disk"; + entry.source = L"Asimov"; + entry.selectable = true; + entry.selected = dc.defaultSelected; + entry.destPaths.push_back (wantPath); + entry.expectedBytes = (std::uint64_t) spec->expectedSize; + entry.downloadFn = [spec, destPath = wantPath] ( + std::atomic & bytesDone, + std::atomic & cancel, + std::string & err) -> HRESULT + { + HRESULT hr = S_OK; + HINTERNET hSes = nullptr; + vector payload; + error_code ecLocal; + + hSes = WinHttpOpen (s_kpszUserAgent, + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + CBRF (hSes != nullptr, err = "Cannot initialize WinHTTP session"); + + hr = DownloadHttp (hSes, + s_kpszAsimovHost, + spec->asimovUrlPath, + spec->expectedSize, + spec->shortLabel, + payload, + err, + &bytesDone, + &cancel); + CHR (hr); + + fs::create_directories (destPath.parent_path (), ecLocal); + hr = WriteFileBytes (destPath, payload); + CHRF (hr, err = format ("Cannot write {}", destPath.string ())); + + Error: + if (hSes != nullptr) + { + WinHttpCloseHandle (hSes); + } + return hr; + }; + + set.entries.push_back (std::move (entry)); + } + } + + BAIL_OUT_IF (set.entries.empty (), S_OK); + + result = StartupDownloadDialog::Show (hInstance, hwndParent, prefs.activeTheme, + MachineDisplayName (narrowMachine), set); + + switch (result) + { + case StartupDownloadResult::NothingToDo: + case StartupDownloadResult::AllDone: + case StartupDownloadResult::PartialDone: + if (audioIncluded) + { + prefs.audioDownloadConsent = "allow"; + } + // Pick the first boot-disk entry whose file is actually on + // disk -- preserves catalog order (DOS 3.3 over ProDOS) and + // tolerates the user unchecking the default. + for (const StartupAssetEntry & entry : set.entries) + { + if (entry.kind != StartupAssetKind::BootDisk) continue; + if (entry.destPaths.empty ()) continue; + if (!fs::exists (entry.destPaths.front (), ec)) continue; + + outBootDiskPath = entry.destPaths.front ().wstring (); + break; + } + hr = S_OK; + break; + + case StartupDownloadResult::Skipped: + if (audioIncluded) + { + prefs.audioDownloadConsent = "decline"; + } + hr = S_OK; + break; + + case StartupDownloadResult::Exit: + hr = S_FALSE; + break; + } + +Error: + return hr; +} diff --git a/Casso/AssetBootstrap.h b/Casso/AssetBootstrap.h index b390315d..b5277ff7 100644 --- a/Casso/AssetBootstrap.h +++ b/Casso/AssetBootstrap.h @@ -61,6 +61,7 @@ class AssetBootstrap HWND hwndParent, const vector & searchPaths, const fs::path & assetBaseDir, + std::string_view themeName, string & outError); // Audio / FR-017 / FR-018. Inspects @@ -97,7 +98,9 @@ class AssetBootstrap LPCWSTR urlPath, uint32_t targetSampleRate, vector & outPcm, - string & outError); + string & outError, + std::atomic * progressBytes = nullptr, + std::atomic * cancelRequested = nullptr); // Write `pcm` as a 16-bit PCM mono WAV file to `outPath`. Clips // out-of-range samples to int16 (drive noise stays well inside @@ -110,10 +113,58 @@ class AssetBootstrap uint32_t sampleRate, string & outError); - static HRESULT OfferBootDiskDownload (HINSTANCE hInstance, - const wstring & machineName, + // Themed boot-disk picker. Lists the user's recent disk images + // plus "Download" rows for the DOS 3.3 and ProDOS stock masters + // (sourced from the Asimov archive). Always shown when the + // machine has a Disk ][ controller and no boot disk has been + // resolved yet, even when the MRU is empty -- the download rows + // give a fresh install somewhere to go. Picking a row mounts it + // (downloading on demand for the stock rows); Skip leaves the + // slot empty. + // + // On return: + // outDiskPath = path to mount, or empty if the user skipped / + // the machine has no Disk ][ controller. + static HRESULT PromptBootDiskMru (HINSTANCE hInstance, HWND hwndParent, + const wstring & machineName, + const vector & mruEntries, const fs::path & diskDir, + std::string_view themeName, wstring & outDiskPath, + bool & outUserClosed, + string & outError); + + // Unified startup downloader. Inspects the current install for + // every required-or-optional asset that's missing (ROMs from the + // catalog, Disk II drive audio per mechanism) and presents a + // SINGLE themed dialog letting the user accept or decline the + // download in one decision. Downloads run on a worker thread with + // live per-asset progress; the user can Exit at any point and + // partial files are removed before this returns. + // + // Returns: + // S_OK -> everything required is present (some optional + // items may have failed or been skipped) + // S_FALSE -> user chose Exit + // <0 HRESULT -> hard failure + // + // `prefs.audioDownloadConsent` is read AND updated to reflect the + // user's choice (allow / decline). The caller is responsible for + // flushing prefs to disk after this returns. + // `outBootDiskPath` (if non-empty on return) names the freshly + // downloaded stock master disk; the caller should treat it as + // disk1 for the impending machine boot. Empty if no boot-disk + // entry was included or download did not finish. + static HRESULT RunStartupDownloader (HINSTANCE hInstance, + const wstring & machineName, + HWND hwndParent, + const vector & romSearchPaths, + const fs::path & assetBaseDir, + bool considerDiskAudio, + bool offerBootDisk, + const fs::path & diskDir, + struct GlobalUserPrefs & prefs, + wstring & outBootDiskPath, string & outError); }; diff --git a/Casso/Casso.rc b/Casso/Casso.rc index e0508abc..6e3d7ed2 100644 --- a/Casso/Casso.rc +++ b/Casso/Casso.rc @@ -64,7 +64,6 @@ BEGIN VK_F11, IDM_MACHINE_STEP, VIRTKEY VK_RETURN, IDM_VIEW_FULLSCREEN, VIRTKEY, ALT "0", IDM_VIEW_RESET_SIZE, VIRTKEY, CONTROL - "D", IDM_HELP_DEBUG, VIRTKEY, CONTROL "1", IDM_DISK_INSERT1, VIRTKEY, CONTROL "2", IDM_DISK_INSERT2, VIRTKEY, CONTROL VK_F1, IDM_HELP_KEYMAP, VIRTKEY diff --git a/Casso/Casso.vcxproj b/Casso/Casso.vcxproj index b03bd08d..b53749c3 100644 --- a/Casso/Casso.vcxproj +++ b/Casso/Casso.vcxproj @@ -114,7 +114,7 @@ Windows - d3d11.lib;dxgi.lib;ole32.lib;winhttp.lib;comctl32.lib;%(AdditionalDependencies) + d3d11.lib;dxgi.lib;ole32.lib;winhttp.lib;comctl32.lib;uxtheme.lib;%(AdditionalDependencies) @@ -134,7 +134,7 @@ Windows true true - d3d11.lib;dxgi.lib;ole32.lib;winhttp.lib;comctl32.lib;%(AdditionalDependencies) + d3d11.lib;dxgi.lib;ole32.lib;winhttp.lib;comctl32.lib;uxtheme.lib;%(AdditionalDependencies) @@ -150,7 +150,7 @@ Windows - d3d11.lib;dxgi.lib;ole32.lib;winhttp.lib;comctl32.lib;%(AdditionalDependencies) + d3d11.lib;dxgi.lib;ole32.lib;winhttp.lib;comctl32.lib;uxtheme.lib;%(AdditionalDependencies) @@ -170,7 +170,7 @@ Windows true true - d3d11.lib;dxgi.lib;ole32.lib;winhttp.lib;comctl32.lib;%(AdditionalDependencies) + d3d11.lib;dxgi.lib;ole32.lib;winhttp.lib;comctl32.lib;uxtheme.lib;%(AdditionalDependencies) @@ -186,7 +186,7 @@ Windows - d3d11.lib;dxgi.lib;ole32.lib;winhttp.lib;comctl32.lib;%(AdditionalDependencies) + d3d11.lib;dxgi.lib;ole32.lib;winhttp.lib;comctl32.lib;uxtheme.lib;%(AdditionalDependencies) @@ -206,7 +206,7 @@ Windows true true - d3d11.lib;dxgi.lib;ole32.lib;winhttp.lib;comctl32.lib;%(AdditionalDependencies) + d3d11.lib;dxgi.lib;ole32.lib;winhttp.lib;comctl32.lib;uxtheme.lib;%(AdditionalDependencies) @@ -225,7 +225,6 @@ - @@ -234,6 +233,7 @@ + @@ -252,7 +252,9 @@ + + @@ -277,10 +279,18 @@ + + + + + + + - + + @@ -292,7 +302,6 @@ - @@ -302,8 +311,18 @@ + + + + + + + + + + @@ -324,7 +343,9 @@ + + @@ -354,7 +375,8 @@ - + + 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/Casso/DebugConsole.cpp b/Casso/DebugConsole.cpp deleted file mode 100644 index 5bf1040c..00000000 --- a/Casso/DebugConsole.cpp +++ /dev/null @@ -1,336 +0,0 @@ -#include "Pch.h" - -#include "DebugConsole.h" - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// DebugConsole -// -//////////////////////////////////////////////////////////////////////////////// - -DebugConsole::DebugConsole () -{ -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// ~DebugConsole -// -//////////////////////////////////////////////////////////////////////////////// - -DebugConsole::~DebugConsole () -{ -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnCreate -// -//////////////////////////////////////////////////////////////////////////////// - -LRESULT DebugConsole::OnCreate (HWND hwnd, CREATESTRUCT * pcs) -{ - HRESULT hr = S_OK; - UINT dpi = 0; - int fontSize = 0; - HFONT hFont = nullptr; - - - - dpi = GetDpiForWindow (hwnd); - CWRA (dpi); - - fontSize = MulDiv (16, dpi, 96); - - - - m_editCtrl = CreateWindowEx (WS_EX_CLIENTEDGE, - L"EDIT", - L"", - WS_CHILD | WS_VISIBLE | WS_VSCROLL | ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL, - 0, 0, 600, 400, - hwnd, - nullptr, - pcs->hInstance, - nullptr); - CWRA (m_editCtrl); - - hFont = CreateFont (fontSize, 0, - 0, 0, - FW_NORMAL, - FALSE, - FALSE, - FALSE, - DEFAULT_CHARSET, - OUT_DEFAULT_PRECIS, - CLIP_DEFAULT_PRECIS, - DEFAULT_QUALITY, - FIXED_PITCH | FF_MODERN, - L"Consolas"); - CWRA (hFont); - - SendMessage (m_editCtrl, WM_SETFONT, (WPARAM) hFont, TRUE); - -Error: - return 0; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnClose -// -//////////////////////////////////////////////////////////////////////////////// - -bool DebugConsole::OnClose (HWND hwnd) -{ - UNREFERENCED_PARAMETER (hwnd); - - Hide (); - return false; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnKeyDown -// -// Intercepts Alt+F4 and forwards it to the main Casso window so the -// whole app exits, matching the user's mental model that Alt+F4 is -// an "exit Casso" gesture regardless of which Casso window currently -// has focus. Without this override the secondary window would -// swallow the close to itself via the standard WM_SYSCOMMAND/SC_CLOSE -// -> WM_CLOSE -> OnClose -> Hide() chain. -// -//////////////////////////////////////////////////////////////////////////////// - -bool DebugConsole::OnKeyDown (WPARAM vk, LPARAM lParam) -{ - static constexpr LONG_PTR s_kAltContextBit = 1LL << 29; - - - if (vk == VK_F4 && (lParam & s_kAltContextBit) && m_hwndMain != nullptr) - { - PostMessage (m_hwndMain, WM_CLOSE, 0, 0); - return false; - } - - return Window::OnKeyDown (vk, lParam); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnSize -// -//////////////////////////////////////////////////////////////////////////////// - -bool DebugConsole::OnSize (HWND hwnd, UINT width, UINT height) -{ - UNREFERENCED_PARAMETER (hwnd); - - if (m_editCtrl != nullptr) - { - MoveWindow (m_editCtrl, 0, 0, static_cast (width), static_cast (height), TRUE); - } - - return false; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// InitializeConsole -// -//////////////////////////////////////////////////////////////////////////////// - -HRESULT DebugConsole::InitializeConsole (HINSTANCE hInstance) -{ - HRESULT hr = S_OK; - - - - m_kpszWndClass = L"CassoDebugConsole"; - m_hbrBackground = reinterpret_cast (COLOR_WINDOW + 1); - - hr = Window::Initialize (hInstance); - CHR (hr); - - hr = Window::Create (0, - L"Casso debug console", - WS_OVERLAPPEDWINDOW, - CW_USEDEFAULT, CW_USEDEFAULT, - 600, 400, - nullptr); - CHR (hr); - -Error: - return hr; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// Show -// -//////////////////////////////////////////////////////////////////////////////// - -bool DebugConsole::Show (HINSTANCE hInstance) -{ - HRESULT hr = S_OK; - bool created = false; - - - - if (m_hwnd == nullptr) - { - hr = InitializeConsole (hInstance); - CHR (hr); - - created = true; - } - - if (m_hwnd != nullptr) - { - ShowWindow (m_hwnd, SW_SHOW); - SetForegroundWindow (m_hwnd); - } - -Error: - return created; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// Hide -// -//////////////////////////////////////////////////////////////////////////////// - -void DebugConsole::Hide () -{ - if (m_hwnd == nullptr) - { - return; - } - - ShowWindow (m_hwnd, SW_HIDE); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// IsVisible -// -//////////////////////////////////////////////////////////////////////////////// - -bool DebugConsole::IsVisible () const -{ - return m_hwnd != nullptr && IsWindowVisible (m_hwnd); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// Log -// -//////////////////////////////////////////////////////////////////////////////// - -void DebugConsole::Log (const wstring & message) -{ - wstring text; - int len = 0; - size_t pos = 0; - - - - if (m_editCtrl == nullptr) - { - return; - } - - // Win32 EDIT controls require \r\n for line breaks - text = message + L"\r\n"; - - while ((pos = text.find (L'\n', pos)) != wstring::npos) - { - if (pos == 0 || text[pos - 1] != L'\r') - { - text.insert (pos, 1, L'\r'); - pos++; - } - - pos++; - } - - len = GetWindowTextLength (m_editCtrl); - - SendMessage (m_editCtrl, EM_SETSEL, len, len); - SendMessage (m_editCtrl, EM_REPLACESEL, FALSE, (LPARAM) text.c_str ()); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// LogConfig -// -//////////////////////////////////////////////////////////////////////////////// - -void DebugConsole::LogConfig (const string & summary) -{ - wstring wide; - - - - if (m_editCtrl == nullptr) - { - return; - } - - wide.assign (summary.begin (), summary.end ()); - Log (wide); -} - - - - - diff --git a/Casso/DebugConsole.h b/Casso/DebugConsole.h deleted file mode 100644 index e3790341..00000000 --- a/Casso/DebugConsole.h +++ /dev/null @@ -1,49 +0,0 @@ -#pragma once - -#include "Pch.h" -#include "Window.h" - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// DebugConsole -// -// In-app debug log window (opened via Ctrl+D). -// -//////////////////////////////////////////////////////////////////////////////// - -class DebugConsole : public Window -{ -public: - DebugConsole (); - ~DebugConsole (); - - void SetMainWindow (HWND hwndMain) { m_hwndMain = hwndMain; } - - bool Show (HINSTANCE hInstance); - void Hide (); - bool IsVisible () const; - - void Log (const wstring & message); - void LogConfig (const string & summary); - -protected: - LRESULT OnCreate (HWND hwnd, CREATESTRUCT * pcs) override; - bool OnClose (HWND hwnd) override; - bool OnKeyDown (WPARAM vk, LPARAM lParam) override; - bool OnSize (HWND hwnd, UINT width, UINT height) override; - -private: - HRESULT InitializeConsole (HINSTANCE hInstance); - - HWND m_editCtrl = nullptr; - HWND m_hwndMain = nullptr; -}; - - - - - diff --git a/Casso/Disk2DebugDialog.cpp b/Casso/Disk2DebugDialog.cpp deleted file mode 100644 index f63c5428..00000000 --- a/Casso/Disk2DebugDialog.cpp +++ /dev/null @@ -1,2656 +0,0 @@ -#include "Pch.h" - -#include "Disk2DebugDialog.h" -#include "RichEditSquiggle.h" -#include "Ehm.h" - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// File-scope constants and helpers -// -//////////////////////////////////////////////////////////////////////////////// - -static const wchar_t s_kpszDebugWndClass[] = L"CassoDisk2DebugWindow"; -static const wchar_t s_kpszDebugWndTitle[] = L"Disk II Debug"; - -static const wchar_t s_kpszFilterRichEditClass[] = L"RICHEDIT50W"; -static const wchar_t s_kpszFilterRichEditModule[] = L"msftedit.dll"; - -static HMODULE s_hMsftEditModule = nullptr; - -static constexpr int kDialogDefaultWidth = 900; -static constexpr int kDialogDefaultHeight = 600; - -static constexpr int kDrainTimerMs = 33; -static constexpr int kFilterTextDebounceMs = 250; - -static constexpr int kMargin = 8; -static constexpr int kRowHeight = 22; -static constexpr int kCheckWidth = 92; -static constexpr int kRawQtCheckWidth = 150; -static constexpr int kAudioCheckWidth = 86; -static constexpr int kRadioWidth = 60; -static constexpr int kEditWidth = 140; -static constexpr int kFilterLabelWidth = 78; -static constexpr int kIgnoredLabelHeight = 18; -static constexpr int kButtonWidth = 90; -static constexpr int kButtonHeight = 26; -static constexpr int kRowGap = 4; - -// Control IDs. Range 100-199 reserved for the dialog's own children. -enum Disk2DebugDialogCtrlId : int -{ - kIdListView = 100, - - kIdChkEventTypeFirst = 110, // 8 contiguous slots - kIdChkAudioMaster = 119, - - kIdChkAudioSubFirst = 120, // 4 contiguous slots - - kIdRdoDriveFirst = 130, // 3 contiguous slots - - kIdEdtTrack = 140, - kIdEdtSector = 141, - kIdLblTrackInvalid = 142, - kIdLblSectorInvalid = 143, - kIdChkTrackRawQt = 144, - - kIdBtnPause = 150, - kIdBtnClear = 151, - - // Header right-click popup column-toggle items. Range 160..164 - // maps directly to LogicalColumn id 0..4 (Wall, Uptime, Cycle, - // Event, Detail) per Disk2DebugDialogState. - kIdColumnToggleFirst = 160, - kIdColumnToggleLast = 165, -}; - -static const wchar_t * const s_kpszEventTypeLabels[8] = -{ - L"Motor", - L"HeadStep", - L"HeadBump", - L"AddrMark", - L"Read", - L"Write", - L"Door", - L"DriveSelect", -}; - -static const wchar_t * const s_kpszAudioSubLabels[4] = -{ - L"Started", - L"Restarted", - L"Continued", - L"Silent", -}; - -static const wchar_t * const s_kpszDriveLabels[3] = -{ - L"All", - L"Drive 1", - L"Drive 2", -}; - -static bool s_classRegistered = false; - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// EnsureMsftEditLoaded -// -// RichEdit 4.1 (RICHEDIT50W) is exposed by msftedit.dll. We load the -// module exactly once on first dialog open and intentionally leak the -// handle for the process lifetime per plan.md (process-lifetime cost -// is negligible and standard for RichEdit usage). -// -//////////////////////////////////////////////////////////////////////////////// - -static void EnsureMsftEditLoaded() -{ - if (s_hMsftEditModule == nullptr) - { - s_hMsftEditModule = LoadLibraryW (s_kpszFilterRichEditModule); - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// Disk2DebugDialog -// -//////////////////////////////////////////////////////////////////////////////// - -Disk2DebugDialog::Disk2DebugDialog() -{ - m_kpszWndClass = s_kpszDebugWndClass; - m_hbrBackground = reinterpret_cast (COLOR_BTNFACE + 1); - m_uptimeAnchor = std::chrono::steady_clock::now(); - - SeedDefaultColumns (m_columns); - - for (int i = 0; i < kColumnCount; i++) - { - m_visibleOrdinalToLogicalId[i] = i; - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// ~Disk2DebugDialog -// -//////////////////////////////////////////////////////////////////////////////// - -Disk2DebugDialog::~Disk2DebugDialog() -{ - Destroy(); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// Create -// -//////////////////////////////////////////////////////////////////////////////// - -HRESULT Disk2DebugDialog::Create (HINSTANCE hInstance, HWND parentHwnd) -{ - HRESULT hr = S_OK; - - if (m_hwnd != nullptr) - { - return S_OK; - } - - EnsureMsftEditLoaded(); - - if (!s_classRegistered) - { - hr = Window::Initialize (hInstance); - CHR (hr); - - s_classRegistered = true; - } - else - { - m_hInstance = hInstance; - } - - hr = Window::Create (0, - s_kpszDebugWndTitle, - WS_OVERLAPPEDWINDOW, - CW_USEDEFAULT, CW_USEDEFAULT, - kDialogDefaultWidth, kDialogDefaultHeight, - parentHwnd); - CHR (hr); - -Error: - return hr; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// Show -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::Show() -{ - HRESULT hr = S_OK; - - CBR (m_hwnd != nullptr); - - ShowWindow (m_hwnd, SW_SHOW); - SetForegroundWindow (m_hwnd); - -Error: - return; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// Hide -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::Hide() -{ - HRESULT hr = S_OK; - - CBR (m_hwnd != nullptr); - - ShowWindow (m_hwnd, SW_HIDE); - -Error: - return; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// Destroy -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::Destroy() -{ - if (m_hwnd != nullptr) - { - DestroyWindow (m_hwnd); - m_hwnd = nullptr; - } - - if (m_uiFont != nullptr) - { - DeleteObject (m_uiFont); - m_uiFont = nullptr; - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// AcquireUiFont -// -// Cache the standard message-box font and apply it to every child -// control we create. Caller owns nothing; the dialog deletes the -// font in Destroy(). -// -//////////////////////////////////////////////////////////////////////////////// - -HFONT Disk2DebugDialog::AcquireUiFont() -{ - NONCLIENTMETRICSW ncm = {}; - - if (m_uiFont != nullptr) - { - return m_uiFont; - } - - ncm.cbSize = sizeof (ncm); - - if (SystemParametersInfoW (SPI_GETNONCLIENTMETRICS, sizeof (ncm), &ncm, 0)) - { - m_uiFont = CreateFontIndirectW (&ncm.lfMessageFont); - } - - if (m_uiFont == nullptr) - { - m_uiFont = reinterpret_cast (GetStockObject (DEFAULT_GUI_FONT)); - } - - return m_uiFont; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnCreate -// -//////////////////////////////////////////////////////////////////////////////// - -LRESULT Disk2DebugDialog::OnCreate (HWND hwnd, CREATESTRUCT * pcs) -{ - HRESULT hr = S_OK; - RECT rc = {}; - - UNREFERENCED_PARAMETER (pcs); - - m_hwnd = hwnd; - - hr = CreateChildControls (hwnd); - CHR (hr); - - RebuildListViewColumns(); - - if (GetClientRect (hwnd, &rc)) - { - LayoutChildControls (rc.right - rc.left, rc.bottom - rc.top); - } - - SetTimer (hwnd, m_drainTimerId, kDrainTimerMs, nullptr); - m_drainTimerActive = true; - -Error: - return SUCCEEDED (hr) ? 0 : -1; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// CreateChildControls -// -// Creates the FR-014 filter controls, Pause/Clear buttons, and the -// virtual-mode ListView. Layout is deferred to LayoutChildControls -// so the WM_SIZE handler can re-flow on user resize. -// -//////////////////////////////////////////////////////////////////////////////// - -HRESULT Disk2DebugDialog::CreateChildControls (HWND hwnd) -{ - HRESULT hr = S_OK; - HFONT font = nullptr; - int i = 0; - - font = AcquireUiFont(); - - for (i = 0; i < 8; i++) - { - m_eventTypeChecks[i] = CreateWindowExW (0, - L"BUTTON", - s_kpszEventTypeLabels[i], - WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX, - 0, 0, 0, 0, - hwnd, - reinterpret_cast (static_cast (kIdChkEventTypeFirst + i)), - m_hInstance, - nullptr); - CWRA (m_eventTypeChecks[i]); - SendMessageW (m_eventTypeChecks[i], WM_SETFONT, reinterpret_cast (font), TRUE); - SendMessageW (m_eventTypeChecks[i], BM_SETCHECK, BST_CHECKED, 0); - } - - m_audioMasterCheck = CreateWindowExW (0, - L"BUTTON", - L"Audio", - WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX, - 0, 0, 0, 0, - hwnd, - reinterpret_cast (static_cast (kIdChkAudioMaster)), - m_hInstance, - nullptr); - CWRA (m_audioMasterCheck); - SendMessageW (m_audioMasterCheck, WM_SETFONT, reinterpret_cast (font), TRUE); - SendMessageW (m_audioMasterCheck, BM_SETCHECK, BST_CHECKED, 0); - - for (i = 0; i < 4; i++) - { - m_audioSubCheck[i] = CreateWindowExW (0, - L"BUTTON", - s_kpszAudioSubLabels[i], - WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX, - 0, 0, 0, 0, - hwnd, - reinterpret_cast (static_cast (kIdChkAudioSubFirst + i)), - m_hInstance, - nullptr); - CWRA (m_audioSubCheck[i]); - SendMessageW (m_audioSubCheck[i], WM_SETFONT, reinterpret_cast (font), TRUE); - SendMessageW (m_audioSubCheck[i], BM_SETCHECK, BST_CHECKED, 0); - } - - for (i = 0; i < 3; i++) - { - DWORD style = WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTORADIOBUTTON; - - if (i == 0) - { - style |= WS_GROUP; - } - - m_driveRadio[i] = CreateWindowExW (0, - L"BUTTON", - s_kpszDriveLabels[i], - style, - 0, 0, 0, 0, - hwnd, - reinterpret_cast (static_cast (kIdRdoDriveFirst + i)), - m_hInstance, - nullptr); - CWRA (m_driveRadio[i]); - SendMessageW (m_driveRadio[i], WM_SETFONT, reinterpret_cast (font), TRUE); - } - - SendMessageW (m_driveRadio[0], BM_SETCHECK, BST_CHECKED, 0); - - m_trackRawQtCheck = CreateWindowExW (0, - L"BUTTON", - L"Quarter-track steps", - WS_CHILD | WS_VISIBLE | WS_TABSTOP | WS_GROUP | BS_AUTOCHECKBOX, - 0, 0, 0, 0, - hwnd, - reinterpret_cast (static_cast (kIdChkTrackRawQt)), - m_hInstance, - nullptr); - CWRA (m_trackRawQtCheck); - SendMessageW (m_trackRawQtCheck, WM_SETFONT, reinterpret_cast (font), TRUE); - - m_trackFilterLabel = CreateWindowExW (0, - L"STATIC", - L"Track filter:", - WS_CHILD | WS_VISIBLE | SS_RIGHT, - 0, 0, 0, 0, - hwnd, - nullptr, - m_hInstance, - nullptr); - CWRA (m_trackFilterLabel); - SendMessageW (m_trackFilterLabel, WM_SETFONT, reinterpret_cast (font), TRUE); - - m_trackRichEdit = CreateWindowExW (WS_EX_CLIENTEDGE, - s_kpszFilterRichEditClass, - L"", - WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_AUTOHSCROLL, - 0, 0, 0, 0, - hwnd, - reinterpret_cast (static_cast (kIdEdtTrack)), - m_hInstance, - nullptr); - CWRA (m_trackRichEdit); - SendMessageW (m_trackRichEdit, WM_SETFONT, reinterpret_cast (font), TRUE); - SendMessageW (m_trackRichEdit, EM_SETEVENTMASK, 0, ENM_CHANGE | ENM_KEYEVENTS); - - m_sectorFilterLabel = CreateWindowExW (0, - L"STATIC", - L"Sector filter:", - WS_CHILD | WS_VISIBLE | SS_RIGHT, - 0, 0, 0, 0, - hwnd, - nullptr, - m_hInstance, - nullptr); - CWRA (m_sectorFilterLabel); - SendMessageW (m_sectorFilterLabel, WM_SETFONT, reinterpret_cast (font), TRUE); - - m_sectorRichEdit = CreateWindowExW (WS_EX_CLIENTEDGE, - s_kpszFilterRichEditClass, - L"", - WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_AUTOHSCROLL, - 0, 0, 0, 0, - hwnd, - reinterpret_cast (static_cast (kIdEdtSector)), - m_hInstance, - nullptr); - CWRA (m_sectorRichEdit); - SendMessageW (m_sectorRichEdit, WM_SETFONT, reinterpret_cast (font), TRUE); - SendMessageW (m_sectorRichEdit, EM_SETEVENTMASK, 0, ENM_CHANGE | ENM_KEYEVENTS); - - m_trackInvalidLabel = CreateWindowExW (0, - L"STATIC", - L"", - WS_CHILD | WS_VISIBLE | SS_LEFT | SS_ENDELLIPSIS, - 0, 0, 0, 0, - hwnd, - reinterpret_cast (static_cast (kIdLblTrackInvalid)), - m_hInstance, - nullptr); - CWRA (m_trackInvalidLabel); - SendMessageW (m_trackInvalidLabel, WM_SETFONT, reinterpret_cast (font), TRUE); - - m_sectorInvalidLabel = CreateWindowExW (0, - L"STATIC", - L"", - WS_CHILD | WS_VISIBLE | SS_LEFT | SS_ENDELLIPSIS, - 0, 0, 0, 0, - hwnd, - reinterpret_cast (static_cast (kIdLblSectorInvalid)), - m_hInstance, - nullptr); - CWRA (m_sectorInvalidLabel); - SendMessageW (m_sectorInvalidLabel, WM_SETFONT, reinterpret_cast (font), TRUE); - - m_pauseButton = CreateWindowExW (0, - L"BUTTON", - L"Pause", - WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON, - 0, 0, 0, 0, - hwnd, - reinterpret_cast (static_cast (kIdBtnPause)), - m_hInstance, - nullptr); - CWRA (m_pauseButton); - SendMessageW (m_pauseButton, WM_SETFONT, reinterpret_cast (font), TRUE); - - m_clearButton = CreateWindowExW (0, - L"BUTTON", - L"Clear", - WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON, - 0, 0, 0, 0, - hwnd, - reinterpret_cast (static_cast (kIdBtnClear)), - m_hInstance, - nullptr); - CWRA (m_clearButton); - SendMessageW (m_clearButton, WM_SETFONT, reinterpret_cast (font), TRUE); - - m_listView = CreateWindowExW (WS_EX_CLIENTEDGE, - WC_LISTVIEWW, - L"", - WS_CHILD | WS_VISIBLE | WS_TABSTOP - | LVS_REPORT | LVS_OWNERDATA | LVS_SHOWSELALWAYS, - 0, 0, 0, 0, - hwnd, - reinterpret_cast (static_cast (kIdListView)), - m_hInstance, - nullptr); - CWRA (m_listView); - SendMessageW (m_listView, WM_SETFONT, reinterpret_cast (font), TRUE); - ListView_SetExtendedListViewStyle (m_listView, LVS_EX_FULLROWSELECT | LVS_EX_HEADERDRAGDROP); - - hr = InstallListViewSubclass(); - CHR (hr); - - // Spec-006 bug-fix. Inline tooltip control documenting the - // Track / Sector filter syntax (comma-separated integers, ranges, - // and -- for Track only -- decimals interpreted as quarter-track - // positions when "Quarter-track steps" is unchecked). - m_filterTooltip = CreateWindowExW (WS_EX_TOPMOST, - TOOLTIPS_CLASS, - nullptr, - WS_POPUP | TTS_ALWAYSTIP | TTS_NOPREFIX, - CW_USEDEFAULT, CW_USEDEFAULT, - CW_USEDEFAULT, CW_USEDEFAULT, - hwnd, - nullptr, - m_hInstance, - nullptr); - - if (m_filterTooltip != nullptr) - { - TOOLINFOW ti = {}; - - SetWindowPos (m_filterTooltip, HWND_TOPMOST, 0, 0, 0, 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); - - ti.cbSize = sizeof (ti); - ti.uFlags = TTF_IDISHWND | TTF_SUBCLASS; - ti.hwnd = hwnd; - ti.uId = reinterpret_cast (m_trackRichEdit); - ti.lpszText = const_cast - (L"Track filter syntax:\r\n" - L" \u2022 Whole tracks: 0 \u2013 39\r\n" - L" \u2022 Quarter tracks: 0.0, 0.25, 0.5, 0.75, \u2026 up to 39.75\r\n" - L" \u2022 Ranges: 17-22, 5.0-5.75\r\n" - L" \u2022 Lists: 0, 5, 17-22, 34.5\r\n" - L" \u2022 Empty matches all tracks\r\n" - L"\r\n" - L"When 'Quarter-track steps' is checked, bare integers are " - L"interpreted as quarter-track indices (0 \u2013 159) instead " - L"of whole tracks.\r\n" - L"\r\n" - L"Out-of-range or unparseable tokens get a red squiggle and " - L"are listed in the red invalid-token label below."); - SendMessageW (m_filterTooltip, TTM_ADDTOOLW, 0, reinterpret_cast (&ti)); - - ti.uId = reinterpret_cast (m_sectorRichEdit); - ti.lpszText = const_cast - (L"Sector filter syntax:\r\n" - L" \u2022 Sectors: 0 \u2013 15 (whole numbers only)\r\n" - L" \u2022 Ranges: 0-15, 8-12\r\n" - L" \u2022 Lists: 0, 1, 8-15\r\n" - L" \u2022 Empty matches all sectors\r\n" - L"\r\n" - L"Stock DOS 3.3 uses sectors 0 \u2013 15. Out-of-range or " - L"unparseable tokens get a red squiggle and are listed in " - L"the red invalid-token label below."); - SendMessageW (m_filterTooltip, TTM_ADDTOOLW, 0, reinterpret_cast (&ti)); - - SendMessageW (m_filterTooltip, TTM_SETMAXTIPWIDTH, 0, 360); - SendMessageW (m_filterTooltip, TTM_ACTIVATE, TRUE, 0); - } - -Error: - return hr; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// LayoutChildControls -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::LayoutChildControls (int width, int height) -{ - int x = 0; - int y = 0; - int i = 0; - int listViewTop = 0; - int trackEditX = 0; - int sectorEditX = 0; - - if (m_listView == nullptr) - { - return; - } - - // Row 1: event-type checkboxes - x = kMargin; - y = kMargin; - - for (i = 0; i < 8; i++) - { - MoveWindow (m_eventTypeChecks[i], x, y, kCheckWidth, kRowHeight, TRUE); - x += kCheckWidth; - } - - // Row 2: Audio master + 4 sub-checkboxes - x = kMargin; - y += kRowHeight + kRowGap; - - MoveWindow (m_audioMasterCheck, x, y, kCheckWidth, kRowHeight, TRUE); - x += kCheckWidth; - - for (i = 0; i < 4; i++) - { - MoveWindow (m_audioSubCheck[i], x, y, kAudioCheckWidth, kRowHeight, TRUE); - x += kAudioCheckWidth; - } - - // Row 3: Drive radio + Track label + Track edit + Sector label + Sector edit - x = kMargin; - y += kRowHeight + kRowGap; - - for (i = 0; i < 3; i++) - { - MoveWindow (m_driveRadio[i], x, y, kRadioWidth, kRowHeight, TRUE); - x += kRadioWidth; - } - - x += kRowGap; - - MoveWindow (m_trackFilterLabel, x, y + 3, kFilterLabelWidth, kRowHeight, TRUE); - x += kFilterLabelWidth + kRowGap; - - // Spec-006 bug-fix. The Quarter-track steps checkbox modifies how - // bare integers in the track edit are interpreted, so it lives in - // row 4 directly under the track edit (trackEditX is captured here - // so row 4 and the row-5 invalid label can align to the same x). - trackEditX = x; - - MoveWindow (m_trackRichEdit, x, y, kEditWidth, kRowHeight, TRUE); - x += kEditWidth + kRowGap; - - MoveWindow (m_sectorFilterLabel, x, y + 3, kFilterLabelWidth, kRowHeight, TRUE); - x += kFilterLabelWidth + kRowGap; - - sectorEditX = x; - - MoveWindow (m_sectorRichEdit, x, y, kEditWidth, kRowHeight, TRUE); - - // Row 4: Quarter-track steps checkbox aligned under the track edit. - y += kRowHeight + kRowGap; - MoveWindow (m_trackRawQtCheck, trackEditX, y, kRawQtCheckWidth, kRowHeight, TRUE); - - // Row 5: per-side invalid labels (red text, SS_ENDELLIPSIS so over- - // long token lists truncate rather than disappear off-edge). Track - // label hangs under the track edit, sector label under the sector - // edit. Each consumes the width of its own edit-column extending - // out to the right margin / next column boundary. - y += kRowHeight + kRowGap; - - MoveWindow (m_trackInvalidLabel, - trackEditX, - y, - sectorEditX - trackEditX - kRowGap, - kIgnoredLabelHeight, - TRUE); - - MoveWindow (m_sectorInvalidLabel, - sectorEditX, - y, - width - sectorEditX - kMargin, - kIgnoredLabelHeight, - TRUE); - - // Row 6: Pause + Clear - y += kIgnoredLabelHeight + kRowGap; - - MoveWindow (m_pauseButton, kMargin, y, kButtonWidth, kButtonHeight, TRUE); - MoveWindow (m_clearButton, kMargin + kButtonWidth + kRowGap, y, kButtonWidth, kButtonHeight, TRUE); - - listViewTop = y + kButtonHeight + kRowGap; - - if (height > listViewTop + kMargin) - { - MoveWindow (m_listView, - kMargin, - listViewTop, - width - 2 * kMargin, - height - listViewTop - kMargin, - TRUE); - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// RebuildListViewColumns -// -// Recreate the ListView's columns from the logical column model. -// Hidden columns are absent from the LV entirely (no zero-width). -// Each newly-shown column gets an FR-027 auto-size-to-header pass -// on its first appearance. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::RebuildListViewColumns() -{ - int virtualIdx = 0; - int i = 0; - int contentWidth = 0; - - if (m_listView == nullptr) - { - return; - } - - while (ListView_DeleteColumn (m_listView, 0)) - { - // loop until empty - } - - for (i = 0; i < kColumnCount; i++) - { - m_visibleOrdinalToLogicalId[i] = -1; - } - - for (i = 0; i < kColumnCount; i++) - { - LVCOLUMNW lvc = {}; - - if (!m_columns[i].visible) - { - continue; - } - - lvc.mask = LVCF_TEXT | LVCF_WIDTH; - lvc.pszText = const_cast (m_columns[i].headerText); - lvc.cx = m_columns[i].savedWidth; - - ListView_InsertColumn (m_listView, virtualIdx, &lvc); - - m_visibleOrdinalToLogicalId[virtualIdx] = m_columns[i].id; - - // Spec-006 bug-fix. Auto-size to MAX (header, widest current - // cell) on first show; the previous header-only auto-size - // truncated multi-digit Cycle / wide Detail strings to the - // header's width. The Detail column is special-cased: it - // always flexes to fill the LV client remainder so the user - // never sees a Detail column that's narrower than its data - // AND ends with empty space to its right. - if (m_columns[i].id != kDetailColumnId && !m_columns[i].autoSizedYet) - { - contentWidth = MeasureColumnContentWidth (m_columns[i].id, 0); - ListView_SetColumnWidth (m_listView, virtualIdx, contentWidth); - m_columns[i].savedWidth = contentWidth; - m_columns[i].autoSizedYet = true; - } - - virtualIdx++; - } - - SizeDetailColumnToRemainder(); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// MeasureColumnContentWidth -// -// Walk the dialog's deque starting at startIdx, project each row's -// cell text through AppendColumnText into a scratch buffer, and ask -// the ListView for the pixel width via LVM_GETSTRINGWIDTH (which uses -// the LV's font). Returns max (header text width, max cell width in -// the [startIdx, end) chunk) + padding. -// -// The auto-grow caller in HandleDrainTick passes startIdx = -// m_dequeSizeAtLastGrow so each periodic check only measures rows -// added since the last grow -- the "never shrink" invariant means we -// can compare the new chunk's max width against the current -// savedWidth and grow if larger without re-walking history. The first -// auto-fit pass passes startIdx = 0 to measure the whole deque. -// -//////////////////////////////////////////////////////////////////////////////// - -int Disk2DebugDialog::MeasureColumnContentWidth (int logicalId, size_t startIdx) const -{ - constexpr int kAutoSizePaddingPx = 16; - constexpr int kMinColumnWidth = 32; - - int headerWidth = 0; - int maxCellWidth = 0; - int cellWidth = 0; - size_t i = 0; - std::wstring scratch; - - if (m_listView == nullptr || logicalId < 0 || logicalId >= kColumnCount) - { - return kMinColumnWidth; - } - - headerWidth = ListView_GetStringWidth (m_listView, m_columns[logicalId].headerText); - - for (i = startIdx; i < m_deque.size(); i++) - { - scratch.clear(); - AppendColumnText (scratch, m_deque[i], logicalId); - - if (!scratch.empty()) - { - cellWidth = ListView_GetStringWidth (m_listView, scratch.c_str()); - - if (cellWidth > maxCellWidth) - { - maxCellWidth = cellWidth; - } - } - } - - return std::max (headerWidth, maxCellWidth) + kAutoSizePaddingPx; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// SizeDetailColumnToRemainder -// -// Spec-006 bug-fix. After every other visible column has its width -// set, give the Detail column whatever's left of the LV client area -// (clamped to a sensible minimum). Called from -// RebuildListViewColumns AND from OnSize so user resize flexes the -// Detail column rather than leaving the trailing whitespace dead. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::SizeDetailColumnToRemainder() -{ - constexpr int kDetailMinWidth = 200; - - RECT rc = {}; - int clientWidth = 0; - int usedWidth = 0; - int detailVirtualIdx = -1; - int virtualIdx = 0; - int i = 0; - - if (m_listView == nullptr) - { - return; - } - - if (!m_columns[kDetailColumnId].visible) - { - return; - } - - if (!GetClientRect (m_listView, &rc)) - { - return; - } - - clientWidth = rc.right - rc.left; - - for (i = 0; i < kColumnCount; i++) - { - if (!m_columns[i].visible) - { - continue; - } - - if (m_columns[i].id == kDetailColumnId) - { - detailVirtualIdx = virtualIdx; - } - else - { - usedWidth += ListView_GetColumnWidth (m_listView, virtualIdx); - } - - virtualIdx++; - } - - if (detailVirtualIdx < 0) - { - return; - } - - int remainder = clientWidth - usedWidth; - - if (remainder < kDetailMinWidth) - { - remainder = kDetailMinWidth; - } - - ListView_SetColumnWidth (m_listView, detailVirtualIdx, remainder); - m_columns[kDetailColumnId].savedWidth = remainder; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// ToggleColumn -// -// Flip one LogicalColumn's visible bit and rebuild the LV's column -// set. The pre-flip CaptureCurrentWidthsIntoModel() preserves any -// user-dragged width that hasn't been written back yet (HDN_ENDTRACK -// catches drag-end but Win32 doesn't fire it for programmatic -// changes). Hiding all five columns is allowed -- the LV draws a -// blank canvas and the user can re-show via the same popup. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::ToggleColumn (int id) -{ - if (id < 0 || id >= kColumnCount) - { - return; - } - - CaptureCurrentWidthsIntoModel(); - - m_columns[id].visible = !m_columns[id].visible; - - RebuildListViewColumns(); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// CaptureCurrentWidthsIntoModel -// -// Walk the ListView's currently-visible columns and copy each one's -// width back into the matching LogicalColumn::savedWidth via the -// m_visibleOrdinalToLogicalId map. Called before any rebuild that -// could lose user-dragged widths, and from the HDN_ENDTRACK notify -// when the user finishes a drag. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::CaptureCurrentWidthsIntoModel() -{ - int width = 0; - int logicalId = 0; - int i = 0; - - if (m_listView == nullptr) - { - return; - } - - for (i = 0; i < kColumnCount; i++) - { - logicalId = m_visibleOrdinalToLogicalId[i]; - - if (logicalId < 0 || logicalId >= kColumnCount) - { - continue; - } - - width = ListView_GetColumnWidth (m_listView, i); - - if (width > 0) - { - m_columns[logicalId].savedWidth = width; - } - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// ShowHeaderContextMenu -// -// Build and display the FR-026 column-visibility popup. Each item -// is keyed by IDM = kIdColumnToggleFirst + LogicalColumn.id, with -// MFS_CHECKED tracking m_columns[id].visible. TPM_RETURNCMD lets us -// consume the user's selection inline instead of routing through -// the dialog's WM_COMMAND -- the popup is owned-this-call only and -// doesn't need to coexist with kIdBtnPause / kIdBtnClear dispatch. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::ShowHeaderContextMenu (int x, int y) -{ - HMENU hMenu = nullptr; - BOOL appended = FALSE; - int cmdResult = 0; - int chosenId = 0; - int i = 0; - UINT flags = 0; - - if (m_hwnd == nullptr) - { - return; - } - - hMenu = CreatePopupMenu(); - - if (hMenu == nullptr) - { - return; - } - - for (i = 0; i < kColumnCount; i++) - { - flags = MF_STRING | (m_columns[i].visible ? MF_CHECKED : MF_UNCHECKED); - appended = AppendMenuW (hMenu, - flags, - static_cast (kIdColumnToggleFirst + i), - m_columns[i].headerText); - - if (!appended) - { - DestroyMenu (hMenu); - return; - } - } - - cmdResult = TrackPopupMenu (hMenu, - TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY, - x, y, 0, - m_hwnd, nullptr); - - DestroyMenu (hMenu); - - if (cmdResult >= kIdColumnToggleFirst && cmdResult <= kIdColumnToggleLast) - { - chosenId = cmdResult - kIdColumnToggleFirst; - ToggleColumn (chosenId); - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// SetMultiControllerHint -// -// FR-017. Append " (controller #0 only)" when the active machine -// config has more than one Disk II controller, otherwise restore the -// base title. Safe to call before or after Create(). -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::SetMultiControllerHint (bool isMulti) noexcept -{ - if (m_hwnd == nullptr) - { - return; - } - - if (isMulti) - { - SetWindowTextW (m_hwnd, L"Disk II Debug (controller #0 only)"); - } - else - { - SetWindowTextW (m_hwnd, s_kpszDebugWndTitle); - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnSize -// -//////////////////////////////////////////////////////////////////////////////// - -bool Disk2DebugDialog::OnSize (HWND hwnd, UINT width, UINT height) -{ - UNREFERENCED_PARAMETER (hwnd); - - LayoutChildControls (static_cast (width), static_cast (height)); - - // Spec-006 bug-fix. The Detail column flexes with the dialog so - // user resize moves the trailing free space INTO the Detail - // cell rather than leaving it dead at the right edge of the LV. - SizeDetailColumnToRemainder(); - - return false; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnClose -// -// WM_CLOSE hides the dialog. The dialog is reused across opens; only -// shell shutdown calls Destroy() to actually tear it down. -// -//////////////////////////////////////////////////////////////////////////////// - -bool Disk2DebugDialog::OnClose (HWND hwnd) -{ - UNREFERENCED_PARAMETER (hwnd); - - Hide(); - return false; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnKeyDown -// -// Forwards Alt+F4 to the owner Casso window so the whole app exits -// rather than just hiding this dialog -- matches the user's mental -// model that Alt+F4 is an "exit Casso" gesture. -// -//////////////////////////////////////////////////////////////////////////////// - -bool Disk2DebugDialog::OnKeyDown (WPARAM vk, LPARAM lParam) -{ - static constexpr LONG_PTR s_kAltContextBit = 1LL << 29; - - HWND hwndOwner = nullptr; - - - if (vk == VK_F4 && (lParam & s_kAltContextBit)) - { - hwndOwner = GetWindow (m_hwnd, GW_OWNER); - if (hwndOwner != nullptr) - { - PostMessage (hwndOwner, WM_CLOSE, 0, 0); - return false; - } - } - - return Window::OnKeyDown (vk, lParam); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnDestroy -// -// Cancel any timers, drop the HWND. Override the base Window -// PostQuitMessage default so closing the debug dialog does not -// terminate the host application. -// -//////////////////////////////////////////////////////////////////////////////// - -bool Disk2DebugDialog::OnDestroy (HWND hwnd) -{ - if (m_drainTimerActive) - { - KillTimer (hwnd, m_drainTimerId); - m_drainTimerActive = false; - } - - if (m_filterDebouncePending) - { - KillTimer (hwnd, m_filterDebounceTimerId); - m_filterDebouncePending = false; - } - - m_hwnd = nullptr; - return false; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnCommandEx -// -// Filter checkboxes / radios fire BN_CLICKED. Track/Sector RichEdits -// fire EN_CHANGE on every keystroke and EN_KILLFOCUS when focus -// moves; the former arms the debounce timer, the latter flushes -// immediately per FR-014d. -// -//////////////////////////////////////////////////////////////////////////////// - -bool Disk2DebugDialog::OnCommandEx (HWND hwnd, int id, int notifyCode, HWND hCtl) -{ - UNREFERENCED_PARAMETER (hwnd); - - if (id == kIdBtnPause || id == kIdBtnClear) - { - if (notifyCode == BN_CLICKED) - { - if (id == kIdBtnPause) - { - m_paused = !m_paused; - SetWindowTextW (m_pauseButton, m_paused ? L"Resume" : L"Pause"); - } - else - { - m_deque.clear(); - m_filteredIndices.clear(); - m_lastPublishedCount = -1; - - if (m_listView != nullptr) - { - ListView_SetItemCountEx (m_listView, 0, LVSICF_NOSCROLL); - InvalidateRect (m_listView, nullptr, FALSE); - } - } - } - - return false; - } - - if (id == kIdEdtTrack || id == kIdEdtSector) - { - if (notifyCode == EN_CHANGE) - { - OnFilterTextChanged(); - } - else if (notifyCode == EN_KILLFOCUS) - { - OnFilterTextKillFocus(); - } - - return false; - } - - if (notifyCode == BN_CLICKED) - { - OnFilterControlToggled (id, hCtl); - } - - return false; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnFilterControlToggled -// -// Read the new check / select state straight off the control handle -// rather than tracking a parallel bool per checkbox -- the Win32 -// control is the source of truth and BST_CHECKED is one IPC. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::OnFilterControlToggled (int id, HWND hCtl) -{ - bool checked = false; - uint32_t catBit = 0; - int slot = 0; - int priorFocusedDequeIdx = 0; - bool didRefilter = true; - - if (hCtl != nullptr) - { - checked = SendMessageW (hCtl, BM_GETCHECK, 0, 0) == BST_CHECKED; - - if (id >= kIdChkEventTypeFirst && id < kIdChkEventTypeFirst + 8) - { - slot = id - kIdChkEventTypeFirst; - catBit = 1u << slot; - - if (checked) - { - m_filter.eventTypeMask |= catBit; - } - else - { - m_filter.eventTypeMask &= ~catBit; - } - } - else if (id == kIdChkAudioMaster) - { - m_filter.audioMaster = checked; - UpdateAudioSubEnableState(); - } - else if (id >= kIdChkAudioSubFirst && id < kIdChkAudioSubFirst + 4) - { - slot = id - kIdChkAudioSubFirst; - - switch (slot) - { - case 0: m_filter.audioStarted = checked; break; - case 1: m_filter.audioRestarted = checked; break; - case 2: m_filter.audioContinued = checked; break; - case 3: m_filter.audioSilent = checked; break; - default: break; - } - } - else if (id >= kIdRdoDriveFirst && id < kIdRdoDriveFirst + 3) - { - if (checked) - { - m_filter.driveFilter = id - kIdRdoDriveFirst; - } - } - else if (id == kIdChkTrackRawQt) - { - m_filter.trackFilterRawQt = checked; - SetWindowTextW (m_trackFilterLabel, - checked ? L"Quarter-track:" : L"Track filter:"); - // raw-qt re-interprets bare integers as quarter tracks so the - // track predicate has to be re-parsed against the new flag. - FlushFilterDebounce(); - didRefilter = false; - } - else - { - didRefilter = false; - } - - if (didRefilter) - { - priorFocusedDequeIdx = CapturedFocusedDequeIdx(); - - RebuildFilteredIndices(); - InvalidateListView(); - RestoreFocusedDequeIdx (priorFocusedDequeIdx); - } - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// UpdateAudioSubEnableState -// -// FR-014c: when the Audio master is unchecked the four sub-checkboxes -// grey out but keep their checked state for restoration. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::UpdateAudioSubEnableState() -{ - int i = 0; - - for (i = 0; i < 4; i++) - { - EnableWindow (m_audioSubCheck[i], m_filter.audioMaster ? TRUE : FALSE); - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnFilterTextChanged -// -// FR-014d: arm a one-shot 250 ms timer; subsequent keystrokes cancel -// and re-arm so we only re-parse / re-project after the user pauses -// typing. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::OnFilterTextChanged() -{ - if (m_hwnd == nullptr) - { - return; - } - - if (m_filterDebouncePending) - { - KillTimer (m_hwnd, m_filterDebounceTimerId); - } - - SetTimer (m_hwnd, m_filterDebounceTimerId, kFilterTextDebounceMs, nullptr); - m_filterDebouncePending = true; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnFilterTextKillFocus -// -// EN_KILLFOCUS bypasses the debounce wait and flushes immediately. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::OnFilterTextKillFocus() -{ - if (m_hwnd != nullptr && m_filterDebouncePending) - { - KillTimer (m_hwnd, m_filterDebounceTimerId); - m_filterDebouncePending = false; - } - - FlushFilterDebounce(); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// ReadEditText -// -//////////////////////////////////////////////////////////////////////////////// - -std::wstring Disk2DebugDialog::ReadEditText (HWND hEdit) const -{ - HRESULT hr = S_OK; - int len = 0; - std::wstring out; - - CBR (hEdit != nullptr); - - len = GetWindowTextLengthW (hEdit); - CBR (len > 0); - - out.resize (static_cast (len)); - GetWindowTextW (hEdit, out.data(), len + 1); - -Error: - return out; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// FlushFilterDebounce -// -// Re-parse Track and Sector inputs, refresh m_filter, rebuild the -// filtered-index vector, repaint the ListView, and update the -// FR-014e squiggle + ignored-tokens label on each input. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::FlushFilterDebounce() -{ - std::wstring trackText; - std::wstring sectorText; - bool trackTextChanged = false; - bool sectorTextChanged = false; - int priorFocusedDequeIdx = 0; - - m_filterDebouncePending = false; - - trackText = ReadEditText (m_trackRichEdit); - sectorText = ReadEditText (m_sectorRichEdit); - - m_filter.trackFilter = TrackSectorPredicate::Parse (trackText, - TrackSectorPredicate::Mode::Track, - m_filter.trackFilterRawQt); - m_filter.sectorFilter = TrackSectorPredicate::Parse (sectorText, - TrackSectorPredicate::Mode::Sector); - - priorFocusedDequeIdx = CapturedFocusedDequeIdx(); - - RebuildFilteredIndices(); - InvalidateListView(); - RestoreFocusedDequeIdx (priorFocusedDequeIdx); - - // Spec-006 bug 4. Only re-apply squiggle formats when the text - // content actually changed. Re-painting the same squiggles on - // every EN_CHANGE / debounce tick re-anchors the selection and - // breaks right-to-left mouse drags. - trackTextChanged = (trackText != m_lastFormattedTrackText); - sectorTextChanged = (sectorText != m_lastFormattedSectorText); - - if (trackTextChanged) - { - ApplyRejectedTokenSquiggles (m_trackRichEdit, m_filter.trackFilter.RejectedSpans()); - m_lastFormattedTrackText = trackText; - } - - if (sectorTextChanged) - { - ApplyRejectedTokenSquiggles (m_sectorRichEdit, m_filter.sectorFilter.RejectedSpans()); - m_lastFormattedSectorText = sectorText; - } - - SetPerSideInvalidLabel (m_trackInvalidLabel, - L"Invalid track: ", - trackText, m_filter.trackFilter.RejectedSpans()); - SetPerSideInvalidLabel (m_sectorInvalidLabel, - L"Invalid sector: ", - sectorText, m_filter.sectorFilter.RejectedSpans()); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// RebuildFilteredIndices -// -// Full O(deque) rebuild. Cheap relative to the user's typing cadence -// and the only correct behavior when the filter state changes mid- -// stream (re-checking a filter MUST reveal events from the off -// window in chronological order per User Story 3). -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::RebuildFilteredIndices() -{ - size_t i = 0; - - m_filteredIndices.clear(); - - for (i = 0; i < m_deque.size(); i++) - { - if (MatchesFilter (m_deque[i], m_filter)) - { - m_filteredIndices.push_back (static_cast (i)); - } - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// CapturedFocusedDequeIdx -// -// Snapshot helper for PreserveFocusedRowAcrossRebuild. Maps the -// currently-focused ListView item index through the pre-rebuild -// filtered-indices vector to the underlying deque index. Returns -1 -// when no row is focused or the ListView isn't realized. -// -//////////////////////////////////////////////////////////////////////////////// - -int Disk2DebugDialog::CapturedFocusedDequeIdx() const noexcept -{ - int focused = -1; - - if (m_listView == nullptr) - { - return -1; - } - - focused = ListView_GetNextItem (m_listView, -1, LVNI_FOCUSED); - - if (focused < 0) - { - return -1; - } - - if (static_cast (focused) >= m_filteredIndices.size()) - { - return -1; - } - - return static_cast (m_filteredIndices[static_cast (focused)]); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// RestoreFocusedDequeIdx -// -// Restore the focused / selected / visible state after a filter -// rebuild. Resolves the target LV item index via -// DebugDialogProjection::PreservedFocusItem and applies LVIS_FOCUSED -// + LVIS_SELECTED + EnsureVisible. priorDequeIdx == -1 (nothing was -// focused) is a no-op. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::RestoreFocusedDequeIdx (int priorDequeIdx) noexcept -{ - int newItem = -1; - - if (m_listView == nullptr || priorDequeIdx < 0) - { - return; - } - - newItem = DebugDialogProjection::PreservedFocusItem ( - static_cast (priorDequeIdx), - m_filteredIndices); - - if (newItem < 0) - { - return; - } - - ListView_SetItemState (m_listView, - newItem, - LVIS_FOCUSED | LVIS_SELECTED, - LVIS_FOCUSED | LVIS_SELECTED); - ListView_EnsureVisible (m_listView, newItem, FALSE); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// InvalidateListView -// -// Spec-006 bug-fix. Filter-toggle / debounce-flush rebuilds used to -// flash because the SetItemCount + InvalidateRect combo redrew the -// full LV even when the filtered set was unchanged. Now: short- -// circuit when the count and head/tail signature match the last -// publish, and wrap the count update + invalidate in -// WM_SETREDRAW(FALSE)..(TRUE) so the LV repaints once at the end of -// the rebuild rather than flickering through intermediate states. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::InvalidateListView() -{ - int newCount = 0; - uint32_t firstIdx = 0; - uint32_t lastIdx = 0; - - if (m_listView == nullptr) - { - return; - } - - newCount = static_cast (m_filteredIndices.size()); - - if (newCount > 0) - { - firstIdx = m_filteredIndices.front(); - lastIdx = m_filteredIndices.back(); - } - - if (newCount == m_lastPublishedCount - && firstIdx == m_lastPublishedFirstIdx - && lastIdx == m_lastPublishedLastIdx) - { - // Same projection as last publish; nothing to redraw. - return; - } - - SendMessageW (m_listView, WM_SETREDRAW, FALSE, 0); - - ListView_SetItemCountEx (m_listView, - newCount, - LVSICF_NOINVALIDATEALL | LVSICF_NOSCROLL); - - SendMessageW (m_listView, WM_SETREDRAW, TRUE, 0); - InvalidateRect (m_listView, nullptr, FALSE); - - m_lastPublishedCount = newCount; - m_lastPublishedFirstIdx = firstIdx; - m_lastPublishedLastIdx = lastIdx; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// InstallListViewSubclass -// -// Hook the ListView's WndProc so Ctrl+C copies the current selection. -// GWLP_USERDATA holds the dialog `this` pointer; the original WndProc -// is saved on the dialog so we can chain. -// -//////////////////////////////////////////////////////////////////////////////// - -HRESULT Disk2DebugDialog::InstallListViewSubclass() -{ - HRESULT hr = S_OK; - LONG_PTR prev = 0; - - if (m_listView == nullptr) - { - return E_UNEXPECTED; - } - - SetWindowLongPtrW (m_listView, GWLP_USERDATA, reinterpret_cast (this)); - - prev = SetWindowLongPtrW (m_listView, - GWLP_WNDPROC, - reinterpret_cast (s_ListViewSubclassProc)); - CWRA (prev); - - m_originalListViewProc = reinterpret_cast (prev); - -Error: - return hr; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// s_ListViewSubclassProc -// -//////////////////////////////////////////////////////////////////////////////// - -LRESULT CALLBACK Disk2DebugDialog::s_ListViewSubclassProc ( - HWND hwnd, - UINT msg, - WPARAM wParam, - LPARAM lParam) -{ - Disk2DebugDialog * pThis = reinterpret_cast ( - GetWindowLongPtrW (hwnd, GWLP_USERDATA)); - - if (msg == WM_KEYDOWN - && wParam == L'C' - && (GetKeyState (VK_CONTROL) & 0x8000)) - { - if (pThis != nullptr) - { - pThis->CopySelectedRowsToClipboard(); - } - - return 0; - } - - if (pThis != nullptr && pThis->m_originalListViewProc != nullptr) - { - return CallWindowProcW (pThis->m_originalListViewProc, hwnd, msg, wParam, lParam); - } - - return DefWindowProcW (hwnd, msg, wParam, lParam); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// CopySelectedRowsToClipboard -// -// FR-019: enumerate ListView selection, format each row as -// tab-separated UTF-16 in visible-column order, and stage on the -// clipboard as CF_UNICODETEXT. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::CopySelectedRowsToClipboard() -{ - HRESULT hr = S_OK; - int selIdx = -1; - uint32_t deqIdx = 0; - std::vector selected; - std::wstring payload; - HGLOBAL hMem = nullptr; - wchar_t * pMem = nullptr; - size_t byteCount = 0; - bool opened = false; - BOOL fSuccess = FALSE; - HANDLE hClipData = nullptr; - - if (m_listView == nullptr || m_hwnd == nullptr) - { - return; - } - - selIdx = ListView_GetNextItem (m_listView, -1, LVNI_SELECTED); - - while (selIdx >= 0) - { - if (static_cast (selIdx) < m_filteredIndices.size()) - { - deqIdx = m_filteredIndices[selIdx]; - - if (deqIdx < m_deque.size()) - { - selected.push_back (&m_deque[deqIdx]); - } - } - - selIdx = ListView_GetNextItem (m_listView, selIdx, LVNI_SELECTED); - } - - if (selected.empty()) - { - return; - } - - payload = BuildClipboardText (selected, m_columns); - - byteCount = (payload.size() + 1) * sizeof (wchar_t); - - hMem = GlobalAlloc (GMEM_MOVEABLE, byteCount); - CWRA (hMem); - - pMem = static_cast (GlobalLock (hMem)); - CWRA (pMem); - - memcpy (pMem, payload.data(), byteCount - sizeof (wchar_t)); - pMem[payload.size()] = L'\0'; - GlobalUnlock (hMem); - - fSuccess = OpenClipboard (m_hwnd); - CWRA (fSuccess); - opened = true; - - fSuccess = EmptyClipboard(); - CWRA (fSuccess); - - hClipData = SetClipboardData (CF_UNICODETEXT, hMem); - CWRA (hClipData); - - // SetClipboardData succeeded -> the system now owns hMem. - hMem = nullptr; - -Error: - - if (opened) - { - CloseClipboard(); - } - - if (hMem != nullptr) - { - GlobalFree (hMem); - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnCtlColorStatic -// -// Spec-006 bug-fix. Paints the single combined invalid label red so -// the error message stands out beside the filter inputs. All other -// static controls (filter labels, etc.) fall through to the default -// system colors. -// -//////////////////////////////////////////////////////////////////////////////// - -HBRUSH Disk2DebugDialog::OnCtlColorStatic (HWND hwndDlg, HDC hdc, HWND hwndStatic) -{ - UNREFERENCED_PARAMETER (hwndDlg); - - if (hwndStatic == m_trackInvalidLabel || hwndStatic == m_sectorInvalidLabel) - { - SetTextColor (hdc, RGB (200, 0, 0)); - SetBkMode (hdc, TRANSPARENT); - return GetSysColorBrush (COLOR_BTNFACE); - } - - return nullptr; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnTimer -// -//////////////////////////////////////////////////////////////////////////////// - -bool Disk2DebugDialog::OnTimer (HWND hwnd, UINT_PTR timerId) -{ - UNREFERENCED_PARAMETER (hwnd); - - if (timerId == m_drainTimerId) - { - HandleDrainTick(); - return false; - } - - if (timerId == m_filterDebounceTimerId) - { - if (m_filterDebouncePending) - { - KillTimer (m_hwnd, m_filterDebounceTimerId); - m_filterDebouncePending = false; - } - - FlushFilterDebounce(); - return false; - } - - return false; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// HandleDrainTick -// -// Implements plan.md "Auto-Tail Scroll Algorithm". Per FR-011 + Q2, -// the drain runs every tick regardless of m_paused and window -// visibility -- the deque's 100k cap bounds memory while the dialog -// is hidden or paused. Only the visible ListView refresh is skipped -// when the window is hidden / minimized / paused. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::HandleDrainTick() -{ - int topIndex = 0; - int countPerPage = 0; - int oldCount = 0; - int newCount = 0; - bool wasAtTail = false; - bool shouldPaint = false; - uint32_t dropped = 0; - size_t preDequeSize = 0; - size_t postDequeSize = 0; - bool hitCap = false; - bool firstFit = false; - size_t rowsSinceCheck = 0; - bool periodicFit = false; - size_t measureStart = 0; - int virtualIdx = 0; - int i = 0; - int width = 0; - bool anyGrew = false; - - if (m_listView == nullptr) - { - return; - } - - oldCount = static_cast (m_filteredIndices.size()); - topIndex = ListView_GetTopIndex (m_listView); - countPerPage = ListView_GetCountPerPage (m_listView); - wasAtTail = ComputeWasAtTail (topIndex, countPerPage, oldCount); - - preDequeSize = m_deque.size(); - dropped = m_droppedSinceLastDrain.exchange (0, std::memory_order_acq_rel); - - DebugDialogProjection::DrainAndProject (m_ring, m_deque, dropped, m_uptimeAnchor); - - postDequeSize = m_deque.size(); - - // pop_front happens only when the rolling cap is hit. When either - // the pre- or post-drain deque sized at the cap, indices may be - // stale by some unknown shift; rebuild from scratch. Otherwise - // run the cheap incremental append for slots [pre..post). - hitCap = preDequeSize >= DebugDialogProjection::kDisplayDequeCap - || postDequeSize >= DebugDialogProjection::kDisplayDequeCap; - - if (hitCap) - { - RebuildFilteredIndices(); - } - else - { - AppendFilteredIndicesFor (preDequeSize); - } - - newCount = static_cast (m_filteredIndices.size()); - - if (newCount == oldCount) - { - return; - } - - shouldPaint = IsWindowVisible (m_hwnd) && !IsIconic (m_hwnd) && !m_paused; - - if (!shouldPaint) - { - return; - } - - ListView_SetItemCountEx (m_listView, newCount, LVSICF_NOINVALIDATEALL | LVSICF_NOSCROLL); - - // Spec-006 bug fix. Auto-grow non-Detail columns periodically so - // wider data arriving later (e.g. cycle counts crossing the - // 7-digit / 9-digit boundary) expands the column instead of - // clipping. Pure grow -- existing widths are never shrunk so any - // user drag persists. Once the user explicitly resizes a column - // (HDN_ENDTRACK flips userResized), this loop stops touching that - // column. Throttled to every kAutoGrowRowThreshold rows so a - // sustained drain doesn't pay the string-width cost on every - // WM_TIMER tick. - firstFit = !m_firstAutoFitDone && !m_deque.empty(); - rowsSinceCheck = (m_deque.size() >= m_dequeSizeAtLastGrow) - ? (m_deque.size() - m_dequeSizeAtLastGrow) - : m_deque.size(); - periodicFit = m_firstAutoFitDone && rowsSinceCheck >= kAutoGrowRowThreshold; - - if (firstFit || periodicFit) - { - // First-ever fit measures the entire deque; subsequent - // periodic fits only measure rows added since the last grow - // (the "never shrink" invariant means existing wider columns - // already hold their floor, so comparing the new chunk's max - // against savedWidth suffices). If the deque shrunk under us - // (rolling-cap pop_front), fall back to measuring everything. - measureStart = firstFit ? 0 : (m_deque.size() - rowsSinceCheck); - - for (i = 0; i < kColumnCount; i++) - { - if (!m_columns[i].visible) - { - continue; - } - - if (m_columns[i].id != kDetailColumnId && !m_columns[i].userResized) - { - width = MeasureColumnContentWidth (m_columns[i].id, measureStart); - - if (width > m_columns[i].savedWidth) - { - ListView_SetColumnWidth (m_listView, virtualIdx, width); - m_columns[i].savedWidth = width; - anyGrew = true; - } - - m_columns[i].autoSizedYet = true; - } - - virtualIdx++; - } - - if (firstFit || anyGrew) - { - SizeDetailColumnToRemainder(); - } - - m_firstAutoFitDone = true; - m_dequeSizeAtLastGrow = m_deque.size(); - } - - if (wasAtTail && newCount > 0) - { - ListView_EnsureVisible (m_listView, newCount - 1, FALSE); - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// AppendFilteredIndicesFor -// -// Phase 7: extend m_filteredIndices with deque slots from startIdx -// that pass MatchesFilter. The drain-tick call site hands us the -// pre-drain filtered-indices count -- but startIdx here is computed -// by callers as "the deque position at which the next match should -// begin from", which is the deque size we last fully scanned. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::AppendFilteredIndicesFor (size_t startIdx) -{ - size_t i = 0; - - // m_deque may have shrunk (rolling-cap pop_front). Resync from - // scratch when that happens so the indices never dangle. - if (m_filteredIndices.size() > m_deque.size()) - { - RebuildFilteredIndices(); - return; - } - - if (startIdx > m_deque.size()) - { - startIdx = m_deque.size(); - } - - for (i = startIdx; i < m_deque.size(); i++) - { - if (MatchesFilter (m_deque[i], m_filter)) - { - m_filteredIndices.push_back (static_cast (i)); - } - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// OnNotify -// -//////////////////////////////////////////////////////////////////////////////// - -bool Disk2DebugDialog::OnNotify (HWND hwnd, WPARAM wParam, LPARAM lParam) -{ - NMHDR * pHdr = reinterpret_cast (lParam); - HWND header = nullptr; - DWORD msgPos = 0; - POINT pt = {}; - - UNREFERENCED_PARAMETER (hwnd); - UNREFERENCED_PARAMETER (wParam); - - if (pHdr == nullptr) - { - return false; - } - - if (pHdr->idFrom == kIdListView && pHdr->code == LVN_GETDISPINFOW) - { - HandleGetDispInfo (reinterpret_cast (lParam)); - return false; - } - - if (m_listView != nullptr) - { - header = ListView_GetHeader (m_listView); - } - - // FR-026 / FR-027. The ListView's header subcontrol surfaces - // right-clicks as NM_RCLICK and user width-drag completion as - // HDN_ENDTRACK. Both fire through the parent's WM_NOTIFY. - if (header != nullptr && pHdr->hwndFrom == header) - { - if (pHdr->code == NM_RCLICK) - { - msgPos = static_cast (GetMessagePos()); - pt.x = static_cast (static_cast (LOWORD (msgPos))); - pt.y = static_cast (static_cast (HIWORD (msgPos))); - ShowHeaderContextMenu (pt.x, pt.y); - return false; - } - - if (pHdr->code == HDN_ENDTRACKW || pHdr->code == HDN_ENDTRACKA) - { - NMHEADERW * hdrN = reinterpret_cast (lParam); - int logicalId = -1; - - CaptureCurrentWidthsIntoModel(); - - // Spec-006 bug fix. Mark the dragged column as "user - // resized" so the periodic auto-grow check in the drain - // tick stops widening it past the user's chosen width. - if (hdrN != nullptr && hdrN->iItem >= 0 && hdrN->iItem < kColumnCount) - { - logicalId = m_visibleOrdinalToLogicalId[hdrN->iItem]; - - if (logicalId >= 0 && logicalId < kColumnCount) - { - m_columns[logicalId].userResized = true; - } - } - - return false; - } - } - - return false; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// HandleGetDispInfo -// -// Virtual-mode ListView row fetch. The control passes iItem (the -// visible-row index into m_filteredIndices) and iSubItem (the -// visible-subset ordinal); we translate both back to the source -// deque entry and the logical column id before picking a string. -// -// pszText must remain valid only until the next message. Wall / -// Uptime / Cycle / Detail strings are stored on the deque entry so -// they are stable for the message duration. Event uses a thread- -// local scratch buffer copied from the wstring_view returned by -// DebugDialogProjection::EventLabel. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::HandleGetDispInfo (NMLVDISPINFOW * pInfo) -{ - thread_local wchar_t s_eventLabelBuf[32] = {}; - thread_local wchar_t s_driveBuf[4] = {}; - - LVITEMW & item = pInfo->item; - uint32_t deqIdx = 0; - int logicalId = 0; - int visibleOrdinal = 0; - std::wstring_view label; - size_t copyLen = 0; - - if ((item.mask & LVIF_TEXT) == 0) - { - return; - } - - if (item.iItem < 0 || static_cast (item.iItem) >= m_filteredIndices.size()) - { - return; - } - - deqIdx = m_filteredIndices[item.iItem]; - - if (deqIdx >= m_deque.size()) - { - return; - } - - visibleOrdinal = item.iSubItem; - - if (visibleOrdinal < 0 || visibleOrdinal >= kColumnCount) - { - return; - } - - logicalId = m_visibleOrdinalToLogicalId[visibleOrdinal]; - - if (logicalId < 0 || logicalId >= kColumnCount) - { - return; - } - - const Disk2EventDisplay & e = m_deque[deqIdx]; - - switch (logicalId) - { - case 0: - item.pszText = const_cast (e.wallStr.data()); - break; - - case 1: - item.pszText = const_cast (e.uptimeStr.data()); - break; - - case 2: - item.pszText = const_cast (e.cycleStr.data()); - break; - - case 3: - if (e.drive != Disk2EventDisplay::kFieldNotApplicable) - { - swprintf_s (s_driveBuf, L"%d", e.drive + 1); - item.pszText = s_driveBuf; - } - else - { - s_driveBuf[0] = L'\0'; - item.pszText = s_driveBuf; - } - break; - - case 4: - label = DebugDialogProjection::EventLabel (e.category, e.type); - copyLen = std::min (label.size(), - (sizeof (s_eventLabelBuf) / sizeof (s_eventLabelBuf[0])) - 1); - std::copy_n (label.data(), copyLen, s_eventLabelBuf); - s_eventLabelBuf[copyLen] = L'\0'; - item.pszText = s_eventLabelBuf; - break; - - case 5: - item.pszText = const_cast (e.detail.c_str()); - break; - - default: - break; - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// PublishToRing -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::PublishToRing (const Disk2Event & e) noexcept -{ - Disk2Event stamped = e; - - if (m_cycleCounter != nullptr) - { - stamped.cycle = *m_cycleCounter; - } - - if (!m_ring.TryPush (stamped)) - { - m_droppedSinceLastDrain.fetch_add (1, std::memory_order_relaxed); - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// ClearEvents -// -// Wipe the dialog's display state and discard any in-flight producer -// events. Called by EmulatorShell::ResetUptimeAnchor on every soft -// reset / power cycle so the debug log doesn't carry stale rows -// from the pre-reset boot into the post-reset uptime anchor. -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::ClearEvents() noexcept -{ - // Stack scratch buffer for the drain loop. 64 entries x ~32 bytes - // per Disk2Event = ~2 KB on the stack -- well under any sensible - // thread-stack reservation. Larger batch sizes save a few drain - // calls on huge backlogs at no behavior cost. - constexpr uint32_t kClearDrainBatchSize = 64; - - Disk2Event scratch[kClearDrainBatchSize] = {}; - uint32_t drained = 0; - - m_droppedSinceLastDrain.store (0, std::memory_order_release); - - do - { - drained = m_ring.Drain (scratch, kClearDrainBatchSize); - } - while (drained > 0); - - m_deque.clear(); - m_filteredIndices.clear(); - m_firstAutoFitDone = false; - m_dequeSizeAtLastGrow = 0; - m_lastPublishedCount = -1; - m_currentDrive = 0; - - if (m_listView != nullptr) - { - ListView_SetItemCountEx (m_listView, 0, LVSICF_NOSCROLL); - InvalidateRect (m_listView, nullptr, FALSE); - } -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// MakeStampedEvent -// -// Boilerplate factory for the seven Push*Event helpers. Stamps the -// category, the event type, and the currently-selected drive (each -// per-payload Push* helper then fills in its own payload struct). -// -//////////////////////////////////////////////////////////////////////////////// - -Disk2Event Disk2DebugDialog::MakeStampedEvent (EventCategory cat, Disk2EventType type) const noexcept -{ - Disk2Event e = {}; - - e.category = cat; - e.type = type; - e.drive = static_cast (m_currentDrive); - - return e; -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// PushControllerEvent -// -// Helper for the simple controller-side events whose only payload is -// the event type itself (motor strobes / engagement edges). -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::PushControllerEvent (Disk2EventType type) noexcept -{ - Disk2Event e = MakeStampedEvent (EventCategory::Controller, type); - - PublishToRing (e); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// PushHeadStepEvent -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::PushHeadStepEvent (int prevQt, int newQt) noexcept -{ - Disk2Event e = MakeStampedEvent (EventCategory::Controller, Disk2EventType::HeadStep); - - e.payload.step.prevQt = prevQt; - e.payload.step.newQt = newQt; - - PublishToRing (e); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// PushHeadBumpEvent -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::PushHeadBumpEvent (int atQt) noexcept -{ - Disk2Event e = MakeStampedEvent (EventCategory::Controller, Disk2EventType::HeadBump); - - e.payload.bump.atQt = atQt; - - PublishToRing (e); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// PushAddrMarkEvent -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::PushAddrMarkEvent (int track, int sector, int volume) noexcept -{ - Disk2Event e = MakeStampedEvent (EventCategory::Controller, Disk2EventType::AddrMark); - - e.payload.addrMark.track = track; - e.payload.addrMark.sector = sector; - e.payload.addrMark.volume = volume; - - PublishToRing (e); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// PushDataMarkEvent -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::PushDataMarkEvent (Disk2EventType type, int track, int sector, int volume, int byteCount) noexcept -{ - Disk2Event e = MakeStampedEvent (EventCategory::Controller, type); - - e.payload.dataMark.track = track; - e.payload.dataMark.sector = sector; - e.payload.dataMark.volume = volume; - e.payload.dataMark.byteCount = byteCount; - - PublishToRing (e); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// PushDriveEvent -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::PushDriveEvent (Disk2EventType type, int drive) noexcept -{ - Disk2Event e = MakeStampedEvent (EventCategory::Controller, type); - - // Drive events carry their target drive explicitly (eject / insert - // routes through whichever bay the user touched, not the currently - // selected one). - e.drive = static_cast (drive); - e.payload.drive.drive = drive; - - PublishToRing (e); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// PushAudioEvent -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::PushAudioEvent ( - Disk2EventType type, - SoundKind kind, - int drive, - SilentReason reason) noexcept -{ - Disk2Event e = MakeStampedEvent (EventCategory::Audio, type); - - // Audio events carry their target drive explicitly (sourced from - // the per-drive Disk2AudioSource, not the controller's currently - // selected drive). - e.drive = static_cast (drive); - e.payload.audio.kind = kind; - e.payload.audio.reason = reason; - e.payload.audio.drive = drive; - - PublishToRing (e); -} - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// IDisk2EventSink overrides -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::OnMotorCommandOn() { PushControllerEvent (Disk2EventType::MotorCommandOn); } -void Disk2DebugDialog::OnMotorEngaged() { PushControllerEvent (Disk2EventType::MotorEngaged); } -void Disk2DebugDialog::OnMotorCommandOff() { PushControllerEvent (Disk2EventType::MotorCommandOff); } -void Disk2DebugDialog::OnMotorDisengaged() { PushControllerEvent (Disk2EventType::MotorDisengaged); } - -void Disk2DebugDialog::OnHeadStep (int prevQt, int newQt) { PushHeadStepEvent (prevQt, newQt); } -void Disk2DebugDialog::OnHeadBump (int atQt) { PushHeadBumpEvent (atQt); } -void Disk2DebugDialog::OnAddressMark (int track, int sector, int volume) { PushAddrMarkEvent (track, sector, volume); } -void Disk2DebugDialog::OnDataMarkRead (int track, int sector, int volume, int byteCount) { PushDataMarkEvent (Disk2EventType::DataRead, track, sector, volume, byteCount); } -void Disk2DebugDialog::OnDataMarkWrite (int track, int sector, int volume, int byteCount) { PushDataMarkEvent (Disk2EventType::DataWrite, track, sector, volume, byteCount); } -void Disk2DebugDialog::OnDriveSelect (int drive) { m_currentDrive = drive; PushDriveEvent (Disk2EventType::DriveSelect, drive); } -void Disk2DebugDialog::OnDiskInserted (int drive) { PushDriveEvent (Disk2EventType::DiskInserted, drive); } -void Disk2DebugDialog::OnDiskEjected (int drive) { PushDriveEvent (Disk2EventType::DiskEjected, drive); } - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// IDriveAudioEventSink overrides -// -//////////////////////////////////////////////////////////////////////////////// - -void Disk2DebugDialog::OnAudioStarted (SoundKind kind, int drive) { PushAudioEvent (Disk2EventType::AudioStarted, kind, drive, SilentReason::DriveAudioDisabled); } -void Disk2DebugDialog::OnAudioRestarted (SoundKind kind, int drive) { PushAudioEvent (Disk2EventType::AudioRestarted, kind, drive, SilentReason::DriveAudioDisabled); } -void Disk2DebugDialog::OnAudioContinued (SoundKind kind, int drive) { PushAudioEvent (Disk2EventType::AudioContinued, kind, drive, SilentReason::DriveAudioDisabled); } -void Disk2DebugDialog::OnAudioSilent (SoundKind kind, int drive, SilentReason reason) { PushAudioEvent (Disk2EventType::AudioSilent, kind, drive, reason); } -void Disk2DebugDialog::OnAudioLoopStarted (SoundKind kind, int drive) { PushAudioEvent (Disk2EventType::AudioLoopStarted, kind, drive, SilentReason::DriveAudioDisabled); } -void Disk2DebugDialog::OnAudioLoopStopped (SoundKind kind, int drive) { PushAudioEvent (Disk2EventType::AudioLoopStopped, kind, drive, SilentReason::DriveAudioDisabled); } diff --git a/Casso/Disk2DebugDialog.h b/Casso/Disk2DebugDialog.h deleted file mode 100644 index 04885db0..00000000 --- a/Casso/Disk2DebugDialog.h +++ /dev/null @@ -1,251 +0,0 @@ -#pragma once - -#include "Pch.h" - -#include "Window.h" -#include "Disk2DebugDialogState.h" -#include "DebugDialogProjection.h" -#include "../CassoEmuCore/Devices/IDisk2EventSink.h" -#include "../CassoEmuCore/Devices/Disk2EventRing.h" -#include "../CassoEmuCore/Audio/IDriveAudioEventSink.h" - - - - - -//////////////////////////////////////////////////////////////////////////////// -// -// Disk2DebugDialog -// -// Spec-006 modeless debug window. Implements BOTH IDisk2EventSink -// (the controller-side contract) and IDriveAudioEventSink (the -// audio-side contract). Each sink callback packs a Disk2Event POD -// and tries to push it onto m_ring; ring-full bumps -// m_droppedSinceLastDrain (atomic, CPU-thread only) so the next -// UI-thread drain can emit a single coalesced [N events lost] marker. -// -// The dialog is owned by EmulatorShell and reused across opens; the -// WM_CLOSE handler hides the window rather than destroying it. -// -//////////////////////////////////////////////////////////////////////////////// - -class Disk2DebugDialog : public Window, - public IDisk2EventSink, - public IDriveAudioEventSink -{ -public: - Disk2DebugDialog(); - ~Disk2DebugDialog() override; - - HRESULT Create (HINSTANCE hInstance, HWND parentHwnd); - void Show(); - void Hide(); - void Destroy(); - - HWND GetHwnd() const noexcept { return m_hwnd; } - - // Spec-006 / FR-004a. The shell owns the canonical uptime anchor - // and pokes it through here on construction, on every Open, and - // on every SoftReset / PowerCycle. The dialog reads its private - // copy on each WM_TIMER drain when formatting Uptime strings. - void SetUptimeAnchor (std::chrono::steady_clock::time_point anchor) noexcept - { - m_uptimeAnchor = anchor; - } - - // Spec-006 bug-fix. SoftReset / PowerCycle wipes the //e back to a - // known state; the debug log's still-pending events from the old - // boot are no longer useful at that point. Shell calls this from - // ResetUptimeAnchor (which already fires on both reset paths). - // Clears the UI-thread deque, the filtered-index vector, the - // dropped-since-last-drain counter, and drains the SPSC ring to - // discard any in-flight producer events. Pause / resume state is - // intentionally preserved -- the user may be paused inspecting - // pre-reset state and a reset shouldn't yank them out of pause. - void ClearEvents() noexcept; - - // Spec-006 / FR-017. When the active machine config has more - // than one Disk II controller, append " (controller #0 only)" to - // the window title so the user knows the dialog is wired to the - // first controller. Called by the shell at open time. - void SetMultiControllerHint (bool isMulti) noexcept; - - // Spec-006 / FR-005 / bug-fix. The shell owns the CPU cycle - // counter; the dialog dereferences this pointer on every - // PublishToRing call so each event carries the cycle at which - // the controller / audio source fired it. nullptr default keeps - // headless tests (and the pre-Open window) safe. - void SetCycleCounter (const uint64_t * counter) noexcept - { - m_cycleCounter = counter; - } - - // IDisk2EventSink - void OnMotorCommandOn() override; - void OnMotorEngaged() override; - void OnMotorCommandOff() override; - void OnMotorDisengaged() override; - void OnHeadStep (int prevQt, int newQt) override; - void OnHeadBump (int atQt) override; - void OnAddressMark (int track, int sector, int volume) override; - void OnDataMarkRead (int track, int sector, int volume, int byteCount) override; - void OnDataMarkWrite (int track, int sector, int volume, int byteCount) override; - void OnDriveSelect (int drive) override; - void OnDiskInserted (int drive) override; - void OnDiskEjected (int drive) override; - - // IDriveAudioEventSink - void OnAudioStarted (SoundKind kind, int drive) override; - void OnAudioRestarted (SoundKind kind, int drive) override; - void OnAudioContinued (SoundKind kind, int drive) override; - void OnAudioSilent (SoundKind kind, int drive, SilentReason reason) override; - void OnAudioLoopStarted (SoundKind kind, int drive) override; - void OnAudioLoopStopped (SoundKind kind, int drive) override; - -protected: - LRESULT OnCreate (HWND hwnd, CREATESTRUCT * pcs) override; - bool OnClose (HWND hwnd) override; - bool OnDestroy (HWND hwnd) override; - bool OnKeyDown (WPARAM vk, LPARAM lParam) override; - bool OnSize (HWND hwnd, UINT width, UINT height) override; - bool OnCommandEx (HWND hwnd, int id, int notifyCode, HWND hCtl) override; - HBRUSH OnCtlColorStatic (HWND hwndDlg, HDC hdc, HWND hwndStatic) override; - bool OnTimer (HWND hwnd, UINT_PTR timerId) override; - bool OnNotify (HWND hwnd, WPARAM wParam, LPARAM lParam) override; - -private: - Disk2Event MakeStampedEvent (EventCategory cat, Disk2EventType type) const noexcept; - void PushControllerEvent (Disk2EventType type) noexcept; - void PushHeadStepEvent (int prevQt, int newQt) noexcept; - void PushHeadBumpEvent (int atQt) noexcept; - void PushAddrMarkEvent (int track, int sector, int volume) noexcept; - void PushDataMarkEvent (Disk2EventType type, int track, int sector, int volume, int byteCount) noexcept; - void PushDriveEvent (Disk2EventType type, int drive) noexcept; - void PushAudioEvent (Disk2EventType type, SoundKind kind, int drive, SilentReason reason) noexcept; - void PublishToRing (const Disk2Event & e) noexcept; - - HRESULT CreateChildControls (HWND hwnd); - void LayoutChildControls (int width, int height); - void RebuildListViewColumns(); - int MeasureColumnContentWidth (int logicalId, size_t startIdx) const; - void SizeDetailColumnToRemainder(); - void ToggleColumn (int id); - void CaptureCurrentWidthsIntoModel(); - void ShowHeaderContextMenu (int x, int y); - HFONT AcquireUiFont(); - - void HandleDrainTick(); - void HandleGetDispInfo (NMLVDISPINFOW * pInfo); - void AppendFilteredIndicesFor (size_t startDeqIdx); - void RebuildFilteredIndices(); - void InvalidateListView(); - - // Spec-006 round-4 bug 5. Capture the focused row's deque - // index before a filter rebuild and restore focus to either the - // same row (if it survived the filter) or the nearest earlier - // surviving row. -1 sentinel means "nothing was focused". - int CapturedFocusedDequeIdx() const noexcept; - void RestoreFocusedDequeIdx (int priorDequeIdx) noexcept; - - void OnFilterControlToggled (int id, HWND hCtl); - void OnFilterTextChanged(); - void OnFilterTextKillFocus(); - void FlushFilterDebounce(); - std::wstring ReadEditText (HWND hEdit) const; - void UpdateAudioSubEnableState(); - - HRESULT InstallListViewSubclass(); - void CopySelectedRowsToClipboard(); - static LRESULT CALLBACK s_ListViewSubclassProc (HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); - - WNDPROC m_originalListViewProc = nullptr; - - HWND m_listView = nullptr; - - // Event-type checkboxes (FR-014 categories, fixed order) - // 0 Motor / 1 HeadStep / 2 HeadBump / 3 AddrMark - // 4 Read / 5 Write / 6 Door / 7 DriveSelect - std::array m_eventTypeChecks {}; - - HWND m_audioMasterCheck = nullptr; - - // Audio sub-checkboxes: Started / Restarted / Continued / Silent - std::array m_audioSubCheck {}; - - // Drive radio: 0 = All, 1 = Drive 1, 2 = Drive 2 - std::array m_driveRadio {}; - - HWND m_trackRichEdit = nullptr; - HWND m_sectorRichEdit = nullptr; - HWND m_trackFilterLabel = nullptr; - HWND m_sectorFilterLabel = nullptr; - HWND m_filterTooltip = nullptr; - HWND m_trackInvalidLabel = nullptr; - HWND m_sectorInvalidLabel = nullptr; - HWND m_trackRawQtCheck = nullptr; - - // Spec-006 bug 4. The squiggle re-application reaches into the - // RichEdit's selection state via SetSel + SetCharFormat; even - // with EM_EXGETSEL/EM_EXSETSEL bracketing, fighting an active - // user mouse-drag selection can re-anchor the selection caret - // in the middle of a drag. Skip the squiggle pass entirely - // when the text content matches what was last formatted. - std::wstring m_lastFormattedTrackText; - std::wstring m_lastFormattedSectorText; - - HWND m_pauseButton = nullptr; - HWND m_clearButton = nullptr; - - HFONT m_uiFont = nullptr; - - Disk2EventRing m_ring; - std::atomic m_droppedSinceLastDrain { 0 }; - std::deque m_deque; - std::vector m_filteredIndices; - - FilterState m_filter; - bool m_paused = false; - - std::array m_columns {}; - std::array m_visibleOrdinalToLogicalId {}; - - UINT_PTR m_drainTimerId = 1; - UINT_PTR m_filterDebounceTimerId = 2; - bool m_drainTimerActive = false; - bool m_filterDebouncePending = false; - - // Spec-006 bug fix. The dialog opens with an empty deque so the - // first RebuildListViewColumns pass can only size each non-Detail - // column to the width of its header text. As soon as the first - // batch of real events lands in the deque, the drain tick re-fits - // every still-untouched column to MAX (header, widest cell). Set - // to false on every ClearEvents() so a soft-reset re-runs the fit. - bool m_firstAutoFitDone = false; - - // Spec-006 bug fix. Throttle the periodic auto-grow check to - // every kAutoGrowRowThreshold rows so a busy drain (DOS RWTS - // sustained reads) doesn't pay the O(deque * columns) string- - // width cost on every WM_TIMER. The check still runs on the - // first non-empty drain regardless (gated by m_firstAutoFitDone). - static constexpr size_t kAutoGrowRowThreshold = 100; - size_t m_dequeSizeAtLastGrow = 0; - - // Spec-006 bug-fix. InvalidateListView short-circuits when the - // (count, first-deque-idx, last-deque-idx) triple matches the - // last publish -- prevents flashing on filter-checkbox toggles - // that don't actually change the projection. - int m_lastPublishedCount = -1; - uint32_t m_lastPublishedFirstIdx = 0; - uint32_t m_lastPublishedLastIdx = 0; - - std::chrono::steady_clock::time_point m_uptimeAnchor; - const uint64_t * m_cycleCounter = nullptr; - - // Spec-006 bug fix. Cached active-drive index used to stamp - // Disk2Event::drive on every controller-side event that doesn't - // carry its own drive (motor / head / address mark / data mark). - // Initialized to 0 (controller boots with drive 0 active); - // updated on OnDriveSelect BEFORE the event is pushed so the - // stamped value matches the controller's new active drive. - int m_currentDrive = 0; -}; diff --git a/Casso/Disk2DebugDialogState.h b/Casso/Disk2DebugDialogState.h index e63e3312..24b10801 100644 --- a/Casso/Disk2DebugDialogState.h +++ b/Casso/Disk2DebugDialogState.h @@ -82,7 +82,7 @@ struct FilterState constexpr int kColWallWidth = 110; constexpr int kColUptimeWidth = 90; constexpr int kColCycleWidth = 110; -constexpr int kColDriveWidth = 56; +constexpr int kColDriveWidth = 76; constexpr int kColEventWidth = 130; constexpr int kColDetailWidth = 360; constexpr int kColumnCount = 6; diff --git a/Casso/EmulatorShell.cpp b/Casso/EmulatorShell.cpp index 2dbe69ce..528c52c9 100644 --- a/Casso/EmulatorShell.cpp +++ b/Casso/EmulatorShell.cpp @@ -2,6 +2,7 @@ #include "EmulatorShell.h" #include "AssetBootstrap.h" + #include "Core/PathResolver.h" #include "Version.h" #include "resource.h" @@ -32,6 +33,7 @@ #include "Ui/TitleBarHitTest.h" #include "Ui/Chrome/ChromeMetrics.h" #include "Ui/DriveWidgetController.h" +#include "Shell/DiskMru.h" #pragma comment(lib, "ole32.lib") #pragma comment(lib, "comctl32.lib") @@ -63,6 +65,7 @@ static constexpr int kFramebufferHeight = ChromeMetrics::kFramebufferHe static constexpr LPCWSTR kWindowClass = L"CassoWindow"; static constexpr int s_kBaseDpi = ChromeMetrics::kBaseDpi; static constexpr int s_kDriveWidgetGapDp = 16; +static constexpr int s_kLabelBottomGapDp = 2; @@ -85,8 +88,8 @@ namespace { int bottomInset = layout.bottomInsetPx; int commandBarTop = std::max (0, clientH - bottomInset); - int commandBarH = std::max (0, clientH - commandBarTop); int gap = MulDiv (s_kDriveWidgetGapDp, static_cast (dpi), s_kBaseDpi); + int bottomGap = 0; RECT probe = {}; int widgetW = 0; int widgetH = 0; @@ -98,12 +101,16 @@ namespace driveChrome[0].Layout (0, 0, dpi); - probe = driveChrome[0].BodyRect(); + probe = driveChrome[0].OuterRect(); widgetW = probe.right - probe.left; widgetH = probe.bottom - probe.top; totalW = widgetW * static_cast (driveChrome.size()) + gap * (static_cast (driveChrome.size()) - 1); x = std::max (0, (clientW - totalW) / 2); - y = commandBarTop + (commandBarH - widgetH) / 2; + // Anchor the widget to the bottom so the margin between the + // basename label and the window edge mirrors the gap between + // the drive body and the label (s_kLabelStripGapPx, scaled). + bottomGap = MulDiv (s_kLabelBottomGapDp, static_cast (dpi), s_kBaseDpi); + y = std::max (commandBarTop, clientH - widgetH - bottomGap); for (i = 0; i < driveChrome.size(); i++) { @@ -375,7 +382,7 @@ EmulatorShell::~EmulatorShell() // is destroyed, which happens via m_ownedDevices / m_diskAudioSources // below). Controller sink first, then audio sink, matching the // attachment order in OpenDisk2DebugDialog. - if (m_disk2DebugDialog != nullptr) + if (m_disk2DebugPanel != nullptr) { controller = m_diskManager->FindSlot6Controller(); @@ -392,8 +399,7 @@ EmulatorShell::~EmulatorShell() } } - m_disk2DebugDialog->Destroy(); - m_disk2DebugDialog.reset(); + m_disk2DebugPanel.reset(); } // / T097 / FR-025. Final auto-flush of any dirty disks on @@ -1447,7 +1453,53 @@ bool EmulatorShell::OnNotify (HWND hwnd, WPARAM wParam, LPARAM lParam) HRESULT EmulatorShell::Mount (int slot, int drive, const std::wstring & path) { - return m_diskManager->Mount (slot, drive, path); + HRESULT hr = S_OK; + + + + hr = m_diskManager->Mount (slot, drive, path); + CHR (hr); + + RecordRecentDisk (path); + +Error: + return hr; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// RecordRecentDisk +// +// Push a successfully-mounted disk image onto the recent-disks MRU +// and persist the updated prefs. Best-effort; failures are swallowed +// so an MRU write hiccup never blocks a successful mount. +// +//////////////////////////////////////////////////////////////////////////////// + +void EmulatorShell::RecordRecentDisk (const std::wstring & path) +{ + DiskMru mru; + std::filesystem::path fsPath; + std::vector serialized; + + + + if (path.empty()) + { + return; + } + + fsPath = std::filesystem::path (path); + mru = DiskMru::FromUtf8 (m_globalPrefs.recentDisks); + mru.RecordMount (fsPath); + mru.ToUtf8 (serialized); + m_globalPrefs.recentDisks = std::move (serialized); + + SaveGlobalPrefs(); } @@ -1654,6 +1706,35 @@ void EmulatorShell::SaveGlobalPrefs() +//////////////////////////////////////////////////////////////////////////////// +// +// EmulatorShell::ShowModalDialog +// +// Lazy-registers the DialogPrimitive window class on first use and +// blocks until the user dismisses the dialog. Returns the chosen +// button's resultCode, or -1 when the user closes via window gesture. +// +//////////////////////////////////////////////////////////////////////////////// + +int EmulatorShell::ShowModalDialog (const DialogDefinition & def) +{ + HRESULT hr = S_OK; + + + hr = m_dialogPrimitive.RegisterClass (m_hInstance); + IGNORE_RETURN_VALUE (hr, S_OK); + + return m_dialogPrimitive.Show (m_hwnd, + m_d3dRenderer.GetDevice(), + m_d3dRenderer.GetContext(), + &m_chromeTheme, + def); +} + + + + + //////////////////////////////////////////////////////////////////////////////// // // EmulatorShell::ApplyThemeToChrome @@ -1674,8 +1755,12 @@ void EmulatorShell::SaveGlobalPrefs() void EmulatorShell::ApplyThemeToChrome (const ChromeTheme & theme) { - constexpr int s_kFullDriveBarDp = 192; - constexpr int s_kCompactDriveBarDp = 64; + // Drive-bar slot thickness. Skeuomorphic shows the full 3D drive + // body (160 px) plus ~30 px of vertical slack and the basename + // label strip (~20 px). Compact themes show only the flat card + // (40 px) plus padding and the label strip. + constexpr int s_kFullDriveBarDp = 212; + constexpr int s_kCompactDriveBarDp = 84; int desiredThicknessDp = theme.compactDrives ? s_kCompactDriveBarDp : s_kFullDriveBarDp; int priorThicknessDp = m_driveBarSlot.DesiredThicknessDp(); @@ -1878,6 +1963,10 @@ int EmulatorShell::RunMessageLoop() // open in state-only and never repaint, looking dead. m_settingsPanel.UpdatePreviewOverlap (m_d3dRenderer.GetEmulatorContentScreenRect()); IGNORE_RETURN_VALUE (hr, m_settingsPanel.RenderPopup()); + if (m_disk2DebugPanel != nullptr) + { + IGNORE_RETURN_VALUE (hr, m_disk2DebugPanel->RenderFrame()); + } if (m_navLayer.IsOpen()) { m_d3dRenderer.MarkRedrawNeeded(); @@ -3071,43 +3160,43 @@ void EmulatorShell::OpenDisk2DebugDialog() } } - if (m_disk2DebugDialog == nullptr) + if (m_disk2DebugPanel == nullptr || m_disk2DebugPanel->Hwnd() == nullptr) { - m_disk2DebugDialog = std::make_unique(); - - hInstance = reinterpret_cast (GetWindowLongPtr (m_hwnd, GWLP_HINSTANCE)); + hInstance = reinterpret_cast (GetWindowLongPtr (m_hwnd, GWLP_HINSTANCE)); + m_disk2DebugPanel = std::make_unique(); - hr = m_disk2DebugDialog->Create (hInstance, m_hwnd); - CHRF (hr, m_disk2DebugDialog.reset()); + hr = m_disk2DebugPanel->Create (hInstance, + m_hwnd, + m_d3dRenderer.GetDevice(), + m_d3dRenderer.GetContext(), + &m_chromeTheme); + CHRF (hr, m_disk2DebugPanel.reset()); - m_disk2DebugDialog->SetUptimeAnchor (m_uptimeAnchor); - m_disk2DebugDialog->SetMultiControllerHint (Disk2Count > 1); + m_disk2DebugPanel->SetUptimeAnchor (m_uptimeAnchor); + m_disk2DebugPanel->SetMultiControllerHint (Disk2Count > 1); if (m_cpu != nullptr) { - m_disk2DebugDialog->SetCycleCounter (m_cpu->GetCycleCounterPtr()); + m_disk2DebugPanel->SetCycleCounter (m_cpu->GetCycleCounterPtr()); } - // FR-024: both sinks attached together, dialog implements - // both interfaces. Audio sink is a no-op if the mixer has no - // source registered (e.g., audio subsystem disabled). - controller->SetEventSink (m_disk2DebugDialog.get()); + controller->SetEventSink (m_disk2DebugPanel.get()); for (i = 0; i < m_diskAudioSources.size(); i++) { if (m_diskAudioSources[i] != nullptr) { - m_diskAudioSources[i]->SetAudioEventSink (m_disk2DebugDialog.get()); + m_diskAudioSources[i]->SetAudioEventSink (m_disk2DebugPanel.get()); } } } else { - m_disk2DebugDialog->SetMultiControllerHint (Disk2Count > 1); + m_disk2DebugPanel->SetMultiControllerHint (Disk2Count > 1); } - m_disk2DebugDialog->Show(); - SetForegroundWindow (m_disk2DebugDialog->GetHwnd()); + m_disk2DebugPanel->Show(); + SetForegroundWindow (m_disk2DebugPanel->Hwnd()); Error: return; @@ -3123,11 +3212,11 @@ void EmulatorShell::OpenDisk2DebugDialog() // // Spec-006 bug 15. SwitchMachine tears down the old controller and // audio source then constructs new ones via CreateMemoryDevices, -// but the dialog's sink wiring only ran inside OpenDisk2DebugDialog +// but the panel's sink wiring only ran inside OpenDisk2DebugDialog // on first open -- the new controller starts with m_eventSink == // nullptr and the new audio source with m_audioEventSink == nullptr, // so the debug window goes silent post-switch. Re-attach both -// sinks if the dialog is still open. No-op when the dialog has +// sinks if the panel is still open. No-op when the panel has // never been opened. // //////////////////////////////////////////////////////////////////////////////// @@ -3138,20 +3227,20 @@ void EmulatorShell::AttachDebugSinksIfOpen() Disk2Controller * controller = nullptr; size_t i = 0; - CBR (m_disk2DebugDialog != nullptr); + CBR (m_disk2DebugPanel != nullptr); controller = m_diskManager->FindSlot6Controller(); if (controller != nullptr) { - controller->SetEventSink (m_disk2DebugDialog.get()); + controller->SetEventSink (m_disk2DebugPanel.get()); } for (i = 0; i < m_diskAudioSources.size(); i++) { if (m_diskAudioSources[i] != nullptr) { - m_diskAudioSources[i]->SetAudioEventSink (m_disk2DebugDialog.get()); + m_diskAudioSources[i]->SetAudioEventSink (m_disk2DebugPanel.get()); } } diff --git a/Casso/EmulatorShell.h b/Casso/EmulatorShell.h index 199a35ff..6f922c26 100644 --- a/Casso/EmulatorShell.h +++ b/Casso/EmulatorShell.h @@ -9,7 +9,6 @@ #include "Core/ComponentRegistry.h" #include "D3DRenderer.h" #include "UiCommandTypes.h" -#include "DebugConsole.h" #include "Ui/Chrome/TitleBar.h" #include "Ui/Chrome/NavLayer.h" #include "Ui/Chrome/LayoutManager.h" @@ -19,6 +18,8 @@ #include "Ui/DriveWidgetController.h" #include "Ui/DragDropTarget.h" #include "Ui/IDriveCommandSink.h" +#include "Ui/Dialog/DialogDefinition.h" +#include "Ui/Dialog/DialogPrimitive.h" #include "Ui/Settings/SettingsPanel.h" #include "Ui/ThemeManager.h" #include "Ui/UiShell.h" @@ -32,7 +33,7 @@ #include "Audio/DriveAudioMixer.h" #include "Audio/Disk2AudioSource.h" #include "WasapiAudio.h" -#include "Disk2DebugDialog.h" +#include "Ui/Disk2DebugPanel.h" #include "Shell/ClipboardManager.h" #include "Shell/CpuManager.h" #include "Shell/DiskManager.h" @@ -106,13 +107,13 @@ class EmulatorShell : public Window, public IDriveCommandSink { m_uptimeAnchor = std::chrono::steady_clock::now(); - if (m_disk2DebugDialog != nullptr) + if (m_disk2DebugPanel != nullptr) { - m_disk2DebugDialog->SetUptimeAnchor (m_uptimeAnchor); + m_disk2DebugPanel->SetUptimeAnchor (m_uptimeAnchor); // Spec-006 bug-fix. Clear stale rows from the pre-reset // boot so the post-reset uptime anchor doesn't end up // formatting events that pre-date its own zero point. - m_disk2DebugDialog->ClearEvents(); + m_disk2DebugPanel->ClearEvents(); } } @@ -215,6 +216,22 @@ class EmulatorShell : public Window, public IDriveCommandSink return m_uiFramebuffer.empty() ? nullptr : m_uiFramebuffer.data(); } + // Accessor for the Settings → Theme preview so it can render the + // basename label with the actual filename of whatever disk image is + // currently mounted in each drive (or an empty string if the drive + // is empty). Index 0 is drive 1, index 1 is drive 2. + const std::wstring & MountedImagePath (int driveIndex) const + { + static const std::wstring s_kEmpty; + + if (driveIndex < 0 || driveIndex >= (int) m_driveWidgetState.size()) + { + return s_kEmpty; + } + + return m_driveWidgetState[(size_t) driveIndex].mountedImagePath; + } + // Base directory for user preferences. SettingsPanel.CommitApply // uses this as the fallback save path when the unified store is not // available. @@ -247,6 +264,16 @@ class EmulatorShell : public Window, public IDriveCommandSink // shell's Initialize sequence. void SaveGlobalPrefs (); + // Lazily registers the DialogPrimitive window class on first call + // and shows the supplied dialog modally. Returns the resultCode + // of the chosen button, or -1 on close-gesture. + int ShowModalDialog (const DialogDefinition & def); + + // Push a freshly mounted disk image onto the recent-disks MRU + // and persist user prefs. Best-effort; never propagates failures + // back into the mount path. + void RecordRecentDisk (const std::wstring & path); + // MachineManager and WindowCommandManager touch enough shell // state during construction and command dispatch that friend // declarations are the pragmatic seam; no new global state is @@ -267,7 +294,7 @@ class EmulatorShell : public Window, public IDriveCommandSink D3DRenderer m_d3dRenderer; WasapiAudio m_wasapiAudio; - DebugConsole m_debugConsole; + DialogPrimitive m_dialogPrimitive; // UI-thread filesystem and chrome ownership. The painter pass // and shell composition is reintroduced in a later phase; for now @@ -292,7 +319,7 @@ class EmulatorShell : public Window, public IDriveCommandSink LayoutManager m_layout { Scaler() }; SimpleEdgeContributor m_titleBarSlot { ChromeEdge::Top, 32 }; SimpleEdgeContributor m_navStripSlot { ChromeEdge::Top, 32 }; - SimpleEdgeContributor m_driveBarSlot { ChromeEdge::Bottom, 192 }; + SimpleEdgeContributor m_driveBarSlot { ChromeEdge::Bottom, 212 }; // Drive widget state pump. The controller channel publishes // per-drive door/spin sync events the chrome painter will consume @@ -409,11 +436,11 @@ class EmulatorShell : public Window, public IDriveCommandSink uint32_t m_cyclesPerFrame = 17050; double m_sampleRemainder = 0.0; - // Spec-006 / FR-001 / FR-004a. Owned by the shell so the dialog - // can be lazy-created on first Ctrl+Shift+D and reused across - // opens. The uptime anchor lives on the shell (not the dialog) - // so resets re-zero it even while the dialog is closed. - std::unique_ptr m_disk2DebugDialog; + // Spec-011 / US7. DX-themed panel for the Disk II debug window. + // Lazy-created on first Ctrl+Shift+D and reused across opens. + // The uptime anchor lives on the shell (not the panel) so resets + // re-zero it even while the panel is closed. + std::unique_ptr m_disk2DebugPanel; std::chrono::steady_clock::time_point m_uptimeAnchor { std::chrono::steady_clock::now() }; // Extracted shell-side managers. WindowManager owns the per-monitor diff --git a/Casso/Main.cpp b/Casso/Main.cpp index f9e3ea69..984295b6 100644 --- a/Casso/Main.cpp +++ b/Casso/Main.cpp @@ -9,6 +9,7 @@ #include "DiskSettings.h" #include "EmulatorShell.h" #include "Core/MachineScanner.h" +#include "Shell/DiskMru.h" #pragma comment(lib, "ole32.lib") @@ -121,53 +122,72 @@ static HRESULT LoadMachineConfig ( } } - // Pre-flight: detect missing ROMs and offer to download them - // BEFORE we open the on-disk JSON. The download set is decided - // strictly from the embedded default for `machineName`, so if - // the user has edited their on-disk JSON they're responsible - // for any extra ROMs they reference. + // Pre-flight: detect everything missing (ROMs + optional Disk II + // drive audio) and present a SINGLE themed dialog that downloads + // it all on a worker thread with live progress. Decisions for the + // download set are made strictly from the embedded default for + // `machineName` and the user's prior audio-consent choice. romDir = AssetBootstrap::GetAssetBaseDirectory(); - hr = AssetBootstrap::CheckAndFetchRoms (hInstance, machineName, hwndParent, - romSearchPaths, romDir, error); - BAIL_OUT_IF (hr == S_FALSE, S_FALSE); - CHRN (hr, format (L"ROM download failed:\n{}", - wstring (error.begin(), error.end())).c_str()); - - // Disk II audio bootstrap (spec 005-disk-ii-audio / - // FR-017). Only relevant when the active machine actually has a - // Disk II controller wired up. Failures are best-effort: we log - // and continue so a missing-internet startup still launches the - // emulator (the source mutes any unloaded sample, FR-009). { - bool hasDisk = false; - string hasDiskErr; - HRESULT hrHasDisk = AssetBootstrap::HasDiskController (hInstance, machineName, - hasDisk, hasDiskErr); + bool hasDisk = false; + string hasDiskErr; + HRESULT hrHasDisk = AssetBootstrap::HasDiskController (hInstance, machineName, + hasDisk, hasDiskErr); + GlobalUserPrefs prefs; + Win32FileSystem fs_io; + std::wstring assetBase = AssetBootstrap::GetAssetBaseDirectory().wstring(); + wstring downloadedDisk; + fs::path bootDiskDir = AssetBootstrap::GetDiskDirectory(); + bool offerBootDisk = false; + HRESULT hrLoad; + HRESULT hrSave; + IGNORE_RETURN_VALUE (hrHasDisk, S_OK); - if (hasDisk) + hrLoad = prefs.Load (assetBase, fs_io); + IGNORE_RETURN_VALUE (hrLoad, S_OK); + + // Read the per-machine saved disk path up front so we can ask + // the unified downloader to also fetch a stock boot disk on + // first launch (no --disk1, no remembered disk, machine has a + // Disk ][ controller). Doing this here keeps the entire + // first-launch experience inside one themed dialog. + if (inoutDisk1Path.empty()) { - fs::path devicesDir = romDir / L"Devices" / L"DiskII"; - string diskAudioErr; - GlobalUserPrefs prefs; - Win32FileSystem fs_io; - std::wstring assetBase = AssetBootstrap::GetAssetBaseDirectory().wstring(); - HRESULT hrLoad; - HRESULT hrDiskAudio; - - hrLoad = prefs.Load (assetBase, fs_io); - IGNORE_RETURN_VALUE (hrLoad, S_OK); - - hrDiskAudio = AssetBootstrap::CheckAndFetchDiskAudio ( - hInstance, machineName, hwndParent, devicesDir, prefs, diskAudioErr); - IGNORE_RETURN_VALUE (hrDiskAudio, S_OK); - - // The consent choice may have changed (user just answered - // the prompt). Flush regardless so any in-memory mutation - // lands on disk for the next launch. - HRESULT hrSave = prefs.Save (assetBase, fs_io); - IGNORE_RETURN_VALUE (hrSave, S_OK); + UserConfigStore store (assetBase); + + hrSaved = DiskSettings::ReadSavedDiskPath (store, fs_io, 0, machineName, savedDisk); + IGNORE_RETURN_VALUE (hrSaved, S_OK); + + if (!savedDisk.empty() && !fs::exists (fs::path (savedDisk))) + { + HRESULT hrClear = DiskSettings::WriteSavedDiskPath ( + store, fs_io, 0, machineName, wstring()); + IGNORE_RETURN_VALUE (hrClear, S_OK); + savedDisk.clear(); + } + + offerBootDisk = savedDisk.empty() && hasDisk; + } + + hr = AssetBootstrap::RunStartupDownloader (hInstance, machineName, hwndParent, + romSearchPaths, romDir, hasDisk, + offerBootDisk, bootDiskDir, + prefs, downloadedDisk, error); + + hrSave = prefs.Save (assetBase, fs_io); + IGNORE_RETURN_VALUE (hrSave, S_OK); + + BAIL_OUT_IF (hr == S_FALSE, S_FALSE); + CHRN (hr, format (L"Asset download failed:\n{}", + wstring (error.begin(), error.end())).c_str()); + + // If the unified downloader pulled a boot disk, treat it as + // disk1 so the legacy picker downstream short-circuits. + if (!downloadedDisk.empty()) + { + inoutDisk1Path = downloadedDisk; } } @@ -198,20 +218,36 @@ static HRESULT LoadMachineConfig ( if (savedDisk.empty()) { - wstring downloaded; + wstring downloaded; + GlobalUserPrefs prefs; + Win32FileSystem fs_prefs; + DiskMru mru; + vector mruExisting; + HRESULT hrPrefs = S_OK; + bool userClosed = false; diskDir = AssetBootstrap::GetDiskDirectory(); - hr = AssetBootstrap::OfferBootDiskDownload ( - hInstance, machineName, hwndParent, diskDir, downloaded, error); + hrPrefs = prefs.Load (AssetBootstrap::GetAssetBaseDirectory().wstring(), fs_prefs); + IGNORE_RETURN_VALUE (hrPrefs, S_OK); + + mru = DiskMru::FromUtf8 (prefs.recentDisks); + mruExisting = mru.Prune ([] (const fs::path & p) { return fs::exists (p); }); + + hr = AssetBootstrap::PromptBootDiskMru ( + hInstance, hwndParent, machineName, mruExisting, diskDir, prefs.activeTheme, downloaded, userClosed, error); + + CHRN (hr, format (L"Boot disk download failed:\n{}", + wstring (error.begin(), error.end())).c_str()); + + if (userClosed) + { + hr = S_FALSE; + goto Error; + } - // S_FALSE = "user said no" or "no disk controller for this - // machine" — both are fine, just keep the slot empty. - // Hard failure surfaces a notification and bails. - if (hr != S_FALSE) + if (!downloaded.empty()) { - CHRN (hr, format (L"Boot disk download failed:\n{}", - wstring (error.begin(), error.end())).c_str()); inoutDisk1Path = downloaded; } diff --git a/Casso/Shell/DiskMru.cpp b/Casso/Shell/DiskMru.cpp new file mode 100644 index 00000000..35eca42d --- /dev/null +++ b/Casso/Shell/DiskMru.cpp @@ -0,0 +1,180 @@ +#include "Pch.h" + +#include "DiskMru.h" + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// EnforceCap +// +// Trims the tail of `m_entries` until size <= k_capacity. +// +//////////////////////////////////////////////////////////////////////////////// + +void DiskMru::EnforceCap () +{ + while (m_entries.size() > k_capacity) + { + m_entries.pop_back(); + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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()) + { + 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(); + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Snapshot +// +//////////////////////////////////////////////////////////////////////////////// + +std::vector DiskMru::Snapshot () const +{ + return m_entries; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Prune +// +// Removes entries the predicate rejects, preserving order. A null +// predicate is a no-op. +// +//////////////////////////////////////////////////////////////////////////////// + +std::vector DiskMru::Prune ( + const std::function & existsPredicate) +{ + std::vector::iterator last; + + + + if (existsPredicate) + { + 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); + 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 new file mode 100644 index 00000000..05a06aa6 --- /dev/null +++ b/Casso/Shell/DiskMru.h @@ -0,0 +1,56 @@ +#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(); } + + // 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 (); + + std::vector m_entries; // index 0 == most recent +}; diff --git a/Casso/Shell/MachineManager.cpp b/Casso/Shell/MachineManager.cpp index 6cea24d1..c67e8143 100644 --- a/Casso/Shell/MachineManager.cpp +++ b/Casso/Shell/MachineManager.cpp @@ -29,7 +29,7 @@ #include "Audio/Disk2AudioSource.h" #include "Shell/CpuManager.h" #include "Shell/DiskManager.h" -#include "../Disk2DebugDialog.h" +#include "../Ui/Disk2DebugPanel.h" @@ -864,6 +864,8 @@ HRESULT MachineManager::SwitchMachine (const std::wstring & machineName) JsonValue mergedJson; JsonParseError parseErr; WORD speedCmd = 0; + std::string carryDisk1; + std::string carryDisk2; @@ -954,13 +956,23 @@ HRESULT MachineManager::SwitchMachine (const std::wstring & machineName) IGNORE_RETURN_VALUE (hrFlush, S_OK); } + // Snapshot the currently-mounted slot-6 disks so they follow the + // user across the machine switch. The mental model is physical: + // the user mounted a disk, changed the host machine, and expects + // the disk to still be in the drive. Re-mounting on the new + // machine also updates its per-machine prefs so the disk sticks + // on subsequent launches. Empty paths fall through to the + // per-machine prefs lookup inside MountCommandLineDisks. + carryDisk1 = m_shell.m_diskStore.GetSourcePath (6, 0); + carryDisk2 = m_shell.m_diskStore.GetSourcePath (6, 1); + // Tear down current machine. The Disk II debug dialog (if open) // holds a raw pointer into the old CPU's cycle counter; revoke it // before the CPU is reset so the dialog can't dereference dangling // memory between here and CreateCpu below. - if (m_shell.m_disk2DebugDialog != nullptr) + if (m_shell.m_disk2DebugPanel != nullptr) { - m_shell.m_disk2DebugDialog->SetCycleCounter (nullptr); + m_shell.m_disk2DebugPanel->SetCycleCounter (nullptr); } // Tear down ALL per-machine state in one atomic move. m_refs is a @@ -1011,9 +1023,9 @@ HRESULT MachineManager::SwitchMachine (const std::wstring & machineName) // Re-attach the new CPU's cycle counter to the debug dialog (the // pointer was revoked above before the old CPU was destroyed). - if (m_shell.m_disk2DebugDialog != nullptr && m_shell.m_cpu != nullptr) + if (m_shell.m_disk2DebugPanel != nullptr && m_shell.m_cpu != nullptr) { - m_shell.m_disk2DebugDialog->SetCycleCounter (m_shell.m_cpu->GetCycleCounterPtr()); + m_shell.m_disk2DebugPanel->SetCycleCounter (m_shell.m_cpu->GetCycleCounterPtr()); } // Re-wire the debug dialog onto the freshly built controller + @@ -1045,9 +1057,22 @@ HRESULT MachineManager::SwitchMachine (const std::wstring & machineName) PowerCycle(); // Remount per-machine disks if any were saved last time this - // machine was active. Empty paths fall through harmlessly so a - // never-used machine won't try to mount anything. - m_shell.m_diskManager->MountCommandLineDisks (std::string(), std::string()); + // machine was active. The disks that were in the drives before + // the switch take priority (passed explicitly here) so the user's + // physical mental model holds: the disk in the drive stays in + // the drive across a machine swap. Empty paths fall through + // harmlessly so a never-used machine won't try to mount anything. + // + // If the new machine has no Disk II controller at slot 6 (future + // non-Apple-II family), drop the carry rather than silently relying + // on MountDiskInSlot6's nullptr CBR. The disk in DiskImageStore + // was already flushed above, so no user data is lost. + if (!m_shell.m_diskManager->HasSlot6Controller()) + { + carryDisk1.clear(); + carryDisk2.clear(); + } + m_shell.m_diskManager->MountCommandLineDisks (carryDisk1, carryDisk2); if (speedCmd != 0) { diff --git a/Casso/Shell/WindowCommandManager.cpp b/Casso/Shell/WindowCommandManager.cpp index da156351..6c1d193d 100644 --- a/Casso/Shell/WindowCommandManager.cpp +++ b/Casso/Shell/WindowCommandManager.cpp @@ -229,7 +229,12 @@ void WindowCommandManager::OnMachineCommand (int id) (m_shell.m_config.ram.size() + 1 + m_shell.m_config.slots.size()), (m_shell.m_config.internalDevices.size() + m_shell.m_config.slots.size())); - MessageBoxW (m_shell.m_hwnd, info.c_str(), L"Machine info", MB_ICONINFORMATION | MB_OK); + DialogDefinition def = {}; + def.title = L"Machine info"; + def.icon = DialogIcon::Info; + def.body.push_back ({ info, false, L"" }); + def.buttons.push_back ({ L"OK", 0, true, true }); + (void) m_shell.ShowModalDialog (def); break; } } @@ -428,8 +433,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 +443,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; } @@ -477,30 +477,12 @@ void WindowCommandManager::OnHelpCommand (int id) { switch (id) { - case IDM_HELP_DEBUG: - { - if (m_shell.m_debugConsole.IsVisible()) - { - m_shell.m_debugConsole.Hide(); - } - else - { - m_shell.m_debugConsole.SetMainWindow (m_shell.m_hwnd); - - if (m_shell.m_debugConsole.Show (m_shell.m_hInstance)) - { - m_shell.m_debugConsole.LogConfig ( - std::format ("Machine: {}\nCPU: {}\nClock: {} Hz\nDevices: {}", - m_shell.m_config.name, m_shell.m_config.cpu, m_shell.m_config.clockSpeed, - (m_shell.m_config.internalDevices.size() + m_shell.m_config.slots.size()))); - } - } - break; - } - case IDM_HELP_KEYMAP: { - MessageBoxW (m_shell.m_hwnd, + DialogDefinition def = {}; + def.title = L"Keyboard map"; + def.icon = DialogIcon::Info; + def.body.push_back ({ L"PC key mapping:\n\n" L"Arrow keys -> Apple ][ cursor movement\n" L"Enter -> Return\n" @@ -516,30 +498,32 @@ void WindowCommandManager::OnHelpCommand (int id) L"Pause -> Pause/resume\n" L"F11 -> Step (when paused)\n" L"Alt+Enter -> Fullscreen\n" - L"Ctrl+0 -> Reset window size\n" - L"Ctrl+D -> Debug console", - L"Keyboard map", MB_ICONINFORMATION | MB_OK); + L"Ctrl+0 -> Reset window size", + false, L"" }); + def.buttons.push_back ({ L"OK", 0, true, true }); + (void) m_shell.ShowModalDialog (def); break; } case IDM_HELP_ABOUT: { - MessageBoxW (m_shell.m_hwnd, - L"Casso Emulator\n" - L"\n" - L"Version " _CRT_WIDE (VERSION_STRING) L"\n" - L"Built " _CRT_WIDE (VERSION_BUILD_TIMESTAMP) L"\n" - L"\n" - L"An Apple ][, ][ plus, and //e platform emulator built \n" - L"on the Casso 6502 assembler/emulator project.\n" - L"\n" - L"https://github.com/relmer/Casso" - L"\n" - L"Copyright (C) by Robert Elmer\n" - L"MIT License\n", - L"About Casso", - MB_ICONINFORMATION | MB_OK); - break; + DialogDefinition def = {}; + def.title = L"About Casso"; + def.icon = DialogIcon::AppPhotoreal; + def.body.push_back ({ L"Casso Emulator\n\nVersion " _CRT_WIDE (VERSION_STRING) + L"\nBuilt " _CRT_WIDE (VERSION_BUILD_TIMESTAMP) + L"\n\nAn Apple ][, Apple ][ plus, and Apple //e platform emulator " + L"built on the Casso 6502 assembler/emulator project.\n\n", + false, L"" }); + def.body.push_back ({ L"https://github.com/relmer/Casso", + true, L"https://github.com/relmer/Casso" }); + def.body.push_back ({ L"\nCopyright (C) by Robert Elmer\n", + false, L"" }); + def.body.push_back ({ L"MIT License", + true, L"https://github.com/relmer/Casso/blob/master/LICENSE" }); + def.buttons.push_back ({ L"OK", 0, true, true }); + (void) m_shell.ShowModalDialog (def); + break; } } } diff --git a/Casso/Ui/Chrome/ChromeTheme.h b/Casso/Ui/Chrome/ChromeTheme.h index 1404ac4a..8c1c3b41 100644 --- a/Casso/Ui/Chrome/ChromeTheme.h +++ b/Casso/Ui/Chrome/ChromeTheme.h @@ -46,6 +46,14 @@ struct ChromeTheme uint32_t ledPresentArgb = 0; uint32_t ledActiveArgb = 0; uint32_t ledHaloArgb = 0; + uint32_t linkArgb = 0; + uint32_t linkHoverArgb = 0; + uint32_t panelBgArgb = 0; + uint32_t panelEdgeArgb = 0; + uint32_t buttonIdleArgb = 0; + uint32_t buttonHoverArgb = 0; + uint32_t buttonPressedArgb = 0; + uint32_t buttonBorderArgb = 0; static ChromeTheme Skeuomorphic() { @@ -90,6 +98,14 @@ struct ChromeTheme theme.ledPresentArgb = 0xFF1A0606; theme.ledActiveArgb = 0xFFFF2818; theme.ledHaloArgb = 0x60FF2818; + theme.linkArgb = 0xFF6FB8FF; + theme.linkHoverArgb = 0xFFB7DFFF; + theme.panelBgArgb = 0xFF1A2230; + theme.panelEdgeArgb = 0xFF334050; + theme.buttonIdleArgb = 0xFF2D3F58; + theme.buttonHoverArgb = 0xFF3D547A; + theme.buttonPressedArgb = 0xFF1F2C40; + theme.buttonBorderArgb = 0xFF4A5F80; return theme; } @@ -126,6 +142,14 @@ struct ChromeTheme theme.ledPresentArgb = 0xFF06121A; theme.ledActiveArgb = 0xFF3DA1FF; theme.ledHaloArgb = 0x603DA1FF; + theme.linkArgb = 0xFF6FB8FF; + theme.linkHoverArgb = 0xFFA8D2FF; + theme.panelBgArgb = 0xFF1E2024; + theme.panelEdgeArgb = 0xFF3A3D42; + theme.buttonIdleArgb = 0xFF323539; + theme.buttonHoverArgb = 0xFF45494F; + theme.buttonPressedArgb = 0xFF23252A; + theme.buttonBorderArgb = 0xFF55595F; return theme; } @@ -162,6 +186,14 @@ struct ChromeTheme theme.ledPresentArgb = 0xFF071907; theme.ledActiveArgb = 0xFF2BFF6A; theme.ledHaloArgb = 0x602BFF6A; + theme.linkArgb = 0xFF8AFF8A; + theme.linkHoverArgb = 0xFFB7FCB9; + theme.panelBgArgb = 0xFF0E2612; + theme.panelEdgeArgb = 0xFF2A5C30; + theme.buttonIdleArgb = 0xFF1A3F22; + theme.buttonHoverArgb = 0xFF286036; + theme.buttonPressedArgb = 0xFF0F2814; + theme.buttonBorderArgb = 0xFF3A7548; return theme; } diff --git a/Casso/Ui/Chrome/ChromedPanelWindow.cpp b/Casso/Ui/Chrome/ChromedPanelWindow.cpp new file mode 100644 index 00000000..4bc91348 --- /dev/null +++ b/Casso/Ui/Chrome/ChromedPanelWindow.cpp @@ -0,0 +1,1198 @@ +#include "Pch.h" + +#include "ChromedPanelWindow.h" + +#include "IChromedPanelContent.h" +#include "ChromeTheme.h" +#include "TitleBar.h" + +#include "../../resource.h" + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Constants +// +//////////////////////////////////////////////////////////////////////////////// + +static constexpr DWORD s_kChromedPanelStyle = WS_POPUP | WS_THICKFRAME | WS_SYSMENU | WS_VISIBLE; +static constexpr DWORD s_kChromedPanelExStyle = WS_EX_DLGMODALFRAME | WS_EX_TOOLWINDOW | WS_EX_NOREDIRECTIONBITMAP; +static constexpr int s_kBaseDpi = 96; +static constexpr int s_kCenterDivisor = 2; +static constexpr int s_kMinResizeBorderPx = 8; +static constexpr int s_kIconSizePx = 32; +static constexpr WORD s_kBgraBitCount = 32; + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// LoadIconAsPremulBgra +// +// Lifted verbatim from SettingsWindow. Used by OnCreate to upload the +// Casso app icon into the title-bar's BGRA cache. +// +//////////////////////////////////////////////////////////////////////////////// + +static bool LoadIconAsPremulBgra ( + HINSTANCE hInstance, + int iconResourceId, + int sizePx, + std::vector & outPixels, + int & outW, + int & outH) +{ + static constexpr int s_kAlphaShift = 24; + static constexpr int s_kRedShift = 16; + static constexpr int s_kGreenShift = 8; + static constexpr int s_kByteMask = 0xFF; + static constexpr int s_kByteMax = 255; + + HICON hIcon = nullptr; + HDC screenDc = nullptr; + HDC memDc = nullptr; + HBITMAP dib = nullptr; + HBITMAP oldBitmap = nullptr; + void * dibBits = nullptr; + BITMAPINFO bmi = {}; + bool success = false; + size_t pixelCount = (size_t) sizePx * (size_t) sizePx; + + + + hIcon = (HICON) LoadImageW (hInstance, + MAKEINTRESOURCEW (iconResourceId), + IMAGE_ICON, + sizePx, sizePx, + LR_DEFAULTCOLOR); + if (hIcon == nullptr) + { + return false; + } + + screenDc = GetDC (nullptr); + memDc = CreateCompatibleDC (screenDc); + + bmi.bmiHeader.biSize = sizeof (BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = sizePx; + bmi.bmiHeader.biHeight = -sizePx; + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = s_kBgraBitCount; + bmi.bmiHeader.biCompression = BI_RGB; + + dib = CreateDIBSection (memDc, &bmi, DIB_RGB_COLORS, &dibBits, nullptr, 0); + + if (dib != nullptr && dibBits != nullptr) + { + oldBitmap = (HBITMAP) SelectObject (memDc, dib); + memset (dibBits, 0, pixelCount * sizeof (uint32_t)); + + if (DrawIconEx (memDc, 0, 0, hIcon, sizePx, sizePx, 0, nullptr, DI_NORMAL)) + { + uint32_t * src = (uint32_t *) dibBits; + size_t i = 0; + + + + outPixels.assign (pixelCount, 0); + + for (i = 0; i < pixelCount; i++) + { + uint32_t px = src[i]; + uint8_t a = (uint8_t) ((px >> s_kAlphaShift) & s_kByteMask); + uint8_t r = (uint8_t) ((px >> s_kRedShift) & s_kByteMask); + uint8_t g = (uint8_t) ((px >> s_kGreenShift) & s_kByteMask); + uint8_t b = (uint8_t) ( px & s_kByteMask); + + r = (uint8_t) ((r * a) / s_kByteMax); + g = (uint8_t) ((g * a) / s_kByteMax); + b = (uint8_t) ((b * a) / s_kByteMax); + + outPixels[i] = ((uint32_t) a << s_kAlphaShift) | + ((uint32_t) r << s_kRedShift) | + ((uint32_t) g << s_kGreenShift) | + (uint32_t) b; + } + + outW = sizePx; + outH = sizePx; + success = true; + } + + SelectObject (memDc, oldBitmap); + } + + if (dib != nullptr) { DeleteObject (dib); } + if (memDc != nullptr) { DeleteDC (memDc); } + if (screenDc != nullptr) { ReleaseDC (nullptr, screenDc); } + DestroyIcon (hIcon); + + return success; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ~ChromedPanelWindow +// +//////////////////////////////////////////////////////////////////////////////// + +ChromedPanelWindow::~ChromedPanelWindow () +{ + Destroy(); +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// RegisterClass +// +// Idempotent. className is owned by the caller (typically a constexpr +// string literal in the content TU). The same class can be registered +// more than once cheaply -- GetClassInfoExW short-circuits. +// +//////////////////////////////////////////////////////////////////////////////// + +HRESULT ChromedPanelWindow::RegisterClass (HINSTANCE hInstance, LPCWSTR className) +{ + HRESULT hr = S_OK; + WNDCLASSEXW wcex = { sizeof (wcex) }; + BOOL ok = FALSE; + ATOM atom = 0; + + + + CBRAEx (hInstance, E_INVALIDARG); + CBRAEx (className, E_INVALIDARG); + + ok = GetClassInfoExW (hInstance, className, &wcex); + if (ok) + { + m_hInstance = hInstance; + m_className = className; + BAIL_OUT_IF (true, S_OK); + } + + wcex.style = CS_DBLCLKS | CS_HREDRAW | CS_VREDRAW; + wcex.lpfnWndProc = ChromedPanelWindow::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 = className; + wcex.hIconSm = nullptr; + + atom = RegisterClassExW (&wcex); + CWRA (atom); + + m_hInstance = hInstance; + m_className = className; + +Error: + return hr; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Create +// +//////////////////////////////////////////////////////////////////////////////// + +HRESULT ChromedPanelWindow::Create ( + HWND hwndOwner, + IChromedPanelContent * content, + ID3D11Device * device, + ID3D11DeviceContext * context, + const ChromeTheme * theme) +{ + HRESULT hr = S_OK; + UINT dpi = s_kBaseDpi; + RECT windowRect = {}; + HWND hwndCreated = nullptr; + HWND hwndParent = nullptr; + BOOL ok = FALSE; + LPCWSTR effectiveClass = nullptr; + LPCWSTR title = nullptr; + DWORD exStyle = s_kChromedPanelExStyle; + + + + CBRAEx (hwndOwner, E_INVALIDARG); + CBRAEx (content, 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_content = content; + m_device = device; + m_context = context; + m_theme = theme; + + effectiveClass = (m_className != nullptr) ? m_className : content->GetWindowClassName(); + title = content->GetWindowTitle(); + CBRAEx (effectiveClass, E_INVALIDARG); + CBRAEx (title, E_INVALIDARG); + + dpi = GetDpiForWindow (hwndOwner); + windowRect = GetInitialWindowRect (hwndOwner, dpi); + + // Non-modal panels: parent=nullptr (don't pin above owner) + + // WS_EX_APPWINDOW (give them a taskbar button so the user can + // alt-tab back to them after Casso covers them) + drop + // WS_EX_TOOLWINDOW (which hides from alt-tab/taskbar). Modal/inline + // panels (Settings) stay owned and keep the tool-window style so + // they don't pollute the taskbar. + if (content->IsNonModal()) + { + hwndParent = nullptr; + exStyle = (exStyle & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW; + } + else + { + hwndParent = hwndOwner; + } + + hwndCreated = CreateWindowExW (exStyle, + effectiveClass, + title, + s_kChromedPanelStyle, + windowRect.left, + windowRect.top, + windowRect.right - windowRect.left, + windowRect.bottom - windowRect.top, + hwndParent, + nullptr, + m_hInstance, + this); + CWRA (hwndCreated); + + ok = SetWindowTextW (hwndCreated, title); + CWRA (ok); + + ShowWindow (hwndCreated, SW_SHOWNORMAL); + SetForegroundWindow (hwndCreated); + SetFocus (hwndCreated); + +Error: + if (FAILED (hr)) + { + m_hwndOwner = nullptr; + m_content = nullptr; + m_device = nullptr; + m_context = nullptr; + m_theme = nullptr; + } + return hr; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Destroy +// +//////////////////////////////////////////////////////////////////////////////// + +void ChromedPanelWindow::Destroy () +{ + HWND hwnd = m_hwnd; + + + + if (hwnd != nullptr) + { + DestroyWindow (hwnd); + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// SetTheme +// +//////////////////////////////////////////////////////////////////////////////// + +void ChromedPanelWindow::SetTheme (const ChromeTheme * theme) +{ + m_theme = theme; + if (m_content != nullptr) + { + m_content->SetChromeTheme (&m_titleBar, m_theme); + } + + if (m_hwnd != nullptr) + { + InvalidateRect (m_hwnd, nullptr, FALSE); + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Render +// +//////////////////////////////////////////////////////////////////////////////// + +HRESULT ChromedPanelWindow::Render () +{ + HRESULT hr = S_OK; + + + + BAIL_OUT_IF (m_content == nullptr || m_hwnd == nullptr, S_OK); + + hr = m_content->Render(); + CHRA (hr); + +Error: + return hr; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// s_WndProc +// +//////////////////////////////////////////////////////////////////////////////// + +LRESULT CALLBACK ChromedPanelWindow::s_WndProc (HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) +{ + ChromedPanelWindow * 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 ChromedPanelWindow::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: + CloseWithCancel(); + result = 0; + break; + + case WM_NCCALCSIZE: + if (!OnNcCalcSize (hwnd, wParam, lParam, result)) + { + break; + } + result = DefWindowProcW (hwnd, message, wParam, lParam); + break; + + case WM_NCHITTEST: + result = OnNcHitTest (hwnd, (int) (short) LOWORD (lParam), (int) (short) HIWORD (lParam)); + break; + + case WM_NCLBUTTONDOWN: + OnNcMouse (message, wParam, lParam); + if (OnNcLButtonDown (hwnd, (LRESULT) wParam)) + { + result = 0; + break; + } + result = DefWindowProcW (hwnd, message, wParam, lParam); + break; + + case WM_NCLBUTTONUP: + case WM_NCMOUSEMOVE: + case WM_NCMOUSELEAVE: + OnNcMouse (message, wParam, lParam); + result = DefWindowProcW (hwnd, message, wParam, lParam); + break; + + case WM_GETMINMAXINFO: + OnGetMinMax (reinterpret_cast (lParam)); + result = 0; + break; + + case WM_SIZE: + OnSize ((int) LOWORD (lParam), (int) HIWORD (lParam)); + result = 0; + break; + + case WM_DPICHANGED: + OnDpiChanged (HIWORD (wParam), *reinterpret_cast (lParam)); + result = 0; + break; + + case WM_SETFOCUS: + m_hasFocus = true; + result = 0; + break; + + case WM_KILLFOCUS: + m_hasFocus = false; + result = 0; + break; + + case WM_KEYDOWN: + OnKeyDown (wParam); + result = 0; + break; + + case WM_CHAR: + if (m_content != nullptr) + { + m_content->OnChar ((wchar_t) wParam); + } + result = 0; + break; + + case WM_MOUSEMOVE: + case WM_LBUTTONDOWN: + case WM_LBUTTONUP: + case WM_LBUTTONDBLCLK: + case WM_RBUTTONDOWN: + case WM_MOUSEWHEEL: + OnMouse (message, wParam, lParam); + result = 0; + break; + + case WM_SETCURSOR: + { + HCURSOR cursor = nullptr; + + if (m_content != nullptr && LOWORD (lParam) == HTCLIENT) + { + POINT pt = {}; + if (GetCursorPos (&pt) && ScreenToClient (hwnd, &pt)) + { + cursor = m_content->OnSetCursor (pt.x, pt.y); + } + } + + if (cursor != nullptr) + { + SetCursor (cursor); + result = TRUE; + } + else + { + result = DefWindowProcW (hwnd, message, wParam, lParam); + } + } + break; + + case WM_GETOBJECT: + // TODO: expose the panel content tree through UI Automation. + result = DefWindowProcW (hwnd, message, wParam, lParam); + break; + + default: + result = DefWindowProcW (hwnd, message, wParam, lParam); + break; + } + + return result; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnCreate +// +//////////////////////////////////////////////////////////////////////////////// + +HRESULT ChromedPanelWindow::OnCreate (HWND hwnd) +{ + HRESULT hr = S_OK; + RECT rc = {}; + BOOL ok = FALSE; + UINT dpi = s_kBaseDpi; + std::vector iconPixels; + int iconW = 0; + int iconH = 0; + + + + m_hwnd = hwnd; + ok = GetClientRect (m_hwnd, &rc); + CWRA (ok); + + dpi = GetDpiForWindow (m_hwnd); + m_titleBar.UpdateGeometry (rc.right - rc.left, dpi); + + if (LoadIconAsPremulBgra (m_hInstance, IDI_CASSO, s_kIconSizePx, iconPixels, iconW, iconH)) + { + m_titleBar.SetAppIcon (std::move (iconPixels), iconW, iconH); + } + + CBRA (m_content); + hr = m_content->OnHostCreated (m_hwnd, + m_device, + m_context, + rc.right - rc.left, + rc.bottom - rc.top, + dpi, + &m_titleBar, + m_theme); + CHRA (hr); + +Error: + return hr; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnDestroy +// +//////////////////////////////////////////////////////////////////////////////// + +void ChromedPanelWindow::OnDestroy () +{ + if (m_content != nullptr) + { + m_content->OnHostDestroyed(); + } + m_hwnd = nullptr; + m_hwndOwner = nullptr; + m_content = nullptr; + m_device = nullptr; + m_context = nullptr; + m_theme = nullptr; + m_hasFocus = false; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnSize +// +//////////////////////////////////////////////////////////////////////////////// + +void ChromedPanelWindow::OnSize (int widthPx, int heightPx) +{ + HRESULT hr = S_OK; + UINT dpi = s_kBaseDpi; + + + + BAIL_OUT_IF (m_hwnd == nullptr || m_content == nullptr, S_OK); + + dpi = GetDpiForWindow (m_hwnd); + m_titleBar.UpdateGeometry (widthPx, dpi); + hr = m_content->OnHostResize (widthPx, heightPx, dpi); + IGNORE_RETURN_VALUE (hr, S_OK); + + // Force a render at the new size. WM_SIZE arrives inside Windows' + // modal resize loop, which blocks the host's main render loop + // until the user releases the mouse. Without this explicit + // Render(), the popup paints with stale layout for the entire drag. + hr = Render(); + IGNORE_RETURN_VALUE (hr, S_OK); + +Error: + return; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnDpiChanged +// +//////////////////////////////////////////////////////////////////////////////// + +void ChromedPanelWindow::OnDpiChanged (UINT dpi, const RECT & suggestedRect) +{ + HRESULT hr = S_OK; + BOOL ok = FALSE; + + + + CBRA (m_hwnd); + CBRA (m_content); + + ok = SetWindowPos (m_hwnd, + nullptr, + suggestedRect.left, + suggestedRect.top, + suggestedRect.right - suggestedRect.left, + suggestedRect.bottom - suggestedRect.top, + SWP_NOZORDER | SWP_NOACTIVATE); + CWRA (ok); + + m_titleBar.UpdateGeometry (suggestedRect.right - suggestedRect.left, dpi); + + hr = m_content->OnHostResize (suggestedRect.right - suggestedRect.left, + suggestedRect.bottom - suggestedRect.top, + dpi); + IGNORE_RETURN_VALUE (hr, S_OK); + +Error: + return; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnGetMinMax +// +//////////////////////////////////////////////////////////////////////////////// + +void ChromedPanelWindow::OnGetMinMax (MINMAXINFO * minMaxInfo) +{ + RECT rc = {}; + UINT dpi = s_kBaseDpi; + SIZE minClient = {}; + + + + if (minMaxInfo == nullptr) + { + return; + } + + if (m_hwnd != nullptr) + { + dpi = GetDpiForWindow (m_hwnd); + } + + minClient = GetPreferredClientSize (dpi); + rc = { 0, 0, minClient.cx, minClient.cy }; + AdjustWindowRectExForDpi (&rc, s_kChromedPanelStyle, FALSE, s_kChromedPanelExStyle, dpi); + + minMaxInfo->ptMinTrackSize.x = rc.right - rc.left; + minMaxInfo->ptMinTrackSize.y = rc.bottom - rc.top; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnNcCalcSize +// +//////////////////////////////////////////////////////////////////////////////// + +bool ChromedPanelWindow::OnNcCalcSize (HWND hwnd, WPARAM wParam, LPARAM lParam, LRESULT & outResult) +{ + NCCALCSIZE_PARAMS * pParams = nullptr; + LRESULT defResult = 0; + LONG originalTop = 0; + + + + if (wParam == FALSE) + { + outResult = 0; + return false; + } + + pParams = reinterpret_cast (lParam); + if (pParams == nullptr) + { + outResult = 0; + return false; + } + + originalTop = pParams->rgrc[0].top; + defResult = DefWindowProcW (hwnd, WM_NCCALCSIZE, wParam, lParam); + if (defResult != 0) + { + outResult = defResult; + return false; + } + + pParams->rgrc[0].top = originalTop; + outResult = 0; + return false; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnNcHitTest +// +//////////////////////////////////////////////////////////////////////////////// + +LRESULT ChromedPanelWindow::OnNcHitTest (HWND hwnd, int xScreen, int yScreen) +{ + POINT pt = { xScreen, yScreen }; + RECT rcClient = {}; + RECT rcTitle = {}; + RECT rcMin = {}; + RECT rcMax = {}; + RECT rcClose = {}; + TitleBarHitTestInput in = {}; + UINT dpi = s_kBaseDpi; + int framePx = 0; + int padPx = 0; + int borderPx = 0; + + + + if (!ScreenToClient (hwnd, &pt)) + { + return HTNOWHERE; + } + + if (!GetClientRect (hwnd, &rcClient)) + { + return HTNOWHERE; + } + + rcTitle = m_titleBar.GetTitleBarRect(); + rcMin = m_titleBar.GetButtonRect (SystemButton::Minimize); + rcMax = m_titleBar.GetButtonRect (SystemButton::Maximize); + rcClose = m_titleBar.GetButtonRect (SystemButton::Close); + + dpi = GetDpiForWindow (hwnd); + framePx = GetSystemMetricsForDpi (SM_CXSIZEFRAME, dpi); + padPx = GetSystemMetricsForDpi (SM_CXPADDEDBORDER, dpi); + borderPx = framePx + padPx; + if (borderPx < s_kMinResizeBorderPx) + { + borderPx = s_kMinResizeBorderPx; + } + + in.clientWidth = rcClient.right - rcClient.left; + in.clientHeight = rcClient.bottom - rcClient.top; + in.mouseX = pt.x; + in.mouseY = pt.y; + in.titleLeft = rcTitle.left; + in.titleTop = rcTitle.top; + in.titleRight = rcTitle.right; + in.titleBottom = rcTitle.bottom; + in.minLeft = rcMin.left; in.minTop = rcMin.top; + in.minRight = rcMin.right; in.minBottom = rcMin.bottom; + in.maxLeft = rcMax.left; in.maxTop = rcMax.top; + in.maxRight = rcMax.right; in.maxBottom = rcMax.bottom; + in.closeLeft = rcClose.left; in.closeTop = rcClose.top; + in.closeRight = rcClose.right; in.closeBottom = rcClose.bottom; + in.resizeBorderPx = borderPx; + + return TitleBarHitTest::Test (in); +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnNcLButtonDown +// +//////////////////////////////////////////////////////////////////////////////// + +bool ChromedPanelWindow::OnNcLButtonDown (HWND hwnd, LRESULT hitTest) +{ + WPARAM command = 0; + + + + switch (hitTest) + { + case HTCLOSE: + command = SC_CLOSE; + break; + + case HTMINBUTTON: + command = SC_MINIMIZE; + break; + + case HTMAXBUTTON: + command = IsZoomed (hwnd) ? SC_RESTORE : SC_MAXIMIZE; + break; + + default: + return false; + } + + PostMessageW (hwnd, WM_SYSCOMMAND, command, 0); + return true; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnNcMouse +// +//////////////////////////////////////////////////////////////////////////////// + +void ChromedPanelWindow::OnNcMouse (UINT message, WPARAM wParam, LPARAM lParam) +{ + POINT pt = { (int) (short) LOWORD (lParam), (int) (short) HIWORD (lParam) }; + bool leftDown = (GetKeyState (VK_LBUTTON) & 0x8000) != 0; + + + + if (message == WM_NCMOUSELEAVE) + { + pt.x = -1; + pt.y = -1; + leftDown = false; + } + else + { + ScreenToClient (m_hwnd, &pt); + if (message == WM_NCLBUTTONDOWN) + { + leftDown = true; + } + else if (message == WM_NCLBUTTONUP) + { + leftDown = false; + } + } + + m_titleBar.SetMousePosition (pt.x, pt.y, leftDown); + InvalidateRect (m_hwnd, nullptr, FALSE); + (void) wParam; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnMouse +// +//////////////////////////////////////////////////////////////////////////////// + +void ChromedPanelWindow::OnMouse (UINT message, WPARAM wParam, LPARAM lParam) +{ + HRESULT hr = S_OK; + int x = (int) (short) LOWORD (lParam); + int y = (int) (short) HIWORD (lParam); + int delta = 0; + POINT pt = {}; + + + + BAIL_OUT_IF (m_content == nullptr, S_OK); + + if (message == WM_MOUSEWHEEL) + { + pt.x = (int) (short) LOWORD (lParam); + pt.y = (int) (short) HIWORD (lParam); + ScreenToClient (m_hwnd, &pt); + x = pt.x; + y = pt.y; + delta = GET_WHEEL_DELTA_WPARAM (wParam); + } + + m_titleBar.SetMousePosition (x, y, (wParam & MK_LBUTTON) != 0); + + if (message == WM_LBUTTONDOWN || message == WM_LBUTTONDBLCLK) + { + SetCapture (m_hwnd); + SetFocus (m_hwnd); + m_content->OnLButtonDown (x, y); + } + else if (message == WM_LBUTTONUP) + { + ReleaseCapture(); + m_content->OnLButtonUp (x, y); + } + else if (message == WM_RBUTTONDOWN) + { + SetFocus (m_hwnd); + m_content->OnRButtonDown (x, y); + } + else if (message == WM_MOUSEWHEEL) + { + m_content->OnMouseWheel (x, y, delta); + } + else + { + m_content->OnMouseMove (x, y); + } + + DestroyIfContentInactive(); + (void) wParam; + +Error: + return; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnKeyDown +// +//////////////////////////////////////////////////////////////////////////////// + +void ChromedPanelWindow::OnKeyDown (WPARAM vk) +{ + HRESULT hr = S_OK; + bool consumed = false; + + + + BAIL_OUT_IF (m_content == nullptr, S_OK); + + consumed = m_content->OnKey (vk); + if (!consumed && vk == VK_RETURN) + { + m_content->Accept(); + } + else if (!consumed && vk == VK_ESCAPE) + { + m_content->Cancel(); + } + + DestroyIfContentInactive(); + +Error: + return; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// CloseWithCancel +// +//////////////////////////////////////////////////////////////////////////////// + +void ChromedPanelWindow::CloseWithCancel () +{ + if (m_content != nullptr) + { + m_content->Cancel(); + } + Destroy(); +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DestroyIfContentInactive +// +//////////////////////////////////////////////////////////////////////////////// + +void ChromedPanelWindow::DestroyIfContentInactive () +{ + if (m_content != nullptr && !m_content->IsContentActive()) + { + Destroy(); + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// GetPreferredClientSize +// +//////////////////////////////////////////////////////////////////////////////// + +SIZE ChromedPanelWindow::GetPreferredClientSize (UINT dpi) const +{ + SIZE size = {}; + + + + if (m_content != nullptr) + { + size = m_content->PreferredClientSize (dpi); + size.cy += TitleBarLayout::DefaultTitleHeight (dpi); + } + return size; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// GetInitialWindowRect +// +//////////////////////////////////////////////////////////////////////////////// + +RECT ChromedPanelWindow::GetInitialWindowRect (HWND hwndOwner, UINT dpi) const +{ + constexpr int s_kSideGapPx = 8; + + RECT windowRect = {}; + RECT ownerRect = {}; + RECT workRect = {}; + HMONITOR monitor = nullptr; + MONITORINFO mi = { sizeof (mi) }; + SIZE client = GetPreferredClientSize (dpi); + int width = 0; + int height = 0; + int x = 0; + int y = 0; + bool ownerKnown = false; + + + + windowRect = { 0, 0, client.cx, client.cy }; + AdjustWindowRectExForDpi (&windowRect, s_kChromedPanelStyle, FALSE, s_kChromedPanelExStyle, dpi); + + width = windowRect.right - windowRect.left; + height = windowRect.bottom - windowRect.top; + ownerKnown = (GetWindowRect (hwndOwner, &ownerRect) != FALSE); + monitor = MonitorFromWindow (hwndOwner, MONITOR_DEFAULTTONEAREST); + if (monitor != nullptr && GetMonitorInfoW (monitor, &mi)) + { + workRect = mi.rcWork; + } + else + { + workRect = ownerRect; + } + + // Placement rules (matching SettingsWindow): + // 1. Owner maximized -> center on owner. + // 2. Else, prefer right edge of owner, top-aligned, if the popup + // fits entirely on the owner's monitor. + // 3. Else, try left edge, same rule. + // 4. Else, pick the side with more room and align flush with + // that monitor's work-area edge. + // 5. NEVER span monitor boundaries (final clamp to workRect). + bool ownerMaximized = ownerKnown && (IsZoomed (hwndOwner) != FALSE); + + if (! ownerKnown) + { + x = workRect.left + (workRect.right - workRect.left - width) / s_kCenterDivisor; + y = workRect.top + (workRect.bottom - workRect.top - height) / s_kCenterDivisor; + } + else if (ownerMaximized) + { + x = ownerRect.left + (ownerRect.right - ownerRect.left - width) / s_kCenterDivisor; + y = ownerRect.top + (ownerRect.bottom - ownerRect.top - height) / s_kCenterDivisor; + } + else if (ownerRect.right + s_kSideGapPx + width <= workRect.right) + { + x = ownerRect.right + s_kSideGapPx; + y = ownerRect.top; + } + else if (ownerRect.left - s_kSideGapPx - width >= workRect.left) + { + x = ownerRect.left - s_kSideGapPx - width; + y = ownerRect.top; + } + else + { + int roomRight = workRect.right - ownerRect.right; + int roomLeft = ownerRect.left - workRect.left; + + if (roomRight >= roomLeft) + { + x = workRect.right - width; + } + else + { + x = workRect.left; + } + y = ownerRect.top; + } + + // Final clamp to monitor work area. + x = std::max (workRect.left, std::min (x, workRect.right - width)); + y = std::max (workRect.top, std::min (y, workRect.bottom - height)); + + return { x, y, x + width, y + height }; +} diff --git a/Casso/Ui/Chrome/ChromedPanelWindow.h b/Casso/Ui/Chrome/ChromedPanelWindow.h new file mode 100644 index 00000000..fa40832a --- /dev/null +++ b/Casso/Ui/Chrome/ChromedPanelWindow.h @@ -0,0 +1,82 @@ +#pragma once + +#include "Pch.h" + +#include "TitleBar.h" + + +class IChromedPanelContent; +struct ChromeTheme; + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ChromedPanelWindow +// +// Reusable chrome shell for DX-painted modeless popup windows. Owns +// the HWND + class registration + title bar + NC hit testing + DPI +// handling + mouse / keyboard dispatch. The actual rendering, layout, +// and interaction model live in an IChromedPanelContent provided to +// Create. +// +// Use cases: Settings popup, Disk II Debug panel +// panel. Each provides its own content + renderer; the chrome is +// identical and lives here. +// +//////////////////////////////////////////////////////////////////////////////// + +class ChromedPanelWindow +{ +public: + ChromedPanelWindow () = default; + ~ChromedPanelWindow (); + + HRESULT RegisterClass (HINSTANCE hInstance, LPCWSTR className); + HRESULT Create (HWND hwndOwner, + IChromedPanelContent * content, + ID3D11Device * device, + ID3D11DeviceContext * context, + const ChromeTheme * theme); + void Destroy (); + void SetTheme (const ChromeTheme * theme); + HRESULT Render (); + + bool IsOpen () const { return m_hwnd != nullptr; } + HWND Hwnd () const { return m_hwnd; } + TitleBar & GetTitleBar() { return m_titleBar; } + +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 OnGetMinMax (MINMAXINFO * minMaxInfo); + bool OnNcCalcSize (HWND hwnd, WPARAM wParam, LPARAM lParam, LRESULT & outResult); + LRESULT OnNcHitTest (HWND hwnd, int xScreen, int yScreen); + bool OnNcLButtonDown (HWND hwnd, LRESULT hitTest); + void OnNcMouse (UINT message, WPARAM wParam, LPARAM lParam); + void OnMouse (UINT message, WPARAM wParam, LPARAM lParam); + void OnKeyDown (WPARAM vk); + void CloseWithCancel (); + void DestroyIfContentInactive (); + + SIZE GetPreferredClientSize (UINT dpi) const; + RECT GetInitialWindowRect (HWND hwndOwner, UINT dpi) const; + + HINSTANCE m_hInstance = nullptr; + HWND m_hwnd = nullptr; + HWND m_hwndOwner = nullptr; + IChromedPanelContent * m_content = nullptr; + ID3D11Device * m_device = nullptr; + ID3D11DeviceContext * m_context = nullptr; + TitleBar m_titleBar; + const ChromeTheme * m_theme = nullptr; + LPCWSTR m_className = nullptr; + bool m_hasFocus = false; +}; diff --git a/Casso/Ui/Chrome/DriveLabelTruncation.cpp b/Casso/Ui/Chrome/DriveLabelTruncation.cpp new file mode 100644 index 00000000..ea93ae5e --- /dev/null +++ b/Casso/Ui/Chrome/DriveLabelTruncation.cpp @@ -0,0 +1,114 @@ +#include "Pch.h" + +#include "DriveLabelTruncation.h" + +#include "../../UnicodeSymbols.h" + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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 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; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// TruncateToWidth +// +// Returns the literal basename when it fits, just the ellipsis when +// even that doesn't fit, otherwise the longest prefix + ellipsis that +// fits. +// +//////////////////////////////////////////////////////////////////////////////// + +std::wstring TruncateToWidth ( + std::wstring_view basename, + float maxWidthPx, + const std::function & measure) +{ + std::wstring result; + std::wstring ellipsisOnly (1, s_kchEllipsis); + size_t bestPrefixLen = 0; + + + + if (!measure) + { + result.assign (basename); + } + else if (basename.empty()) + { + // result stays empty + } + else if (measure (basename) <= maxWidthPx) + { + result.assign (basename); + } + else if (measure (ellipsisOnly) > maxWidthPx) + { + result = ellipsisOnly; + } + else + { + bestPrefixLen = LongestFittingPrefixLen (basename, maxWidthPx, measure); + result.assign (basename.substr (0, bestPrefixLen)); + result.push_back (s_kchEllipsis); + } + + return result; +} 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/Chrome/DriveWidget.cpp b/Casso/Ui/Chrome/DriveWidget.cpp index dd902785..319976cc 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" @@ -36,6 +37,9 @@ namespace constexpr int s_kCassowaryWidthPx = 28; constexpr int s_kCassowaryHeightPx = 42; constexpr int s_kCassowaryMarginPx = 6; + constexpr int s_kLabelStripHeightPx = 18; + constexpr int s_kLabelStripGapPx = 2; + constexpr float s_kBasenameFontDip = 11.0f; constexpr wchar_t s_kFontFamily[] = L"Segoe UI"; // Compact paint-path dimensions. The compact widget is a flat @@ -272,6 +276,11 @@ void DriveWidget::Layout (int x, int y, UINT dpi) m_slotRect = {}; m_ejectRect = {}; + m_labelRect.left = m_bodyRect.left; + m_labelRect.top = m_bodyRect.bottom + Scale (s_kLabelStripGapPx, dpi); + m_labelRect.right = m_bodyRect.right; + m_labelRect.bottom = m_labelRect.top + Scale (s_kLabelStripHeightPx, dpi); + m_led.Layout (m_bodyRect.right - pad - Scale (10, dpi), m_bodyRect.top + cBodyH / 2 - Scale (3, dpi), dpi); @@ -300,6 +309,11 @@ void DriveWidget::Layout (int x, int y, UINT dpi) m_ejectRect.right = m_ejectRect.left + doorW; m_ejectRect.bottom = m_ejectRect.top + doorH; + m_labelRect.left = m_bodyRect.left; + m_labelRect.top = m_bodyRect.bottom + Scale (s_kLabelStripGapPx, dpi); + m_labelRect.right = m_bodyRect.right; + m_labelRect.bottom = m_labelRect.top + Scale (s_kLabelStripHeightPx, dpi); + m_led.Layout (m_faceRect.left + Scale (s_kLabelPadPx + s_kInUseWidthPx + s_kInUseGapPx, dpi), m_faceRect.top + Scale (s_kLedCenterYPx, dpi) - Scale (3, dpi), dpi); @@ -410,6 +424,7 @@ void DriveWidget::Paint ( UNREFERENCED_PARAMETER (doorOffset); m_led.Paint (painter, theme); + PaintBasenameLabel (text, theme, dpi); return; } @@ -757,7 +772,10 @@ void DriveWidget::Paint ( painter.FillRect (farL, farY, farR - farL, 1.0f, edgeArgb); } - // "DRIVE N" upper-left of faceplate. + // "DRIVE N" upper-left of faceplate. Mounted-disk basename is + // painted in m_labelRect (below the body) after the case + face + // rendering completes, so it's the same code path in both + // skeuomorphic and compact modes. swprintf_s (label, L"DRIVE %d", m_drive + 1); IGNORE_RETURN_VALUE (hr, text.DrawString (label, (float) (m_faceRect.left + labelPad), @@ -794,6 +812,67 @@ void DriveWidget::Paint ( DrawCassowaryRainbow (painter, iconX, iconY, (float) iconW, (float) iconH); } + + PaintBasenameLabel (text, theme, dpi); +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// PaintBasenameLabel +// +// Paints the mounted disk's basename inside m_labelRect (below the +// drive icon body) in both compact and skeuomorphic paint paths. +// Hidden when no disk is mounted; ellipsis-truncated to the label +// strip width via the pure TruncateToWidth algorithm. +// +//////////////////////////////////////////////////////////////////////////////// + +void DriveWidget::PaintBasenameLabel ( + DwriteTextRenderer & text, + const ChromeTheme & theme, + UINT dpi) +{ + HRESULT hr = S_OK; + float basenameDip = s_kBasenameFontDip * (float) dpi / (float) s_kBaseDpi; + float maxWidthPx = (float) (m_labelRect.right - m_labelRect.left); + std::filesystem::path imagePath; + std::wstring basename; + std::wstring truncated; + 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; + }; + + + + if (m_state.mountedImagePath.empty()) + { + return; + } + + imagePath = std::filesystem::path (m_state.mountedImagePath); + basename = imagePath.filename().wstring(); + truncated = TruncateToWidth (basename, maxWidthPx, measure); + + IGNORE_RETURN_VALUE (hr, text.DrawString (truncated.c_str(), + (float) m_labelRect.left, + (float) m_labelRect.top, + maxWidthPx, + (float) (m_labelRect.bottom - m_labelRect.top), + theme.driveLabelArgb, + basenameDip, + s_kFontFamily, + DwriteTextRenderer::HAlign::Center, + DwriteTextRenderer::VAlign::Center)); } diff --git a/Casso/Ui/Chrome/DriveWidget.h b/Casso/Ui/Chrome/DriveWidget.h index 2918359d..bd7bb48d 100644 --- a/Casso/Ui/Chrome/DriveWidget.h +++ b/Casso/Ui/Chrome/DriveWidget.h @@ -37,6 +37,7 @@ class DriveWidget m_faceRect = {}; m_slotRect = {}; m_ejectRect = {}; + m_labelRect = {}; } void SetPerspectiveSkewPx (int skewPx) { m_perspectiveSkewPx = skewPx; } void SetCompact (bool compact) { m_compact = compact; } @@ -49,11 +50,21 @@ class DriveWidget DriveWidgetRegion HitTest (int x, int y) const; HRESULT OnDrop (const std::wstring & path); RECT BodyRect () const { return m_bodyRect; } + RECT OuterRect () const + { + RECT r = m_bodyRect; + if (m_labelRect.bottom > r.bottom) { r.bottom = m_labelRect.bottom; } + return r; + } RECT EjectRect () const { return m_ejectRect; } LedState Led () const { return m_led.GetState(); } int Drive () const { return m_drive; } private: + void PaintBasenameLabel (DwriteTextRenderer & text, + const ChromeTheme & theme, + UINT dpi); + int m_slot = 6; int m_drive = 0; IDriveCommandSink * m_sink = nullptr; @@ -61,6 +72,7 @@ class DriveWidget RECT m_faceRect = {}; RECT m_slotRect = {}; RECT m_ejectRect = {}; + RECT m_labelRect = {}; LedIndicator m_led; DriveWidgetState m_state; int m_perspectiveSkewPx = 0; diff --git a/Casso/Ui/Chrome/IChromedPanelContent.h b/Casso/Ui/Chrome/IChromedPanelContent.h new file mode 100644 index 00000000..2e6e2e07 --- /dev/null +++ b/Casso/Ui/Chrome/IChromedPanelContent.h @@ -0,0 +1,94 @@ +#pragma once + +#include "Pch.h" + + +class TitleBar; +struct ChromeTheme; + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// IChromedPanelContent +// +// Abstract content interface implemented by every panel hosted in a +// ChromedPanelWindow. The chrome shell owns the HWND, the title bar +// geometry, NC hit testing, DPI handling, and the mouse / keyboard +// routing. The content owns the panel's renderer (each panel has its +// own shader stack), the preferred-size policy, and the input +// semantics (what Accept / Cancel mean, what's a click target, etc). +// +// Lifecycle: ChromedPanelWindow calls OnHostCreated when its HWND is +// ready (renderer init), Render on each paint, OnHostResize on +// WM_SIZE / WM_DPICHANGED, OnHostDestroyed in WM_DESTROY. The content +// must NOT touch the HWND after OnHostDestroyed returns. +// +//////////////////////////////////////////////////////////////////////////////// + +class IChromedPanelContent +{ +public: + virtual ~IChromedPanelContent () = default; + + // Identity used to register the Win32 window class for this content. + // Each content type uses a distinct class name so multiple panel + // types can be open simultaneously without sharing a wndproc dispatch. + virtual LPCWSTR GetWindowClassName () const = 0; + virtual LPCWSTR GetWindowTitle () const = 0; + + // Renderer lifecycle. The content owns the renderer; the shell + // hands it the HWND + device + initial size so it can stand up + // its swap chain. SetChromeTheme also runs at create time so the + // renderer can size title-bar metrics on first paint. + virtual HRESULT OnHostCreated (HWND hwnd, + ID3D11Device * device, + ID3D11DeviceContext * context, + int widthPx, + int heightPx, + UINT dpi, + TitleBar * titleBar, + const ChromeTheme * theme) = 0; + virtual void OnHostDestroyed () = 0; + virtual HRESULT OnHostResize (int widthPx, int heightPx, UINT dpi) = 0; + virtual HRESULT Render () = 0; + virtual void SetChromeTheme (TitleBar * titleBar, const ChromeTheme * theme) = 0; + + // Layout. The shell uses this to size the popup on first show and + // to honour WM_GETMINMAXINFO under each DPI. + virtual SIZE PreferredClientSize (UINT dpi) const = 0; + + // Input routing. Coordinates are client-relative. Mouse-wheel default + // is a no-op since most panels don't scroll. + virtual void OnLButtonDown (int x, int y) = 0; + virtual void OnLButtonUp (int x, int y) = 0; + virtual void OnRButtonDown (int x, int y) { (void) x; (void) y; } + virtual void OnMouseMove (int x, int y) = 0; + virtual void OnMouseWheel (int x, int y, int delta) { (void) x; (void) y; (void) delta; } + virtual bool OnKey (WPARAM vk) = 0; + virtual bool OnChar (wchar_t ch) { (void) ch; return false; } + + // Action semantics. Accept = VK_RETURN, Cancel = VK_ESCAPE / + // WM_CLOSE / window-close. After either, the shell calls + // IsContentActive to decide whether to destroy the host window. + virtual void Accept () = 0; + virtual void Cancel () = 0; + virtual bool IsContentActive () const = 0; + + // Window-ownership policy. Returning true makes ChromedPanelWindow + // create the host HWND with NO owner so the panel can move freely + // behind the main emulator window. Default is false (owned popup, + // Windows pins it above the owner — appropriate for modal/inline + // panels like Settings). Non-modal observability panels (debug + // console, Disk II event viewer) should return true so the user + // can park them behind Casso while watching the emulator. + virtual bool IsNonModal () const { return false; } + + // Cursor hook. Called from WM_SETCURSOR with client-relative coords + // for the current cursor position. Return a loaded HCURSOR to + // override the default arrow, or nullptr to let the shell fall + // through to DefWindowProc (which paints the standard arrow). + virtual HCURSOR OnSetCursor (int x, int y) { (void) x; (void) y; return nullptr; } +}; diff --git a/Casso/Ui/Chrome/NavLayer.cpp b/Casso/Ui/Chrome/NavLayer.cpp index 9822d793..d8a9b32d 100644 --- a/Casso/Ui/Chrome/NavLayer.cpp +++ b/Casso/Ui/Chrome/NavLayer.cpp @@ -52,7 +52,6 @@ namespace { IDM_MACHINE_PAUSE, NavMenu::Debug, L"&Pause", L"Pause" }, { IDM_MACHINE_STEP, NavMenu::Debug, L"&Step", L"F11" }, { IDM_VIEW_DISK2_DEBUG, NavMenu::Debug, L"Disk ][ Debug...", L"Ctrl+Shift+D" }, - { IDM_HELP_DEBUG, NavMenu::Debug, L"De&bug console", L"Ctrl+D" }, }; diff --git a/Casso/Ui/Dialog/DialogDefinition.h b/Casso/Ui/Dialog/DialogDefinition.h new file mode 100644 index 00000000..8e59d45a --- /dev/null +++ b/Casso/Ui/Dialog/DialogDefinition.h @@ -0,0 +1,130 @@ +#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 DwriteTextRenderer; +struct 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; + DwriteTextRenderer * text = 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; + // Per-dialog icon size override in DIPs. 0 = use the default + // primitive icon size. + float iconSizeOverrideDp = 0.0f; + 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 and may return a result + // code to request that the dialog close with that value. + std::function onPaintCustomBody; + std::function (const DialogInputEvent &)> onInputCustomBody; + // Optional measurement hook fired during layout, giving consumers + // access to a DwriteTextRenderer so they can size the custom body + // based on string metrics. Returning a non-zero SIZE overrides + // `customBodyMinSizePx` for that layout pass. + std::function onMeasureCustomBody; + SIZE customBodyMinSizePx = {}; + + // Optional hook fired when a button is activated (mouse, default, + // cancel). Receives the button index. Return true to close the + // dialog with that button's resultCode; return false to keep the + // dialog open (e.g. to start an async operation in-place). The + // primitive passes its own `this` so the hook can call + // SetButtonLabel / SetButtonEnabled / SetButtonVisible / Repaint + // to update the dialog without closing it. + std::function onButtonActivated; + + // Optional periodic tick. When `tickIntervalMs > 0` the primitive + // installs a WM_TIMER and invokes `onTick` on every fire. Use to + // poll worker-thread state and either repaint or call Close. The + // primitive guarantees this fires only on the UI thread. + std::function onTick; + unsigned int tickIntervalMs = 0; + + // When set, WM_CLOSE (title-bar X, Alt+F4) returns this result + // code instead of clicking the cancel button. Use to distinguish + // "user closed the window" from "user clicked Cancel/Skip". + std::optional closeBoxResult; +}; diff --git a/Casso/Ui/Dialog/DialogLayout.cpp b/Casso/Ui/Dialog/DialogLayout.cpp new file mode 100644 index 00000000..8b091221 --- /dev/null +++ b/Casso/Ui/Dialog/DialogLayout.cpp @@ -0,0 +1,496 @@ +#include "Pch.h" + +#include "DialogLayout.h" + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogLayout::FindWrapBoundary +// +// Returns the largest character count in [1..remaining] whose measured +// width fits in `maxWidthPx`. Prefers a whitespace break when possible. +// Single-exit. +// +//////////////////////////////////////////////////////////////////////////////// + +size_t DialogLayout::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; + size_t result = 0; + float widthPx = 0.0f; + + + + if (remaining > 0) + { + 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. + result = 1; + } + else if (fit < remaining && lastSpace > 0) + { + result = lastSpace; + } + else + { + result = fit; + } + } + + return result; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +//////////////////////////////////////////////////////////////////////////////// + +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 segStart = 0; + size_t nlPos = 0; + size_t segEnd = 0; + size_t pos = 0; + size_t tentative = 0; + float cursorXPx = 0.0f; + float cursorYPx = 0.0f; + float remainingPx = maxBodyWidthPx; + float pieceWidthPx = 0.0f; + bool moreSegments = false; + + + + outWrapped.clear(); + outTotalHeightPx = 0.0f; + + for (runIndex = 0; runIndex < runs.size(); runIndex++) + { + std::wstring_view view (runs[runIndex].text); + segStart = 0; + + while (true) + { + nlPos = view.find (L'\n', segStart); + segEnd = (nlPos == std::wstring_view::npos) ? view.size() : nlPos; + moreSegments = (nlPos != std::wstring_view::npos); + + // Emit a zero-width marker for empty segments so the + // run's bounding rect (built downstream) includes this + // blank line. Without this DWrite would draw an extra + // line outside the unioned rect. + if (segStart == segEnd) + { + outWrapped.push_back ({ runIndex, segStart, 0, cursorXPx, cursorYPx, 0.0f }); + } + else + { + std::wstring_view segment = view.substr (segStart, segEnd - segStart); + pos = 0; + + while (pos < segment.size()) + { + tentative = FindWrapBoundary (segment, pos, remainingPx, measure); + if (tentative == 0) + { + cursorXPx = 0.0f; + cursorYPx += lineHeightPx; + remainingPx = maxBodyWidthPx; + } + else + { + pieceWidthPx = measure (segment.substr (pos, tentative)); + outWrapped.push_back ({ runIndex, segStart + pos, tentative, + cursorXPx, cursorYPx, pieceWidthPx }); + pos += tentative; + cursorXPx += pieceWidthPx; + remainingPx = maxBodyWidthPx - cursorXPx; + if (pos < segment.size()) + { + cursorXPx = 0.0f; + cursorYPx += lineHeightPx; + remainingPx = maxBodyWidthPx; + } + } + } + } + + if (!moreSegments) + { + break; + } + + // Force a hard break at the explicit \n. + cursorXPx = 0.0f; + cursorYPx += lineHeightPx; + remainingPx = maxBodyWidthPx; + segStart = nlPos + 1; + } + } + + outTotalHeightPx = cursorYPx + (outWrapped.empty() ? 0.0f : lineHeightPx); +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogLayout::BeginLayout +// +// Sets up the body/icon origins from the metrics. Must run before any +// build-rects helper. +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogLayout::BeginLayout (LayoutState & s) +{ + 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) + { + 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. +// +//////////////////////////////////////////////////////////////////////////////// + +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 = s.wrapped[wi]; + RECT & rect = s.result->bodyRunRectsPx[w.runIndex]; + + 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) + { + 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); + } + } + + s.contentBottomPx = s.bodyOriginYPx + std::max (s.bodyTotalHeightPx, + s.hasIcon ? s.metrics->iconSizePx : 0.0f); + s.contentRightPx = s.bodyOriginXPx + s.metrics->maxBodyWidthPx; + + s.result->wrappedPiecesPx.clear(); + s.result->wrappedPiecesPx.reserve (s.wrapped.size()); + for (wi = 0; wi < s.wrapped.size(); wi++) + { + DialogWrappedPiece piece = s.wrapped[wi]; + + piece.xPx += s.bodyOriginXPx; + piece.yPx += s.bodyOriginYPx; + s.result->wrappedPiecesPx.push_back (piece); + } + s.result->bodyLineHeightPx = s.metrics->bodyLineHeightPx; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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 (s.def->body[bi].isHyperlink) + { + s.result->hyperlinkHitRectsPx.push_back (s.result->bodyRunRectsPx[bi]); + } + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DialogLayout::BuildIconRect +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogLayout::BuildIconRect (LayoutState & s) +{ + if (s.hasIcon) + { + 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); + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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) + { + long minWPx = (s.metrics->customBodyOverridePx.cx > 0) + ? s.metrics->customBodyOverridePx.cx + : s.def->customBodyMinSizePx.cx; + long minHPx = (s.metrics->customBodyOverridePx.cy > 0) + ? s.metrics->customBodyOverridePx.cy + : s.def->customBodyMinSizePx.cy; + + cbTopPx = s.contentBottomPx + s.metrics->bodyButtonsGapPx; + cbLeftPx = s.metrics->outerPaddingPx; + cbWidthPx = std::max ((float) minWPx, + s.contentRightPx - cbLeftPx); + cbHeightPx = (float) minHPx; + + 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); + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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); + } + + s.result->buttonRectsPx.assign (s.def->buttons.size(), RECT {}); + for (bi = s.def->buttons.size(); bi > 0; bi--) + { + 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); + + 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 new file mode 100644 index 00000000..9f283b1d --- /dev/null +++ b/Casso/Ui/Dialog/DialogLayout.h @@ -0,0 +1,114 @@ +#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; + SIZE customBodyOverridePx = {}; // when non-zero, overrides def.customBodyMinSizePx +}; + + + +struct DialogWrappedPiece +{ + size_t runIndex = 0; + size_t start = 0; + size_t count = 0; + float xPx = 0.0f; // absolute, includes body origin + float yPx = 0.0f; // absolute, includes body origin + float widthPx = 0.0f; +}; + + + +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 + std::vector wrappedPiecesPx; // per-line slices for renderer + float bodyLineHeightPx = 0.0f; +}; + + + +class DialogLayout +{ +public: + static DialogLayoutResult Compute ( + const DialogDefinition & def, + const DialogLayoutMetrics & metrics); + +private: + using WrappedRun = DialogWrappedPiece; + + 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/Ui/Dialog/DialogPrimitive.cpp b/Casso/Ui/Dialog/DialogPrimitive.cpp new file mode 100644 index 00000000..be14f22b --- /dev/null +++ b/Casso/Ui/Dialog/DialogPrimitive.cpp @@ -0,0 +1,1537 @@ +#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_kCloseButtonWidthDp = 46.0f; +static constexpr float s_kBodyFontDp = 13.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 = 22.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); + + m_hInstance = hInstance; + + 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); + +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; + m_focusedHyperlink = SIZE_MAX; + m_hoveredHyperlink = 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; + + case WM_SETCURSOR: + if (LOWORD (lParam) == HTCLIENT) + { + POINT pt = {}; + size_t hl = SIZE_MAX; + + if (GetCursorPos (&pt) && ScreenToClient (m_hwnd, &pt) + && HitTestHyperlink (pt.x, pt.y, hl)) + { + SetCursor (LoadCursorW (nullptr, IDC_HAND)); + result = TRUE; + break; + } + } + result = DefWindowProcW (hwnd, message, wParam, lParam); + break; + + case WM_TIMER: + if (m_def != nullptr && m_def->onTick) + { + m_def->onTick (*this); + } + 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); + + if (m_def != nullptr && m_def->tickIntervalMs > 0) + { + SetTimer (m_hwnd, 1, m_def->tickIntervalMs, nullptr); + } + +Error: + return hr; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnDestroy +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::OnDestroy() +{ + if (m_hwnd != nullptr) + { + KillTimer (m_hwnd, 1); + } + + 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) +{ + if (DispatchCustomBodyInput (DialogInputEvent::Kind::KeyDown, 0, 0, (int) vk)) + { + return; + } + + switch (vk) + { + case VK_RETURN: + if (m_focusedHyperlink != SIZE_MAX) + { + LaunchHyperlink (m_focusedHyperlink); + } + else if (m_focusedButton < m_buttons.size()) + { + m_buttons[m_focusedButton].Click(); + } + else + { + ActivateDefaultButton(); + } + break; + + case VK_ESCAPE: + if (m_def != nullptr && m_def->closeBoxResult.has_value()) + { + Close (m_def->closeBoxResult.value()); + } + else + { + ActivateCancelButton(); + } + break; + + case VK_TAB: + { + int delta = (GetKeyState (VK_SHIFT) & 0x8000) ? -1 : 1; + CycleFocus (delta); + break; + } + + case VK_SPACE: + if (m_focusedHyperlink != SIZE_MAX) + { + LaunchHyperlink (m_focusedHyperlink); + } + else 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; + size_t hlRunIdx = SIZE_MAX; + size_t newHover = SIZE_MAX; + DialogInputEvent::Kind kind = DialogInputEvent::Kind::MouseMove; + + UNREFERENCED_PARAMETER (wParam); + + if (message == WM_LBUTTONDOWN) { kind = DialogInputEvent::Kind::LeftButtonDown; } + else if (message == WM_LBUTTONUP) { kind = DialogInputEvent::Kind::LeftButtonUp; } + + if (DispatchCustomBodyInput (kind, xPx, yPx, 0)) + { + return; + } + + { + bool inClose = PointInCloseButton (xPx, yPx); + bool newHovered = inClose; + bool newPressed = m_closePressed; + + if (down) + { + newPressed = inClose; + } + else if (up) + { + bool wasPressed = m_closePressed; + newPressed = false; + if (wasPressed && inClose) + { + OnClose(); + return; + } + } + + if (newHovered != m_closeHovered || newPressed != m_closePressed) + { + m_closeHovered = newHovered; + m_closePressed = newPressed; + dirty = true; + } + } + + 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 (HitTestHyperlink (xPx, yPx, hlRunIdx)) + { + newHover = hlRunIdx; + } + + if (newHover != m_hoveredHyperlink) + { + m_hoveredHyperlink = newHover; + dirty = true; + } + + 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; + } + + if (HitTestHyperlink (xPx, yPx, hlRunIdx)) + { + LaunchHyperlink (hlRunIdx); + } + } + + if (dirty || message == WM_MOUSEMOVE) + { + InvalidateRect (m_hwnd, nullptr, FALSE); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnClose +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::OnClose() +{ + if (m_def != nullptr && m_def->closeBoxResult.has_value()) + { + Close (m_def->closeBoxResult.value()); + return; + } + + 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 = ((m_def != nullptr && m_def->iconSizeOverrideDp > 0.0f) + ? m_def->iconSizeOverrideDp + : 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; + }; + + if (m_def->onMeasureCustomBody) + { + SIZE measured = m_def->onMeasureCustomBody (measurer, dpiScale); + metrics.customBodyOverridePx = measured; + } + + 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, + m_focusedHyperlink, + m_hoveredHyperlink, + m_closeHovered, + m_closePressed)); + +Error: + return; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ActivateButton +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::ActivateButton (size_t idx) +{ + HRESULT hr = S_OK; + bool shouldClose = true; + int resultCode = 0; + + + + CBR (idx < m_def->buttons.size()); + + if (idx < m_buttons.size() && (!m_buttons[idx].Enabled() || !m_buttons[idx].Visible())) + { + return; + } + + resultCode = m_def->buttons[idx].resultCode; + + if (m_def->onButtonActivated) + { + shouldClose = m_def->onButtonActivated (idx, *this); + } + + if (shouldClose) + { + Close (resultCode); + } + +Error: + return; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// SetButtonLabel +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::SetButtonLabel (size_t idx, const std::wstring & label) +{ + if (idx < m_buttons.size()) + { + m_buttons[idx].SetLabel (label); + RecomputeLayout (m_dpi); + Repaint(); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// SetButtonEnabled +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::SetButtonEnabled (size_t idx, bool enabled) +{ + if (idx < m_buttons.size()) + { + m_buttons[idx].SetEnabled (enabled); + + if (!enabled && m_focusedButton == idx) + { + CycleFocus (1); + } + + Repaint(); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// SetButtonVisible +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::SetButtonVisible (size_t idx, bool visible) +{ + if (idx < m_buttons.size()) + { + m_buttons[idx].SetVisible (visible); + + if (!visible && m_focusedButton == idx) + { + CycleFocus (1); + } + + Repaint(); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Repaint +// +//////////////////////////////////////////////////////////////////////////////// + +void DialogPrimitive::Repaint () +{ + if (m_hwnd != nullptr) + { + InvalidateRect (m_hwnd, nullptr, FALSE); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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 buttonN = m_buttons.size(); + size_t linkN = HyperlinkCount(); + size_t total = buttonN + linkN; + size_t cur = 0; + size_t next = 0; + + + + CBR (total != 0); + + if (m_focusedHyperlink != SIZE_MAX) + { + size_t hlOrdinal = 0; + + for (size_t bi = 0; bi < m_def->body.size() && bi < m_focusedHyperlink; bi++) + { + if (m_def->body[bi].isHyperlink) + { + hlOrdinal++; + } + } + cur = buttonN + hlOrdinal; + } + else if (m_focusedButton < buttonN) + { + cur = m_focusedButton; + } + else + { + cur = (delta > 0) ? (total - 1) : 0; + } + + if (delta > 0) + { + next = (cur + 1) % total; + } + else + { + next = (cur == 0) ? (total - 1) : (cur - 1); + } + + // Skip past hidden/disabled buttons. Bail after `total` attempts + // so we don't spin if nothing in the cycle is focusable. + for (size_t guard = 0; guard < total; guard++) + { + if (next >= buttonN) + { + break; + } + + if (m_buttons[next].Visible() && m_buttons[next].Enabled()) + { + break; + } + + next = (delta > 0) ? ((next + 1) % total) + : (next == 0 ? total - 1 : next - 1); + } + + if (m_focusedButton < buttonN) + { + m_buttons[m_focusedButton].SetFocused (false); + } + + m_focusedButton = SIZE_MAX; + m_focusedHyperlink = SIZE_MAX; + + if (next < buttonN) + { + m_focusedButton = next; + m_buttons[next].SetFocused (true); + } + else + { + m_focusedHyperlink = NthHyperlinkBodyIdx (next - buttonN); + } + + InvalidateRect (m_hwnd, nullptr, FALSE); + +Error: + return; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// HyperlinkCount +// +//////////////////////////////////////////////////////////////////////////////// + +size_t DialogPrimitive::HyperlinkCount() const +{ + size_t count = 0; + + if (m_def == nullptr) + { + return 0; + } + + for (const DialogTextRun & run : m_def->body) + { + if (run.isHyperlink) + { + count++; + } + } + + return count; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// NthHyperlinkBodyIdx +// +//////////////////////////////////////////////////////////////////////////////// + +size_t DialogPrimitive::NthHyperlinkBodyIdx (size_t hyperlinkIdx) const +{ + size_t seen = 0; + + if (m_def == nullptr) + { + return SIZE_MAX; + } + + for (size_t bi = 0; bi < m_def->body.size(); bi++) + { + if (!m_def->body[bi].isHyperlink) + { + continue; + } + + if (seen == hyperlinkIdx) + { + return bi; + } + + seen++; + } + + return SIZE_MAX; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// DispatchCustomBodyInput +// +// When the definition has custom-body hooks, forwards the input +// event in window-relative coordinates (translated into custom-body +// coordinates) and closes the dialog with the returned result code +// if the hook requests it. Returns true when the event was consumed +// so the default button/hyperlink dispatch is suppressed. +// +//////////////////////////////////////////////////////////////////////////////// + +bool DialogPrimitive::DispatchCustomBodyInput (DialogInputEvent::Kind kind, int xPx, int yPx, int vkCode) +{ + DialogInputEvent ev = {}; + std::optional req; + int titleH = TitleHeightPx(); + RECT rc = m_layout.customBodyRectPx; + bool consumed = false; + + if (m_def == nullptr || !m_def->onInputCustomBody) + { + return false; + } + + rc.top += titleH; + rc.bottom += titleH; + + if (kind == DialogInputEvent::Kind::KeyDown) + { + ev.kind = kind; + ev.vkCode = vkCode; + req = m_def->onInputCustomBody (ev); + } + else + { + bool insideX = (xPx >= rc.left && xPx < rc.right); + bool insideY = (yPx >= rc.top && yPx < rc.bottom); + if (!(insideX && insideY)) + { + return false; + } + + ev.kind = kind; + ev.xPx = xPx - rc.left; + ev.yPx = yPx - rc.top; + req = m_def->onInputCustomBody (ev); + consumed = true; + } + + if (req.has_value()) + { + Close (req.value()); + return true; + } + + InvalidateRect (m_hwnd, nullptr, FALSE); + return consumed; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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)); +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// CloseButtonRectPx +// +// Returns the close caption widget rect in client coordinates. Sized +// 46dp wide x titleH tall, right-aligned in the title bar. +// +//////////////////////////////////////////////////////////////////////////////// + +RECT DialogPrimitive::CloseButtonRectPx() const +{ + RECT rect = {}; + int widthPx = static_cast (s_kCloseButtonWidthDp * static_cast (m_dpi) / static_cast (DpiScaler::kBaseDpi)); + int clientW = 0; + RECT clientRect = {}; + + + + if (m_hwnd != nullptr && GetClientRect (m_hwnd, &clientRect)) + { + clientW = clientRect.right - clientRect.left; + } + + rect.right = clientW; + rect.left = clientW - widthPx; + rect.top = 0; + rect.bottom = TitleHeightPx(); + + return rect; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// PointInCloseButton +// +//////////////////////////////////////////////////////////////////////////////// + +bool DialogPrimitive::PointInCloseButton (int xPx, int yPx) const +{ + RECT rect = CloseButtonRectPx(); + + + + return (xPx >= rect.left) && (xPx < rect.right) + && (yPx >= rect.top) && (yPx < rect.bottom); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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..e5e90d77 --- /dev/null +++ b/Casso/Ui/Dialog/DialogPrimitive.h @@ -0,0 +1,105 @@ +#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); + + // Runtime button mutation. Safe to call from inside an + // `onButtonActivated` hook to switch the dialog into a different + // mode (e.g. show "Downloading..." after the user clicks Download) + // without closing the window. The dialog repaints on the next + // frame; call Repaint() to schedule an immediate invalidation. + void SetButtonLabel (size_t idx, const std::wstring & label); + void SetButtonEnabled (size_t idx, bool enabled); + void SetButtonVisible (size_t idx, bool visible); + void Repaint (); + HWND Hwnd () const { return m_hwnd; } + +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); + bool DispatchCustomBodyInput (DialogInputEvent::Kind kind, int xPx, int yPx, int vkCode); + size_t HyperlinkCount () const; + size_t NthHyperlinkBodyIdx (size_t hyperlinkIdx) const; + + int TitleHeightPx () const; + RECT CloseButtonRectPx () const; + bool PointInCloseButton (int xPx, int yPx) 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