diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fab907..252ee94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ All notable changes to Q1View are documented here. Releases follow [semantic ver --- +## [2.7.27] — 2026-10-04 + +### Changed +- Windows Viewer and Comparator: finish Pretendard typography for help prose, align shortcut columns using measured text with wrapping and scroll access, and share DPI/system-text-size-aware font roles while keeping numeric monospace and native Windows fonts. (issue #117) + +- Windows Viewer and Comparator: use normal-weight Pretendard in the main application menus with wider horizontal spacing and roomier dropdown rows, preserving native menu tracking, command shortcuts, selection states and accessible names. (issue #117) + +- Windows Viewer and Comparator: match window titles to 14 DIP Pretendard menus using a client-painted title with native DWM window buttons, accessible menu buttons, wrapped narrow-window menu rows and retained popup commands; preserve the same application menu across Viewer full-screen transitions and use the native frame in high-contrast mode. (issue #117) + +- Windows Comparator: apply the shared Pretendard popup typography to pane color-format selection and pane context menus, which are independent of the main application menu. (issue #117) + +- Windows Comparator: limit the metrics graph to one third of the content height so enlarged Windows text leaves useful space for the compared images. (issue #117) + +--- + ## [2.7.26] — 2026-09-27 ### Fixed @@ -460,7 +475,8 @@ All notable changes to Q1View are documented here. Releases follow [semantic ver --- -[Unreleased]: https://github.com/chammoru/Q1View/compare/v2.7.26...HEAD +[Unreleased]: https://github.com/chammoru/Q1View/compare/v2.7.27...HEAD +[2.7.27]: https://github.com/chammoru/Q1View/compare/v2.7.26...v2.7.27 [2.7.26]: https://github.com/chammoru/Q1View/compare/v2.7.25...v2.7.26 [2.7.25]: https://github.com/chammoru/Q1View/compare/v2.7.24...v2.7.25 [2.7.24]: https://github.com/chammoru/Q1View/compare/v2.7.23...v2.7.24 diff --git a/Comparator/Comparator.cpp b/Comparator/Comparator.cpp index b93f57c..b83f779 100644 --- a/Comparator/Comparator.cpp +++ b/Comparator/Comparator.cpp @@ -70,6 +70,9 @@ BOOL CComparatorApp::InitInstance() Gdiplus::GdiplusStartup(&mGdiplusToken, &gdiplusStartupInput, NULL); SetRegistryKey(_T("Chammoru")); +#ifdef Q1VIEW_COMPARER_TESTS + SetRegistryKey(_T("Q1ViewComparerTests")); +#endif LoadStdProfileSettings(4); // Load standard INI file options (including MRU) // Register the application's document templates. Document templates @@ -112,13 +115,24 @@ BOOL CComparatorApp::InitInstance() // call DragAcceptFiles only if there's a suffix // In an SDI app, this should occur after ProcessShellCommand +#ifdef Q1VIEW_COMPARER_TESTS + extern int RunComparerTypographyTests(); + mTestExitCode = RunComparerTypographyTests(); + m_pMainWnd->SendMessage(WM_CLOSE); + return FALSE; +#endif return TRUE; } int CComparatorApp::ExitInstance() { Gdiplus::GdiplusShutdown(mGdiplusToken); - return CWinApp::ExitInstance(); + const int result = CWinApp::ExitInstance(); +#ifdef Q1VIEW_COMPARER_TESTS + return mTestExitCode; +#else + return result; +#endif } diff --git a/Comparator/Comparator.h b/Comparator/Comparator.h index ca46e4e..eb2cdce 100644 --- a/Comparator/Comparator.h +++ b/Comparator/Comparator.h @@ -32,6 +32,9 @@ class CComparatorApp : public CWinApp afx_msg void OnFileOpen(); private: +#ifdef Q1VIEW_COMPARER_TESTS + int mTestExitCode = 0; +#endif ULONG_PTR mGdiplusToken; }; diff --git a/Comparator/Comparator.vcxproj b/Comparator/Comparator.vcxproj index 5074f04..472f81e 100644 --- a/Comparator/Comparator.vcxproj +++ b/Comparator/Comparator.vcxproj @@ -306,6 +306,12 @@ copy /Y "$(ProjectDir)..\assets\fonts\Pretendard-LICENSE.txt" "$(TargetDir)Fonts {9f2e09be-9cc5-4460-80a0-0ab612f78ba0} + + Q1VIEW_COMPARER_TESTS;%(PreprocessorDefinitions) + + + NotUsing + diff --git a/Comparator/ComparatorView.cpp b/Comparator/ComparatorView.cpp index 9cce04b..8816ee0 100644 --- a/Comparator/ComparatorView.cpp +++ b/Comparator/ComparatorView.cpp @@ -102,6 +102,19 @@ BEGIN_MESSAGE_MAP(CComparatorView, CScrollView) ON_WM_RBUTTONUP() END_MESSAGE_MAP() +LRESULT CComparatorView::WindowProc(UINT message, WPARAM wp, LPARAM lp) +{ + if (message == WM_INITMENUPOPUP && !HIWORD(lp)) + mUiPopupMenus.SyncPopup(m_hWnd, reinterpret_cast(wp)); + if (message == WM_MEASUREITEM && mUiPopupMenus.Measure(reinterpret_cast(lp))) return TRUE; + if (message == WM_DRAWITEM && mUiPopupMenus.Draw(reinterpret_cast(lp))) return TRUE; + if (message == WM_MENUCHAR) { + LRESULT result; + if (mUiPopupMenus.MenuChar(LOWORD(wp), reinterpret_cast(lp), result)) return result; + } + return CScrollView::WindowProc(message, wp, lp); +} + // Right-click popup items, mirroring the MFC Viewer's mouse menu. The command // IDs are ID_MOUSEMENU_START + this index. enum QMouseMenuId { @@ -246,6 +259,7 @@ void CComparatorView::OnDraw(CDC *pDC) if (!mMemDC.GetSafeHdc()) return; + q1view::WindowsUiDcState dcState(mMemDC.GetSafeHdc()); if (pDoc->mWDst < mWCanvas || pDoc->mHDst < mHCanvas) mMemDC.FillSolidRect(CRect(0, 0, mWCanvas, mHCanvas), Q1UI_COLOR_CANVAS_BG); @@ -628,13 +642,8 @@ void CComparatorView::DrawSelection(CDC *pDC, CComparatorDoc *pDoc) // Size readout (width x height in source pixels) pinned just inside the // rectangle's top-left corner, on a translucent plate for legibility. - LOGFONT lf; - mDefPixelTextFont.GetLogFont(&lf); - lf.lfHeight = 14; - lf.lfWeight = FW_NORMAL; - CFont sizeFont; - sizeFont.CreateFontIndirect(&lf); - CFont *prevFont = pDC->SelectObject(&sizeFont); + CFont *prevFont = pDC->SelectObject(CFont::FromHandle( + mUiFonts.Get(q1view::WindowsUiFontRole::Numeric, m_hWnd))); CString label; label.Format(_T("%d\x00D7%d"), r - l + 1, b - t + 1); @@ -671,14 +680,8 @@ void CComparatorView::DrawCursorCoord(CDC *pDC, CComparatorDoc *pDoc, Comparator ? pDoc->mCursorY : int((long long)pDoc->mCursorY * srcH / pDoc->mH); - LOGFONT lf; - mDefPixelTextFont.GetLogFont(&lf); - lf.lfHeight = 16; - lf.lfWeight = FW_NORMAL; - CFont coordFont; - coordFont.CreateFontIndirect(&lf); - - CFont *prevFont = pDC->SelectObject(&coordFont); + CFont *prevFont = pDC->SelectObject(CFont::FromHandle( + mUiFonts.Get(q1view::WindowsUiFontRole::Numeric, m_hWnd))); CString coord; coord.Format(_T("x:%d,y:%d"), dispX, dispY); @@ -799,19 +802,8 @@ void CComparatorView::DrawEmptyPane(CDC *pDC, CComparatorDoc *pDoc) CRect canvas(0, mRcControls.bottom, mWClient, mHClient); pDC->FillSolidRect(canvas, Q1UI_COLOR_CANVAS_BG); - LOGFONT lf; - mDefPixelTextFont.GetLogFont(&lf); - ::lstrcpy(lf.lfFaceName, q1view::WindowsUiTextFontFamily()); - - CFont titleFont; - lf.lfHeight = 18; - lf.lfWeight = FW_SEMIBOLD; - titleFont.CreateFontIndirect(&lf); - - CFont bodyFont; - lf.lfHeight = 13; - lf.lfWeight = FW_NORMAL; - bodyFont.CreateFontIndirect(&lf); + CFont *titleFont = CFont::FromHandle(mUiFonts.Get(q1view::WindowsUiFontRole::EmptyTitle, m_hWnd)); + CFont *bodyFont = CFont::FromHandle(mUiFonts.Get(q1view::WindowsUiFontRole::Body, m_hWnd)); CString title(_T("Drop an image or video")); CString body(_T("Use 2-4 panes to compare images, raw dumps, or video frames")); @@ -824,10 +816,10 @@ void CComparatorView::DrawEmptyPane(CDC *pDC, CComparatorDoc *pDoc) bodyRect.top = canvas.CenterPoint().y + 6; pDC->SetBkMode(TRANSPARENT); - CFont *prevFont = pDC->SelectObject(&titleFont); + CFont *prevFont = pDC->SelectObject(titleFont); pDC->SetTextColor(Q1UI_COLOR_TEXT); pDC->DrawText(title, &titleRect, DT_SINGLELINE | DT_CENTER | DT_BOTTOM | DT_END_ELLIPSIS); - pDC->SelectObject(&bodyFont); + pDC->SelectObject(bodyFont); pDC->SetTextColor(Q1UI_COLOR_TEXT_MUTED); pDC->DrawText(body, &bodyRect, DT_SINGLELINE | DT_CENTER | DT_TOP | DT_END_ELLIPSIS); pDC->SelectObject(prevFont); @@ -1051,6 +1043,17 @@ void CComparatorView::OnSize(UINT nType, int cx, int cy) return; mRcControls.right = cx; + if (mCsQMenu.GetSafeHwnd() && mNameQMenu.GetSafeHwnd()) { + CRect measured(0, 0, 0, 0); + mCsQMenu.CalcRect(&measured); + const int padding = q1view::WindowsUiPixels(QMENUITEM_IN_MARGIN_H, q1view::WindowsUiDpi(m_hWnd)); + mRcCsQMenu.SetRect(0, 0, measured.Width(), measured.Height() + padding); + mRcControls.bottom = mRcCsQMenu.bottom; + mCsQMenu.MoveWindow(mRcCsQMenu); + mRcNameQMenu.left = mRcCsQMenu.right; + mRcNameQMenu.top = 0; + mRcNameQMenu.bottom = mRcControls.bottom; + } mWClient = cx; mHClient = cy; @@ -1263,7 +1266,7 @@ void CComparatorView::OnCsChange(UINT nID) CComparatorDoc* pDoc = GetDocument(); CString str; - mCsMenu.GetMenuString(nID, str, MF_BYCOMMAND); + str = mUiPopupMenus.Text(mCsMenu.GetSafeHmenu(), nID).c_str(); str.MakeLower(); const struct qcsc_info * const ci = diff --git a/Comparator/ComparatorView.h b/Comparator/ComparatorView.h index fa2742f..919bfdb 100644 --- a/Comparator/ComparatorView.h +++ b/Comparator/ComparatorView.h @@ -3,6 +3,8 @@ // CComparatorView view #include "QMenuItem.h" #include "QViewerCmn.h" +#include "Q1UiFontWin.h" +#include "Q1UiMenuWin.h" namespace Gdiplus { class Pen; @@ -29,6 +31,7 @@ class CComparatorView : public CScrollView protected: virtual void OnDraw(CDC *pDC); // overridden to draw this view virtual void OnInitialUpdate(); // first time after construct + virtual LRESULT WindowProc(UINT message, WPARAM wp, LPARAM lp) override; std::vector GetOhterViews(CComparatorDoc *pDoc); ComparatorPane *GetPane(CComparatorDoc* pDoc) const; @@ -54,6 +57,8 @@ class CComparatorView : public CScrollView int mRgbBufSize; BYTE *mRgbBuf; CFont mDefPixelTextFont; + q1view::WindowsUiFontCache mUiFonts; + q1view::WindowsUiMenus mUiPopupMenus; CFont mPixelTextFont; LONG mPixelTextFontHeight; CDC mMemDC; diff --git a/Comparator/FrmInfoView.cpp b/Comparator/FrmInfoView.cpp index 0456e49..d9bf60d 100644 --- a/Comparator/FrmInfoView.cpp +++ b/Comparator/FrmInfoView.cpp @@ -49,6 +49,7 @@ int CFrmInfoView::OnCreate(LPCREATESTRUCT lpCreateStruct) void CFrmInfoView::OnDraw(CDC* pDC) { + q1view::EnsureWindowsUiFont(mStateFont, q1view::WindowsUiFontRole::Body, m_hWnd); CComparatorDoc *pDoc = GetDocument(); CString frameState = pDoc->mFrmState; diff --git a/Comparator/FrmsInfoView.cpp b/Comparator/FrmsInfoView.cpp index 6c6c632..6eccbcf 100644 --- a/Comparator/FrmsInfoView.cpp +++ b/Comparator/FrmsInfoView.cpp @@ -81,6 +81,9 @@ static void DrawCenteredMessage(CDC *pDC, CRect rect, CFont *titleFont, CFont *b void CFrmsInfoView::OnDraw(CDC* pDC) { + q1view::EnsureWindowsUiFont(mLabelFont, q1view::WindowsUiFontRole::Numeric, m_hWnd); + q1view::EnsureWindowsUiFont(mResultFont, q1view::WindowsUiFontRole::Status, m_hWnd); + q1view::EnsureWindowsUiFont(mBodyFont, q1view::WindowsUiFontRole::Body, m_hWnd); CComparatorDoc* pDoc = GetDocument(); CDC memDC; @@ -98,7 +101,7 @@ void CFrmsInfoView::OnDraw(CDC* pDC) size_t stepCount = pDoc->mMinFrames; if (stepCount <= 0) { DrawCenteredMessage(&memDC, CRect(0, 0, mWClient, mHClient), - &mResultFont, &mLabelFont, _T("Metrics"), + &mResultFont, &mBodyFont, _T("Metrics"), _T("Open two sources to calculate PSNR or SSIM")); pDC->BitBlt(0, 0, mWClient, mHClient, &memDC, 0, 0, SRCCOPY); @@ -109,13 +112,14 @@ void CFrmsInfoView::OnDraw(CDC* pDC) size_t itemCount = mPsnrCal->CalculateCoords(&mGraphRect, pMainFrm->mMetricIdx); if (itemCount <= 0) { DrawCenteredMessage(&memDC, CRect(0, 0, mWClient, mHClient), - &mResultFont, &mLabelFont, _T("Scanning"), + &mResultFont, &mBodyFont, _T("Scanning"), _T("Per-frame measurements will appear as frames are processed")); pDC->BitBlt(0, 0, mWClient, mHClient, &memDC, 0, 0, SRCCOPY); return; } memDC.SetBkMode(TRANSPARENT); + mPsnrCal->UpdateFont(memDC.GetSafeHdc(), static_cast(mLabelFont.GetSafeHandle())); mPsnrCal->DrawCmpResult(&memDC, &mResultFont); mPsnrCal->DrawYLabel(&memDC, &mYLabelRect, &mLabelFont); @@ -159,16 +163,18 @@ void CFrmsInfoView::Dump(CDumpContext& dc) const void CFrmsInfoView::OnSize(UINT nType, int cx, int cy) { CView::OnSize(nType, cx, cy); + const UINT dpi = q1view::WindowsUiDpi(m_hWnd); + const double scale = q1view::WindowsUiSettings().Scale(); + const int left = q1view::WindowsUiPixels(GRAPH_OUT_MARGIN_L, dpi, scale); + const int right = q1view::WindowsUiPixels(GRAPH_OUT_MARGIN_R, dpi, scale); + const int bottom = q1view::WindowsUiPixels(18, dpi, scale); GetClientRect(&mGraphRect); - mGraphRect.DeflateRect(GRAPH_OUT_MARGIN_L, - GRAPH_OUT_MARGIN_T, - GRAPH_OUT_MARGIN_R, - GRAPH_OUT_MARGIN_B); + mGraphRect.DeflateRect(left, GRAPH_OUT_MARGIN_T, right, bottom); mYLabelRect.SetRect(0, Y_LABEL_MARGIN_T, - GRAPH_OUT_MARGIN_L, - cy - Y_LABEL_MARGIN_B); + left, + cy - bottom); mAverageRect.SetRect(mGraphRect.right + AVG_MARGIN_L, AVG_MARGIN_T, cx - AVG_MARGIN_R, diff --git a/Comparator/FrmsInfoView.h b/Comparator/FrmsInfoView.h index 2d1d05b..7cbbec9 100644 --- a/Comparator/FrmsInfoView.h +++ b/Comparator/FrmsInfoView.h @@ -44,7 +44,7 @@ class CFrmsInfoView : public CView int mWClient, mHClient; CRect mGraphRect, mYLabelRect, mAverageRect; FileScanThread *mFileScanThread; - CFont mLabelFont, mResultFont; + CFont mLabelFont, mResultFont, mBodyFont; ULONG_PTR mGdiplusToken; MetricCal *mPsnrCal; diff --git a/Comparator/MainFrm.cpp b/Comparator/MainFrm.cpp index c01669a..3641bc0 100644 --- a/Comparator/MainFrm.cpp +++ b/Comparator/MainFrm.cpp @@ -25,6 +25,7 @@ #include #include #include "Q1ViewVersion.h" +#include "Q1UiHelpWin.h" #include @@ -45,6 +46,10 @@ IMPLEMENT_DYNCREATE(CMainFrame, CFrameWnd) BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) + ON_WM_MEASUREITEM() + ON_WM_DRAWITEM() + ON_WM_INITMENUPOPUP() + ON_WM_MENUCHAR() ON_COMMAND_RANGE(ID_RESOLUTION_START, ID_RESOLUTION_END, CMainFrame::OnResolutionChange) ON_COMMAND_RANGE(ID_METRIC_START, ID_METRIC_END, CMainFrame::OnMetricChange) ON_COMMAND_RANGE(ID_FPS_START, ID_FPS_END, &CMainFrame::OnFpsChange) @@ -53,6 +58,7 @@ BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_COMMAND(ID_COMPARATOR_HELP, &CMainFrame::OnHelp) ON_WM_SIZE() ON_WM_MOVE() + ON_MESSAGE(q1view::WM_UI_TYPOGRAPHY_CHANGED, &CMainFrame::OnTypographyChanged) ON_WM_TIMER() ON_WM_DESTROY() ON_WM_CREATE() @@ -67,34 +73,26 @@ END_MESSAGE_MAP() BEGIN_MESSAGE_MAP(CHelpOverlay, CWnd) ON_WM_LBUTTONDOWN() ON_WM_ERASEBKGND() + ON_WM_MOUSEWHEEL() END_MESSAGE_MAP() // Target size of the centered shortcut panel (clamped to the window). -static const int kHelpPanelW = 460; -static const int kHelpPanelH = 320; - -static CString BuildHelpText() -{ - // Title + product version, then the shortcut rows -- the same shape as the - // Viewer's help panel (issue #79). - CString manual(_T("Comparator shortcuts")); - manual += _T("\nVersion "); - manual += Q1ViewGetProductVersion(); - manual += _T("\n\n"); - manual += CString( - "? Show or hide this panel\n" - "Drag && Drop Open a source in a pane\n" - "Mouse Wheel Zoom in or out; high zoom shows pixel values\n" - "Left/Right Previous or next video frame\n" - "Space Play or pause\n" - "H Toggle hex pixel values\n" - "I Interpolate pixels\n" - "D Toggle pink diff overlay (grid + dots)\n" - "C Toggle cursor pixel coordinates\n" - "S Toggle selection mode (drag a synced region)\n" - "Esc / RClick Clear the selection rectangle\n" - "Click timeline Seek to a video frame (left/right pane)\n"); - return manual; +static std::vector BuildHelpRows() +{ + return { + {L"?", L"Show or hide this panel"}, + {L"Drag & Drop", L"Open a source in a pane"}, + {L"Mouse Wheel", L"Zoom in or out; high zoom shows pixel values"}, + {L"Left/Right", L"Previous or next video frame"}, + {L"Space", L"Play or pause"}, + {L"H", L"Toggle hex pixel values"}, + {L"I", L"Interpolate pixels"}, + {L"D", L"Toggle pink diff overlay (grid + dots)"}, + {L"C", L"Toggle cursor pixel coordinates"}, + {L"S", L"Toggle selection mode (drag a synced region)"}, + {L"Esc / RClick", L"Clear the selection rectangle"}, + {L"Click timeline", L"Seek to a video frame (left/right pane)"} + }; } BOOL CHelpOverlay::CreateOverlay(CWnd *pParent) @@ -112,7 +110,7 @@ bool CHelpOverlay::OwnerScreenRect(CRect &rc) const { if (!mOwner || !::IsWindow(mOwner->GetSafeHwnd())) return false; - mOwner->GetClientRect(&rc); + static_cast(mOwner)->GetContentRect(rc); mOwner->ClientToScreen(&rc); return true; } @@ -128,6 +126,7 @@ void CHelpOverlay::Toggle() CRect rc; if (!OwnerScreenRect(rc)) return; + mScrollOffset = 0; MoveWindow(&rc, FALSE); // screen coords for a top-level window ShowWindow(SW_SHOWNA); // show without activating Render(); @@ -187,8 +186,10 @@ void CHelpOverlay::Render() } // 2) Opaque shortcut panel centered on the whole window (clamped to fit). - int pw = std::min(W - 24, kHelpPanelW); - int ph = std::min(H - 24, kHelpPanelH); + const UINT dpi = q1view::WindowsUiDpi(mOwner->GetSafeHwnd()); + const double textScale = q1view::WindowsUiSettings().Scale(); + int pw = std::min(W - 24, q1view::WindowsUiPixels(640, dpi)); + int ph = std::min(H - 24, q1view::WindowsUiPixels(420, dpi, textScale)); if (pw < 80) pw = W; if (ph < 80) ph = H; CRect panel((W - pw) / 2, (H - ph) / 2, 0, 0); @@ -207,18 +208,11 @@ void CHelpOverlay::Render() const int W_MARGIN = 18, H_MARGIN = 14; CRect textRc(panel.left + W_MARGIN, panel.top + H_MARGIN, panel.right - W_MARGIN, panel.bottom - H_MARGIN); - LOGFONT lf = {}; - lstrcpyn(lf.lfFaceName, _T("Consolas"), LF_FACESIZE); - lf.lfHeight = 14; - lf.lfWeight = FW_NORMAL; - CFont font; - font.CreateFontIndirect(&lf); - CFont *prevFont = dc.SelectObject(&font); dc.SetBkMode(TRANSPARENT); dc.SetTextColor(Q1UI_COLOR_TEXT); - CString manual = BuildHelpText(); - dc.DrawText(manual, &textRc, DT_LEFT | DT_TOP); - dc.SelectObject(prevFont); + CString version = _T("Version "); version += Q1ViewGetProductVersion(); + mMaxScroll = q1view::DrawWindowsUiHelp(dc.GetSafeHdc(), textRc, L"Comparator shortcuts", + static_cast(version), BuildHelpRows(), mHelpFonts, dpi, textScale, mScrollOffset); dc.Detach(); // 3) GDI leaves the alpha byte at the scrim value; force the panel opaque. Its @@ -250,6 +244,73 @@ void CHelpOverlay::OnLButtonDown(UINT /*nFlags*/, CPoint /*point*/) Hide(); // click anywhere dismisses the overlay } +LRESULT CMainFrame::OnTypographyChanged(WPARAM, LPARAM) +{ + q1view::WindowsUiSettings().Refresh(); + DrawMenuBar(); + SetWindowPos(nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); + SendMessageToDescendants(q1view::WM_UI_TYPOGRAPHY_CHANGED); + CRect client; GetClientRect(&client); + OnSize(SIZE_RESTORED, client.Width(), client.Height()); + mHelpOverlay.Relayout(); + if (auto doc = static_cast(GetActiveDocument())) { + if (doc->mPosInfoView) doc->mPosInfoView->ConfigureScrollSizes(doc); + } + RedrawWindow(nullptr, nullptr, RDW_INVALIDATE | RDW_ALLCHILDREN); + return 0; +} + +void CMainFrame::DrawMenuBar() +{ + if (mUiFrame.Initialized()) { mUiFrame.Sync(); RecalcLayout(); return; } + mUiMenus.Sync(m_hWnd, ::GetMenu(m_hWnd)); + CFrameWnd::DrawMenuBar(); +} + +void CMainFrame::OnMeasureItem(int id, LPMEASUREITEMSTRUCT item) +{ if (!mUiMenus.Measure(item)) CFrameWnd::OnMeasureItem(id, item); } + +void CMainFrame::OnDrawItem(int id, LPDRAWITEMSTRUCT item) +{ if (!mUiMenus.Draw(item)) CFrameWnd::OnDrawItem(id, item); } + +void CMainFrame::OnInitMenuPopup(CMenu* menu, UINT index, BOOL system) +{ + CFrameWnd::OnInitMenuPopup(menu, index, system); + if (!system) mUiMenus.Sync(m_hWnd, GetMenu() ? GetMenu()->GetSafeHmenu() : nullptr, mUiFrame.Custom()); +} + +LRESULT CMainFrame::OnMenuChar(UINT character, UINT flags, CMenu* menu) +{ + LRESULT result; + if (mUiMenus.MenuChar(character, menu->GetSafeHmenu(), result)) return result; + return CFrameWnd::OnMenuChar(character, flags, menu); +} + +BOOL CHelpOverlay::OnMouseWheel(UINT, short delta, CPoint) +{ + mScrollOffset = std::max(0, std::min(mMaxScroll, mScrollOffset - + MulDiv(delta, q1view::WindowsUiPixels(60, q1view::WindowsUiDpi(m_hWnd)), WHEEL_DELTA))); + Render(); + return TRUE; +} + +BOOL CHelpOverlay::HandleNavigation(MSG* message) +{ + if (!IsShown() || !message) return FALSE; + if (message->message == WM_KEYDOWN && message->wParam == VK_ESCAPE) { Hide(); return TRUE; } + CRect client; GetClientRect(&client); + if (!q1view::WindowsUiHelpNavigation(message, std::max(1, client.Height() * 2 / 3), mMaxScroll, mScrollOffset)) return FALSE; + Render(); + return TRUE; +} + +BOOL CMainFrame::PreTranslateMessage(MSG* message) +{ + if (mUiFrame.Translate(message)) return TRUE; + if (mHelpOverlay.HandleNavigation(message)) return TRUE; + return CFrameWnd::PreTranslateMessage(message); +} + BOOL CHelpOverlay::OnEraseBkgnd(CDC * /*pDC*/) { return TRUE; // content comes from UpdateLayeredWindow, not WM_PAINT @@ -283,6 +344,45 @@ CMainFrame::~CMainFrame() mOptionsMenu.DestroyMenu(); } +LRESULT CMainFrame::WindowProc(UINT message, WPARAM wp, LPARAM lp) +{ + // MFC's PostNcDestroy deletes this frame; never touch members afterward. + if (message == WM_NCDESTROY) return CFrameWnd::WindowProc(message, wp, lp); + LRESULT result = 0; + if (mUiFrame.Before(message, wp, lp, result)) return result; + result = CFrameWnd::WindowProc(message, wp, lp); + if (message == WM_DESTROY) { mUiFrame.Destroy(); return result; } + if (message == WM_CREATE && result != -1) { mUiFrame.Initialize(m_hWnd, mUiMenus); RecalcLayout(); } + mUiFrame.After(message); + if (message == WM_DPICHANGED || message == q1view::WM_UI_TYPOGRAPHY_CHANGED) { mUiFrame.RefreshSettings(); mUiFrame.Sync(); RecalcLayout(); } + return result; +} + +CMenu* CMainFrame::GetMenu() const +{ return mUiFrame.Initialized() ? CMenu::FromHandle(mUiFrame.Menu()) : CFrameWnd::GetMenu(); } + +BOOL CMainFrame::SetMenu(CMenu* menu) +{ + if (!mUiFrame.Initialized()) return CFrameWnd::SetMenu(menu); + mUiFrame.SetMenu(menu ? menu->GetSafeHmenu() : nullptr); RecalcLayout(); return TRUE; +} + +void CMainFrame::OnUpdateFrameMenu(HMENU menu) +{ + if (!mUiFrame.Initialized()) { CFrameWnd::OnUpdateFrameMenu(menu); return; } + if (menu && menu != mUiFrame.RetainedMenu()) SetMenu(CMenu::FromHandle(menu)); +} + +void CMainFrame::GetContentRect(CRect& rect) const +{ GetClientRect(&rect); rect.top = std::min(rect.bottom, LONG(mUiFrame.Height())); } + +void CMainFrame::RecalcLayout(BOOL notify) +{ + if (mUiFrame.Initialized()) mUiFrame.Layout(); + m_rectBorder.SetRect(0, mUiFrame.Height(), 0, 0); + CFrameWnd::RecalcLayout(notify); +} + BOOL CMainFrame::PreCreateWindow(CREATESTRUCT& cs) { if (!CFrameWnd::PreCreateWindow(cs)) @@ -361,6 +461,7 @@ BOOL CMainFrame::OnCreateClient(LPCREATESTRUCT lpcs, CCreateContext* pContext) void CMainFrame::OnSize(UINT nType, int cx, int cy) { CFrameWnd::OnSize(nType, cx, cy); + cy = std::max(0, cy - mUiFrame.Height()); // Keep the help overlay covering the whole client; drop it on minimize. if (nType == SIZE_MINIMIZED) @@ -368,7 +469,7 @@ void CMainFrame::OnSize(UINT nType, int cx, int cy) else mHelpOverlay.Relayout(); - if (cx <= 0) + if (cx <= 0 || cy <= 0) return; int hOutside, hInside, wOutside, wInside; @@ -376,31 +477,42 @@ void CMainFrame::OnSize(UINT nType, int cx, int cy) if (!mGraphSplitter.IsWindowVisible()) return; - - hOutside = cy - FRAMES_INFO_H - mSplitMargin; + const UINT dpi = q1view::WindowsUiDpi(m_hWnd); + const double scale = q1view::WindowsUiSettings().Scale(); + // Enlarged text must not produce negative splitter dimensions in a small + // window. Cap the graph at one third of the content height so enlarged + // labels cannot consume almost all of the image canvas. + const int graphHeight = std::min(q1view::WindowsUiPixels(FRAMES_INFO_H, dpi, scale), + std::max(0, cy / 3 - mSplitMargin)); + const int positionWidth = std::min(q1view::WindowsUiPixels(POS_INFO_W, dpi, scale), + std::max(0, cx - MIN_SIDE - mSplitMargin)); + + hOutside = std::max(0, cy - graphHeight - mSplitMargin); mGraphSplitter.SetRowInfo(0, hOutside, MIN_SIDE); - mGraphSplitter.SetRowInfo(1, FRAMES_INFO_H, MIN_SIDE); + mGraphSplitter.SetRowInfo(1, graphHeight, MIN_SIDE); mGraphSplitter.RecalcLayout(); if (mSplitMargin == 0) { mGraphSplitter.GetRowInfo(1, realW1, ignore); - mSplitMargin = FRAMES_INFO_H - realW1; + mSplitMargin = graphHeight - realW1; - hOutside = cy - FRAMES_INFO_H - mSplitMargin; + hOutside = std::max(0, cy - graphHeight - mSplitMargin); mGraphSplitter.SetRowInfo(0, hOutside, MIN_SIDE); - mGraphSplitter.SetRowInfo(1, FRAMES_INFO_H, MIN_SIDE); + mGraphSplitter.SetRowInfo(1, graphHeight, MIN_SIDE); mGraphSplitter.RecalcLayout(); } - wOutside = cx - POS_INFO_W - mSplitMargin; - mPosInfoSplitter.SetColumnInfo(0, POS_INFO_W, MIN_SIDE); + wOutside = std::max(0, cx - positionWidth - mSplitMargin); + mPosInfoSplitter.SetColumnInfo(0, positionWidth, MIN_SIDE); mPosInfoSplitter.SetColumnInfo(1, wOutside, MIN_SIDE); mPosInfoSplitter.RecalcLayout(); - hInside = hOutside - FRAME_INFO_H - mSplitBarW; + const int stateHeight = std::min(q1view::WindowsUiPixels(FRAME_INFO_H, dpi, scale), + std::max(0, hOutside - MIN_SIDE - mSplitBarW)); + hInside = std::max(0, hOutside - stateHeight - mSplitBarW); mFrmInfoSplitter.SetRowInfo(0, hInside, MIN_SIDE); - mFrmInfoSplitter.SetRowInfo(1, FRAME_INFO_H, MIN_SIDE); + mFrmInfoSplitter.SetRowInfo(1, stateHeight, MIN_SIDE); mFrmInfoSplitter.RecalcLayout(); if (mSplitBarW == 0) { @@ -408,13 +520,13 @@ void CMainFrame::OnSize(UINT nType, int cx, int cy) mFrmInfoSplitter.GetRowInfo(1, realW1, ignore); mSplitBarW = hOutside - realW0 - realW1; - hInside = hOutside - FRAME_INFO_H - mSplitBarW; + hInside = std::max(0, hOutside - stateHeight - mSplitBarW); mFrmInfoSplitter.SetRowInfo(0, hInside, MIN_SIDE); - mFrmInfoSplitter.SetRowInfo(1, FRAME_INFO_H, MIN_SIDE); + mFrmInfoSplitter.SetRowInfo(1, stateHeight, MIN_SIDE); mFrmInfoSplitter.RecalcLayout(); } - wInside = (wOutside - mSplitBarW * (mNumOfViews - 1)) / mNumOfViews; + wInside = std::max(0, wOutside - mSplitBarW * (mNumOfViews - 1)) / mNumOfViews; int i = 0; for (; i < mNumOfViews; i++) { mCompSplitter.SetColumnInfo(i, wInside, MIN_SIDE); @@ -581,7 +693,7 @@ void CMainFrame::OnResolutionChange(UINT nID) CString str; CMenu *subMenu = GetMenu()->GetSubMenu(MENU_POS_RESOLUTION); - subMenu->GetMenuString(nID, str, MF_BYCOMMAND); + str = mUiMenus.Text(subMenu->GetSafeHmenu(), nID).c_str(); int w = 0, h = 0; int error = q1::image_parse_w_h(CT2A(str), &w, &h); @@ -614,7 +726,7 @@ void CMainFrame::OnFpsChange(UINT nID) CString str; CMenu* subMenu = GetMenu()->GetSubMenu(MENU_POS_FPS); - subMenu->GetMenuString(nID, str, MF_BYCOMMAND); + str = mUiMenus.Text(subMenu->GetSafeHmenu(), nID).c_str(); double fps = _wtof(str); if (fps == 0) { @@ -641,7 +753,7 @@ void CMainFrame::OnViewsChange(UINT nID) { CString str; CMenu* subMenu = GetMenu()->GetSubMenu(MENU_POS_VIEWS); - subMenu->GetMenuString(nID, str, MF_BYCOMMAND); + str = mUiMenus.Text(subMenu->GetSafeHmenu(), nID).c_str(); int views = _wtoi(str); @@ -860,6 +972,7 @@ int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct) str.Format(_T("OPTIONS")); GetMenu()->InsertMenu(MENU_POS_OPTIONS, MF_BYPOSITION | MF_POPUP, (UINT_PTR)mOptionsMenu.m_hMenu, str); + DrawMenuBar(); return 0; } diff --git a/Comparator/MainFrm.h b/Comparator/MainFrm.h index c40a2f5..a8c20cd 100644 --- a/Comparator/MainFrm.h +++ b/Comparator/MainFrm.h @@ -8,6 +8,9 @@ #include "QSplitterWnd.h" #include "qimage_metrics.h" +#include "Q1UiFontWin.h" +#include "Q1UiMenuWin.h" +#include "Q1UiFrameWin.h" #define FRAME_INFO_H 28 #define FRAMES_INFO_H 84 @@ -49,20 +52,27 @@ class CHelpOverlay : public CWnd void Toggle(); void Hide(); void Relayout(); // re-cover the owner's client; repaint if visible + BOOL HandleNavigation(MSG* message); protected: bool OwnerScreenRect(CRect &rc) const; // owner client rect in screen coords void Render(); // build the per-pixel-alpha image and push it + afx_msg BOOL OnMouseWheel(UINT nFlags, short zDelta, CPoint point); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg BOOL OnEraseBkgnd(CDC *pDC); DECLARE_MESSAGE_MAP() private: CWnd *mOwner = NULL; // the frame whose client this overlay covers + q1view::WindowsUiFontCache mHelpFonts; + int mScrollOffset = 0, mMaxScroll = 0; }; class CMainFrame : public CFrameWnd { +#ifdef Q1VIEW_COMPARER_TESTS + friend int RunComparerTypographyTests(); +#endif protected: // create from serialization only CMainFrame(); @@ -79,6 +89,8 @@ class CMainFrame : public CFrameWnd // Full-window shortcut/help overlay (issue #79). CHelpOverlay mHelpOverlay; + q1view::WindowsUiMenus mUiMenus; + q1view::WindowsUiFrame mUiFrame; public: int mMetricIdx; @@ -107,6 +119,13 @@ class CMainFrame : public CFrameWnd // Overrides public: virtual BOOL PreCreateWindow(CREATESTRUCT& cs); + virtual LRESULT WindowProc(UINT, WPARAM, LPARAM); + virtual void RecalcLayout(BOOL notify = TRUE); + virtual void OnUpdateFrameMenu(HMENU menu); + CMenu* GetMenu() const; + BOOL SetMenu(CMenu* menu); + void GetContentRect(CRect& rect) const; + virtual BOOL PreTranslateMessage(MSG* message); // Implementation public: @@ -127,6 +146,12 @@ class CMainFrame : public CFrameWnd afx_msg void OnSize(UINT nType, int cx, int cy); afx_msg void OnMove(int x, int y); afx_msg void OnHelp(); + void DrawMenuBar(); + afx_msg void OnMeasureItem(int, LPMEASUREITEMSTRUCT); + afx_msg void OnDrawItem(int, LPDRAWITEMSTRUCT); + afx_msg void OnInitMenuPopup(CMenu*, UINT, BOOL); + afx_msg LRESULT OnMenuChar(UINT, UINT, CMenu*); + afx_msg LRESULT OnTypographyChanged(WPARAM, LPARAM); afx_msg void OnTimer(UINT_PTR nIDEvent); afx_msg void OnDestroy(); afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct); diff --git a/Comparator/MetricCal.cpp b/Comparator/MetricCal.cpp index 8fe687d..e253b75 100644 --- a/Comparator/MetricCal.cpp +++ b/Comparator/MetricCal.cpp @@ -21,7 +21,7 @@ MetricCal::MetricCal() , mFrameIdx(0) , mViewStartFrame(0) , mViewEndFrame(0) -, mAvgFont(new Font(&FontFamily(q1view::WindowsUiTextFontFamily()), 9)) +, mAvgFont(nullptr) , mMetricIdx(METRIC_PSNR_IDX) { for (int i = 0; i < QPLANES; i++) { @@ -50,6 +50,16 @@ const Color MetricCal::QMetricColors[QPLANES] = Color(0xff, 0x16, 0x9b, 0x62), }; +void MetricCal::UpdateFont(HDC dc, HFONT font) +{ + LOGFONT lf = {}; + if (!GetObject(font, sizeof(lf), &lf)) return; + if (mAvgFont && mAvgFontHeight == lf.lfHeight) return; + Font* replacement = new Font(dc, &lf); + if (replacement->GetLastStatus() != Ok) { delete replacement; return; } + delete mAvgFont; mAvgFont = replacement; mAvgFontHeight = lf.lfHeight; +} + void MetricCal::DrawFrameID(CDC* pDC, size_t frameID, LONG xPt, int hClient) const { CString xLabel; @@ -376,6 +386,7 @@ void MetricCal::DrawLines(Graphics *graphics) const void MetricCal::DrawAverages(Graphics *graphics, CRect *rect, const char **labels) const { + if (!mAvgFont) return; const qmetric_info *qmi = qmetric_info_table + mMetricIdx; int hUnit = rect->Height() / qmi->plane_count; int wText = rect->Width(); diff --git a/Comparator/MetricCal.h b/Comparator/MetricCal.h index 0963075..82ecc89 100644 --- a/Comparator/MetricCal.h +++ b/Comparator/MetricCal.h @@ -38,6 +38,7 @@ class MetricCal CRect mGraphRect; int mStepW; Font *mAvgFont; + LONG mAvgFontHeight = 0; int mMetricIdx; void DrawFrameID(CDC* pDC, size_t frameID, LONG xPt, int hClient) const; @@ -75,6 +76,7 @@ class MetricCal void DrawXLabel(CDC* pDC, int hClient, CFont *font) const; void DrawLines(Graphics *graphics) const; void DrawAverages(Graphics *graphics, CRect *rect, const char **labels) const; + void UpdateFont(HDC dc, HFONT font); inline double getMaxVal() const { return mMaxValUser; } inline double getMinVal() const { return mMinValUser; } diff --git a/Comparator/PosInfoView.cpp b/Comparator/PosInfoView.cpp index f4f57d1..1776a4f 100644 --- a/Comparator/PosInfoView.cpp +++ b/Comparator/PosInfoView.cpp @@ -172,6 +172,7 @@ void CPosInfoView::DrawFrameRect(CDC* pDC, CRect *clipBox, int w) void CPosInfoView::OnDraw(CDC* pDC) { + q1view::EnsureWindowsUiFont(mPosNumFont, q1view::WindowsUiFontRole::Numeric, m_hWnd); CComparatorDoc* pDoc = GetDocument(); CRect clipBox; @@ -200,14 +201,8 @@ void CPosInfoView::OnDraw(CDC* pDC) || (paneR->isAvail() && paneR->frames > 1); if (!paneL->isAvail() && !paneR->isAvail() || !hasVideo) { - LOGFONT lf; - mPosNumFont.GetLogFont(&lf); - ::lstrcpy(lf.lfFaceName, q1view::WindowsUiTextFontFamily()); - lf.lfHeight = 14; - lf.lfWeight = FW_SEMIBOLD; - CFont labelFont; - labelFont.CreateFontIndirect(&lf); - CFont *prevFont = memDC.SelectObject(&labelFont); + CFont *prevFont = memDC.SelectObject(CFont::FromHandle( + mUiFonts.Get(q1view::WindowsUiFontRole::Command, m_hWnd))); memDC.SetBkMode(TRANSPARENT); memDC.SetTextColor(Q1UI_COLOR_TEXT_MUTED); CRect msgRect(0, 0, mWClient, h); @@ -272,7 +267,8 @@ void CPosInfoView::ConfigureScrollSizes(CComparatorDoc *pDoc) int maxFrames = max(pDoc->mMaxFrames, 1); - mPosLinesPerFrame = max(mHClient / maxFrames, POS_LINE_MIN); + mPosLinesPerFrame = max(mHClient / maxFrames, + q1view::WindowsUiPixels(18, q1view::WindowsUiDpi(m_hWnd), q1view::WindowsUiSettings().Scale())); if (mPosLinesPerFrame > mDiffFlagSize) { delete [] mDiffFlags; diff --git a/Comparator/PosInfoView.h b/Comparator/PosInfoView.h index 665f155..b0d0b4b 100644 --- a/Comparator/PosInfoView.h +++ b/Comparator/PosInfoView.h @@ -1,6 +1,7 @@ #pragma once #include "QViewerCmn.h" +#include "Q1UiFontWin.h" #define POS_LINE_MIN 12 #define POS_NUM_FONT_H POS_LINE_MIN @@ -42,6 +43,7 @@ class CPosInfoView : public CScrollView int mPosLines; int mPosLinesPerFrame; CFont mPosNumFont; + q1view::WindowsUiFontCache mUiFonts; FileScanThread *mFileScanThread; size_t mDiffFlagSize; bool *mDiffFlags; diff --git a/Comparator/QMenuItem.cpp b/Comparator/QMenuItem.cpp index f5a26b1..335d6d9 100644 --- a/Comparator/QMenuItem.cpp +++ b/Comparator/QMenuItem.cpp @@ -80,6 +80,8 @@ BOOL CQMenuItem::Create(LPCTSTR lpszWindowName, CRect &rect, CWnd* pParentWnd, C void CQMenuItem::DefaultSetting(CDC *pDC, CString &str) { + q1view::EnsureWindowsUiFont(mFont, mMenu ? q1view::WindowsUiFontRole::Command : + q1view::WindowsUiFontRole::Caption, m_hWnd); pDC->SetBkMode(TRANSPARENT); pDC->SetTextColor(Q1UI_COLOR_TEXT); pDC->SelectObject(mFont); @@ -91,7 +93,7 @@ void CQMenuItem::DefaultSetting(CDC *pDC, CString &str) void CQMenuItem::CalcRect(CRect *rect) { - CPaintDC dc(this); + CClientDC dc(this); CString str; DefaultSetting(&dc, str); diff --git a/QVisionCore/Q1UiFontWin.cpp b/QVisionCore/Q1UiFontWin.cpp new file mode 100644 index 0000000..cc98fcd --- /dev/null +++ b/QVisionCore/Q1UiFontWin.cpp @@ -0,0 +1,58 @@ +// Keep WinRT headers out of the MFC/GDI+ renderers (which expose Color globally). +#include "Q1UiFontWin.h" +#include +#include +#include + +#pragma comment(lib, "runtimeobject.lib") + +namespace q1view { +namespace { +class TextScaleState { + Microsoft::WRL::ComPtr mSettings; + EventRegistrationToken mToken = {}; + bool mSubscribed = false, mUninitialize = false; + double mScale = 1.0; +public: + TextScaleState() + { + mUninitialize = SUCCEEDED(RoInitialize(RO_INIT_SINGLETHREADED)); + Microsoft::WRL::ComPtr instance; + if (FAILED(RoActivateInstance(Microsoft::WRL::Wrappers::HStringReference( + RuntimeClass_Windows_UI_ViewManagement_UISettings).Get(), &instance)) || + FAILED(instance.As(&mSettings))) return; + Refresh(); + const DWORD uiThread = GetCurrentThreadId(); + using Handler = ABI::Windows::Foundation::ITypedEventHandler< + ABI::Windows::UI::ViewManagement::UISettings*, IInspectable*>; + auto handler = Microsoft::WRL::Callback( + [uiThread](ABI::Windows::UI::ViewManagement::IUISettings*, IInspectable*) -> HRESULT { + // This callback may run off-thread. Do not touch MFC or GDI. + EnumThreadWindows(uiThread, [](HWND window, LPARAM) -> BOOL { + PostMessage(window, WM_UI_TYPOGRAPHY_CHANGED, 0, 0); return TRUE; + }, 0); + return S_OK; + }); + mSubscribed = SUCCEEDED(mSettings->add_TextScaleFactorChanged(handler.Get(), &mToken)); + } + ~TextScaleState() + { + if (mSubscribed) mSettings->remove_TextScaleFactorChanged(mToken); + mSettings.Reset(); + if (mUninitialize) RoUninitialize(); + } + void Refresh() + { + double scale = 1.0; + if (mSettings && SUCCEEDED(mSettings->get_TextScaleFactor(&scale)) && + std::isfinite(scale) && scale >= 1.0 && scale <= 2.25) mScale = scale; + } + double Scale() const { return mScale; } +}; +TextScaleState& State() { static TextScaleState state; return state; } +} // namespace + +void WindowsUiTextSettings::Refresh() { State().Refresh(); } +double WindowsUiTextSettings::Scale() const { return State().Scale(); } +WindowsUiTextSettings& WindowsUiSettings() { static WindowsUiTextSettings settings; return settings; } +} // namespace q1view diff --git a/QVisionCore/Q1UiFontWin.h b/QVisionCore/Q1UiFontWin.h index adc1983..1b6edd8 100644 --- a/QVisionCore/Q1UiFontWin.h +++ b/QVisionCore/Q1UiFontWin.h @@ -6,9 +6,76 @@ #include #include #include +#include +#include +#include namespace q1view { +// Only UI text/its measured bounds change. Image-space pixel labels keep their +// zoom-derived size and OS-owned controls keep their native typography. +constexpr UINT WM_UI_TYPOGRAPHY_CHANGED = WM_APP + 117; + +class WindowsUiTextSettings { +public: + void Refresh(); + double Scale() const; +}; + +WindowsUiTextSettings& WindowsUiSettings(); + +// Persistent back-buffer DCs must not retain a font that a later settings +// change will replace. Local fonts must still be deselected before destruction. +class WindowsUiDcState { + HDC mDc; + int mSaved; +public: + explicit WindowsUiDcState(HDC dc) : mDc(dc), mSaved(SaveDC(dc)) {} + ~WindowsUiDcState() { if (mSaved) RestoreDC(mDc, mSaved); } + WindowsUiDcState(const WindowsUiDcState&) = delete; + WindowsUiDcState& operator=(const WindowsUiDcState&) = delete; +}; + +enum class WindowsUiFontRole { + Body, Command, Caption, Folder, Supporting, Status, EmptyTitle, Metric, Numeric, Count +}; + +struct WindowsUiFontSpec { int size, lineHeight, weight; bool numeric; }; + +inline WindowsUiFontSpec WindowsUiFontSpecification(WindowsUiFontRole role) +{ + switch (role) { + case WindowsUiFontRole::Command: return {14, 20, FW_MEDIUM, false}; + case WindowsUiFontRole::Caption: return {13, 18, FW_NORMAL, false}; + case WindowsUiFontRole::Folder: return {13, 18, FW_MEDIUM, false}; + case WindowsUiFontRole::Supporting: return {12, 16, FW_NORMAL, false}; + case WindowsUiFontRole::Status: return {16, 22, FW_MEDIUM, false}; + case WindowsUiFontRole::EmptyTitle: return {20, 28, FW_SEMIBOLD, false}; + case WindowsUiFontRole::Metric: return {16, 22, FW_NORMAL, true}; + case WindowsUiFontRole::Numeric: return {13, 18, FW_NORMAL, true}; + default: return {14, 20, FW_NORMAL, false}; + } +} + +inline int WindowsUiPixels(double dip, UINT dpi, double textScale = 1.0) +{ + return (std::max)(1, int(std::lround(dip * (dpi ? dpi : 96) / 96.0 * textScale))); +} + +inline UINT WindowsUiDpi(HWND window) +{ + if (!window || !IsWindow(window)) return 96; + // Comparator's historical targetver does not declare the Windows 10 API. + using GetWindowDpi = UINT(WINAPI*)(HWND); + static const auto getDpi = reinterpret_cast( + GetProcAddress(GetModuleHandle(_T("user32.dll")), "GetDpiForWindow")); + if (getDpi) { const UINT dpi = getDpi(window); if (dpi) return dpi; } + HDC dc = GetDC(window); + const UINT dpi = dc ? GetDeviceCaps(dc, LOGPIXELSX) : 96; + if (dc) ReleaseDC(window, dc); + return dpi ? dpi : 96; +} + inline LPCTSTR WindowsUiTextFontFamily() { static const bool loaded = [] { @@ -27,4 +94,77 @@ inline LPCTSTR WindowsUiTextFontFamily() return loaded ? _T("Pretendard Variable") : _T("Segoe UI"); } +inline LPCTSTR WindowsUiNumericFontFamily() +{ + static const bool available = [] { + LOGFONT query = {}; + query.lfCharSet = DEFAULT_CHARSET; + lstrcpyn(query.lfFaceName, _T("Cascadia Mono"), LF_FACESIZE); + bool found = false; + HDC dc = GetDC(nullptr); + if (dc) { + EnumFontFamiliesEx(dc, &query, [](const LOGFONT*, const TEXTMETRIC*, DWORD, LPARAM data) -> int { + *reinterpret_cast(data) = true; return 0; + }, reinterpret_cast(&found), 0); + ReleaseDC(nullptr, dc); + } + return found; + }(); + return available ? _T("Cascadia Mono") : _T("Consolas"); +} + +inline LOGFONT WindowsUiLogFont(WindowsUiFontRole role, UINT dpi, double textScale) +{ + const auto spec = WindowsUiFontSpecification(role); + LOGFONT font = {}; + font.lfHeight = -WindowsUiPixels(spec.size, dpi, textScale); // character height, not cell/point size + font.lfWeight = spec.weight; + font.lfCharSet = DEFAULT_CHARSET; + font.lfQuality = CLEARTYPE_NATURAL_QUALITY; + lstrcpyn(font.lfFaceName, spec.numeric ? WindowsUiNumericFontFamily() : WindowsUiTextFontFamily(), LF_FACESIZE); + return font; +} + +// One fixed-size cache per control. No font creation per frame, no unbounded +// process-global cache keyed by every window/DPI combination. Call Get before +// selecting the font, and restore the previous DC object before a later Get. +class WindowsUiFontCache { + struct Entry { HFONT font = nullptr; LONG height = 0; }; + std::array(WindowsUiFontRole::Count)> mEntries = {}; +public: + WindowsUiFontCache() = default; + WindowsUiFontCache(const WindowsUiFontCache&) = delete; + WindowsUiFontCache& operator=(const WindowsUiFontCache&) = delete; + ~WindowsUiFontCache() { for (auto& entry : mEntries) if (entry.font) DeleteObject(entry.font); } + HFONT Get(WindowsUiFontRole role, UINT dpi, double scale) + { + auto& entry = mEntries[static_cast(role)]; + const auto spec = WindowsUiFontSpecification(role); + const LONG height = -WindowsUiPixels(spec.size, dpi, scale); + if (!entry.font || entry.height != height) { + auto logFont = WindowsUiLogFont(role, dpi, scale); + HFONT replacement = CreateFontIndirect(&logFont); + if (replacement) { + if (entry.font) DeleteObject(entry.font); + entry.font = replacement; entry.height = height; + } + } + return entry.font ? entry.font : static_cast(GetStockObject(DEFAULT_GUI_FONT)); + } + HFONT Get(WindowsUiFontRole role, HWND window) + { return Get(role, WindowsUiDpi(window), WindowsUiSettings().Scale()); } +}; + +// Existing persistent MFC CFont members can share the same role definitions. +template inline void EnsureWindowsUiFont(Font& font, WindowsUiFontRole role, HWND window) +{ + const auto wanted = WindowsUiLogFont(role, WindowsUiDpi(window), WindowsUiSettings().Scale()); + LOGFONT current = {}; + if (font.GetSafeHandle() && font.GetLogFont(¤t) && + current.lfHeight == wanted.lfHeight && current.lfWeight == wanted.lfWeight && + lstrcmp(current.lfFaceName, wanted.lfFaceName) == 0) return; + font.DeleteObject(); + font.CreateFontIndirect(&wanted); +} + } // namespace q1view diff --git a/QVisionCore/Q1UiFrameWin.h b/QVisionCore/Q1UiFrameWin.h new file mode 100644 index 0000000..2f50f61 --- /dev/null +++ b/QVisionCore/Q1UiFrameWin.h @@ -0,0 +1,413 @@ +#pragma once + +#include "Q1UiMenuWin.h" +#include +#include +#include +#pragma comment(lib, "dwmapi.lib") +#pragma comment(lib, "comctl32.lib") + +#ifndef WM_DPICHANGED +#define WM_DPICHANGED 0x02E0 +#endif + +namespace q1view { + +// Client-painted title, native DWM caption buttons, and accessible Win32 menu +// buttons. Popup HMENUs, command IDs, and Windows popup tracking are retained. +// No global NONCLIENTMETRICS changes and no separate overlay windows/timers. +class WindowsUiFrame { + HWND mWindow = nullptr, mBar = nullptr, mPreviousFocus = nullptr; + HMENU mMenu = nullptr, mOwnedMenu = nullptr, mSelectedMenu = nullptr; + WindowsUiMenus* mMenus = nullptr; + WindowsUiFontCache mFonts; + std::vector mButtons; + std::vector mButtonRects; + bool mCustom = false, mMenuVisible = true, mTracking = false, mKeyboard = false; + bool mDeferredClose = false; + int mTitleHeight = 0, mMenuHeight = 0, mOpen = -1, mNext = -1; + UINT mSelected = 0, mSelectedFlags = 0; + static WindowsUiFrame*& Tracking() { static thread_local WindowsUiFrame* value = nullptr; return value; } + static constexpr UINT WM_OPEN_MENU = WM_APP + 119; + static constexpr UINT WM_REPAINT_FRAME = WM_APP + 120; + + static int Metric(int id, UINT dpi) { + using Function = int(WINAPI*)(int, UINT); + static const auto fn = reinterpret_cast(GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetSystemMetricsForDpi")); + return fn ? fn(id, dpi) : GetSystemMetrics(id); + } + int Px(int dip) const { return WindowsUiPixels(dip, WindowsUiDpi(mWindow)); } + bool Caption() const { return (GetWindowLongPtrW(mWindow, GWL_STYLE) & WS_CAPTION) == WS_CAPTION; } + RECT CaptionButtons() const { + RECT client; GetClientRect(mWindow, &client); + RECT rect = {(std::max)(0, int(client.right) - Px(138)), 0, client.right, (std::min)(mTitleHeight, Px(32))}; + RECT native = {}; + if (SUCCEEDED(DwmGetWindowAttribute(mWindow, DWMWA_CAPTION_BUTTON_BOUNDS, &native, sizeof(native))) && native.right > native.left) { + RECT window; GetWindowRect(mWindow, &window); POINT origin = {}; ClientToScreen(mWindow, &origin); + const LONG left = native.left - (origin.x - window.left); + if (left < client.right) rect.left = (std::max)(0L, left); + rect.top = (std::max)(0L, native.top - (origin.y - window.top)); + rect.bottom = (std::min)(LONG(mTitleHeight), native.bottom - (origin.y - window.top)); + if (rect.bottom <= rect.top) { rect.top = 0; rect.bottom = (std::min)(mTitleHeight, Px(32)); } + } + return rect; + } + static bool CanCustomize() { + HIGHCONTRASTW contrast = {sizeof(contrast)}; + SystemParametersInfoW(SPI_GETHIGHCONTRAST, sizeof(contrast), &contrast, 0); + BOOL composition = FALSE; + return !(contrast.dwFlags & HCF_HIGHCONTRASTON) && SUCCEEDED(DwmIsCompositionEnabled(&composition)) && composition; + } + MENUITEMINFOW Info(int position) const { + MENUITEMINFOW item = {sizeof(item)}; + item.fMask = MIIM_ID | MIIM_SUBMENU | MIIM_STATE | MIIM_FTYPE | MIIM_DATA; + GetMenuItemInfoW(mMenu, position, TRUE, &item); return item; + } + int ButtonAt(POINT screen) const { + if (!mBar || !IsWindowVisible(mBar)) return -1; + ScreenToClient(mBar, &screen); + for (size_t i = 0; i < mButtonRects.size(); ++i) + if (PtInRect(&mButtonRects[i], screen) && IsWindowEnabled(mButtons[i])) return int(i); + return -1; + } + bool IsButton(HWND window) const { + return std::find(mButtons.begin(), mButtons.end(), window) != mButtons.end(); + } + void RememberFocus(HWND window) { + if (window && window != mBar && !IsButton(window) && IsChild(mWindow, window)) mPreviousFocus = window; + } + void RestoreFocus() { + if (IsWindow(mPreviousFocus) && IsWindowVisible(mPreviousFocus)) SetFocus(mPreviousFocus); + mKeyboard = false; + } + int NextEnabled(int position, int direction) const { + const int count = int(mButtons.size()); + for (int i = 0; i < count; ++i) { + position = (position + direction + count) % count; + if (IsWindowEnabled(mButtons[position])) return position; + } + return -1; + } + static LRESULT CALLBACK ButtonProc(HWND window, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR data) { + auto* self = reinterpret_cast(data); + if (message == WM_SETFOCUS) self->RememberFocus(reinterpret_cast(wp)); + if (message == WM_NCDESTROY) RemoveWindowSubclass(window, ButtonProc, 1); + return DefSubclassProc(window, message, wp, lp); + } + static LRESULT CALLBACK BarProc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + auto* self = reinterpret_cast(GetWindowLongPtrW(window, GWLP_USERDATA)); + if (message == WM_NCCREATE) { + self = static_cast(reinterpret_cast(lp)->lpCreateParams); + SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast(self)); + } + if (!self) return DefWindowProcW(window, message, wp, lp); + if (message == WM_ERASEBKGND) { + RECT rect; GetClientRect(window, &rect); FillRect(reinterpret_cast(wp), &rect, GetSysColorBrush(COLOR_MENUBAR)); return TRUE; + } + if (message == WM_DRAWITEM) { + auto draw = *reinterpret_cast(lp); + const int index = int(draw.CtlID) - 1; + if (index >= 0 && index < int(self->mButtons.size())) { + draw.CtlType = ODT_MENU; draw.itemData = self->Info(index).dwItemData; + if (self->mTracking && self->mOpen == index) draw.itemState |= ODS_SELECTED; + if (!self->mKeyboard) draw.itemState |= ODS_NOACCEL; + self->mMenus->Draw(&draw); + if (draw.itemState & ODS_FOCUS) { InflateRect(&draw.rcItem, -2, -2); DrawFocusRect(draw.hDC, &draw.rcItem); } + return TRUE; + } + } + if (message == WM_COMMAND && HIWORD(wp) == BN_CLICKED) { + PostMessageW(self->mWindow, WM_OPEN_MENU, LOWORD(wp) - 1, 0); return 0; + } + return DefWindowProcW(window, message, wp, lp); + } + static LRESULT CALLBACK MenuFilter(int code, WPARAM wp, LPARAM lp) { + auto* self = Tracking(); + if (code == MSGF_MENU && self) { + const auto* message = reinterpret_cast(lp); + int next = -1; + if (message->message == WM_MOUSEMOVE) next = self->ButtonAt(message->pt); + if (message->message == WM_KEYDOWN && self->mSelectedMenu == self->Info(self->mOpen).hSubMenu) { + if (message->wParam == VK_LEFT) next = self->NextEnabled(self->mOpen, -1); + if (message->wParam == VK_RIGHT && !(self->mSelectedFlags & MF_POPUP)) next = self->NextEnabled(self->mOpen, 1); + } + if (next >= 0 && next != self->mOpen) { self->mNext = next; EndMenu(); return 1; } + } + return CallNextHookEx(nullptr, code, wp, lp); + } + void Open(int position, bool keyboard) { + if (mTracking || position < 0 || position >= int(mButtons.size()) || !IsWindowEnabled(mButtons[position])) return; + mKeyboard = keyboard; + mTracking = true; + WindowsUiFrame* previous = Tracking(); Tracking() = this; + HHOOK hook = SetWindowsHookExW(WH_MSGFILTER, MenuFilter, nullptr, GetCurrentThreadId()); + UINT command = 0; + do { + mOpen = position; mNext = -1; mSelectedMenu = Info(position).hSubMenu; mSelectedFlags = 0; + if (keyboard) SetFocus(mButtons[position]); + const auto item = Info(position); + if (!item.hSubMenu) { command = item.wID; break; } + RECT rect = mButtonRects[position]; MapWindowPoints(mBar, nullptr, reinterpret_cast(&rect), 2); + InvalidateRect(mButtons[position], nullptr, FALSE); UpdateWindow(mButtons[position]); + TPMPARAMS parameters = {sizeof(parameters)}; parameters.rcExclude = rect; + command = TrackPopupMenuEx(item.hSubMenu, TPM_LEFTALIGN | TPM_TOPALIGN | TPM_RETURNCMD, + rect.left, rect.bottom, mWindow, ¶meters); + InvalidateRect(mButtons[position], nullptr, FALSE); + position = mNext; + } while (!command && position >= 0 && !mDeferredClose && IsWindow(mWindow)); + if (hook) UnhookWindowsHookEx(hook); + Tracking() = previous; mTracking = false; mOpen = -1; + if (command || !keyboard) RestoreFocus(); + if (IsWindow(mWindow)) { + for (HWND button : mButtons) InvalidateRect(button, nullptr, FALSE); + if (mDeferredClose) { mDeferredClose = false; PostMessageW(mWindow, WM_CLOSE, 0, 0); } + else if (command) PostMessageW(mWindow, WM_COMMAND, command, 0); + } + } +public: + ~WindowsUiFrame() { Destroy(); } + void Destroy() { + // Detaching the root menu transfers its former HWND-owned lifetime to + // this helper. Native fallback menus remain HWND-owned on destruction. + if (mOwnedMenu && IsMenu(mOwnedMenu) && ::GetMenu(mWindow) != mOwnedMenu) DestroyMenu(mOwnedMenu); + mOwnedMenu = nullptr; mMenu = nullptr; mWindow = nullptr; mCustom = false; + } + bool Initialized() const { return mWindow != nullptr; } + bool Custom() const { return mCustom; } + HMENU Menu() const { return mMenuVisible ? mMenu : nullptr; } + HMENU RetainedMenu() const { return mMenu; } + int Height() const { return mCustom && Caption() ? mTitleHeight + mMenuHeight : 0; } + int TitleHeight() const { return mTitleHeight; } + HWND MenuHost() const { return mBar; } + const std::vector& MenuRects() const { return mButtonRects; } + void Initialize(HWND window, WindowsUiMenus& menus) { + mWindow = window; mMenus = &menus; mMenu = mOwnedMenu = ::GetMenu(window); + WNDCLASSW cls = {}; cls.hInstance = GetModuleHandleW(nullptr); cls.lpfnWndProc = BarProc; + cls.lpszClassName = L"Q1View.UiMenuHost"; cls.hCursor = LoadCursor(nullptr, IDC_ARROW); + RegisterClassW(&cls); + mBar = CreateWindowExW(0, cls.lpszClassName, L"Application menu", WS_CHILD | WS_CLIPCHILDREN, + 0, 0, 0, 0, window, nullptr, cls.hInstance, this); + RefreshSettings(); Sync(); + } + void SetMenu(HMENU menu) { + if (menu) mMenu = menu; + mMenuVisible = menu != nullptr; + ::SetMenu(mWindow, mCustom ? nullptr : Menu()); Sync(); + } + void RefreshSettings() { + const bool custom = mBar && CanCustomize(); + if (custom != mCustom) { + mCustom = custom; + ::SetMenu(mWindow, mCustom ? nullptr : Menu()); + SetWindowPos(mWindow, nullptr, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); + } + Layout(); Extend(); + } + void Sync() { + if (!Initialized()) return; + mMenus->Sync(mWindow, mMenu, mCustom); + const int count = (std::max)(0, GetMenuItemCount(mMenu)); + while (int(mButtons.size()) > count) { DestroyWindow(mButtons.back()); mButtons.pop_back(); } + while (int(mButtons.size()) < count) { + const UINT id = UINT(mButtons.size()) + 1; + HWND button = CreateWindowExW(0, L"BUTTON", L"", WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS | BS_OWNERDRAW, + 0, 0, 0, 0, mBar, reinterpret_cast(UINT_PTR(id)), GetModuleHandleW(nullptr), nullptr); + if (!button) break; + SetWindowSubclass(button, ButtonProc, 1, reinterpret_cast(this)); mButtons.push_back(button); + } + for (size_t i = 0; i < mButtons.size(); ++i) { + const auto text = mMenus->Text(mMenu, UINT(i), true); + wchar_t current[256] = {}; GetWindowTextW(mButtons[i], current, 256); + if (text != current) SetWindowTextW(mButtons[i], text.c_str()); + EnableWindow(mButtons[i], !(Info(int(i)).fState & (MFS_DISABLED | MFS_GRAYED))); + InvalidateRect(mButtons[i], nullptr, FALSE); + } + Layout(); + } + void Layout() { + if (!Initialized()) return; + RECT client; GetClientRect(mWindow, &client); + const int width = client.right; + const UINT dpi = WindowsUiDpi(mWindow); const double scale = WindowsUiSettings().Scale(); + mTitleHeight = (std::max)(Px(32), WindowsUiPixels(20, dpi, scale) + Px(12)); + const int rowHeight = (std::max)(Px(30), WindowsUiPixels(20, dpi, scale) + Px(8)); + mButtonRects.resize(mButtons.size()); + int left = 0, top = 0; + HDC dc = GetDC(mWindow); + if (dc) { + WindowsUiDcState saved(dc); SelectObject(dc, mFonts.Get(WindowsUiFontRole::Body, mWindow)); + for (size_t i = 0; i < mButtons.size(); ++i) { + auto text = mMenus->Text(mMenu, UINT(i), true); + RECT measured = {}; DrawTextW(dc, text.c_str(), -1, &measured, DT_SINGLELINE | DT_CALCRECT); + const int buttonWidth = (std::min)((std::max)(1, width), int(measured.right) + WindowsUiPixels(16, dpi, scale)); + if (left && left + buttonWidth > width) { left = 0; top += rowHeight; } + mButtonRects[i] = {left, top, left + buttonWidth, top + rowHeight}; left += buttonWidth; + } + } + if (dc) ReleaseDC(mWindow, dc); + // Align the trailing group (Help, Compare, Update, zoom label) only when + // it fits on the same row; narrow windows wrap instead of overlapping. + for (size_t i = 0; i < mButtons.size(); ++i) if (Info(int(i)).fType & MFT_RIGHTJUSTIFY) { + const int shift = width - left; + bool sameRow = true; + for (size_t j = i; j < mButtons.size(); ++j) if (mButtonRects[j].top != mButtonRects[i].top) sameRow = false; + if (sameRow && shift > 0) for (size_t j = i; j < mButtons.size(); ++j) OffsetRect(&mButtonRects[j], shift, 0); + break; + } + mMenuHeight = mMenuVisible && !mButtons.empty() ? top + rowHeight : 0; + const bool visible = mCustom && Caption() && mMenuVisible; + SetWindowPos(mBar, HWND_TOP, 0, mTitleHeight, width, mMenuHeight, + SWP_NOACTIVATE | (visible ? SWP_SHOWWINDOW : SWP_HIDEWINDOW)); + for (size_t i = 0; i < mButtons.size(); ++i) { + const auto& rect = mButtonRects[i]; + MoveWindow(mButtons[i], rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, TRUE); + } + RECT title = {0, 0, width, mTitleHeight}; InvalidateRect(mWindow, &title, FALSE); + } + void Extend() { + if (!Initialized()) return; + MARGINS margins = {}; margins.cyTopHeight = mCustom && Caption() ? mTitleHeight : 0; + DwmExtendFrameIntoClientArea(mWindow, &margins); + const COLORREF background = mCustom ? GetSysColor(COLOR_MENUBAR) : 0xffffffff; + const COLORREF foreground = mCustom ? GetSysColor(COLOR_MENUTEXT) : 0xffffffff; + DwmSetWindowAttribute(mWindow, DWMWA_CAPTION_COLOR, &background, sizeof(background)); + DwmSetWindowAttribute(mWindow, DWMWA_TEXT_COLOR, &foreground, sizeof(foreground)); + } + void Paint(HDC target) { + if (!mCustom || !Caption()) return; + RECT client; GetClientRect(mWindow, &client); + if (client.right <= 0 || mTitleHeight <= 0) return; + // GDI text leaves alpha at zero. In an extended DWM frame that makes + // glyphs transparent; compose one opaque, buffered title surface. + BITMAPINFO bitmap = {}; bitmap.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bitmap.bmiHeader.biWidth = client.right; bitmap.bmiHeader.biHeight = -mTitleHeight; + bitmap.bmiHeader.biPlanes = 1; bitmap.bmiHeader.biBitCount = 32; bitmap.bmiHeader.biCompression = BI_RGB; + void* pixels = nullptr; HDC dc = CreateCompatibleDC(target); + HBITMAP surface = CreateDIBSection(target, &bitmap, DIB_RGB_COLORS, &pixels, nullptr, 0); + if (!dc || !surface) { if (dc) DeleteDC(dc); if (surface) DeleteObject(surface); return; } + const auto previous = SelectObject(dc, surface); + const RECT captionButtons = CaptionButtons(); + { + RECT title = {0, 0, client.right, mTitleHeight}; + WindowsUiDcState saved(dc); + FillRect(dc, &title, GetSysColorBrush(COLOR_MENUBAR)); + const int right = captionButtons.left; + HICON icon = reinterpret_cast(SendMessage(mWindow, WM_GETICON, ICON_SMALL2, 0)); + if (!icon) icon = reinterpret_cast(GetClassLongPtr(mWindow, GCLP_HICONSM)); + if (!icon) icon = reinterpret_cast(GetClassLongPtr(mWindow, GCLP_HICON)); + if (icon) DrawIconEx(dc, Px(10), (mTitleHeight - Px(20)) / 2, icon, Px(20), Px(20), 0, nullptr, DI_NORMAL); + title.left = Px(38); title.right = (std::max)(title.left, LONG(right - Px(8))); + SelectObject(dc, mFonts.Get(WindowsUiFontRole::Body, mWindow)); + SetBkMode(dc, TRANSPARENT); SetTextColor(dc, GetSysColor(GetForegroundWindow() == mWindow ? COLOR_MENUTEXT : COLOR_GRAYTEXT)); + std::vector text(GetWindowTextLengthW(mWindow) + 1); + GetWindowTextW(mWindow, text.data(), int(text.size())); + DrawTextW(dc, text.data(), -1, &title, DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX); + } + for (int y = 0; y < mTitleHeight; ++y) for (int x = 0; x < client.right; ++x) { + auto& pixel = static_cast(pixels)[size_t(y) * client.right + x]; + pixel = x >= captionButtons.left && y >= captionButtons.top && y < captionButtons.bottom ? 0 : pixel | 0xff000000; + } + BitBlt(target, 0, 0, client.right, mTitleHeight, dc, 0, 0, SRCCOPY); + SelectObject(dc, previous); DeleteObject(surface); DeleteDC(dc); + } + bool Before(UINT message, WPARAM wp, LPARAM lp, LRESULT& result) { + if (!Initialized()) return false; + if (message == WM_EXITSIZEMOVE && mCustom) PostMessageW(mWindow, WM_REPAINT_FRAME, 0, 0); + if (message == WM_WINDOWPOSCHANGING && mCustom) { + // Native bit-copy preservation assumes the standard client origin. + // Our client title changes that origin: invalidate instead of copying + // stale child pixels during moves as well as size changes. + auto* position = reinterpret_cast(lp); + if (!(position->flags & SWP_NOMOVE) || !(position->flags & SWP_NOSIZE)) position->flags |= SWP_NOCOPYBITS; + } + if (mTracking && (message == WM_CLOSE || (message == WM_SYSCOMMAND && (wp & 0xfff0) == SC_CLOSE))) { + // Finish popup tracking/unhooking before MFC deletes the owner frame. + mDeferredClose = true; mNext = -1; EndMenu(); result = 0; return true; + } + if (message == WM_OPEN_MENU) { Open(int(wp), mKeyboard); result = 0; return true; } + if (message == WM_MENUSELECT && mTracking) { mSelected = LOWORD(wp); mSelectedFlags = HIWORD(wp); mSelectedMenu = reinterpret_cast(lp); } + if (!mCustom || !Caption()) return false; + if (message == WM_NCCALCSIZE) { + RECT* rect = wp ? &reinterpret_cast(lp)->rgrc[0] : reinterpret_cast(lp); + const UINT dpi = WindowsUiDpi(mWindow); + const int x = Metric(SM_CXSIZEFRAME, dpi) + Metric(SM_CXPADDEDBORDER, dpi); + const int y = Metric(SM_CYSIZEFRAME, dpi) + Metric(SM_CXPADDEDBORDER, dpi); + rect->left += x; rect->right -= x; rect->bottom -= y; if (IsZoomed(mWindow)) rect->top += y; + result = wp ? WVR_REDRAW : 0; return true; + } + LRESULT dwm = 0; + if (DwmDefWindowProc(mWindow, message, wp, lp, &dwm) && + (message != WM_NCHITTEST || dwm == HTMINBUTTON || dwm == HTMAXBUTTON || dwm == HTCLOSE)) { result = dwm; return true; } + if (message == WM_NCHITTEST) { + POINT screen = {GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}; + RECT bounds; GetWindowRect(mWindow, &bounds); + const UINT dpi = WindowsUiDpi(mWindow); + const int x = Metric(SM_CXSIZEFRAME, dpi) + Metric(SM_CXPADDEDBORDER, dpi); + const int y = Metric(SM_CYSIZEFRAME, dpi) + Metric(SM_CXPADDEDBORDER, dpi); + const bool left = screen.x < bounds.left + x, right = screen.x >= bounds.right - x; + const bool top = screen.y < bounds.top + y, bottom = screen.y >= bounds.bottom - y; + if (!IsZoomed(mWindow) && (left || right || top || bottom)) { + result = top ? (left ? HTTOPLEFT : right ? HTTOPRIGHT : HTTOP) : + bottom ? (left ? HTBOTTOMLEFT : right ? HTBOTTOMRIGHT : HTBOTTOM) : left ? HTLEFT : HTRIGHT; + return true; + } + POINT point = screen; ScreenToClient(mWindow, &point); + const RECT buttons = CaptionButtons(); + if (PtInRect(&buttons, point)) { + // DWM may not have initialized its own hit-test state yet (e.g. + // immediately after a frame/DPI change). Retain the native hit + // codes rather than turning a visible caption button into a drag. + const int buttonWidth = (std::max)(1L, (buttons.right - buttons.left) / 3); + result = point.x >= buttons.right - buttonWidth ? HTCLOSE : + point.x >= buttons.right - 2 * buttonWidth ? HTMAXBUTTON : HTMINBUTTON; + return true; + } + result = point.y < mTitleHeight ? (point.x < Px(34) ? HTSYSMENU : HTCAPTION) : HTCLIENT; + return true; + } + if (message == WM_PAINT) { PAINTSTRUCT paint; HDC dc = BeginPaint(mWindow, &paint); Paint(dc); EndPaint(mWindow, &paint); result = 0; return true; } + if (message == WM_ERASEBKGND) { result = TRUE; return true; } + if (message == WM_SYSCOMMAND && (wp & 0xfff0) == SC_KEYMENU && lp != L' ') { + mKeyboard = true; RememberFocus(GetFocus()); const int index = NextEnabled(-1, 1); + if (index >= 0) SetFocus(mButtons[index]); result = 0; return true; + } + return false; + } + void After(UINT message) { + if (!Initialized()) return; + if (message == WM_REPAINT_FRAME && mCustom) { + RedrawWindow(mWindow, nullptr, nullptr, RDW_INVALIDATE | RDW_ERASE | RDW_ALLCHILDREN | RDW_UPDATENOW | RDW_FRAME); + for (HWND button : mButtons) + RedrawWindow(button, nullptr, nullptr, RDW_INVALIDATE | RDW_ERASE | RDW_UPDATENOW); + } + if (message == WM_SIZE || message == WM_STYLECHANGED) { Layout(); Extend(); } + if (message == WM_ACTIVATE || message == WM_SETTEXT) { Extend(); RECT rect; GetClientRect(mWindow, &rect); rect.bottom = mTitleHeight; InvalidateRect(mWindow, &rect, FALSE); } + if (message == WM_SETTINGCHANGE || message == WM_THEMECHANGED || message == WM_DWMCOMPOSITIONCHANGED) { RefreshSettings(); Sync(); } + } + bool Translate(MSG* message) { + if (!mCustom || !Caption() || !message || mTracking) return false; + if ((message->message == WM_SYSCHAR && message->wParam != L' ') || + (message->message == WM_CHAR && mKeyboard && IsButton(GetFocus()))) { + LRESULT match; + if (mMenus->MenuChar(UINT(message->wParam), mMenu, match)) { + RememberFocus(GetFocus()); mKeyboard = true; const int index = LOWORD(match); + SetFocus(mButtons[index]); Open(index, true); return true; + } + } + if (message->message != WM_KEYDOWN) return false; + if (message->wParam == VK_F10 && !(GetKeyState(VK_SHIFT) & 0x8000)) { + if (mKeyboard) RestoreFocus(); else { RememberFocus(GetFocus()); mKeyboard = true; const int index = NextEnabled(-1, 1); if (index >= 0) SetFocus(mButtons[index]); } + return true; + } + if (!IsButton(GetFocus())) return false; + const int current = int(std::find(mButtons.begin(), mButtons.end(), GetFocus()) - mButtons.begin()); + switch (message->wParam) { + case VK_ESCAPE: RestoreFocus(); return true; + case VK_LEFT: case VK_RIGHT: { const int index = NextEnabled(current, message->wParam == VK_LEFT ? -1 : 1); if (index >= 0) SetFocus(mButtons[index]); return true; } + case VK_RETURN: case VK_DOWN: case VK_SPACE: Open(current, true); return true; + default: break; + } + return false; + } +}; +} // namespace q1view diff --git a/QVisionCore/Q1UiHelpWin.h b/QVisionCore/Q1UiHelpWin.h new file mode 100644 index 0000000..c148912 --- /dev/null +++ b/QVisionCore/Q1UiHelpWin.h @@ -0,0 +1,116 @@ +#pragma once + +#include "Q1UiFontWin.h" +#include + +namespace q1view { + +struct WindowsUiHelpRow { std::wstring key, description; }; +struct WindowsUiHelpRowBounds { RECT key, description; }; +struct WindowsUiHelpLayout { + std::vector rows; + int height = 0; +}; + +inline bool WindowsUiHelpNavigation(MSG* message, int page, int maximum, int& offset) +{ + if (!message) return false; + if (message->message == WM_MOUSEWHEEL) { + offset -= MulDiv(GET_WHEEL_DELTA_WPARAM(message->wParam), (std::max)(20, page / 6), WHEEL_DELTA); + } else if (message->message == WM_KEYDOWN) { + switch (message->wParam) { + case VK_PRIOR: offset -= page; break; + case VK_NEXT: offset += page; break; + case VK_HOME: offset = 0; break; + case VK_END: offset = maximum; break; + case VK_UP: offset -= (std::max)(20, page / 12); break; + case VK_DOWN: offset += (std::max)(20, page / 12); break; + default: return false; + } + } else return false; + offset = (std::max)(0, (std::min)(offset, maximum)); + return true; +} + +// Proportional fonts cannot align columns with spaces. Measure each key and +// wrap descriptions in an independent column, preserving literal '&' glyphs. +inline WindowsUiHelpLayout MeasureWindowsUiHelp(HDC dc, const RECT& bounds, + const std::vector& rows, WindowsUiFontCache& fonts, UINT dpi, double scale) +{ + WindowsUiHelpLayout layout; + const int saved = SaveDC(dc); + SelectObject(dc, fonts.Get(WindowsUiFontRole::Body, dpi, scale)); + int keyWidth = 0; + for (const auto& row : rows) { + SIZE size = {}; + GetTextExtentPoint32W(dc, row.key.c_str(), int(row.key.size()), &size); + keyWidth = (std::max)(keyWidth, int(size.cx)); + } + const int width = (std::max)(2, int(bounds.right - bounds.left)); + const int gap = (std::min)(width / 8, WindowsUiPixels(12, dpi)); + keyWidth = (std::min)(keyWidth, (width - gap) * 2 / 5); + const int minimumLine = WindowsUiPixels(20, dpi, scale); + int top = bounds.top; + for (const auto& row : rows) { + RECT key = {bounds.left, top, bounds.left + keyWidth, top}; + RECT description = {key.right + gap, top, bounds.right, top}; + DrawTextW(dc, row.key.c_str(), -1, &key, DT_CALCRECT | DT_WORDBREAK | DT_NOPREFIX); + DrawTextW(dc, row.description.c_str(), -1, &description, DT_CALCRECT | DT_WORDBREAK | DT_NOPREFIX); + const int height = (std::max)(minimumLine, + (std::max)(int(key.bottom - top), int(description.bottom - top))); + // CALCRECT may widen an unbreakable word. Keep the actual draw clip in + // its column rather than overwriting the neighbouring description. + key.right = bounds.left + keyWidth; key.bottom = top + height; + description.left = key.right + gap; description.right = bounds.right; + description.bottom = top + height; + layout.rows.push_back({key, description}); + top += height + WindowsUiPixels(4, dpi); + } + layout.height = top - bounds.top; + RestoreDC(dc, saved); + return layout; +} + +// Returns the maximum scroll offset, used by the owning nonactivating overlay. +inline int DrawWindowsUiHelp(HDC dc, RECT bounds, const std::wstring& title, + const std::wstring& version, const std::vector& rows, + WindowsUiFontCache& fonts, UINT dpi, double scale, int& scrollOffset) +{ + const int saved = SaveDC(dc); + const int titleHeight = WindowsUiPixels(22, dpi, scale); + const int supportingHeight = WindowsUiPixels(16, dpi, scale); + SetBkMode(dc, TRANSPARENT); + SelectObject(dc, fonts.Get(WindowsUiFontRole::Status, dpi, scale)); + RECT titleRect = bounds; titleRect.bottom = titleRect.top + titleHeight; + DrawTextW(dc, title.c_str(), -1, &titleRect, DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS); + SelectObject(dc, fonts.Get(WindowsUiFontRole::Supporting, dpi, scale)); + RECT versionRect = bounds; versionRect.top = titleRect.bottom; + versionRect.bottom = versionRect.top + supportingHeight; + DrawTextW(dc, version.c_str(), -1, &versionRect, DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS); + RECT content = bounds; + content.top = versionRect.bottom + WindowsUiPixels(12, dpi); + content.bottom -= supportingHeight + WindowsUiPixels(8, dpi); + if (content.bottom <= content.top) { scrollOffset = 0; RestoreDC(dc, saved); return 0; } + const auto layout = MeasureWindowsUiHelp(dc, content, rows, fonts, dpi, scale); + const int maxScroll = (std::max)(0, layout.height - int(content.bottom - content.top)); + scrollOffset = (std::max)(0, (std::min)(scrollOffset, maxScroll)); + const int bodySaved = SaveDC(dc); + IntersectClipRect(dc, content.left, content.top, content.right, content.bottom); + SelectObject(dc, fonts.Get(WindowsUiFontRole::Body, dpi, scale)); + for (size_t i = 0; i < rows.size(); ++i) { + RECT key = layout.rows[i].key, description = layout.rows[i].description; + OffsetRect(&key, 0, -scrollOffset); OffsetRect(&description, 0, -scrollOffset); + if (key.bottom <= content.top || key.top >= content.bottom) continue; + DrawTextW(dc, rows[i].key.c_str(), -1, &key, DT_WORDBREAK | DT_NOPREFIX); + DrawTextW(dc, rows[i].description.c_str(), -1, &description, DT_WORDBREAK | DT_NOPREFIX); + } + RestoreDC(dc, bodySaved); + SelectObject(dc, fonts.Get(WindowsUiFontRole::Supporting, dpi, scale)); + RECT footer = bounds; footer.top = bounds.bottom - supportingHeight; + const wchar_t* hint = maxScroll ? L"Scroll / Page Down for more. Click or Esc to close." : L"Click or Esc to close."; + DrawTextW(dc, hint, -1, &footer, DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS); + RestoreDC(dc, saved); + return maxScroll; +} + +} // namespace q1view diff --git a/QVisionCore/Q1UiMenuWin.h b/QVisionCore/Q1UiMenuWin.h new file mode 100644 index 0000000..37e8b1a --- /dev/null +++ b/QVisionCore/Q1UiMenuWin.h @@ -0,0 +1,191 @@ +#pragma once + +#include "Q1UiFontWin.h" +#include +#include +#include +#include + +namespace q1view { + +// Keep real HMENUs and Windows menu tracking. Only measurement/painting change. +// MSAA metadata must be first so native accessibility exposes each item name. +class WindowsUiMenus { + struct Item { + MSAAMENUINFO accessible = {MSAA_MENU_SIG, 0, nullptr}; + HMENU menu = nullptr; + UINT position = 0, type = 0; + bool bar = false; + std::wstring text; + }; + std::vector> mItems; + WindowsUiFontCache mFonts; + WindowsUiFontCache mBarFonts; + HWND mWindow = nullptr; + bool mHostedBar = false; + Item* Find(ULONG_PTR data) const { + for (const auto& item : mItems) if (reinterpret_cast(item.get()) == data) return item.get(); + return nullptr; + } + int Px(int value) const { return WindowsUiPixels(value, WindowsUiDpi(mWindow), WindowsUiSettings().Scale()); } + HFONT Font(const Item* item) { + const UINT dpi = WindowsUiDpi(mWindow); + double scale = WindowsUiSettings().Scale(); + if (!item->bar || mHostedBar) return mFonts.Get(WindowsUiFontRole::Body, dpi, scale); + // Windows, not WM_MEASUREITEM, ultimately owns the native bar's row + // height. Fit within that hit-tested row instead of clipping enlarged + // glyphs or changing global NONCLIENTMETRICS for other applications. + // Do not query menu-item rectangles while Windows is measuring/drawing + // that menu: the query can re-enter native menu layout during creation. + using MetricForDpi = int(WINAPI*)(int, UINT); + static const auto metric = reinterpret_cast( + GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetSystemMetricsForDpi")); + const int height = metric ? metric(SM_CYMENU, dpi) : GetSystemMetrics(SM_CYMENU); + scale = (std::min)(scale, (std::max)(1.0, double(height - WindowsUiPixels(4, dpi)) / WindowsUiPixels(18, dpi))); + return mBarFonts.Get(WindowsUiFontRole::Body, dpi, scale); + } + static std::wstring ReadText(HMENU menu, UINT position) { + MENUITEMINFOW info = {sizeof(info)}; info.fMask = MIIM_STRING; + if (!GetMenuItemInfoW(menu, position, TRUE, &info)) return {}; + std::vector buffer(info.cch + 1); + info.dwTypeData = buffer.data(); info.cch = UINT(buffer.size()); + if (!GetMenuItemInfoW(menu, position, TRUE, &info)) return {}; + return buffer.data(); + } + static std::wstring VisibleText(const std::wstring& text) { + std::wstring result; + for (size_t i = 0; i < text.size(); ++i) { + if (text[i] != L'&') result += text[i]; + else if (i + 1 < text.size() && text[i + 1] == L'&') { result += L'&'; ++i; } + } + return result; + } + static bool Attached(HMENU menu, const Item* item) { + for (int i = 0; i < GetMenuItemCount(menu); ++i) { + MENUITEMINFOW info = {sizeof(info)}; info.fMask = MIIM_DATA; + if (GetMenuItemInfoW(menu, i, TRUE, &info) && info.dwItemData == reinterpret_cast(item)) return true; + } + return false; + } + void SyncMenu(HMENU menu, bool bar) { + for (int position = 0; position < GetMenuItemCount(menu); ++position) { + MENUITEMINFOW info = {sizeof(info)}; + info.fMask = MIIM_FTYPE | MIIM_DATA | MIIM_SUBMENU; + if (!GetMenuItemInfoW(menu, position, TRUE, &info)) continue; + Item* item = Find(info.dwItemData); + // Do not take ownership of unrelated custom menu drawing/data. + if ((info.fType & MFT_OWNERDRAW) && !item) continue; + const auto text = ReadText(menu, UINT(position)); + if (!item) { + for (auto& candidate : mItems) + if (candidate->menu == menu && !Attached(menu, candidate.get())) { item = candidate.get(); break; } + } + if (!item) { mItems.emplace_back(new Item); item = mItems.back().get(); } + item->menu = menu; item->position = UINT(position); item->type = info.fType; + item->bar = bar; item->text = text; + item->accessible.cchWText = DWORD(item->text.size()); + item->accessible.pszWText = const_cast(item->text.c_str()); + MENUITEMINFOW style = {sizeof(style)}; style.fMask = MIIM_FTYPE | MIIM_DATA; + style.fType = info.fType | MFT_OWNERDRAW; + style.dwItemData = reinterpret_cast(item); + SetMenuItemInfoW(menu, position, TRUE, &style); + if (info.hSubMenu) SyncMenu(info.hSubMenu, false); + } + } +public: + void Sync(HWND window, HMENU menu, bool hostedBar = false) { mWindow = window; mHostedBar = hostedBar; if (menu) SyncMenu(menu, true); } + // Standalone pane/context popups are not children of the frame menu. + void SyncPopup(HWND window, HMENU menu) { mWindow = window; if (menu) SyncMenu(menu, false); } + std::wstring Text(HMENU menu, UINT id, bool byPosition = false) const { + MENUITEMINFOW info = {sizeof(info)}; info.fMask = MIIM_DATA; + if (GetMenuItemInfoW(menu, id, byPosition, &info)) + if (auto* item = Find(info.dwItemData)) return item->text; + if (byPosition) return ReadText(menu, id); + for (int i = 0; i < GetMenuItemCount(menu); ++i) { + MENUITEMINFOW entry = {sizeof(entry)}; entry.fMask = MIIM_ID; + if (GetMenuItemInfoW(menu, i, TRUE, &entry) && entry.wID == id) return ReadText(menu, UINT(i)); + } + return {}; + } + bool Measure(MEASUREITEMSTRUCT* measure) { + if (!measure || measure->CtlType != ODT_MENU) return false; + auto* item = Find(measure->itemData); if (!item) return false; + HDC dc = GetDC(mWindow); if (!dc) return false; + { WindowsUiDcState state(dc); SelectObject(dc, Font(item)); + const auto text = VisibleText(item->text); SIZE size = {}; + GetTextExtentPoint32W(dc, text.c_str(), int(text.size()), &size); + measure->itemHeight = (item->type & MFT_SEPARATOR) ? Px(9) : (std::max)(Px(30), int(size.cy) + Px(10)); + measure->itemWidth = size.cx + Px(item->bar ? 16 : 64); + } + ReleaseDC(mWindow, dc); return true; + } + bool Draw(DRAWITEMSTRUCT* draw) { + if (!draw || draw->CtlType != ODT_MENU) return false; + auto* item = Find(draw->itemData); if (!item) return false; + WindowsUiDcState state(draw->hDC); + SelectObject(draw->hDC, Font(item)); SetBkMode(draw->hDC, TRANSPARENT); + const bool disabled = (draw->itemState & (ODS_DISABLED | ODS_GRAYED)) != 0; + const bool selected = (draw->itemState & (ODS_SELECTED | ODS_HOTLIGHT)) != 0; + const int background = selected ? COLOR_HIGHLIGHT : (item->bar ? COLOR_MENUBAR : COLOR_MENU); + FillRect(draw->hDC, &draw->rcItem, GetSysColorBrush(background)); + SetTextColor(draw->hDC, GetSysColor(disabled ? COLOR_GRAYTEXT : (selected ? COLOR_HIGHLIGHTTEXT : COLOR_MENUTEXT))); + RECT rect = draw->rcItem; + if (item->type & MFT_SEPARATOR) { + rect.left += Px(28); rect.right -= Px(8); + rect.top = (rect.top + rect.bottom) / 2; rect.bottom = rect.top + 1; + FillRect(draw->hDC, &rect, GetSysColorBrush(COLOR_3DSHADOW)); return true; + } + if (!item->bar) { + rect.left += Px(28); rect.right -= Px(24); + MENUITEMINFOW current = {sizeof(current)}; + current.fMask = MIIM_FTYPE | MIIM_STATE; + GetMenuItemInfoW(item->menu, item->position, TRUE, ¤t); + const int middle = (rect.top + rect.bottom) / 2; + HPEN pen = CreatePen(PS_SOLID, Px(2), GetTextColor(draw->hDC)); + HBRUSH brush = CreateSolidBrush(GetTextColor(draw->hDC)); + const auto oldPen = SelectObject(draw->hDC, pen), oldBrush = SelectObject(draw->hDC, brush); + if ((draw->itemState & ODS_CHECKED) || (current.fState & MFS_CHECKED)) { + const int x = draw->rcItem.left + Px(14); + if (current.fType & MFT_RADIOCHECK) + Ellipse(draw->hDC, x - Px(2), middle - Px(2), x + Px(3), middle + Px(3)); + else { + POINT tick[] = {{x - Px(5), middle}, {x - Px(1), middle + Px(4)}, {x + Px(6), middle - Px(4)}}; + Polyline(draw->hDC, tick, 3); + } + } + // Windows paints the submenu chevron after WM_DRAWITEM. Reserve + // its gutter, but do not overpaint it with a duplicate indicator. + SelectObject(draw->hDC, oldPen); SelectObject(draw->hDC, oldBrush); + DeleteObject(pen); DeleteObject(brush); + } else { rect.left += Px(8); rect.right -= Px(8); } + UINT flags = DT_SINGLELINE | DT_VCENTER; + if (draw->itemState & ODS_NOACCEL) flags |= DT_HIDEPREFIX; + const auto tab = item->text.find(L'\t'); + const auto label = item->text.substr(0, tab); + DrawTextW(draw->hDC, label.c_str(), -1, &rect, flags | DT_LEFT); + if (tab != std::wstring::npos) + DrawTextW(draw->hDC, item->text.c_str() + tab + 1, -1, &rect, flags | DT_RIGHT | DT_NOPREFIX); + return true; + } + bool MenuChar(UINT character, HMENU menu, LRESULT& result) const { + std::vector matches; int highlighted = -1; + for (int i = 0; i < GetMenuItemCount(menu); ++i) { + MENUITEMINFOW info = {sizeof(info)}; info.fMask = MIIM_DATA | MIIM_STATE; + if (!GetMenuItemInfoW(menu, i, TRUE, &info)) continue; + if (info.fState & MFS_HILITE) highlighted = i; + auto* item = Find(info.dwItemData); + if (!item || info.fState & (MFS_DISABLED | MFS_GRAYED)) continue; + for (size_t j = 0; j + 1 < item->text.size(); ++j) { + if (item->text[j] != L'&') continue; + if (item->text[j + 1] == L'&') { ++j; continue; } + if (towupper(item->text[j + 1]) == towupper(wchar_t(character))) matches.push_back(UINT(i)); + break; + } + } + if (matches.empty()) return false; + UINT next = matches.front(); + for (UINT position : matches) if (int(position) > highlighted) { next = position; break; } + result = MAKELRESULT(next, matches.size() == 1 ? MNC_EXECUTE : MNC_SELECT); return true; + } +}; +} // namespace q1view diff --git a/QVisionCore/QVisionCore.vcxproj b/QVisionCore/QVisionCore.vcxproj index 51dadc0..1cdf086 100644 --- a/QVisionCore/QVisionCore.vcxproj +++ b/QVisionCore/QVisionCore.vcxproj @@ -21,6 +21,7 @@ + diff --git a/Tests/ComparerTypographyIntegrationTests.cpp b/Tests/ComparerTypographyIntegrationTests.cpp new file mode 100644 index 0000000..7bb82d4 --- /dev/null +++ b/Tests/ComparerTypographyIntegrationTests.cpp @@ -0,0 +1,131 @@ +// /p:Q1ViewComparerTests=true: actual MFC document/views, isolated preferences. +#include "../Comparator/stdafx.h" +#include "../Comparator/Comparator.h" +#include "../Comparator/MainFrm.h" +#include "../Comparator/ComparatorDoc.h" +#include "../Comparator/ComparatorView.h" +#include "../Comparator/FrmCmpStrategy.h" +#include "Q1UiFontWin.h" +#include +#include + +namespace { +FILE* report; +constexpr int METRIC_SSIM_IDX = 1; +void Check(bool value, const char* message) { + fprintf(report, "%s: %s\n", value ? "PASS" : "FAIL", message); fflush(report); + if (!value) throw std::runtime_error(message); +} +void Pump(DWORD milliseconds) { + const ULONGLONG end = GetTickCount64() + milliseconds; + do { + MSG message; + while (PeekMessage(&message, nullptr, 0, 0, PM_REMOVE)) { + if (message.message == WM_QUIT) throw std::runtime_error("unexpected quit"); + if (!AfxGetApp()->PreTranslateMessage(&message)) { TranslateMessage(&message); DispatchMessage(&message); } + } + Sleep(1); + } while (GetTickCount64() < end); +} +std::vector Pixels(HWND window) { + RECT bounds; GetClientRect(window, &bounds); + Check(bounds.right > 0 && bounds.bottom > 0, "pixel sample has nonzero bounds"); + HDC source = GetDC(window), target = CreateCompatibleDC(source); + BITMAPINFO info = {}; info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = bounds.right; info.bmiHeader.biHeight = -bounds.bottom; + info.bmiHeader.biPlanes = 1; info.bmiHeader.biBitCount = 32; info.bmiHeader.biCompression = BI_RGB; + void* bits = nullptr; HBITMAP bitmap = CreateDIBSection(target, &info, DIB_RGB_COLORS, &bits, nullptr, 0); + Check(bitmap != nullptr, "pixel sample DIB created"); + HGDIOBJ previous = SelectObject(target, bitmap); + const BOOL copied = BitBlt(target, 0, 0, bounds.right, bounds.bottom, source, 0, 0, SRCCOPY); + GdiFlush(); + std::vector result(static_cast(bits), static_cast(bits) + size_t(bounds.right) * bounds.bottom); + for (auto& pixel : result) pixel &= 0xffffff; + SelectObject(target, previous); DeleteObject(bitmap); DeleteDC(target); ReleaseDC(window, source); + Check(copied != FALSE, "presented window pixels read back"); + return result; +} +} + +int RunComparerTypographyTests() { + wchar_t output[MAX_PATH] = {}; + GetEnvironmentVariableW(L"Q1VIEW_COMPARER_TEST_REPORT", output, _countof(output)); + if (!output[0]) wcscpy_s(output, L"Tests\\bin\\x64\\Release\\comparer-typography-report.txt"); + if (_wfopen_s(&report, output, L"w") || !report) return 2; + try { + auto* frame = static_cast(AfxGetMainWnd()); + Check(frame != nullptr, "real Comparer frame created"); + auto* doc = static_cast(frame->GetActiveDocument()); + Check(doc != nullptr, "real Comparer document created"); + // An isolated test profile may still restore a maximized startup state. + // Normalize it before taking equal-size movement pixel baselines. + frame->ShowWindow(SW_RESTORE); + frame->MoveWindow(50, 50, 1100, 800); Pump(250); + Check(true, "test frame initial layout completed"); + wchar_t first[MAX_PATH], second[MAX_PATH]; + GetFullPathNameW(L"docs\\images\\sample-colored-pencils-reference.png", MAX_PATH, first, nullptr); + GetFullPathNameW(L"docs\\images\\sample-colored-pencils-changed-region.png", MAX_PATH, second, nullptr); + std::vector files = {first, second}; + doc->OpenMultiFiles(files); Pump(500); + // Opening a large source intentionally auto-maximizes the production + // frame. Movement checks need a restored, fixed-size window instead. + frame->ShowWindow(SW_RESTORE); + frame->MoveWindow(50, 50, 1100, 800); Pump(250); + Check(doc->mFrmCmpStrategy && doc->mPane[0].isAvail() && doc->mPane[1].isAvail(), "two real image sources and comparison strategy loaded"); + doc->mHasSelection = true; doc->mSelStart = CPoint(2, 3); doc->mSelCur = CPoint(10, 12); + doc->mN = 3; doc->mD = 1; doc->mXOff = 7; doc->mYOff = 9; + int l, t, r, b; Check(doc->GetSelectionRect(l,t,r,b) && l==2 && t==3 && r==10 && b==12, "ROI uses committed source-pixel corners"); + CString psnr = doc->mFrmCmpStrategy->CropScore(doc->mPane, doc->mPane + 1, METRIC_PSNR_IDX, l,t,r,b); + CString ssim = doc->mFrmCmpStrategy->CropScore(doc->mPane, doc->mPane + 1, METRIC_SSIM_IDX, l,t,r,b); + Check(!psnr.IsEmpty() && !ssim.IsEmpty(), "real PSNR and SSIM ROI scores calculated"); + std::vector original(doc->mPane[0].rgbBuf, doc->mPane[0].rgbBuf + doc->mPane[0].rgbBufSize); + const CString path = doc->mPane[0].pathName; + const QIMAGE_CS color = doc->mPane[0].colorSpace; + const float n = doc->mN, d = doc->mD, x = doc->mXOff, y = doc->mYOff; + frame->SendMessage(q1view::WM_UI_TYPOGRAPHY_CHANGED); Pump(250); + Check(doc->mN==n && doc->mD==d && doc->mXOff==x && doc->mYOff==y, "typography relayout preserves zoom and pan inputs"); + Check(doc->GetSelectionRect(l,t,r,b) && l==2 && t==3 && r==10 && b==12, "typography relayout preserves ROI"); + Check(doc->mPane[0].pathName==path && doc->mPane[0].colorSpace==color && original.size()==doc->mPane[0].rgbBufSize && + std::equal(original.begin(), original.end(), doc->mPane[0].rgbBuf), "typography relayout preserves actual image bytes, file and format"); + Check(psnr == doc->mFrmCmpStrategy->CropScore(doc->mPane,doc->mPane+1,METRIC_PSNR_IDX,l,t,r,b) && + ssim == doc->mFrmCmpStrategy->CropScore(doc->mPane,doc->mPane+1,METRIC_SSIM_IDX,l,t,r,b), "PSNR and SSIM ROI scores unchanged after typography relayout"); + HWND menuButton = GetDlgItem(frame->mUiFrame.MenuHost(), 2); + const auto before = Pixels(menuButton); + const auto imageBefore = Pixels(doc->mPane[0].pView->m_hWnd); + CRect bounds; frame->GetWindowRect(bounds); + for (int i=0;i<12;++i) { + frame->SetWindowPos(nullptr, bounds.left+(i%2)*65, bounds.top+(i%2)*39, 0,0,SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE); + Pump(70); + Check(Pixels(menuButton)==before, "presented menu text pixels survive window movement without mouse/click repaint"); + Check(Pixels(doc->mPane[0].pView->m_hWnd)==imageBefore, "presented image pane pixels survive movement without forced repaint"); + } + Check(doc->GetSelectionRect(l,t,r,b) && l==2 && t==3 && r==10 && b==12 && doc->mN==n && doc->mD==d, "window movement preserves selection and scale"); + wchar_t live[8] = {}; + if (GetEnvironmentVariableW(L"Q1VIEW_COMPARER_TEST_LIVE_MOVE", live, _countof(live))) { + // Passive in-app readback while external native input moves this test + // window. No synthesized mouse/key input or forced repaint here. + CRect observed; frame->GetWindowRect(observed); + const ULONGLONG end = GetTickCount64()+60000; + int moves = 0; + while (GetTickCount64()GetWindowRect(current); + if (current.TopLeft()!=observed.TopLeft()) { + // Do not sample an intermediate native bit-copy while the + // drag button is still down. Observe the settled presentation. + if (GetAsyncKeyState(VK_LBUTTON)&0x8000) continue; + Pump(350); + Check(Pixels(menuButton)==before, "live native mouse-move menu pixels match pre-move readback"); + Check(Pixels(doc->mPane[0].pView->m_hWnd)==imageBefore, "live native mouse-move image pixels match pre-move readback"); + observed=current; ++moves; + } + } + Check(moves>=1, "settled live native window move observed without forcing repaint"); + } + frame->MoveWindow(50, 50, 800, 500); Pump(250); + Check(doc->mPane[0].pView->mHCanvas >= q1view::WindowsUiPixels(60, q1view::WindowsUiDpi(frame->m_hWnd)), "small window retains useful image canvas at current OS text size"); + fprintf(report, "Actual window DPI: %u; OS text factor: %.3f\n", q1view::WindowsUiDpi(frame->m_hWnd), q1view::WindowsUiSettings().Scale()); + fprintf(report, "ALL COMPARER TYPOGRAPHY CHECKS PASSED\n"); fclose(report); return 0; + } catch(const std::exception& error) { + fprintf(report, "ERROR: %s\n", error.what()); fclose(report); return 1; + } +} diff --git a/Tests/GalleryIntegrationTests.cpp b/Tests/GalleryIntegrationTests.cpp index 6141653..90c128c 100644 --- a/Tests/GalleryIntegrationTests.cpp +++ b/Tests/GalleryIntegrationTests.cpp @@ -274,6 +274,37 @@ struct GalleryIntegrationTests { pane.mConfirmRecycle = confirm; pane.mReportRecycle = reportFailure; } + void ChromeTests(CMainFrame* frame, CViewerView* view) { + Require(frame->mUiFrame.Initialized() && frame->mUiFrame.Custom(), "real MFC Viewer uses the custom client-painted title"); + CRect content, splitter; + frame->GetContentRect(content); frame->mwndSplitter.GetWindowRect(splitter); frame->ScreenToClient(splitter); + Require(content == splitter && content.top >= 62, "MFC reserves chrome once without overlapping or double-resizing the image splitter"); + const HMENU menu = frame->GetMenu()->GetSafeHmenu(); + const int menuCount = GetMenuItemCount(menu); + const CString path = static_cast(frame->GetActiveDocument())->GetPathName(); + CRect before; frame->GetWindowRect(before); + frame->SendMessage(WM_SYSCOMMAND, SC_MAXIMIZE); Pump(.4); + MONITORINFO monitor = {sizeof(monitor)}; + GetMonitorInfo(MonitorFromWindow(frame->m_hWnd, MONITOR_DEFAULTTONEAREST), &monitor); + CRect client; frame->GetClientRect(client); frame->ClientToScreen(client); + Require(frame->IsZoomed() && client.left >= monitor.rcWork.left && client.top >= monitor.rcWork.top && + client.right <= monitor.rcWork.right && client.bottom <= monitor.rcWork.bottom, "maximized client stays within the monitor work area and taskbar"); + frame->SendMessage(WM_SYSCOMMAND, SC_RESTORE); Pump(.3); + CRect restored; frame->GetWindowRect(restored); + Require(!frame->IsZoomed() && restored == before, "native restore retains the original window rectangle"); + for (int i = 0; i < 3; ++i) { + view->ToggleFullScreen(); Pump(.1); + Require(view->IsFullScreen() && frame->mUiFrame.Height() == 0 && frame->GetMenu() == nullptr, + "full screen hides both custom title and menu without leftover reservation"); + view->ToggleFullScreen(); Pump(.1); + Require(!view->IsFullScreen() && frame->GetMenu()->GetSafeHmenu() == menu && GetMenuItemCount(menu) == menuCount, + "full-screen exit preserves the same menu and does not duplicate dynamic entries"); + } + Require(static_cast(frame->GetActiveDocument())->GetPathName() == path, "window chrome operations never change the active file"); + frame->GetContentRect(content); frame->mwndSplitter.GetWindowRect(splitter); frame->ScreenToClient(splitter); + Require(content == splitter, "splitter returns to the chrome-reserved client rectangle after full screen"); + } + void Run() { auto frame = static_cast(AfxGetMainWnd()); auto view = static_cast(frame->GetActiveView()); @@ -283,6 +314,7 @@ struct GalleryIntegrationTests { frame->MoveWindow(20, 20, 1200, 850); if (!frame->mDrawerVisible) frame->OnToggleDrawer(); Pump(.4); + ChromeTests(frame, view); auto& pane = *frame->mpDrawer; Require(pane.mPrivateFontLoaded && pane.mFontFamily == L"Pretendard Variable", "bundled Pretendard typography loaded for the MFC drawer"); @@ -292,9 +324,10 @@ struct GalleryIntegrationTests { pane.mLabelFont.GetLogFont(®ular); pane.mFolderFont.GetLogFont(&folderFont); pane.mExtFont.GetLogFont(&badge); - Require(regular.lfHeight == -MulDiv(13, dpi, 96) && regular.lfWeight == FW_NORMAL && - folderFont.lfHeight == -MulDiv(13, dpi, 96) && folderFont.lfWeight == FW_MEDIUM && - badge.lfHeight == -MulDiv(12, dpi, 96) && badge.lfWeight == FW_SEMIBOLD, + const double textScale = q1view::WindowsUiSettings().Scale(); + Require(regular.lfHeight == -q1view::WindowsUiPixels(13, dpi, textScale) && regular.lfWeight == FW_NORMAL && + folderFont.lfHeight == -q1view::WindowsUiPixels(13, dpi, textScale) && folderFont.lfWeight == FW_MEDIUM && + badge.lfHeight == -q1view::WindowsUiPixels(12, dpi, textScale) && badge.lfWeight == FW_SEMIBOLD, "MFC drawer typography scales at 100, 150, and 200 percent DPI"); } pane.RebuildFonts(GetDpiForWindow(pane.m_hWnd)); @@ -802,8 +835,11 @@ struct GalleryIntegrationTests { // Model the same-folder rebuild caused when an asynchronous // video preview is rejected between the click and its posted // activation message. The captured folder action must survive. - pane.Populate(pane.mFolder, child); Pump(.05); - Require(pane.mFolder == child, "folder activation survives a same-folder asynchronous refresh"); + pane.Populate(pane.mFolder, child); + // Activation is posted, not synchronous. Playback/decode work + // may consume a fixed 50 ms pump before that message is reached. + // Await the actual outcome, retaining the bounded failure timeout. + Await([&] { return pane.mFolder == child; }, "folder activation survives a same-folder asynchronous refresh"); MSG up = {}; up.hwnd = pane.GetSafeHwnd(); up.message = WM_KEYDOWN; up.wParam = VK_BACK; Require(pane.PreTranslateMessage(&up) && pane.mFolder == folder, "Backspace remains a parent-navigation shortcut"); CMenu background; pane.BuildContextMenu(background, -1); diff --git a/Tests/WindowsUiTypographyTests.cpp b/Tests/WindowsUiTypographyTests.cpp new file mode 100644 index 0000000..cccba45 --- /dev/null +++ b/Tests/WindowsUiTypographyTests.cpp @@ -0,0 +1,244 @@ +#include "../QVisionCore/Q1UiHelpWin.h" +#include +#include +#include "../QVisionCore/Q1UiMenuWin.h" +#include "../QVisionCore/Q1UiFrameWin.h" + +static void Require(bool value, const char* message) +{ if (!value) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); } } + +static void TestMenus() +{ + using namespace q1view; + HMENU root = CreateMenu(), popup = CreatePopupMenu(); + AppendMenuW(root, MF_POPUP, reinterpret_cast(popup), L"&File"); + AppendMenuW(root, MF_STRING | MF_RIGHTJUSTIFY, 103, L"&Help"); + AppendMenuW(popup, MF_STRING, 100, L"&Open 한글...\tCtrl+O"); + AppendMenuW(popup, MF_STRING, 101, L"1920x1080"); + AppendMenuW(popup, MF_SEPARATOR, 0, nullptr); + AppendMenuW(popup, MF_STRING | MF_GRAYED, 102, L"&Unavailable"); + WindowsUiMenus menus; + menus.Sync(nullptr, root); menus.Sync(nullptr, root); + Require(menus.Text(popup, 101) == L"1920x1080", "owner-drawn menu retains parser source text across repeated synchronization"); + MENUITEMINFOW info = {sizeof(info)}; info.fMask = MIIM_DATA | MIIM_FTYPE | MIIM_STATE; + Require(GetMenuItemInfoW(root, 0, TRUE, &info) != FALSE, "styled native menu item exists"); + auto* accessible = reinterpret_cast(info.dwItemData); + Require(accessible && accessible->dwMSAASignature == MSAA_MENU_SIG && + wcscmp(accessible->pszWText, L"&File") == 0, "native MSAA menu name is preserved"); + MEASUREITEMSTRUCT measure = {}; measure.CtlType = ODT_MENU; measure.itemData = info.dwItemData; + Require(menus.Measure(&measure) && measure.itemHeight >= 30, "menu row includes 30 DIP breathing room"); + Require(GetMenuItemInfoW(root, 1, TRUE, &info) && (info.fType & MFT_RIGHTJUSTIFY), "right-aligned help stays right-aligned"); + Require(GetMenuItemInfoW(popup, 3, TRUE, &info) && (info.fState & MFS_GRAYED), "disabled item remains disabled"); + CheckMenuRadioItem(popup, 100, 101, 101, MF_BYCOMMAND); + Require(GetMenuItemInfoW(popup, 1, TRUE, &info) && (info.fState & MFS_CHECKED) && (info.fType & MFT_RADIOCHECK), "radio state remains native"); + LRESULT result = 0; + Require(menus.MenuChar('f', root, result) && HIWORD(result) == MNC_EXECUTE && LOWORD(result) == 0, "Alt mnemonic selects the original popup"); + Require(!menus.MenuChar('u', popup, result), "disabled mnemonic cannot execute"); + ModifyMenuW(root, 1, MF_BYPOSITION | MF_STRING | MF_RIGHTJUSTIFY, 103, L"&Help updated"); + ModifyMenuW(popup, 101, MF_BYCOMMAND | MF_STRING, 101, L"3840x2160"); + menus.Sync(nullptr, root); + Require(menus.Text(popup, 101) == L"3840x2160", "dynamic labels survive native ModifyMenu"); + Require(menus.Text(root, 1, true) == L"&Help updated", "dynamic main menu source label is refreshed"); + InsertMenuW(root, 0, MF_BYPOSITION | MF_STRING, 104, L"&Added"); menus.Sync(nullptr, root); + Require(menus.Text(root, 0, true) == L"&Added" && menus.Text(root, 1, true) == L"&File", "inserting a menu never aliases existing item metadata"); + HDC dc = CreateCompatibleDC(nullptr); + BITMAPINFO bitmapInfo = {}; bitmapInfo.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bitmapInfo.bmiHeader.biWidth = 320; bitmapInfo.bmiHeader.biHeight = -30; + bitmapInfo.bmiHeader.biPlanes = 1; bitmapInfo.bmiHeader.biBitCount = 32; bitmapInfo.bmiHeader.biCompression = BI_RGB; + void* pixels = nullptr; + HBITMAP bitmap = CreateDIBSection(dc, &bitmapInfo, DIB_RGB_COLORS, &pixels, nullptr, 0); + Require(bitmap != nullptr, "menu paint bitmap created"); + HGDIOBJ oldBitmap = SelectObject(dc, bitmap); + GetMenuItemInfoW(popup, 0, TRUE, &info); + const auto original = GetCurrentObject(dc, OBJ_FONT); + DRAWITEMSTRUCT draw = {}; draw.CtlType = ODT_MENU; draw.itemData = info.dwItemData; + draw.hDC = dc; draw.rcItem = {0, 0, 320, 30}; + Require(menus.Draw(&draw) && GetCurrentObject(dc, OBJ_FONT) == original, "menu painter restores selected font"); + MENUITEMINFOW radio = {sizeof(radio)}; radio.fMask = MIIM_FTYPE; radio.fType = MFT_OWNERDRAW | MFT_RADIOCHECK; + SetMenuItemInfoW(popup, 0, TRUE, &radio); draw.itemState = ODS_CHECKED; + Require(menus.Draw(&draw) && GetPixel(dc, 14, 15) == GetSysColor(COLOR_MENUTEXT), "selected radio marker is actually painted, not just stored in metadata"); + SelectObject(dc, oldBitmap); DeleteObject(bitmap); DeleteDC(dc); DestroyMenu(root); + // Pane/context popups are standalone roots, not frame-bar descendants. + HMENU standalone = CreatePopupMenu(); + AppendMenuW(standalone, MF_STRING | MF_CHECKED, 200, L"YUV420"); + AppendMenuW(standalone, MF_STRING, 201, L"선택 영역 지우기\tEsc"); + WindowsUiMenus paneMenus; paneMenus.SyncPopup(nullptr, standalone); + Require(GetMenuItemInfoW(standalone, 0, TRUE, &info) && (info.fType & MFT_OWNERDRAW) && (info.fState & MFS_CHECKED), "standalone format popup keeps its checked state and receives shared painting"); + measure.itemData = info.dwItemData; + Require(paneMenus.Measure(&measure) && measure.itemWidth >= 64 && measure.itemHeight >= 30, "standalone popup uses popup gutter and shared body-font row size, not bar sizing"); + accessible = reinterpret_cast(info.dwItemData); + Require(accessible && wcscmp(accessible->pszWText, L"YUV420") == 0, "standalone popup retains accessible format label"); + ModifyMenuW(standalone, 200, MF_BYCOMMAND | MF_STRING, 200, L"NV12"); paneMenus.SyncPopup(nullptr, standalone); + Require(paneMenus.Text(standalone, 200) == L"NV12", "standalone popup refreshes modified format text"); + DestroyMenu(standalone); +} + +struct FrameTestHost { + q1view::WindowsUiMenus menus; + q1view::WindowsUiFrame frame; + static LRESULT CALLBACK Proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + auto* host = reinterpret_cast(GetWindowLongPtrW(window, GWLP_USERDATA)); + if (message == WM_NCCREATE) { + host = static_cast(reinterpret_cast(lp)->lpCreateParams); + SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast(host)); + } + LRESULT result = 0; + if (host && host->frame.Before(message, wp, lp, result)) return result; + if (host && message == WM_MEASUREITEM && host->menus.Measure(reinterpret_cast(lp))) return TRUE; + if (host && message == WM_DRAWITEM && host->menus.Draw(reinterpret_cast(lp))) return TRUE; + result = DefWindowProcW(window, message, wp, lp); + if (host && message == WM_DESTROY) { host->frame.Destroy(); return result; } + if (host) host->frame.After(message); + return result; + } +}; + +static void TestFrames() +{ + WNDCLASSW cls = {}; cls.lpfnWndProc = FrameTestHost::Proc; cls.hInstance = GetModuleHandleW(nullptr); + cls.lpszClassName = L"Q1View.FrameRegressionTest"; RegisterClassW(&cls); + FrameTestHost host; + HMENU root = CreateMenu(), popup = CreatePopupMenu(); + AppendMenuW(root, MF_POPUP, reinterpret_cast(popup), L"&File"); + AppendMenuW(root, MF_STRING, 100, L"Resolution 한글"); + AppendMenuW(root, MF_STRING | MF_RIGHTJUSTIFY, 101, L"&Help"); + AppendMenuW(root, MF_STRING | MF_GRAYED | MF_RIGHTJUSTIFY, 102, L"1.00x"); + AppendMenuW(popup, MF_STRING, 103, L"&Open\tCtrl+O"); + HWND window = CreateWindowExW(0, cls.lpszClassName, L"긴 한글 제목 & literal ampersand — Q1View", WS_OVERLAPPEDWINDOW, + -20000, -20000, 640, 480, nullptr, root, cls.hInstance, &host); + Require(window != nullptr, "isolated custom-frame host created"); + host.frame.Initialize(window, host.menus); + Require(host.frame.Menu() == root && host.frame.RetainedMenu() == root, "custom chrome retains the original HMENU and commands"); + Require(host.frame.MenuHost() != nullptr, "menu host uses a real child window"); + if (host.frame.Custom()) { + Require(GetMenu(window) == nullptr, "native bar detached without destroying its popups"); + WINDOWPOS move = {window, nullptr, -19990, -19990, 640, 480, SWP_NOSIZE | SWP_NOZORDER}; + LRESULT moveResult = 0; host.frame.Before(WM_WINDOWPOSCHANGING, 0, reinterpret_cast(&move), moveResult); + Require((move.flags & SWP_NOCOPYBITS) != 0, "custom client origin does not reuse native move pixels"); + Require(host.frame.TitleHeight() >= 32 && host.frame.Height() >= 62, "title and main-menu hit areas have measured breathing room"); + HWND file = GetDlgItem(host.frame.MenuHost(), 1); + wchar_t name[80] = {}; GetWindowTextW(file, name, 80); + Require(wcscmp(name, L"&File") == 0, "real menu button exposes its accessible name"); + Require(!IsWindowEnabled(GetDlgItem(host.frame.MenuHost(), 4)), "disabled zoom label cannot receive input"); + for (int width : {200, 320, 640, 1920}) { + SetWindowPos(window, nullptr, 0, 0, width, 480, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); + host.frame.Layout(); RECT client; GetClientRect(window, &client); + const auto& rects = host.frame.MenuRects(); + for (size_t i = 0; i < rects.size(); ++i) { + Require(rects[i].left >= 0 && rects[i].right <= client.right && rects[i].bottom <= host.frame.Height() - host.frame.TitleHeight(), "narrow-window menu button stays within its wrapped row"); + for (size_t j = i + 1; j < rects.size(); ++j) { RECT overlap; Require(!IntersectRect(&overlap, &rects[i], &rects[j]), "wrapped and right-aligned menu buttons never overlap"); } + } + } + ModifyMenuW(root, 1, MF_BYPOSITION | MF_STRING, 100, L"3840x2160"); host.frame.Sync(); + GetWindowTextW(GetDlgItem(host.frame.MenuHost(), 2), name, 80); + Require(wcscmp(name, L"3840x2160") == 0, "document-driven dynamic menu labels update in custom chrome"); + RECT client; GetClientRect(window, &client); + const UINT dpi = q1view::WindowsUiDpi(window); + POINT maximum = {client.right - q1view::WindowsUiPixels(69, dpi), host.frame.TitleHeight() / 2}; + ClientToScreen(window, &maximum); + const LRESULT hit = SendMessageW(window, WM_NCHITTEST, 0, MAKELPARAM(maximum.x, maximum.y)); + std::printf("Caption maximize hit: %lld\n", static_cast(hit)); + Require(hit == HTMAXBUTTON, "native maximize area keeps its non-client hit code for click and Snap layouts"); + HDC screen = GetDC(nullptr), dc = CreateCompatibleDC(screen); + HBITMAP image = CreateCompatibleBitmap(screen, 1920, 80); ReleaseDC(nullptr, screen); + const auto old = SelectObject(dc, image); + host.frame.Paint(dc); const DWORD before = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS); + for (int i = 0; i < 100; ++i) host.frame.Paint(dc); + Require(GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS) == before, "buffered caption painting has no accumulating GDI resources"); + SelectObject(dc, old); DeleteObject(image); DeleteDC(dc); + const LONG_PTR style = GetWindowLongPtrW(window, GWL_STYLE); + host.frame.SetMenu(nullptr); SetWindowLongPtrW(window, GWL_STYLE, style & ~WS_CAPTION); host.frame.Layout(); + Require(host.frame.Height() == 0 && !IsWindowVisible(host.frame.MenuHost()), "full screen has no custom title or menu reservation"); + SetWindowLongPtrW(window, GWL_STYLE, style); host.frame.SetMenu(root); + Require(host.frame.Menu() == root && host.frame.Height() >= 62, "leaving full screen restores the same menu instead of recreating it"); + } + DestroyWindow(window); if (IsMenu(root)) DestroyMenu(root); + std::puts("Windows custom-frame tests passed (native menu retention, accessible buttons, wrapping, full screen, caption GDI lifetime)."); +} + +int main() +{ + using namespace q1view; + TestMenus(); + TestFrames(); + HDC dc = CreateCompatibleDC(nullptr); + Require(dc != nullptr, "GDI test DC"); + const std::vector rows = { + {L"Drag & Drop", L"긴 한글 파일명과 English 설명을 함께 표시합니다."}, + {L"Mouse Wheel", L"Zoom in or out; high zoom shows pixel values"}, + {L"Page Up/Down", L"Previous or next file"}, + {L"I", L"Cycle scaling: Auto -> Smooth -> Pixel Exact"} + }; + const HGDIOBJ original = GetCurrentObject(dc, OBJ_FONT); + WindowsUiTextFontFamily(); WindowsUiNumericFontFamily(); + GetStockObject(DEFAULT_GUI_FONT); // warm up lazily created OS stock resources + { + WindowsUiFontCache warm; + RECT bounds = {0, 0, 320, 320}; int offset = 0; + DrawWindowsUiHelp(dc, bounds, L"Help", L"Version", rows, warm, 96, 1.0, offset); + } // GDI lazily creates a DC clipping region on the first clipped draw. + const DWORD before = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS); + { + WindowsUiFontCache fonts; + HFONT specimenBody = fonts.Get(WindowsUiFontRole::Body, 96, 1.0); + HGDIOBJ previous = SelectObject(dc, specimenBody); + wchar_t actualFace[LF_FACESIZE] = {}; + GetTextFaceW(dc, LF_FACESIZE, actualFace); + Require(wcsstr(actualFace, L"Pretendard") != nullptr, "bundled UI font actually resolves, not just a requested family name"); + const wchar_t* mixed = L"한글English"; + WORD glyphs[9] = {}; + Require(GetGlyphIndicesW(dc, mixed, 9, glyphs, GGI_MARK_NONEXISTING_GLYPHS) != GDI_ERROR, "mixed script glyph query"); + for (WORD glyph : glyphs) Require(glyph != 0xffff, "Korean/English glyphs exist in bundled font"); + SelectObject(dc, previous); + for (UINT dpi : {96u, 120u, 144u, 192u}) for (double scale : {1.0, 1.25, 1.5, 2.0, 2.25}) { + HFONT body = fonts.Get(WindowsUiFontRole::Body, dpi, scale); + LOGFONT actual = {}; GetObject(body, sizeof(actual), &actual); + Require(actual.lfHeight == -WindowsUiPixels(14, dpi, scale), "DIP character height, not points/cell height"); + Require(lstrcmp(actual.lfFaceName, WindowsUiTextFontFamily()) == 0, "prose uses shared UI family"); + Require(fonts.Get(WindowsUiFontRole::Body, dpi, scale) == body, "same-role font is reused"); + { + WindowsUiDcState frame(dc); + SelectObject(dc, body); + } + Require(GetCurrentObject(dc, OBJ_FONT) == original, "persistent render DC releases selected role font before replacement"); + HFONT numeric = fonts.Get(WindowsUiFontRole::Numeric, dpi, scale); + GetObject(numeric, sizeof(actual), &actual); + Require(lstrcmp(actual.lfFaceName, WindowsUiNumericFontFamily()) == 0, "numeric font has explicit fallback"); + for (int width : {320, 640, 960}) { + RECT bounds = {0, 0, WindowsUiPixels(width, dpi), WindowsUiPixels(320, dpi)}; + const auto layout = MeasureWindowsUiHelp(dc, bounds, rows, fonts, dpi, scale); + Require(layout.rows.size() == rows.size(), "all help rows survive measurement"); + LONG previousBottom = 0; + for (const auto& row : layout.rows) { + Require(row.key.top >= previousBottom, "wrapped rows do not overlap"); + Require(row.key.right < row.description.left && row.description.right == bounds.right, "independent measured columns"); + previousBottom = row.key.bottom; + } + int offset = 100000; + const int maximum = DrawWindowsUiHelp(dc, bounds, L"도움말 Help", L"Version 0.0.0.dev", rows, fonts, dpi, scale, offset); + Require(offset == maximum, "scroll clamps to end, exposing final row"); + Require(GetCurrentObject(dc, OBJ_FONT) == original, "help renderer restores selected GDI font"); + } + } + for (int i = 0; i < 1000; ++i) { + const UINT dpi = i % 2 ? 96 : 192; + for (int role = 0; role < int(WindowsUiFontRole::Count); ++role) + fonts.Get(static_cast(role), dpi, i % 3 ? 1.0 : 2.25); + } + Require(GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS) <= before + 16, "font cache remains bounded across settings changes"); + } + const DWORD after = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS); + std::printf("GDI objects: before=%lu after=%lu\n", before, after); + Require(after <= before, "cache releases owned GDI fonts"); + Require(WindowsUiSettings().Scale() >= 1.0, "system text scale has a safe fallback"); + MSG message = {}; message.message = WM_KEYDOWN; message.wParam = VK_NEXT; + int offset = 0; + Require(WindowsUiHelpNavigation(&message, 200, 500, offset) && offset == 200, "Page Down scrolls help instead of the image"); + message.wParam = VK_END; + Require(WindowsUiHelpNavigation(&message, 200, 500, offset) && offset == 500, "End exposes the final help row"); + message.wParam = 'E'; + Require(!WindowsUiHelpNavigation(&message, 200, 500, offset), "help does not reroute unrelated shortcuts"); + DeleteDC(dc); + std::puts("Windows UI typography tests passed (4 DPIs x 5 text scales x 3 widths, cache lifetime)."); + return 0; +} diff --git a/Tests/WindowsUiTypographyTests.vcxproj b/Tests/WindowsUiTypographyTests.vcxproj new file mode 100644 index 0000000..16444f4 --- /dev/null +++ b/Tests/WindowsUiTypographyTests.vcxproj @@ -0,0 +1,22 @@ + + + + Releasex64 + + {0EAA42A8-117F-4A8D-98CE-FBFB0B1F39D8} + + Applicationv143Unicode + + $(ProjectDir)bin\$(Platform)\$(Configuration)\$(ProjectDir)obj\$(Platform)\$(Configuration)\WindowsUiTypographyTests\ + + Level4MaxSpeedMultiThreadedstdcpp17/utf-8 %(AdditionalOptions) + Consolegdi32.lib;user32.lib;%(AdditionalDependencies) + + + + + + + + + diff --git a/Viewer/GalleryGridCanvas.cpp b/Viewer/GalleryGridCanvas.cpp index 85fba99..4f603fc 100644 --- a/Viewer/GalleryGridCanvas.cpp +++ b/Viewer/GalleryGridCanvas.cpp @@ -4,6 +4,7 @@ #include "MainFrm.h" #include "QViewerCmn.h" #include "QDebug.h" +#include "Q1UiFontWin.h" #include #pragma comment(lib, "d2d1.lib") @@ -37,6 +38,7 @@ BEGIN_MESSAGE_MAP(CGalleryGridCanvas, CWnd) ON_WM_DESTROY() ON_MESSAGE(WM_MOUSELEAVE, OnMouseLeave) ON_MESSAGE(WM_DPICHANGED_AFTERPARENT, OnDpiChanged) + ON_MESSAGE(q1view::WM_UI_TYPOGRAPHY_CHANGED, OnDpiChanged) END_MESSAGE_MAP() CGalleryGridCanvas::CGalleryGridCanvas(CThumbnailPane& owner) : mOwner(owner) {} @@ -272,11 +274,13 @@ bool CGalleryGridCanvas::EnsureDevice(int width, int height) { } } if (FAILED(mWriteFactory->CreateTextFormat(family, collection, DWRITE_FONT_WEIGHT_MEDIUM, - DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_STRETCH_NORMAL, 13 * GetDpiForWindow(m_hWnd) / 96.0f, L"ko-kr", &mText))) return false; + DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_STRETCH_NORMAL, + float(q1view::WindowsUiPixels(13, GetDpiForWindow(m_hWnd), q1view::WindowsUiSettings().Scale())), L"ko-kr", &mText))) return false; mText->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_CENTER); mText->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER); if (FAILED(mWriteFactory->CreateTextFormat(family, collection, DWRITE_FONT_WEIGHT_SEMI_BOLD, - DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_STRETCH_NORMAL, 12 * GetDpiForWindow(m_hWnd) / 96.0f, L"ko-kr", &mBadgeText))) return false; + DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_STRETCH_NORMAL, + float(q1view::WindowsUiPixels(12, GetDpiForWindow(m_hWnd), q1view::WindowsUiSettings().Scale())), L"ko-kr", &mBadgeText))) return false; mBadgeText->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_CENTER); mBadgeText->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER); mContext->SetDpi(96, 96); // layout and pointer coordinates are physical pixels diff --git a/Viewer/MainFrm.cpp b/Viewer/MainFrm.cpp index e0e1182..04cb367 100644 --- a/Viewer/MainFrm.cpp +++ b/Viewer/MainFrm.cpp @@ -25,6 +25,7 @@ #include "QViewerShortcuts.h" #include "Q1ViewVersion.h" #include "qimage_util.h" +#include "Q1UiHelpWin.h" #include "FrmSrc.h" @@ -89,6 +90,10 @@ static bool IsRepeatedDrawerShortcut(const MSG *message) IMPLEMENT_DYNCREATE(CMainFrame, CFrameWnd) BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) + ON_WM_MEASUREITEM() + ON_WM_DRAWITEM() + ON_WM_INITMENUPOPUP() + ON_WM_MENUCHAR() ON_WM_DROPFILES() ON_COMMAND(ID_VIEWER_HELP, &CMainFrame::OnHelp) ON_COMMAND(ID_FILE_OPEN, &CMainFrame::OnFileOpen) @@ -107,6 +112,7 @@ BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_COMMAND(ID_EDIT_PASTE, &CMainFrame::OnEditPaste) ON_WM_SIZE() ON_WM_MOVE() + ON_MESSAGE(q1view::WM_UI_TYPOGRAPHY_CHANGED, &CMainFrame::OnTypographyChanged) ON_WM_DESTROY() ON_WM_TIMER() ON_COMMAND(ID_TOGGLE_DRAWER, &CMainFrame::OnToggleDrawer) @@ -244,26 +250,21 @@ void CDrawerSplitter::OnDrawSplitter(CDC *pDC, ESplitType nType, const CRect &re BEGIN_MESSAGE_MAP(CHelpOverlay, CWnd) ON_WM_LBUTTONDOWN() ON_WM_ERASEBKGND() + ON_WM_MOUSEWHEEL() END_MESSAGE_MAP() // The shortcut panel text, built from the shared table (QViewerShortcuts.h) -- // the same source the in-view help used and the Qt viewer renders from. -static CString BuildHelpText() +static std::vector BuildHelpRows() { - CString manual(Q1VIEW_SHORTCUTS_TITLE); - manual += _T("\nVersion "); - manual += Q1ViewGetProductVersion(); - manual += _T("\n\n"); + std::vector rows; for (int i = 0; i < ARRAY_SIZE(Q1VIEW_SHORTCUTS); i++) { const Q1ViewShortcutRow &row = Q1VIEW_SHORTCUTS[i]; if (!(row.fe & Q1VIEW_FE_MFC)) continue; - CString line; - line.Format(_T("%-*hs%hs\n"), - Q1VIEW_SHORTCUTS_KEY_WIDTH, row.key, row.desc); - manual += line; + rows.push_back({static_cast(CString(row.key)), static_cast(CString(row.desc))}); } - return manual; + return rows; } BOOL CHelpOverlay::CreateOverlay(CWnd *pParent) @@ -285,7 +286,7 @@ bool CHelpOverlay::OwnerScreenRect(CRect &rc) const { if (!mOwner || !::IsWindow(mOwner->GetSafeHwnd())) return false; - mOwner->GetClientRect(&rc); + static_cast(mOwner)->GetContentRect(rc); mOwner->ClientToScreen(&rc); return true; } @@ -302,6 +303,7 @@ void CHelpOverlay::Toggle() if (!OwnerScreenRect(rc)) return; MoveWindow(&rc, FALSE); // screen coords for a top-level window + mScrollOffset = 0; ShowWindow(SW_SHOWNA); // show without activating Render(); } @@ -360,8 +362,10 @@ void CHelpOverlay::Render() } // 2) Opaque shortcut panel centered on the whole window (clamped to fit). - int pw = std::min(W - 24, (int)VIEWER_DEF_W); - int ph = std::min(H - 24, (int)VIEWER_DEF_H); + const UINT dpi = q1view::WindowsUiDpi(mOwner->GetSafeHwnd()); + const double textScale = q1view::WindowsUiSettings().Scale(); + int pw = std::min(W - 24, q1view::WindowsUiPixels(640, dpi)); + int ph = std::min(H - 24, q1view::WindowsUiPixels(620, dpi, textScale)); if (pw < 80) pw = W; if (ph < 80) ph = H; CRect panel((W - pw) / 2, (H - ph) / 2, 0, 0); @@ -380,19 +384,11 @@ void CHelpOverlay::Render() const int W_MARGIN = 18, H_MARGIN = 14; CRect textRc(panel.left + W_MARGIN, panel.top + H_MARGIN, panel.right - W_MARGIN, panel.bottom - H_MARGIN); - LOGFONT lf = {}; - lstrcpyn(lf.lfFaceName, _T("Consolas"), LF_FACESIZE); - lf.lfHeight = 14; - lf.lfWeight = FW_NORMAL; - CFont font; - font.CreateFontIndirect(&lf); - CFont *prevFont = dc.SelectObject(&font); dc.SetBkMode(TRANSPARENT); dc.SetTextColor(Q1UI_COLOR_TEXT); - // DT_NOPREFIX so a literal '&' in a key (e.g. "Drag & Drop") is drawn as-is. - CString manual = BuildHelpText(); - dc.DrawText(manual, &textRc, DT_LEFT | DT_TOP | DT_NOPREFIX); - dc.SelectObject(prevFont); + CString version = _T("Version "); version += Q1ViewGetProductVersion(); + mMaxScroll = q1view::DrawWindowsUiHelp(dc.GetSafeHdc(), textRc, L"Viewer shortcuts", + static_cast(version), BuildHelpRows(), mHelpFonts, dpi, textScale, mScrollOffset); dc.Detach(); // 3) GDI leaves the alpha byte at the scrim value; force the panel opaque. Its @@ -424,6 +420,61 @@ void CHelpOverlay::OnLButtonDown(UINT /*nFlags*/, CPoint /*point*/) Hide(); // click anywhere dismisses the overlay } +LRESULT CMainFrame::OnTypographyChanged(WPARAM, LPARAM) +{ + q1view::WindowsUiSettings().Refresh(); + DrawMenuBar(); + SetWindowPos(nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); + SendMessageToDescendants(q1view::WM_UI_TYPOGRAPHY_CHANGED); + mHelpOverlay.Relayout(); + RedrawWindow(nullptr, nullptr, RDW_INVALIDATE | RDW_ALLCHILDREN); + return 0; +} + +void CMainFrame::DrawMenuBar() +{ + if (mUiFrame.Initialized()) { mUiFrame.Sync(); RecalcLayout(); return; } + mUiMenus.Sync(m_hWnd, ::GetMenu(m_hWnd)); + CFrameWnd::DrawMenuBar(); +} + +void CMainFrame::OnMeasureItem(int id, LPMEASUREITEMSTRUCT item) +{ if (!mUiMenus.Measure(item)) CFrameWnd::OnMeasureItem(id, item); } + +void CMainFrame::OnDrawItem(int id, LPDRAWITEMSTRUCT item) +{ if (!mUiMenus.Draw(item)) CFrameWnd::OnDrawItem(id, item); } + +void CMainFrame::OnInitMenuPopup(CMenu* menu, UINT index, BOOL system) +{ + CFrameWnd::OnInitMenuPopup(menu, index, system); + if (!system) mUiMenus.Sync(m_hWnd, GetMenu() ? GetMenu()->GetSafeHmenu() : nullptr, mUiFrame.Custom()); +} + +LRESULT CMainFrame::OnMenuChar(UINT character, UINT flags, CMenu* menu) +{ + LRESULT result; + if (mUiMenus.MenuChar(character, menu->GetSafeHmenu(), result)) return result; + return CFrameWnd::OnMenuChar(character, flags, menu); +} + +BOOL CHelpOverlay::OnMouseWheel(UINT, short delta, CPoint) +{ + mScrollOffset = std::max(0, std::min(mMaxScroll, mScrollOffset - + MulDiv(delta, q1view::WindowsUiPixels(60, q1view::WindowsUiDpi(m_hWnd)), WHEEL_DELTA))); + Render(); + return TRUE; +} + +BOOL CHelpOverlay::HandleNavigation(MSG* message) +{ + if (!IsShown() || !message) return FALSE; + if (message->message == WM_KEYDOWN && message->wParam == VK_ESCAPE) { Hide(); return TRUE; } + CRect client; GetClientRect(&client); + if (!q1view::WindowsUiHelpNavigation(message, std::max(1, client.Height() * 2 / 3), mMaxScroll, mScrollOffset)) return FALSE; + Render(); + return TRUE; +} + BOOL CHelpOverlay::OnEraseBkgnd(CDC * /*pDC*/) { return TRUE; // content comes from UpdateLayeredWindow, not WM_PAINT @@ -444,7 +495,7 @@ bool CDrawerTransitionOverlay::ShowSnapshot() return false; CRect rc; - mOwner->GetClientRect(&rc); + static_cast(mOwner)->GetContentRect(rc); mOwner->ClientToScreen(&rc); const int width = rc.Width(); const int height = rc.Height(); @@ -556,6 +607,48 @@ LRESULT CMainFrame::Reload(WPARAM wParam, LPARAM lParam) return S_OK; } +LRESULT CMainFrame::WindowProc(UINT message, WPARAM wp, LPARAM lp) +{ + // MFC's PostNcDestroy deletes this frame; never touch members afterward. + if (message == WM_NCDESTROY) return CFrameWnd::WindowProc(message, wp, lp); + LRESULT result = 0; + if (mUiFrame.Before(message, wp, lp, result)) return result; + result = CFrameWnd::WindowProc(message, wp, lp); + if (message == WM_DESTROY) { mUiFrame.Destroy(); return result; } + if (message == WM_CREATE && result != -1) { mUiFrame.Initialize(m_hWnd, mUiMenus); RecalcLayout(); } + mUiFrame.After(message); + if (message == WM_DPICHANGED || message == q1view::WM_UI_TYPOGRAPHY_CHANGED) { mUiFrame.RefreshSettings(); mUiFrame.Sync(); RecalcLayout(); } + return result; +} + +CMenu* CMainFrame::GetMenu() const +{ return mUiFrame.Initialized() ? CMenu::FromHandle(mUiFrame.Menu()) : CFrameWnd::GetMenu(); } + +BOOL CMainFrame::SetMenu(CMenu* menu) +{ + if (!mUiFrame.Initialized()) return CFrameWnd::SetMenu(menu); + mUiFrame.SetMenu(menu ? menu->GetSafeHmenu() : nullptr); RecalcLayout(); return TRUE; +} + +void CMainFrame::RestoreApplicationMenu() +{ SetMenu(CMenu::FromHandle(mUiFrame.RetainedMenu())); } + +void CMainFrame::OnUpdateFrameMenu(HMENU menu) +{ + if (!mUiFrame.Initialized()) { CFrameWnd::OnUpdateFrameMenu(menu); return; } + if (menu && menu != mUiFrame.RetainedMenu()) SetMenu(CMenu::FromHandle(menu)); +} + +void CMainFrame::GetContentRect(CRect& rect) const +{ GetClientRect(&rect); rect.top = std::min(rect.bottom, LONG(mUiFrame.Height())); } + +void CMainFrame::RecalcLayout(BOOL notify) +{ + if (mUiFrame.Initialized()) mUiFrame.Layout(); + m_rectBorder.SetRect(0, mUiFrame.Height(), 0, 0); + CFrameWnd::RecalcLayout(notify); +} + BOOL CMainFrame::PreCreateWindow(CREATESTRUCT& cs) { if (!CFrameWnd::PreCreateWindow(cs)) @@ -580,6 +673,8 @@ BOOL CMainFrame::PreCreateWindow(CREATESTRUCT& cs) BOOL CMainFrame::PreTranslateMessage(MSG *pMsg) { + if (mUiFrame.Translate(pMsg)) return TRUE; + if (mHelpOverlay.HandleNavigation(pMsg)) return TRUE; // A toggle command represents one physical key press. Ignoring keyboard // auto-repeat prevents a held E from rapidly opening and closing the drawer. if (IsRepeatedDrawerShortcut(pMsg)) @@ -1148,7 +1243,7 @@ void CMainFrame::OnResolutionChange(UINT nID) CString str; CMenu *subMenu = GetMenu()->GetSubMenu(MENU_POS_RESOLUTION); - subMenu->GetMenuString(nID, str, MF_BYCOMMAND); + str = mUiMenus.Text(subMenu->GetSafeHmenu(), nID).c_str(); int w = 0, h = 0; int error = q1::image_parse_w_h(CT2A(str), &w, &h); @@ -1209,7 +1304,7 @@ void CMainFrame::OnCsChange(UINT nID) CString str; CMenu *subMenu = GetMenu()->GetSubMenu(MENU_POS_COLORSPACE); - subMenu->GetMenuString(nID, str, MF_BYCOMMAND); + str = mUiMenus.Text(subMenu->GetSafeHmenu(), nID).c_str(); str.MakeLower(); const struct qcsc_info * const ci = @@ -1236,7 +1331,7 @@ void CMainFrame::OnFpsChange(UINT nID) CString str; CMenu *subMenu = GetMenu()->GetSubMenu(MENU_POS_FPS); - subMenu->GetMenuString(nID, str, MF_BYCOMMAND); + str = mUiMenus.Text(subMenu->GetSafeHmenu(), nID).c_str(); double fps = _wtof(str); if (fps == 0) { diff --git a/Viewer/MainFrm.h b/Viewer/MainFrm.h index 41ded09..360cbbf 100644 --- a/Viewer/MainFrm.h +++ b/Viewer/MainFrm.h @@ -5,6 +5,9 @@ #pragma once #include +#include "Q1UiFontWin.h" +#include "Q1UiMenuWin.h" +#include "Q1UiFrameWin.h" #define WM_RELOAD (WM_USER + 100) #define WM_APPLY_SYNC_INPUT (WM_APP + 100) @@ -113,16 +116,20 @@ class CHelpOverlay : public CWnd void Toggle(); void Hide(); void Relayout(); // re-cover the owner's client; repaint if visible + BOOL HandleNavigation(MSG* message); protected: bool OwnerScreenRect(CRect &rc) const; // owner client rect in screen coords void Render(); // build the per-pixel-alpha image and push it + afx_msg BOOL OnMouseWheel(UINT nFlags, short zDelta, CPoint point); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg BOOL OnEraseBkgnd(CDC *pDC); DECLARE_MESSAGE_MAP() private: CWnd *mOwner = NULL; // the frame whose client this overlay covers + q1view::WindowsUiFontCache mHelpFonts; + int mScrollOffset = 0, mMaxScroll = 0; }; // Holds the last fully composed client image above the separate MFC and DXGI @@ -167,6 +174,8 @@ class CMainFrame : public CFrameWnd // Full-window shortcut/help overlay (issue #79). CHelpOverlay mHelpOverlay; + q1view::WindowsUiMenus mUiMenus; + q1view::WindowsUiFrame mUiFrame; CDrawerTransitionOverlay mDrawerTransitionOverlay; // Operations @@ -175,6 +184,13 @@ class CMainFrame : public CFrameWnd // Overrides public: virtual BOOL PreCreateWindow(CREATESTRUCT& cs); + virtual LRESULT WindowProc(UINT, WPARAM, LPARAM); + virtual void RecalcLayout(BOOL notify = TRUE); + virtual void OnUpdateFrameMenu(HMENU menu); + CMenu* GetMenu() const; + BOOL SetMenu(CMenu* menu); + void RestoreApplicationMenu(); + void GetContentRect(CRect& rect) const; virtual BOOL PreTranslateMessage(MSG *pMsg); virtual void OnUpdateFrameTitle(BOOL bAddToTitle); virtual BOOL OnCreateClient(LPCREATESTRUCT lpcs, CCreateContext* pContext); @@ -227,6 +243,12 @@ class CMainFrame : public CFrameWnd public: afx_msg void OnDropFiles(HDROP hDropInfo); afx_msg void OnHelp(); + void DrawMenuBar(); + afx_msg void OnMeasureItem(int, LPMEASUREITEMSTRUCT); + afx_msg void OnDrawItem(int, LPDRAWITEMSTRUCT); + afx_msg void OnInitMenuPopup(CMenu*, UINT, BOOL); + afx_msg LRESULT OnMenuChar(UINT, UINT, CMenu*); + afx_msg LRESULT OnTypographyChanged(WPARAM, LPARAM); afx_msg void OnFileOpen(); afx_msg void OnExecComparator(); afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct); diff --git a/Viewer/ThumbnailPane.cpp b/Viewer/ThumbnailPane.cpp index 7e44d77..c5ad1ed 100644 --- a/Viewer/ThumbnailPane.cpp +++ b/Viewer/ThumbnailPane.cpp @@ -22,6 +22,7 @@ #include "ViewerFileTypes.h" #include "QFileActionsWin.h" #include "QRecycleFilesWin.h" +#include "Q1UiFontWin.h" #include @@ -65,6 +66,7 @@ BEGIN_MESSAGE_MAP(CThumbnailPane, CListCtrl) ON_WM_KEYDOWN() ON_WM_TIMER() ON_MESSAGE(WM_DPICHANGED_AFTERPARENT, &CThumbnailPane::OnDpiChanged) + ON_MESSAGE(q1view::WM_UI_TYPOGRAPHY_CHANGED, &CThumbnailPane::OnDpiChanged) ON_NOTIFY_REFLECT(NM_DBLCLK, &CThumbnailPane::OnItemActivate) ON_NOTIFY_REFLECT(NM_RETURN, &CThumbnailPane::OnItemActivate) ON_NOTIFY_REFLECT(LVN_GETINFOTIP, &CThumbnailPane::OnGetInfoTip) @@ -195,9 +197,8 @@ void CThumbnailPane::RebuildFonts(UINT dpi) mFolderFont.DeleteObject(); mExtFont.DeleteObject(); - LOGFONT label = {}; + LOGFONT label = q1view::WindowsUiLogFont(q1view::WindowsUiFontRole::Caption, dpi, q1view::WindowsUiSettings().Scale()); lstrcpyn(label.lfFaceName, mFontFamily, LF_FACESIZE); - label.lfHeight = -MulDiv(13, dpi, 96); label.lfWeight = FW_NORMAL; label.lfQuality = CLEARTYPE_NATURAL_QUALITY; mLabelFont.CreateFontIndirect(&label); @@ -205,9 +206,8 @@ void CThumbnailPane::RebuildFonts(UINT dpi) label.lfWeight = FW_MEDIUM; mFolderFont.CreateFontIndirect(&label); - LOGFONT lf = {}; + LOGFONT lf = q1view::WindowsUiLogFont(q1view::WindowsUiFontRole::Supporting, dpi, q1view::WindowsUiSettings().Scale()); lstrcpyn(lf.lfFaceName, mFontFamily, LF_FACESIZE); - lf.lfHeight = -MulDiv(12, dpi, 96); lf.lfWeight = FW_SEMIBOLD; lf.lfQuality = CLEARTYPE_NATURAL_QUALITY; mExtFont.CreateFontIndirect(&lf); diff --git a/Viewer/ViewerView.cpp b/Viewer/ViewerView.cpp index a6192a0..ebe9d73 100644 --- a/Viewer/ViewerView.cpp +++ b/Viewer/ViewerView.cpp @@ -165,6 +165,7 @@ BEGIN_MESSAGE_MAP(CViewerView, CView) ON_WM_RBUTTONUP() ON_MESSAGE(WM_VIEWER_PLAY_TIMER, CViewerView::OnPlayTimer) ON_MESSAGE(WM_VIEWER_AUTOPLAY_VIDEO, CViewerView::OnAutoplayVideo) + ON_MESSAGE(q1view::WM_UI_TYPOGRAPHY_CHANGED, CViewerView::OnTypographyChanged) END_MESSAGE_MAP() CViewerView::CViewerView() @@ -517,9 +518,10 @@ void CViewerView::Initialize(int nFrame, size_t rgbStride, int w, int h, bool pr mFitToWindow = true; if (nFrame > 1) - mHProgress = PROGRESS_BAR_H; + mHProgress = q1view::WindowsUiPixels(22, q1view::WindowsUiDpi(m_hWnd), q1view::WindowsUiSettings().Scale()); else mHProgress = 0; + mHCanvas = max(0, mHClient - mHProgress); SetDstSize(); @@ -593,7 +595,7 @@ void CViewerView::ProgressiveDraw(CDC *pDC, CViewerDoc* pDoc, int frameID) const int barMargin = MARGIN_PROGESS_BAR; const int volWidth = 64; const int volGap = 6; - const int muteSize = PROGRESS_BAR_H - barMargin * 2; // square button inside the band + const int muteSize = mHProgress - barMargin * 2; // square button inside the band const int muteGap = 6; int frameMax = pDoc->mFrames - 1; @@ -603,7 +605,7 @@ void CViewerView::ProgressiveDraw(CDC *pDC, CViewerDoc* pDoc, int frameID) CRect progressBand(0, mHCanvas, mWClient, mHClient); pDC->FillSolidRect(progressBand, Q1UI_COLOR_SURFACE_ALT); - pDC->SelectObject(&mProgressFont); + CFont* previousFont = pDC->SelectObject(&mProgressFont); pDC->SetTextColor(COLOR_PROGRESS_TEXT); pDC->SetBkMode(TRANSPARENT); @@ -677,6 +679,7 @@ void CViewerView::ProgressiveDraw(CDC *pDC, CViewerDoc* pDoc, int frameID) barTextRect.right = rightEnd; barTextRect.left = 0; pDC->DrawText(str, &barTextRect, DT_SINGLELINE | DT_RIGHT | DT_VCENTER); + pDC->SelectObject(previousFont); } // Draws a small speaker icon centered in |rect|. The icon is rendered from @@ -1155,7 +1158,7 @@ void CViewerView::DrawPixelText(CDC *pDC, q1::GridInfo &gi) mDefPixelTextFont.GetLogFont(&lf); lf.lfHeight = LONG(mN * 4 / 15); pixelTextFont.CreateFontIndirect(&lf); - pDC->SelectObject(&pixelTextFont); + CFont* previousFont = pDC->SelectObject(&pixelTextFont); pDC->SetTextColor(COLOR_PIXEL_TEXT); pDC->DrawText(_T("0000\n0000\n0000"), -1, refRect, DT_CENTER | DT_VCENTER | DT_CALCRECT); @@ -1206,25 +1209,15 @@ void CViewerView::DrawPixelText(CDC *pDC, q1::GridInfo &gi) } y += gi.Hs[i]; } + pDC->SelectObject(previousFont); } void CViewerView::DrawEmptyState(CDC *pDC) { pDC->FillSolidRect(CRect(0, 0, mWClient, mHClient), Q1UI_COLOR_CANVAS_BG); - LOGFONT lf; - mConsolasFont.GetLogFont(&lf); - ::lstrcpy(lf.lfFaceName, q1view::WindowsUiTextFontFamily()); - - CFont titleFont; - lf.lfHeight = 22; - lf.lfWeight = FW_SEMIBOLD; - titleFont.CreateFontIndirect(&lf); - - CFont bodyFont; - lf.lfHeight = 14; - lf.lfWeight = FW_NORMAL; - bodyFont.CreateFontIndirect(&lf); + CFont *titleFont = CFont::FromHandle(mUiFonts.Get(q1view::WindowsUiFontRole::EmptyTitle, m_hWnd)); + CFont *bodyFont = CFont::FromHandle(mUiFonts.Get(q1view::WindowsUiFontRole::Body, m_hWnd)); CString title(_T("Open or drop an image")); CString body(_T("Ctrl+O to open, Ctrl+V to paste, mouse wheel to zoom")); @@ -1237,10 +1230,10 @@ void CViewerView::DrawEmptyState(CDC *pDC) bodyRect.top = rect.CenterPoint().y + 6; pDC->SetBkMode(TRANSPARENT); - CFont *prevFont = pDC->SelectObject(&titleFont); + CFont *prevFont = pDC->SelectObject(titleFont); pDC->SetTextColor(Q1UI_COLOR_TEXT); pDC->DrawText(title, &titleRect, DT_SINGLELINE | DT_CENTER | DT_BOTTOM | DT_END_ELLIPSIS); - pDC->SelectObject(&bodyFont); + pDC->SelectObject(bodyFont); pDC->SetTextColor(Q1UI_COLOR_TEXT_MUTED); pDC->DrawText(body, &bodyRect, DT_SINGLELINE | DT_CENTER | DT_TOP | DT_END_ELLIPSIS); pDC->SelectObject(prevFont); @@ -1248,12 +1241,8 @@ void CViewerView::DrawEmptyState(CDC *pDC) void CViewerView::DrawCursorCoordinates(CDC *pDC) { - LOGFONT lf; - CFont cursorCoordFont; - mConsolasFont.GetLogFont(&lf); - lf.lfHeight = 16; - cursorCoordFont.CreateFontIndirect(&lf); - pDC->SelectObject(&cursorCoordFont); + CFont *previous = pDC->SelectObject(CFont::FromHandle( + mUiFonts.Get(q1view::WindowsUiFontRole::Numeric, m_hWnd))); CString coord; coord.Format(_T("x:%d,y:%d"), mXCursor, mYCursor); @@ -1264,6 +1253,7 @@ void CViewerView::DrawCursorCoordinates(CDC *pDC) pDC->FillSolidRect(bgRect, COLOR_COORDINATE_RECT); pDC->SetTextColor(Q1UI_COLOR_OVERLAY_TEXT); pDC->DrawText(coord, &bgRect, DT_CENTER | DT_VCENTER); + pDC->SelectObject(previous); } void CViewerView::DrawPixelValueMode(CDC *pDC) @@ -1274,12 +1264,8 @@ void CViewerView::DrawPixelValueMode(CDC *pDC) return; } - LOGFONT lf; - CFont modeFont; - mConsolasFont.GetLogFont(&lf); - lf.lfHeight = 14; - modeFont.CreateFontIndirect(&lf); - CFont *prevFont = pDC->SelectObject(&modeFont); + CFont *prevFont = pDC->SelectObject(CFont::FromHandle( + mUiFonts.Get(q1view::WindowsUiFontRole::Body, m_hWnd))); CString mode = mShowSourceYuv ? _T("PIXEL: Y/U/V SOURCE") : _T("PIXEL: R/G/B DISPLAY"); @@ -1297,12 +1283,8 @@ void CViewerView::DrawPixelValueMode(CDC *pDC) int CViewerView::DrawBoxInfoText(CDC *pDC, CRect &rect, COLORREF color, int hAccumGap) { - LOGFONT lf; - CFont cursorCoordFont; - mConsolasFont.GetLogFont(&lf); - lf.lfHeight = 16; - cursorCoordFont.CreateFontIndirect(&lf); - pDC->SelectObject(&cursorCoordFont); + CFont *previous = pDC->SelectObject(CFont::FromHandle( + mUiFonts.Get(q1view::WindowsUiFontRole::Numeric, m_hWnd))); CString coord; coord.Format(_T("w:%d,h:%d"), rect.Width() + 1, rect.Height() + 1); @@ -1314,18 +1296,12 @@ int CViewerView::DrawBoxInfoText(CDC *pDC, CRect &rect, COLORREF color, int hAcc pDC->FillSolidRect(bgRect, COLOR_BOXINFO_RECT); pDC->SetTextColor(Q1UI_COLOR_OVERLAY_TEXT); pDC->DrawText(coord, &bgRect, DT_CENTER | DT_VCENTER); + pDC->SelectObject(previous); return hAccumGap; } void CViewerView::DrawBoxInfo(CDC *pDC) { - LOGFONT lf; - CFont cursorCoordFont; - mConsolasFont.GetLogFont(&lf); - lf.lfHeight = 16; - cursorCoordFont.CreateFontIndirect(&lf); - pDC->SelectObject(&cursorCoordFont); - int x0, y0, xn, yn; int hAccumGap = 0; @@ -1360,6 +1336,7 @@ void CViewerView::DrawBoxInfo(CDC *pDC) void CViewerView::OnDraw(CDC *pDC) { + q1view::EnsureWindowsUiFont(mProgressFont, q1view::WindowsUiFontRole::Numeric, m_hWnd); CViewerDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) @@ -1374,6 +1351,7 @@ void CViewerView::OnDraw(CDC *pDC) QueryPerformanceCounter(&renderStart); CDC &memDC = mBackDC; + q1view::WindowsUiDcState dcState(memDC.GetSafeHdc()); memDC.SetStretchBltMode(COLORONCOLOR); memDC.FillSolidRect(CRect(0, 0, mWClient, mHClient), Q1UI_COLOR_CANVAS_BG); @@ -2101,13 +2079,11 @@ void CViewerView::ToggleFullScreen() } } else { mFullMode = false; - HMENU hMenu = ::LoadMenu(theApp.m_hInstance, MAKEINTRESOURCE(IDR_MAINFRAME)); - ::SetMenu(pMainFrm->GetSafeHwnd(), hMenu); + pMainFrm->RestoreApplicationMenu(); pMainFrm->ModifyStyle(WS_POPUP, WS_OVERLAPPED | WS_CAPTION | FWS_ADDTOTITLE | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU | WS_THICKFRAME, 0); - pMainFrm->AddMainMenu(); GetDocument()->UpdateMenu(); if (mHavePreFullPlacement && mPreFullPlacement.showCmd != SW_SHOWMAXIMIZED) { const CRect rc(mPreFullPlacement.rcNormalPosition); @@ -2229,13 +2205,8 @@ void CViewerView::DrawScalingToast(CDC *pDC) if (mScalingToast.IsEmpty() || mWCanvas <= 0 || mHCanvas <= 0) return; - LOGFONT lf; - mProgressFont.GetLogFont(&lf); - lf.lfHeight = 16; - lf.lfWeight = FW_SEMIBOLD; - CFont font; - font.CreateFontIndirect(&lf); - CFont *oldFont = pDC->SelectObject(&font); + CFont *oldFont = pDC->SelectObject(CFont::FromHandle( + mUiFonts.Get(q1view::WindowsUiFontRole::Command, m_hWnd))); pDC->SetBkMode(TRANSPARENT); pDC->SetTextColor(Q1UI_COLOR_OVERLAY_TEXT); @@ -2592,7 +2563,9 @@ void CViewerView::OnSize(UINT nType, int cx, int cy) mHClient = cy; mWCanvas = mWClient; - mHCanvas = mHClient - mHProgress; + if (mHProgress > 0) + mHProgress = q1view::WindowsUiPixels(22, q1view::WindowsUiDpi(m_hWnd), q1view::WindowsUiSettings().Scale()); + mHCanvas = max(0, mHClient - mHProgress); mXDst = q1::DeterminDestPos(mWCanvas, mWDst, mXOff, mN); mYDst = q1::DeterminDestPos(mHCanvas, mHDst, mYOff, mN); @@ -2600,6 +2573,13 @@ void CViewerView::OnSize(UINT nType, int cx, int cy) mRcProgress.SetRect(0, mHCanvas, mWClient, mHClient); } +LRESULT CViewerView::OnTypographyChanged(WPARAM, LPARAM) +{ + OnSize(SIZE_RESTORED, mWClient, mHClient); + Invalidate(FALSE); + return 0; +} + void CViewerView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == SCALING_TOAST_TIMER) { diff --git a/Viewer/ViewerView.h b/Viewer/ViewerView.h index 6640c6b..cdb0039 100644 --- a/Viewer/ViewerView.h +++ b/Viewer/ViewerView.h @@ -13,6 +13,7 @@ class DxgiPresenter; #include "SMutex.h" #include +#include "Q1UiFontWin.h" #include #include @@ -122,6 +123,7 @@ class CViewerView : public CView // Progressive int mHProgress; CFont mProgressFont, mDefPixelTextFont, mConsolasFont; + q1view::WindowsUiFontCache mUiFonts; COLORREF mBarColor; // RGB buffers used by SetDIBitsToDevice. @@ -299,6 +301,7 @@ class CViewerView : public CView afx_msg void OnRButtonUp(UINT nFlags, CPoint point); afx_msg LRESULT OnPlayTimer(WPARAM wParam, LPARAM lParam); afx_msg LRESULT OnAutoplayVideo(WPARAM wParam, LPARAM lParam); + afx_msg LRESULT OnTypographyChanged(WPARAM, LPARAM); }; #ifndef _DEBUG // debug version in ViewerView.cpp diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md index d9cc925..bd6aa58 100644 --- a/docs/STORE_LISTING.md +++ b/docs/STORE_LISTING.md @@ -25,6 +25,8 @@ Pixel-level viewer and frame comparer for codec, imaging, and QA work. Inspect r ``` Q1View is a Windows toolkit for engineers who need to inspect image and video data at the pixel level instead of just playing it back. +Viewer and Comparator use coordinated Pretendard typography for app-drawn Korean and English text, window titles, and application menus. Text follows Windows text-size and display scaling settings; narrow menu rows wrap, and help descriptions wrap and scroll for access to the full shortcut list. Native Windows window buttons and system dialogs retain their familiar behavior. + It is built for codec developers, imaging engineers, computer vision engineers, researchers, and QA teams who need to verify raw buffers, decoded frames, compression artifacts, color conversion, timing behavior, and small visual differences between processing pipelines. Q1View includes two focused applications: @@ -107,6 +109,7 @@ TECHNICAL NOTES 17. Cycle raw color space (N) and preset resolutions (D) without reopening the file 18. Windows Viewer media thumbnail gallery with automatic directory refresh, multi-selection and comparison of two to four sources in Comparator, safe batch Recycle Bin actions, smooth sizing, bounded caching, compact folder notation, modern Korean/Latin typography, global shortcuts, and top-level logical-drive switching 19. Coordinated Viewer, Comparator, photo, video, and raw-file icons designed for clear recognition across Windows taskbar, Start, and File Explorer surfaces +20. Coordinated Korean/English application typography with Windows text-size scaling, wrapping menus, and scrollable shortcut help 20. BT.2020 HLG video tone mapping for accurate color and contrast on standard SDR displays ``` diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 3d6c63b..86fce04 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -44,6 +44,8 @@ Choose **View → Image Scaling** or press `I` to cycle through **Auto**, **Smoo Viewer includes a built-in control panel, opened with `?`. +In the Windows Viewer and Comparator, window titles, help descriptions and main application menus share the Pretendard UI font. Titles and menus use normal-weight 14 DIP text; the custom main-menu row and dropdown rows provide at least 30 DIP height, follow Windows text enlargement and wrap the main-menu buttons when the window is narrow. Windows still draws and operates the minimize, maximize/restore and close buttons. Drag or double-click the title to move or maximize the window, right-click it or press Alt+Space for the Windows system menu, and use F10 or Alt to access application menus. Alt mnemonics, arrow-key navigation, check/radio states and command shortcuts remain available. Menu buttons expose accessible names and retain their original popup commands. Viewer full-screen mode hides the title and menu, then restores the same menu when leaving full screen. High-contrast mode or unavailable DWM composition uses the native Windows frame instead. Shortcut columns in help are measured rather than aligned with spaces, and descriptions wrap when needed. If the panel overflows, use the mouse wheel, Up/Down, Page Up/Page Down, or Home/End to read it; these navigation keys operate on help while it is open instead of changing the underlying image. Click the panel or press Esc to close it. Numeric inspection values retain a monospaced font; system menus, other context menus and Windows dialogs retain native typography. + | Action | Control | | --- | --- | | Open a file | Drag and drop or `Ctrl+O` | diff --git a/docs/verification/issue-117-titlebar.md b/docs/verification/issue-117-titlebar.md new file mode 100644 index 0000000..1fc0c15 --- /dev/null +++ b/docs/verification/issue-117-titlebar.md @@ -0,0 +1,53 @@ +# Issue #117: approved Windows title/menu frame extension + +Date: 2026-10-04. Branch: `codex/issue-117-typography`. Windows MFC Viewer and Comparator only. The user approved expanding beyond a font-only edit after two non-client painting prototypes proved unsuitable. The user subsequently approved temporary Windows text-size and display-scale changes with restoration. No master merge, release tag or version bump is authorized by this work. + +## Snap hover acceptance and user-approved scope reduction — 2026-10-04 + +The user requested checking only Snap and explicitly waived the remaining 200%/mixed-monitor, high-contrast and exhaustive-glyph checks. Those items remain unverified, not passed, but are no longer acceptance blockers under the revised scope. Existing results below are retained. + +Both final Release review executables displayed the native Windows Snap layout flyout with the pointer over their DWM maximize button. Viewer retained the repository pencil source/title and its File popup operated normally afterward. Comparer retained its two-pane layout and title/menu controls. No product changes were needed for this hover check, and no OS settings were changed in this session. + +Additional edge-drag attempts displayed 960×1032 half-screen layouts at screen origin (0,0), but reactivation/cancellation returned to the prior restore geometry. The automation tool refused clicks on the Snap flyout because it belongs to Explorer's unlisted popup host, not the selected app HWND. Consequently, this evidence confirms the originally pending **Snap hover/flyout** acceptance, not a committed flyout-zone selection or every Snap arrangement. Do not report those extra attempts as full placement passes. The requested hover check is complete; the issue stays open until implementation is merged, and merge/release still requires user direction. + +## Live acceptance follow-up — 2026-10-04 + +This section supersedes the earlier pending text-size/Comparer matrix notes below; earlier observations remain as historical evidence, not current results. Using Windows Settings, display scale was changed from the original 100% to 125%, 150% and 175%, and restored to 100%. The available preset list on this display stops at 175%; no custom scale requiring sign-out was applied. Live 200% and mixed-monitor movement are therefore not verified. The existing explicit 192-DPI layout matrix does not substitute for those checks. + +Windows text size was changed from 100% to 225%, then restored to 100%. Both running applications updated their title/menu/content typography. Menu rows wrapped instead of colliding with the right-hand commands. At extreme text size a very small window cannot display all empty-state prose; useful-canvas acceptance uses an 800×500 test window rather than claiming every phrase fits an arbitrarily small window. Comparer's graph height is now capped at one third of content height so enlarged graph labels do not consume nearly all of the image canvas. + +A new opt-in real MFC Comparer suite loads the two repository pencil images, commits a source-pixel ROI and zoom/pan inputs, obtains actual PSNR/SSIM crop results, and verifies their preservation together with source bytes/path/format across typography relayout. It reads back the presented menu-button and image-pane pixels before and after 12 fixed-size window moves without injecting a repaint. Actual-window DPI reports were 120, 144 and 168 at the respective live OS scales; OS text factor was 2.250 in the text-size run. Each completed successfully. Reports are generated under ignored `Tests/bin/x64/Release/` rather than checking machine-specific output into Git. + +The live native-drag variant waits for an external title-bar drag, then reads pixels after the mouse button is released. Menu and image pixels matched the pre-drag readback at DPI 96/text factor 1.000 without a corrective click. Initial full-pane comparisons were invalid because opening the large source automatically maximized the frame and dragging restored a different canvas size. The test now explicitly restores and normalizes the frame after loading the sources. The first timed attempts with no settled move failed rather than being reported as passes. Automated capture can also include an occluding Settings window or stale/incomplete presentation; capture alone is not evidence that application glyph pixels are missing. The direct in-app settled-drag check removes that particular acceptance blocker, but does not claim testing every driver or multi-monitor configuration. + +The Viewer gallery suite's DPI assertion previously assumed OS text size 100%. It now includes the actual OS text factor when checking font heights; this is a test expectation fix, not a production font change. Rebuilding and running the full MFC gallery suite at live text factor 2.250 completed with exit 0 / ALL INTEGRATION CHECKS PASSED, including E transitions, selection/zoom/pan, playback and actual GPU recreation. CoreRegressionTests and WindowsUiTypographyTests also passed again (GDI 5 before / 5 after). A final Comparer run after restoring both OS settings reported DPI 96/text factor 1.000 and passed. Remaining acceptance limits are real 200%/mixed-monitor transitions, live high-contrast switching, Windows Snap hover and exhaustive fallback/emoji review. Keep #117 open and PR #135 draft pending those limits and the user's final visual review. + +Comparer test build: `MSBuild Comparator/Comparator.sln /m /restore /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 /p:Q1ViewComparerTests=true /p:IntDir=x64\TypographyIntegration\ /p:OutDir=x64\TypographyIntegration\`. Set `Q1VIEW_COMPARER_TEST_REPORT` to a report path before running `Comparator/x64/TypographyIntegration/Comparator.exe`; optionally set `Q1VIEW_COMPARER_TEST_LIVE_MOVE=1` and drag its restored title bar within 60 seconds. Its application preferences use an isolated registry profile. Ordinary builds do not include the test entry point. + +## Design and implementation + +The main title is client-painted using the same normal-weight 14 DIP Pretendard body role as the main menu. A buffered 32-bit title surface explicitly supplies opaque alpha for the title, while leaving the DWM caption-button area transparent. This avoids invisible GDI glyphs on an extended frame and preserves the system-rendered window buttons. The actual window title remains unchanged for Windows accessibility, task switching and taskbar use; long titles use end ellipsis and literal ampersands are not interpreted as mnemonics. + +The main menu row uses real accessible Win32 buttons and retains the original HMENU tree for native popup tracking, state updates, shortcuts and owner-drawn popup metadata. Normal text enlargement is not clamped to the former native bar height. Narrow windows wrap menu buttons rather than overlapping the right-aligned Help/Compare/Update/zoom group. Alt mnemonics, Alt/F10 focus, arrow switching, Enter/Down, and Esc focus restoration are handled explicitly. A thread-local, temporary MSGF_MENU filter switches sibling main-menu popups without replacing nested-submenu navigation; it is unhooked before deferred owner-window closure. + +Native DWM button handling is given first refusal, with the same non-client button hit codes supplied when DWM hit state is not ready after a frame change. Resize borders, title dragging, title double-click and the Windows system menu continue through the native non-client path. Maximized client bounds account for the invisible resize border and monitor work area. Custom-frame moves/sizes set SWP_NOCOPYBITS rather than assuming standard-client-origin pixel preservation; the flag is regression-tested, but it has not conclusively fixed the mouse-capture observation below. No snap-layout UI is implemented by the app; the maximize button retains HTMAXBUTTON for Windows handling. + +MFC reserves the title/menu region via its existing m_rectBorder layout mechanism, so it does not resize the image to full client size and then resize it again below the title. Help and drawer-transition overlays cover the content region rather than intercepting window controls. Comparator splitter heights use the content height. Viewer full-screen transitions hide the chrome and restore the same menu instead of loading another root and reinserting dynamic submenus. + +High contrast or unavailable DWM composition restores the native frame/menu. This is a deliberate native-title typography exception. The detached root menu has an explicit lifetime; WM_DESTROY releases it if no HWND owns it. Frame overrides do not access members after MFC's WM_NCDESTROY/PostNcDestroy path deletes the frame. + +The architecture follows Microsoft's [custom DWM frame guidance](https://learn.microsoft.com/en-us/windows/win32/dwm/customframe) and keeps [TrackPopupMenuEx](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-trackpopupmenuex) for popup tracking. This is not the rejected approach of painting on top of the system title or disabling DWM non-client rendering. + +## Verification + +Follow-up to the user's screenshot: Comparator pane format selection and right-click menus were standalone popups, outside the main frame HMENU tree, and still used Windows default popup typography. Each pane now owns a shared WindowsUiMenus painter for its standalone popups and routes native initialization/measurement/drawing/mnemonic messages through it. Color-space command parsing reads the preserved menu label rather than relying on GetMenuString for an owner-drawn item. Standalone popup tests cover row measurement, accessible names, check-state preservation and modified labels. The remaining frame/mouse-capture acceptance caveat below is unchanged. + +The typography executable now also creates an isolated Win32 frame and checks original menu retention, accessible button names, disabled-item input, dynamic labels, wrapped/non-overlapping rows at window widths 200/320/640/1920, full-screen hide/restore, native maximize hit codes and repeated buffered caption drawing without accumulating GDI objects. Body/help tests continue to cover explicit 96/120/144/192 DPI and text factors 1/1.25/1.5/2/2.25. These explicit font/layout inputs are not a substitute for a real mixed-monitor or live Windows text-size acceptance run. + +The MFC gallery suite adds real chrome-reserved splitter bounds, maximize/work-area bounds, original restore rectangle, three full-screen round trips without duplicate menus and preservation of the active file. It then runs the existing E transition, zoom/pan, playback, selection, directory refresh, recycling and actual GPU recreation tests with an isolated profile and repository/temp fixtures. + +Native visual inspection has confirmed 14 DIP title/menu balance in both applications, retained DWM window buttons and rounded frame, actual maximize/restore button clicks, title dragging with unchanged window dimensions, Alt+Space system menu, Alt+F popup access, Right switching to the next main-menu popup and F10/Esc focus restoration. The custom frame requests WVR_REDRAW and posts a full child-tree repaint after native move tracking, without a timer or image resizing. A Windows system-menu keyboard move completes with intact text. However, automated mouse-drag captures still sometimes show missing portions of menu/content text until the next click or popup; the repaint change has not conclusively resolved that observation. Manual mouse-drag acceptance and identifying whether the remaining defect is in the app or capture path are required before merging. + +CoreRegressionTests and the extended WindowsUiTypographyTests pass. The final warmed GDI count is 5 before / 5 after; the caption buffer also survives 100 repeated paint calls with no accumulating GDI resources. Both applications build as Release/x64/v143 into the isolated TitleTypographyReview output so the user's already-running normal executables are not replaced. The actual MFC integration run exits 0 with ALL INTEGRATION CHECKS PASSED, including the new chrome/full-screen assertions and actual GPU recreation. Local version generation remains 0.0.0-dev (0.0.0.0). + +Manual mouse-drag acceptance, real mixed-monitor moves, live Windows text-size changes, a live high-contrast switch, Snap-layout hover acceptance, and the full Comparator ROI/metrics matrix have not been completed. No system settings were changed to manufacture those results. Keep #117 open and PR #135 draft pending those acceptance checks and the user's visual review; this work does not merge master or cut a release. diff --git a/docs/verification/issue-117-typography.md b/docs/verification/issue-117-typography.md new file mode 100644 index 0000000..c02180b --- /dev/null +++ b/docs/verification/issue-117-typography.md @@ -0,0 +1,60 @@ +# Issue #117: Windows UI typography verification + +Date: 2026-10-04. Branch: `codex/issue-117-typography`, based on `origin/master` `7b8d50f57eb0738d85d73aeba86c05f94120a4a9`. Windows MFC applications only; no Qt, image-processing, metric-input, release-tag or version changes. The original body/help-only scope was later expanded with explicit user approval for application menu typography and then a custom title/menu frame; the sections below record those stages. + +## Implementation + +- Retain the bundled Pretendard family and Segoe UI fallback. Use shared negative character-height GDI role definitions for body, commands, captions, folders, supporting text and status. Aligned numeric values use installed Cascadia Mono or Consolas. +- Replace Viewer/Comparator help's all-Consolas, space-aligned paragraphs with measured columns, wrapped descriptions and bounded scrolling. Preserve commands; wheel/arrows/Page Up/Page Down/Home/End navigate help only while it is open, and Esc closes it. +- Reuse role fonts; invalidate GDI/DirectWrite resources on Windows text-size notification, marshal notifications onto the UI thread and remeasure affected control bounds. Preserve CPU thumbnail cache when recreating GPU text resources. +- Scale timeline/graph/header and video progress text areas. Restore selected fonts and persistent render DC state before font replacement or deletion. Keep image-space pixel-label sizes zoom-derived, not accessibility-scaled. + +## Local automated checks + +Use Visual Studio 2022 MSBuild, Release/x64/v143. `build/Write-Q1ViewVersion.ps1` generates the local development version; do not replace it with a release number. + +```powershell +$msbuild = 'C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe' +& $msbuild Tests\CoreRegressionTests.vcxproj /m /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 +.\Tests\bin\x64\Release\CoreRegressionTests.exe +& $msbuild Tests\WindowsUiTypographyTests.vcxproj /m /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 +.\Tests\bin\x64\Release\WindowsUiTypographyTests.exe +& $msbuild Viewer\Viewer.sln /m /restore /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 +& $msbuild Comparator\Comparator.sln /m /restore /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 +``` + +Results: core regression tests passed; both native Release applications built successfully. Typography tests passed for explicit DPI values 96/120/144/192, text factors 1/1.25/1.5/2/2.25 and help widths 320/640/960 DIP. Tests check actual bundled font resolution, Korean/English glyph availability, font heights and reuse, independent wrapped columns, access to the final row, navigation routing, persistent DC font restoration and bounded cache lifetime. GDI object counts after warm-up: before 2, after 2. + +Build the existing native gallery integration suite in an isolated output directory, then run from the repository root so its fixture lookup succeeds: + +```powershell +& $msbuild Viewer\Viewer.sln /m /restore /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 /p:Q1ViewGalleryTests=true /p:IntDir=x64\UiTypographyIntegration\ /p:OutDir=x64\UiTypographyIntegration\ +$env:Q1VIEW_GALLERY_TEST_REPORT = Join-Path $PWD 'Tests/bin/x64/Release/typography-gallery-report.txt' +$test = Start-Process -FilePath (Join-Path $PWD 'Viewer/x64/UiTypographyIntegration/Viewer.exe') -WorkingDirectory $PWD -WindowStyle Hidden -PassThru -Wait +$test.ExitCode +Remove-Item Env:Q1VIEW_GALLERY_TEST_REPORT +``` + +The suite uses an isolated test registry profile and repository fixtures/temporary copies, not private photographs. Coverage includes bundled native GDI/DirectWrite font use, long wrapped folder names, multi-selection, E drawer transitions and key repeat, current-image/zoom/pan preservation, directory changes, playback, bounded thumbnail memory and actual GPU-resource recreation. The final run after the font/DC-lifetime and progress-band fixes exited 0 with `ALL INTEGRATION CHECKS PASSED`. A first invocation from the executable directory failed fixture lookup; running from the repository root corrected that test-environment error. + +## Native visual checks and remaining limits + +Viewer and Comparator were launched and inspected on this machine. Empty-state prose, Comparator headers and help columns were legible. Viewer help could be scrolled to its final row with End; Comparator's `Drag & Drop` retained its literal ampersand, and Esc closed help. + +Explicit DPI/text-scale inputs in the automated tests are not evidence of live Windows Settings changes or cross-monitor DPI behavior. Real monitor moves at 100/125/150/200%, live OS text enlargement, every missing-script/emoji fallback case, and a full Comparator ROI/metric interaction matrix still require native visual acceptance. Keep the issue's full acceptance boxes open until those checks are performed; this implementation does not authorize the next design job or a release. + +Windows text scaling follows [Microsoft's text-scaling guidance](https://learn.microsoft.com/en-us/windows/apps/develop/input/text-scaling) using UISettings2, without changing system settings. + +## Approved menu extension + +This section records the earlier native-menu-bar implementation. The later approved client-painted title and hosted menu row supersede its native bar-height/font-fitting limitations; see [title-bar verification](issue-117-titlebar.md). Native popup tracking and item metadata remain in use. + +After inspecting the native menu/body balance, the user explicitly requested code changes to the menus. Main application menu bars and their dropdown trees now use normal-weight Pretendard 14 DIP with wider horizontal text padding. Dropdowns request at least 30 DIP row height; Windows still owns the native menu-bar height, and the bar font fits within that native hit-tested row rather than changing global system settings. This is not a new toolbar or a replacement menu-tracking implementation. System menus, unrelated context menus and dialogs are not restyled. + +Real HMENUs and command IDs, enabled/disabled states, check/radio states, submenu trees and native keyboard tracking remain. Owner-drawn items expose native names using [Microsoft's MSAAMENUINFO mechanism](https://learn.microsoft.com/en-us/windows/win32/api/oleacc/ns-oleacc-msaamenuinfo). WM_MENUCHAR handles ampersand mnemonics, including disabled items and duplicate cycling; dynamic ModifyMenu/InsertMenu labels are synchronized without aliasing item metadata. Source-string parsers query the retained text, not GetMenuString's owner-draw limitations. + +The typography test executable additionally verifies menu metadata, accessible names, retained states, mnemonic routing, dynamic source strings, insertion, 30 DIP requested measurement and font restoration. Native menu-bar height must be assessed in the real window: a WM_MEASUREITEM request alone is not proof that Windows raised it to 30 DIP. Live enlarged-text and mixed-monitor acceptance remains open. + +Native review found and corrected display defects in the prototype: querying menu-item rectangles while drawing could re-enter menu layout and hide startup labels, so the bar now uses system DPI-aware menu metrics; owner-draw painting explicitly renders check/radio marks from the retained native states, while the native submenu chevron is retained without a duplicate custom indicator. The test renders a checked radio into a DIB and verifies its pixels. The final typography test's warmed GDI count is 3 before / 3 after (the earlier body-only run was 2/2). + +The integration rerun initially reached the deferred-folder-activation assertion before its posted message had been processed. That assertion used a fixed 50 ms pump; it now uses the suite's existing bounded Await helper to check the actual asynchronous outcome. No product folder-navigation code was changed, and the subsequent full integration run passed. Keep this timing repair distinct from menu appearance verification.