diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f191812..28b20491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ 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.1398] — Themed disk-insert MRU picker + +The runtime disk-insert flow (Disk → Insert Disk Image, drive-widget +click, or `IDM_DISK_INSERT1`/`2`) now opens the themed MRU picker +instead of jumping straight to `IFileOpenDialog`. Lists the same +existing-on-disk recents as the boot picker plus the always-available +DOS 3.3 / ProDOS download rows. A **Browse...** button preserves the +native `IFileOpenDialog` path for off-MRU images. + +### Added +- **feat(011): `AssetBootstrap::PromptInsertDiskMru`.** Sibling to the + boot-time `PromptBootDiskMru` — same `ListView`-based DialogPrimitive + surface, theme-aware, but titled per drive and wired with a Browse + fallback instead of Skip. Uses out-of-range negative sentinel result + codes to avoid colliding with row indices. +- **feat(011): `WindowCommandManager::PromptInsertDiskMru`.** Loads MRU + from `GlobalUserPrefs::recentDisks`, prunes vanished files, invokes + the themed picker, and routes the result: chosen row → + `EmulatorShell::Mount(6, drive, path)`; Browse → existing + `PromptForDiskImage` (`IFileOpenDialog`); Cancel/Esc/close → no-op. + +### Changed +- **refactor(011): `OnDiskCommand` IDM_DISK_INSERT1/2** now invoke + `PromptInsertDiskMru` instead of `PromptForDiskImage` directly. + ## [1.5.1395] — Native dialogs migration (spec 011) Themed DX-based modal dialogs now replace every Win32 `MessageBoxW` / diff --git a/Casso/AssetBootstrap.cpp b/Casso/AssetBootstrap.cpp index a1ffaeff..0bd802cf 100644 --- a/Casso/AssetBootstrap.cpp +++ b/Casso/AssetBootstrap.cpp @@ -1857,6 +1857,187 @@ HRESULT AssetBootstrap::PromptBootDiskMru ( +//////////////////////////////////////////////////////////////////////////////// +// +// PromptInsertDiskMru +// +// Runtime-insert sibling of PromptBootDiskMru. Same MRU + DOS 3.3 / +// ProDOS "Download" rows, but the dialog footer offers Browse... / +// Cancel instead of Skip. Browse pops back to the caller which then +// fires the existing IFileOpenDialog path. Cancel / close box leaves +// the drive untouched. +// +//////////////////////////////////////////////////////////////////////////////// + +HRESULT AssetBootstrap::PromptInsertDiskMru ( + HINSTANCE hInstance, + HWND hwndParent, + int drive, + const vector & mruEntries, + const fs::path & diskDir, + std::string_view themeName, + wstring & outDiskPath, + bool & outBrowse, + string & outError) +{ + struct DownloadRow { const BootDiskSpec * spec; wstring label; }; + + static constexpr int s_kBrowseResult = -2000; + static constexpr int s_kCancelResult = -2001; + static constexpr int s_kCloseBoxResult = -1000; + + HRESULT hr = S_OK; + DialogDefinition def = {}; + wstring title; + wstring intro; + 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(); + outBrowse = false; + + title = L"Casso "; + title += s_kchEmDash; + title += format (L" Insert Disk in Drive {}", drive); + + if (mruCount > 0) + { + intro = format (L"Choose a disk image for Drive {}, browse for " + L"another, or download a stock master from the " + L"Asimov archive.", drive); + } + else + { + intro = format (L"No recent disks for Drive {}. Browse for an " + L"image, or download a stock master from the " + L"Asimov archive.", drive); + } + + { + std::vector cols; + std::vector> rows; + + cols.push_back ({ L"Disk image", 0, false, DwriteTextRenderer::HAlign::Left }); + cols.push_back ({ L"Location", 0, false, DwriteTextRenderer::HAlign::Left }); + + rows.reserve ((size_t) rowCount); + + 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) }); + } + + 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 + { + 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"&Browse...", s_kBrowseResult, false, false }); + def.buttons.push_back ({ L"Cancel", s_kCancelResult, true, true }); + def.closeBoxResult = s_kCloseBoxResult; + + chosen = ShowStandaloneDialog (hInstance, hwndParent, themeName, def); + + if (chosen == s_kBrowseResult) + { + outBrowse = true; + } + else if (chosen == s_kCloseBoxResult || chosen == s_kCancelResult) + { + // user cancelled; outDiskPath stays empty + } + 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; +} + + + + + //////////////////////////////////////////////////////////////////////////////// // // FetchAndDecodeOgg diff --git a/Casso/AssetBootstrap.h b/Casso/AssetBootstrap.h index b5277ff7..9b55b083 100644 --- a/Casso/AssetBootstrap.h +++ b/Casso/AssetBootstrap.h @@ -135,6 +135,30 @@ class AssetBootstrap bool & outUserClosed, string & outError); + // Themed runtime disk-insert picker. Mirrors PromptBootDiskMru + // but is used when the user clicks a drive widget (or invokes + // Disk -> Insert in drive N) on a running machine. Lists the + // user's recent disk images plus "Download" rows for the DOS 3.3 + // and ProDOS stock masters so the picker is never empty even on + // a fresh install. The "Browse..." footer button falls through to + // the Win32 IFileOpenDialog for ad-hoc images. Cancel / close box + // leaves the slot untouched. + // + // On return: + // outDiskPath = path to mount, or empty if the user cancelled + // or chose Browse (caller then runs IFileOpenDialog) + // outBrowse = true if the user clicked Browse... (caller + // should fall through to its file-picker path) + static HRESULT PromptInsertDiskMru (HINSTANCE hInstance, + HWND hwndParent, + int drive, + const vector & mruEntries, + const fs::path & diskDir, + std::string_view themeName, + wstring & outDiskPath, + bool & outBrowse, + 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 diff --git a/Casso/EmulatorShell.cpp b/Casso/EmulatorShell.cpp index 528c52c9..7a93b9fe 100644 --- a/Casso/EmulatorShell.cpp +++ b/Casso/EmulatorShell.cpp @@ -1592,7 +1592,7 @@ void EmulatorShell::BrowseForDisk (int drive) Sleep (8); } - hrBrowse = m_windowCommandManager->PromptForDiskImage (drive); + hrBrowse = m_windowCommandManager->PromptInsertDiskMru (drive); IGNORE_RETURN_VALUE (hrBrowse, S_OK); // Cancel / error path: door follows mount state. Mounted drive diff --git a/Casso/Shell/WindowCommandManager.cpp b/Casso/Shell/WindowCommandManager.cpp index 6c1d193d..ee91655d 100644 --- a/Casso/Shell/WindowCommandManager.cpp +++ b/Casso/Shell/WindowCommandManager.cpp @@ -2,8 +2,10 @@ #include "WindowCommandManager.h" +#include "../AssetBootstrap.h" #include "../EmulatorShell.h" #include "../resource.h" +#include "../Shell/DiskMru.h" #include "Version.h" #include "Ui/Chrome/LayoutManager.h" #include "Ui/Chrome/ChromeMetrics.h" @@ -425,6 +427,65 @@ HRESULT WindowCommandManager::PromptForDiskImage (int drive) +//////////////////////////////////////////////////////////////////////////////// +// +// PromptInsertDiskMru +// +// Shows the themed disk MRU picker. Routes the user's chosen disk +// (recent image or stock master download) to Mount(); if the user +// clicks "Browse..." this falls through to the IFileOpenDialog path +// via PromptForDiskImage. +// +//////////////////////////////////////////////////////////////////////////////// + +HRESULT WindowCommandManager::PromptInsertDiskMru (int drive) +{ + HRESULT hr = S_OK; + DiskMru mru; + std::vector mruExisting; + std::filesystem::path diskDir; + std::wstring chosenPath; + std::string error; + bool userBrowsed = false; + + + diskDir = AssetBootstrap::GetDiskDirectory(); + mru = DiskMru::FromUtf8 (m_shell.m_globalPrefs.recentDisks); + mruExisting = mru.Prune ([] (const std::filesystem::path & p) + { + return std::filesystem::exists (p); + }); + + hr = AssetBootstrap::PromptInsertDiskMru (GetModuleHandle (nullptr), + m_shell.m_hwnd, + drive, + mruExisting, + diskDir, + m_shell.m_globalPrefs.activeTheme, + chosenPath, + userBrowsed, + error); + CHR (hr); + + if (userBrowsed) + { + hr = PromptForDiskImage (drive); + CHR (hr); + } + else if (!chosenPath.empty()) + { + hr = m_shell.Mount (6, drive, chosenPath); + CHR (hr); + } + +Error: + return hr; +} + + + + + //////////////////////////////////////////////////////////////////////////////// // // OnDiskCommand @@ -443,13 +504,9 @@ void WindowCommandManager::OnDiskCommand (int id) case IDM_DISK_INSERT1: case IDM_DISK_INSERT2: { - // 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); + hr = PromptInsertDiskMru (drive); IGNORE_RETURN_VALUE (hr, S_OK); break; } diff --git a/Casso/Shell/WindowCommandManager.h b/Casso/Shell/WindowCommandManager.h index 27bf74bb..b42082b6 100644 --- a/Casso/Shell/WindowCommandManager.h +++ b/Casso/Shell/WindowCommandManager.h @@ -41,7 +41,8 @@ class WindowCommandManager bool OnInitMenuPopup (HWND hwnd, HMENU hMenu, UINT itemIndex, bool isWindowMenu); - HRESULT PromptForDiskImage (int drive); + HRESULT PromptForDiskImage (int drive); + HRESULT PromptInsertDiskMru (int drive); private: EmulatorShell & m_shell; diff --git a/Casso/Ui/Dialog/DialogPrimitive.cpp b/Casso/Ui/Dialog/DialogPrimitive.cpp index be14f22b..aeaecd3c 100644 --- a/Casso/Ui/Dialog/DialogPrimitive.cpp +++ b/Casso/Ui/Dialog/DialogPrimitive.cpp @@ -325,6 +325,15 @@ LRESULT DialogPrimitive::WndProc (HWND hwnd, UINT message, WPARAM wParam, LPARAM result = 0; break; + case WM_SYSCHAR: + if (OnSysChar (wParam)) + { + result = 0; + break; + } + result = DefWindowProcW (hwnd, message, wParam, lParam); + break; + case WM_CHAR: result = 0; break; @@ -583,6 +592,48 @@ void DialogPrimitive::OnKeyDown (WPARAM vk) + + + +//////////////////////////////////////////////////////////////////////////////// +// +// OnSysChar +// +// Alt+letter accelerator dispatch. Buttons strip a single `&` from +// their label and remember the following character as their +// accelerator. Returns true if the keystroke matched a button. +// +//////////////////////////////////////////////////////////////////////////////// + +bool DialogPrimitive::OnSysChar (WPARAM ch) +{ + wchar_t key = (wchar_t) towlower ((wint_t) ch); + + + if (key == 0) + { + return false; + } + + for (size_t i = 0; i < m_buttons.size(); ++i) + { + if (!m_buttons[i].Visible() || !m_buttons[i].Enabled()) + { + continue; + } + if (m_buttons[i].Accelerator() == key) + { + ActivateButton (i); + return true; + } + } + + return false; +} + + + + //////////////////////////////////////////////////////////////////////////////// // // OnMouse @@ -747,10 +798,6 @@ void DialogPrimitive::BuildButtons() 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) { diff --git a/Casso/Ui/Dialog/DialogPrimitive.h b/Casso/Ui/Dialog/DialogPrimitive.h index e5e90d77..3fdef6df 100644 --- a/Casso/Ui/Dialog/DialogPrimitive.h +++ b/Casso/Ui/Dialog/DialogPrimitive.h @@ -60,6 +60,7 @@ class DialogPrimitive void OnSize (int widthPx, int heightPx); void OnDpiChanged (UINT dpi, const RECT & suggestedRect); void OnKeyDown (WPARAM vk); + bool OnSysChar (WPARAM ch); void OnMouse (UINT message, WPARAM wParam, LPARAM lParam); void OnClose (); diff --git a/Casso/Ui/Widgets/Button.cpp b/Casso/Ui/Widgets/Button.cpp index 476f8e55..e69dd626 100644 --- a/Casso/Ui/Widgets/Button.cpp +++ b/Casso/Ui/Widgets/Button.cpp @@ -6,6 +6,53 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// Button::SetLabel +// +// Stores the label with a single ampersand stripped (Win32 accelerator +// convention). Captures the character after the first single `&` as +// the lowercase accelerator key. `&&` is preserved as a literal `&`. +// +//////////////////////////////////////////////////////////////////////////////// + +void Button::SetLabel (const std::wstring & label) +{ + std::wstring out; + wchar_t accel = 0; + size_t i = 0; + + + out.reserve (label.size()); + + while (i < label.size()) + { + if (label[i] == L'&' && i + 1 < label.size()) + { + if (label[i + 1] == L'&') + { + out.push_back (L'&'); + i += 2; + continue; + } + if (accel == 0) + { + accel = (wchar_t) towlower (label[i + 1]); + } + out.push_back (label[i + 1]); + i += 2; + continue; + } + out.push_back (label[i]); + ++i; + } + + m_label = std::move (out); + m_accelerator = accel; +} + + + bool Button::HitTest (int x, int y) const { if (!m_visible || !m_enabled) @@ -107,12 +154,11 @@ void Button::Paint (DxUiPainter & painter, DwriteTextRenderer & text, const Chro m_outlineThick, m_outlineArgb); } - else if (!m_useOverrides && borderColor != 0) + else if (borderColor != 0) { - // Default themed buttons always paint a 1dip border so the + // Themed buttons always paint a 1dip border so the // shape is legible against the panel background even when the - // button fill is similar to the surface. Buttons that opt into - // a custom palette (m_useOverrides) own their own border. + // button fill is similar to the surface. painter.OutlineRect ((float) m_rect.left, (float) m_rect.top, (float) (m_rect.right - m_rect.left), diff --git a/Casso/Ui/Widgets/Button.h b/Casso/Ui/Widgets/Button.h index 06238f4f..f5c45fb9 100644 --- a/Casso/Ui/Widgets/Button.h +++ b/Casso/Ui/Widgets/Button.h @@ -17,7 +17,8 @@ class Button using ClickFn = std::function; void Layout (const RECT & rect) { m_rect = rect; } - void SetLabel (const std::wstring & label) { m_label = label; } + void SetLabel (const std::wstring & label); + wchar_t Accelerator () const { return m_accelerator; } void SetClick (ClickFn click) { m_click = std::move (click); } void SetDpi (UINT dpi) { m_scaler.SetDpi (dpi); } void SetColors (uint32_t idleArgb, uint32_t hoverArgb, uint32_t pressedArgb) @@ -48,6 +49,7 @@ class Button private: RECT m_rect = {}; std::wstring m_label; + wchar_t m_accelerator = 0; ClickFn m_click; bool m_hover = false; bool m_pressed = false; diff --git a/CassoCore/Version.h b/CassoCore/Version.h index c4b87b80..014c29dd 100644 --- a/CassoCore/Version.h +++ b/CassoCore/Version.h @@ -5,7 +5,7 @@ #define VERSION_MAJOR 1 #define VERSION_MINOR 5 -#define VERSION_BUILD 1396 +#define VERSION_BUILD 1403 #define VERSION_YEAR 2026 // Helper macros for stringification