diff --git a/CHANGELOG.md b/CHANGELOG.md index 300c41b..33fe434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ All notable changes to Q1View are documented here. Releases follow [semantic ver --- +## [2.8.2] — 2026-10-19 + +### Changed + +- Windows Viewer: retain direct resolution, color-space and FPS menus; show sampling at bottom left and zoom at bottom right. Simplify Options and Browse without duplicate Open/Browser, View/More or Fit/100% controls. (#120) + +- Windows Viewer and Comparator: add Selection Mode to Options, retaining S/context shortcuts and matching checks. Add Viewer window synchronization to Options; keep Comparator Clear Selection in its context menu/Esc. + +### Fixed + +- Viewer: autoplay videos and multi-frame RAW on open and Page Up/Page Down, honoring the saved setting without resizing the window. Keep single frames and internal reloads paused. + +- Viewer: allow supported Y4M/.yuv resolution and color-space reinterpretation with correct frame boundaries, FPS, seeking and autoplay; refresh RAW menu labels after changes. + +- Viewer and Comparator: prevent hover/arrow navigation from executing commands; unify Viewer context-menu fonts/colors and remove dashed disabled outlines while retaining keyboard focus. + +- Viewer: keep Compare enabled without a file, opening an empty Comparator; avoid full menu scans when idle command availability is unchanged. + +--- + ## [2.8.1] — 2026-10-18 ### Changed @@ -515,7 +535,8 @@ Native display checks passed at 100–175%; 200% layout/font calculations passed --- -[Unreleased]: https://github.com/chammoru/Q1View/compare/v2.8.1...HEAD +[Unreleased]: https://github.com/chammoru/Q1View/compare/v2.8.2...HEAD +[2.8.2]: https://github.com/chammoru/Q1View/compare/v2.8.1...v2.8.2 [2.8.1]: https://github.com/chammoru/Q1View/compare/v2.8.0...v2.8.1 [2.8.0]: https://github.com/chammoru/Q1View/compare/v2.7.28...v2.8.0 [2.7.28]: https://github.com/chammoru/Q1View/compare/v2.7.27...v2.7.28 diff --git a/Comparator/ComparatorDoc.cpp b/Comparator/ComparatorDoc.cpp index f442a9e..0583396 100644 --- a/Comparator/ComparatorDoc.cpp +++ b/Comparator/ComparatorDoc.cpp @@ -36,8 +36,22 @@ IMPLEMENT_DYNCREATE(CComparatorDoc, CDocument) BEGIN_MESSAGE_MAP(CComparatorDoc, CDocument) + ON_COMMAND(ID_SELECTION_MODE, &CComparatorDoc::ToggleSelectionMode) + ON_UPDATE_COMMAND_UI(ID_SELECTION_MODE, &CComparatorDoc::OnUpdateSelectionMode) END_MESSAGE_MAP() +void CComparatorDoc::ToggleSelectionMode() +{ + mSelMode = !mSelMode; + UpdateAllViews(nullptr); +} + +void CComparatorDoc::OnUpdateSelectionMode(CCmdUI *command) +{ + command->Enable(TRUE); + command->SetCheck(mSelMode); +} + CComparatorDoc::CComparatorDoc() : mPane(new ComparatorPane[IMG_VIEW_MAX]) diff --git a/Comparator/ComparatorDoc.h b/Comparator/ComparatorDoc.h index 5e145da..21108e5 100644 --- a/Comparator/ComparatorDoc.h +++ b/Comparator/ComparatorDoc.h @@ -173,6 +173,8 @@ class CComparatorDoc : public CDocument DECLARE_MESSAGE_MAP() public: void ViewOnMouseWheel(short zDelta, int wCanvas, int hCanvas); + void ToggleSelectionMode(); + afx_msg void OnUpdateSelectionMode(CCmdUI *command); virtual BOOL OnOpenDocument(LPCTSTR lpszPathName); }; diff --git a/Comparator/ComparatorView.cpp b/Comparator/ComparatorView.cpp index b788343..bc5ab17 100644 --- a/Comparator/ComparatorView.cpp +++ b/Comparator/ComparatorView.cpp @@ -886,9 +886,9 @@ int CComparatorView::OnCreate(LPCREATESTRUCT lpCreateStruct) mNameQMenu.MoveWindow(&mRcNameQMenu); // Right-click popup, mirroring the MFC Viewer's mouse menu: a checkable - // "Sel Mode" toggle plus "Clear Selection" for the synchronized rectangle. + // Selection Mode is also available in Options; clearing stays contextual. mMouseMenu.CreatePopupMenu(); - mMouseMenu.AppendMenu(MF_STRING, ID_MOUSEMENU_START + QMouseMenuSel, _T("Sel Mode")); + mMouseMenu.AppendMenu(MF_STRING, ID_SELECTION_MODE, L"&Selection Mode\tS"); mMouseMenu.AppendMenu(MF_STRING, ID_MOUSEMENU_START + QMouseMenuClear, _T("Clear Selection")); return 0; @@ -1376,7 +1376,11 @@ void CComparatorView::OnCsChange(UINT nID) void CComparatorView::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { - if (nChar == VK_TAB && mCsQMenu.IsWindowEnabled()) { mCsQMenu.SetFocus(); return; } + if (nChar == VK_TAB && mCsQMenu.IsWindowEnabled()) { + mCsQMenu.SetFocus(); + mCsQMenu.SendMessage(WM_CHANGEUISTATE, MAKEWPARAM(UIS_CLEAR, UISF_HIDEFOCUS)); + return; + } CComparatorDoc* pDoc = GetDocument(); bool isProcessing = pDoc->CheckImgViewProcessing(); @@ -1414,8 +1418,8 @@ void CComparatorView::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) Invalidate(FALSE); break; case 'S': // Toggle selection mode: left-drag selects a region (issue #74). - pDoc->mSelMode = !pDoc->mSelMode; - break; + pDoc->ToggleSelectionMode(); + return; case VK_ESCAPE: // Clear the synchronized selection rectangle. ClearSelection(pDoc); break; @@ -1489,8 +1493,7 @@ void CComparatorView::OnMouseMenu(UINT nID) switch (nID - ID_MOUSEMENU_START) { case QMouseMenuSel: // Same effect as the 'S' shortcut: left-drag selects instead of panning. - pDoc->mSelMode = !pDoc->mSelMode; - pDoc->UpdateAllViews(NULL); + pDoc->ToggleSelectionMode(); break; case QMouseMenuClear: ClearSelection(pDoc); diff --git a/Comparator/MainFrm.cpp b/Comparator/MainFrm.cpp index dcc74bb..7bf7922 100644 --- a/Comparator/MainFrm.cpp +++ b/Comparator/MainFrm.cpp @@ -1048,6 +1048,8 @@ int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct) // Options menu. str.Format(_T("Allow Different Resolutions")); mOptionsMenu.AppendMenu(MF_STRING, (UINT_PTR)ID_OPTIONS_DIFF_RESOLUTION, str); + mOptionsMenu.AppendMenu(MF_SEPARATOR); + mOptionsMenu.AppendMenu(MF_STRING, ID_SELECTION_MODE, L"&Selection Mode\tS"); mAppliedCanvasColor = Q1UI_COLOR_CANVAS_BG; str.Format(_T("&Options")); diff --git a/Comparator/QMenuItem.cpp b/Comparator/QMenuItem.cpp index 005b4c3..42274e9 100644 --- a/Comparator/QMenuItem.cpp +++ b/Comparator/QMenuItem.cpp @@ -71,7 +71,7 @@ void CQMenuItem::Paint(HDC dc) if (mMenu) { const int arrow = q1view::WindowsUiPixels(10, q1view::WindowsUiDpi(m_hWnd)); const int x = rect.right - arrow / 2, y = rect.CenterPoint().y; - CPen pen(enabled ? PS_SOLID : PS_DASH, 1, q1view::WindowsUiColorValue(q1view::WindowsUiColor::Boundary)); + CPen pen(PS_SOLID, 1, q1view::WindowsUiColorValue(q1view::WindowsUiColor::Boundary)); CPen* oldPen = target->SelectObject(&pen); auto* oldBrush = target->SelectStockObject(NULL_BRUSH); target->Rectangle(mRcClient); @@ -81,7 +81,7 @@ void CQMenuItem::Paint(HDC dc) } if (pressed) rect.OffsetRect(1, 1); target->DrawText(text, &rect, DT_SINGLELINE | DT_VCENTER | mTextHorizAlign | DT_END_ELLIPSIS | DT_NOPREFIX); - if (mMenu && enabled && GetFocus() == this) { + if (mMenu && enabled && GetFocus() == this && !(SendMessage(WM_QUERYUISTATE) & UISF_HIDEFOCUS)) { CRect focus = mRcClient; focus.DeflateRect(3, 3); target->DrawFocusRect(focus); } } @@ -121,13 +121,14 @@ BOOL CQMenuItem::OnEraseBkgnd(CDC*) { return TRUE; } LRESULT CQMenuItem::WindowProc(UINT message, WPARAM wp, LPARAM lp) { if (message == OpenPopupMessage) { if (mPopupQueued) OpenPopup(); return 0; } + if (mMenu && message == WM_LBUTTONDOWN) SendMessage(WM_CHANGEUISTATE, MAKEWPARAM(UIS_SET, UISF_HIDEFOCUS)); const HWND self = m_hWnd; const LRESULT result = CWnd::WindowProc(message, wp, lp); // Owner-drawn native buttons can request a parent draw synchronously on a // state change. Our local buffered WM_PAINT path must be invalidated too. if (::IsWindow(self) && CWnd::FromHandlePermanent(self) == this && (message == BM_SETSTATE || message == WM_CAPTURECHANGED || message == WM_LBUTTONDOWN || - message == WM_LBUTTONUP || message == WM_KEYDOWN || message == WM_KEYUP)) Invalidate(FALSE); + message == WM_LBUTTONUP || message == WM_KEYDOWN || message == WM_KEYUP || message == WM_UPDATEUISTATE)) Invalidate(FALSE); return result; } void CQMenuItem::OnSize(UINT type, int cx, int cy) { CWnd::OnSize(type, cx, cy); mRcClient.SetRect(0, 0, cx, cy); } @@ -145,6 +146,7 @@ void CQMenuItem::OnKillFocus(CWnd* next) { CWnd::OnKillFocus(next); Invalidate(F void CQMenuItem::OnEnable(BOOL enable) { CWnd::OnEnable(enable); mMouseIn = false; Invalidate(FALSE); } void CQMenuItem::OnKeyDown(UINT key, UINT repeat, UINT flags) { + if (mMenu && IsWindowEnabled()) SendMessage(WM_CHANGEUISTATE, MAKEWPARAM(UIS_CLEAR, UISF_HIDEFOCUS)); if (mMenu && key == VK_TAB) { GetParent()->SetFocus(); return; } if (mMenu && IsWindowEnabled() && (key == VK_RETURN || key == VK_DOWN || key == VK_F4)) { if (!(flags & KF_REPEAT)) OnClicked(); diff --git a/Comparator/Resource.h b/Comparator/Resource.h index 4b64c7a..0214e3c 100644 --- a/Comparator/Resource.h +++ b/Comparator/Resource.h @@ -24,6 +24,7 @@ #define ID_OPTIONS_END 0x75ff #define ID_MOUSEMENU_START 0x7600 #define ID_MOUSEMENU_END 0x76ff +#define ID_SELECTION_MODE ID_MOUSEMENU_START #define ID_MAGNIFY 32771 #define ID_COMPARATOR_HELP 32772 diff --git a/QVisionCore/Q1UiCommandLayout.h b/QVisionCore/Q1UiCommandLayout.h new file mode 100644 index 0000000..c7892c6 --- /dev/null +++ b/QVisionCore/Q1UiCommandLayout.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include + +namespace q1view { +struct WindowsUiCommandLayoutItem { int width, order, priority; }; +struct WindowsUiCommandLayoutPlan { std::vector visible, overflow; }; + +// Geometry-only policy; widths have already been measured using the actual +// font, DPI and accessibility scale. priority >= 100 is always in More. +inline WindowsUiCommandLayoutPlan PlanWindowsUiCommandRow( + const std::vector& items, int more, int available) +{ + WindowsUiCommandLayoutPlan plan; + int total = more >= 0 ? items[more].width : 0; + for (int i = 0; i < int(items.size()); ++i) { + if (i == more) continue; + if (items[i].priority >= 100) plan.overflow.push_back(i); + else { plan.visible.push_back(i); total += items[i].width; } + } + while (total > available && !plan.visible.empty()) { + auto least = std::max_element(plan.visible.begin(), plan.visible.end(), [&](int a, int b) { + return items[a].priority != items[b].priority ? items[a].priority < items[b].priority : items[a].order < items[b].order; + }); + total -= items[*least].width; plan.overflow.push_back(*least); plan.visible.erase(least); + } + auto order = [&](int a, int b) { return items[a].order < items[b].order; }; + std::stable_sort(plan.visible.begin(), plan.visible.end(), order); + std::stable_sort(plan.overflow.begin(), plan.overflow.end(), order); + if (more >= 0 && !plan.overflow.empty()) plan.visible.push_back(more); + return plan; +} +} // namespace q1view diff --git a/QVisionCore/Q1UiCommandWin.h b/QVisionCore/Q1UiCommandWin.h index 55d6195..9656bec 100644 --- a/QVisionCore/Q1UiCommandWin.h +++ b/QVisionCore/Q1UiCommandWin.h @@ -5,7 +5,7 @@ namespace q1view { // Incremental policy for existing commands, not a replacement command router. -enum class WindowsUiCommand { RawInput, CopyPixels, PasteImage, CompareFile, ClearSelection }; +enum class WindowsUiCommand { RawInput, CopyPixels, PasteImage, OpenComparator, ClearSelection }; struct WindowsUiCommandContext { bool rawInput = true; // An empty session may preconfigure RAW interpretation. bool pixels = false, clipboardImage = false, file = false, selection = false; @@ -17,7 +17,7 @@ inline WindowsUiCommandState WindowsUiResolveCommand(WindowsUiCommand command, c case WindowsUiCommand::RawInput: return {context.rawInput, L"Raw input only"}; case WindowsUiCommand::CopyPixels: return {context.pixels, L"No image pixels available"}; case WindowsUiCommand::PasteImage: return {context.clipboardImage, L"No image on the clipboard"}; - case WindowsUiCommand::CompareFile: return {context.file, L"Open a file first"}; + case WindowsUiCommand::OpenComparator: return {true, L""}; // Launch an empty comparison workspace too. case WindowsUiCommand::ClearSelection: return {context.selection, L"No selection to clear"}; } return {false, L"Unavailable"}; diff --git a/QVisionCore/Q1UiFrameWin.h b/QVisionCore/Q1UiFrameWin.h index e1e384e..c02df2b 100644 --- a/QVisionCore/Q1UiFrameWin.h +++ b/QVisionCore/Q1UiFrameWin.h @@ -3,6 +3,7 @@ #include "Q1UiMenuWin.h" #include "Q1UiBufferedPaintWin.h" #include "Q1UiCommandWin.h" +#include "Q1UiCommandLayout.h" #include #include #include @@ -19,6 +20,13 @@ namespace q1view { // buttons. Popup HMENUs, command IDs, and Windows popup tracking are retained. // No global NONCLIENTMETRICS changes and no separate overlay windows/timers. class WindowsUiFrame { +public: + // Optional overflow policy; normal application menus use wrapping. + struct MenuRule { + HMENU popup; UINT command; int order; int priority; + std::wstring overflowPrefix; + }; +private: HWND mWindow = nullptr, mBar = nullptr, mPreviousFocus = nullptr; HWND mHotButton = nullptr; HMENU mMenu = nullptr, mOwnedMenu = nullptr, mSelectedMenu = nullptr; @@ -27,8 +35,15 @@ class WindowsUiFrame { std::vector mButtons; std::vector mButtonRects; std::vector> mMinimumMenuText; + HMENU mOverflow = nullptr; + std::vector mMenuRules; + std::vector mOverflowPositions; + std::vector mVisibleOrder; bool mCustom = false, mMenuVisible = true, mTracking = false, mKeyboard = false; bool mDeferredClose = false; + bool mKeepTrailingMenuGroupTogether = false; + bool mActivateNext = false; + int mPressedMenuButton = -1; 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; } @@ -67,6 +82,29 @@ class WindowsUiFrame { item.fMask = MIIM_ID | MIIM_SUBMENU | MIIM_STATE | MIIM_FTYPE | MIIM_DATA; GetMenuItemInfoW(mMenu, position, TRUE, &item); return item; } + MenuRule Rule(int position) const { + const auto item = Info(position); + for (const auto& rule : mMenuRules) + if (rule.popup ? rule.popup == item.hSubMenu : !item.hSubMenu && rule.command == item.wID) return rule; + return {item.hSubMenu, item.wID, 90, 5, {}}; + } + void ClearOverflow() { + // These submenus are borrowed from the retained root. Detach, never + // DeleteMenu/DestroyMenu them through the temporary overflow tree. + while (IsMenu(mOverflow) && GetMenuItemCount(mOverflow) > 0) RemoveMenu(mOverflow, 0, MF_BYPOSITION); + } + void PopulateOverflow() { + ClearOverflow(); + for (int position : mOverflowPositions) { + auto item = Info(position); + auto text = Rule(position).overflowPrefix + mMenus->Text(mMenu, UINT(position), true); + item.fMask = MIIM_ID | MIIM_SUBMENU | MIIM_STATE | MIIM_FTYPE | MIIM_STRING; + item.fType &= ~(MFT_OWNERDRAW | MFT_RIGHTJUSTIFY | MFT_MENUBREAK | MFT_MENUBARBREAK); + item.dwTypeData = const_cast(text.c_str()); + InsertMenuItemW(mOverflow, UINT(-1), TRUE, &item); + } + mMenus->SyncPopup(mWindow, mOverflow); + } int ButtonAt(POINT screen) const { if (!mBar || !IsWindowVisible(mBar)) return -1; ScreenToClient(mBar, &screen); @@ -108,6 +146,16 @@ class WindowsUiFrame { mKeyboard = false; } int NextEnabled(int position, int direction) const { + if (mOverflow) { + if (mVisibleOrder.empty()) return -1; + auto found = std::find(mVisibleOrder.begin(), mVisibleOrder.end(), position); + int index = found == mVisibleOrder.end() ? (direction > 0 ? -1 : 0) : int(found - mVisibleOrder.begin()); + for (size_t i = 0; i < mVisibleOrder.size(); ++i) { + index = (index + direction + int(mVisibleOrder.size())) % int(mVisibleOrder.size()); + if (IsWindowEnabled(mButtons[mVisibleOrder[index]])) return mVisibleOrder[index]; + } + return -1; + } const int count = int(mButtons.size()); for (int i = 0; i < count; ++i) { position = (position + direction + count) % count; @@ -117,6 +165,7 @@ class WindowsUiFrame { } 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_LBUTTONDOWN || message == WM_RBUTTONDOWN) self->mKeyboard = false; if (message == WM_MOUSEMOVE && IsWindowEnabled(window) && self->mHotButton != window) { if (self->mHotButton) InvalidateRect(self->mHotButton, nullptr, FALSE); self->mHotButton = window; @@ -155,11 +204,14 @@ class WindowsUiFrame { draw.CtlType = ODT_MENU; draw.itemData = self->Info(index).dwItemData; if (self->mHotButton == draw.hwndItem) draw.itemState |= ODS_HOTLIGHT; if (self->mTracking && self->mOpen == index) draw.itemState |= ODS_SELECTED; + if (self->Info(index).fState & MFS_CHECKED) draw.itemState |= ODS_CHECKED; if (!self->mKeyboard) draw.itemState |= ODS_NOACCEL; WindowsUiBufferedPaint paint(draw.hDC,draw.rcItem,WindowsUiColor::Window); draw.hDC = paint.Dc(); if (!self->mMenus->Draw(&draw)) FillRect(draw.hDC, &draw.rcItem, WindowsUiColorBrush(WindowsUiColor::Window)); - if (draw.itemState & ODS_FOCUS) { InflateRect(&draw.rcItem, -2, -2); DrawFocusRect(draw.hDC, &draw.rcItem); } + if (self->mKeyboard && !(draw.itemState & ODS_DISABLED) && (draw.itemState & ODS_FOCUS)) { + InflateRect(&draw.rcItem, -2, -2); DrawFocusRect(draw.hDC, &draw.rcItem); + } return TRUE; } } @@ -173,7 +225,17 @@ class WindowsUiFrame { if (code == MSGF_MENU && self) { const auto* message = reinterpret_cast(lp); int next = -1; - if (message->message == WM_MOUSEMOVE) next = self->ButtonAt(message->pt); + const int hit = self->ButtonAt(message->pt); + // Hover switches popups, never executes toolbar-like commands. + if (message->message == WM_MOUSEMOVE && hit >= 0 && self->Info(hit).hSubMenu) next = hit; + if (message->message == WM_LBUTTONDOWN && hit >= 0 && !self->Info(hit).hSubMenu) { + self->mPressedMenuButton = hit; return 1; + } + if (message->message == WM_LBUTTONUP && self->mPressedMenuButton >= 0) { + const int pressed = self->mPressedMenuButton; self->mPressedMenuButton = -1; + if (hit != pressed) return 1; + next = hit; self->mActivateNext = true; + } 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); @@ -189,22 +251,34 @@ class WindowsUiFrame { WindowsUiFrame* previous = Tracking(); Tracking() = this; HHOOK hook = SetWindowsHookExW(WH_MSGFILTER, MenuFilter, nullptr, GetCurrentThreadId()); UINT command = 0; + bool activate = true; + mPressedMenuButton = -1; do { - mOpen = position; mNext = -1; mSelectedMenu = Info(position).hSubMenu; mSelectedFlags = 0; - if (keyboard) SetFocus(mButtons[position]); + mOpen = position; mNext = -1; mActivateNext = false; mSelectedMenu = Info(position).hSubMenu; mSelectedFlags = 0; + int anchor = position; + if (mOverflow && mButtonRects[position].right == mButtonRects[position].left) + for (size_t i = 0; i < mButtons.size(); ++i) if (Info(int(i)).hSubMenu == mOverflow) anchor = int(i); + if (keyboard) SetFocus(mButtons[anchor]); const auto item = Info(position); - if (!item.hSubMenu) { command = item.wID; break; } - RECT rect = mButtonRects[position]; MapWindowPoints(mBar, nullptr, reinterpret_cast(&rect), 2); + if (!item.hSubMenu) { + if (activate) command = item.wID; + else { mKeyboard = true; SetFocus(mButtons[position]); } + break; + } + if (item.hSubMenu == mOverflow) PopulateOverflow(); + RECT rect = mButtonRects[anchor]; 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); + if (item.hSubMenu == mOverflow) ClearOverflow(); InvalidateRect(mButtons[position], nullptr, FALSE); position = mNext; + activate = mActivateNext; } while (!command && position >= 0 && !mDeferredClose && IsWindow(mWindow)); if (hook) UnhookWindowsHookEx(hook); - Tracking() = previous; mTracking = false; mOpen = -1; - if (command || !keyboard) RestoreFocus(); + Tracking() = previous; mTracking = false; mOpen = -1; mPressedMenuButton = -1; + if (command || !mKeyboard) RestoreFocus(); if (IsWindow(mWindow)) { for (HWND button : mButtons) InvalidateRect(button, nullptr, FALSE); if (mDeferredClose) { mDeferredClose = false; PostMessageW(mWindow, WM_CLOSE, 0, 0); } @@ -214,6 +288,7 @@ class WindowsUiFrame { public: ~WindowsUiFrame() { Destroy(); } void Destroy() { + ClearOverflow(); // 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); @@ -227,6 +302,14 @@ class WindowsUiFrame { int TitleHeight() const { return mTitleHeight; } HWND MenuHost() const { return mBar; } const std::vector& MenuRects() const { return mButtonRects; } + const std::vector& OverflowPositions() const { return mOverflowPositions; } + // Viewer keeps its right-aligned zoom group intact when narrowing a window. + // Opt-in so Comparator retains its existing wrapping behavior. + void KeepTrailingMenuGroupTogether() { mKeepTrailingMenuGroupTogether = true; } + void ConfigureSingleRow(HMENU overflow, const std::vector& rules) { + ClearOverflow(); mOverflow = overflow; mMenuRules = rules; + if (Initialized()) Layout(); + } // Availability changes do not recreate controls, relayout, or restart media. void SyncEnabledStates() { if (!mMenu || !mBar) return; @@ -268,7 +351,9 @@ class WindowsUiFrame { // Callers may measure normal text independently of the user's enlargement. int SingleRowMenuWidth(UINT dpi, double scale) { int width = 0; - for (int button : MeasureMenuWidths(dpi, scale)) width += button; + const auto widths = MeasureMenuWidths(dpi, scale); + for (size_t i = 0; i < widths.size(); ++i) + if (!mOverflow || Info(int(i)).hSubMenu == mOverflow || Rule(int(i)).priority < 100) width += widths[i]; return width; } void Initialize(HWND window, WindowsUiMenus& menus) { @@ -321,11 +406,38 @@ class WindowsUiFrame { 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(32), WindowsUiPixels(20, dpi, scale) + Px(8)); + const int rowHeight = mOverflow ? (std::max)(Px(36), WindowsUiPixels(20, dpi, scale) + Px(12)) : + (std::max)(Px(32), WindowsUiPixels(20, dpi, scale) + Px(8)); mButtonRects.resize(mButtons.size()); int left = 0, top = 0; const auto widths = MeasureMenuWidths(dpi, scale); + if (mOverflow && mCustom) { + // Measure at the real font/DPI/text scale. Overflow lower-priority + // actions before changing row height or shrinking any labels. + int more = -1; + std::vector items; + for (size_t i = 0; i < mButtons.size(); ++i) { + mButtonRects[i] = {}; + if (Info(int(i)).hSubMenu == mOverflow) more = int(i); + const auto rule = Rule(int(i)); items.push_back({widths[i], rule.order, rule.priority}); + } + auto plan = PlanWindowsUiCommandRow(items, more, width); + mVisibleOrder = std::move(plan.visible); mOverflowPositions = std::move(plan.overflow); + for (int index : mVisibleOrder) { + const int w = (std::min)((std::max)(1, width - left), widths[index]); + mButtonRects[index] = {left, 0, left + w, rowHeight}; left += w; + } + } else { + size_t firstRight = mButtons.size(); + int trailingWidth = 0; for (size_t i = 0; i < mButtons.size(); ++i) { + if (firstRight == mButtons.size() && (Info(int(i)).fType & MFT_RIGHTJUSTIFY)) firstRight = i; + if (i >= firstRight) trailingWidth += widths[i]; + } + for (size_t i = 0; i < mButtons.size(); ++i) { + if (mKeepTrailingMenuGroupTogether && i == firstRight && trailingWidth <= width && left && left + trailingWidth > width) { + left = 0; top += rowHeight; + } const int buttonWidth = (std::min)((std::max)(1, width), widths[i]); if (left && left + buttonWidth > width) { left = 0; top += rowHeight; } mButtonRects[i] = {left, top, left + buttonWidth, top + rowHeight}; left += buttonWidth; @@ -339,17 +451,32 @@ class WindowsUiFrame { 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]; + const bool show = rect.right > rect.left; + if (!show && GetFocus() == mButtons[i]) RestoreFocus(); + ShowWindow(mButtons[i], show ? SW_SHOWNA : SW_HIDE); RECT current; GetWindowRect(mButtons[i], ¤t); MapWindowPoints(nullptr, mBar, reinterpret_cast(¤t), 2); if (!EqualRect(&rect, ¤t)) MoveWindow(mButtons[i], rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, TRUE); } + if (mOverflow && mCustom) { + // Native accessibility enumerates HWND sibling order. Keep that order + // consistent with the visual row and keyboard left/right traversal. + HWND previous = nullptr; + for (int index : mVisibleOrder) { + if (GetWindow(mButtons[index], GW_HWNDPREV) != previous) + SetWindowPos(mButtons[index], previous ? previous : HWND_TOP, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + previous = mButtons[index]; + } + } RECT title = {0, 0, width, mTitleHeight}; InvalidateRect(mWindow, &title, FALSE); } void Extend() { @@ -481,7 +608,17 @@ class WindowsUiFrame { 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 (IsWindowVisible(mButtons[index])) { SetFocus(mButtons[index]); Open(index, true); } + else { + // Preserve existing Alt access keys even after an item overflows. + for (size_t i = 0; i < mButtons.size(); ++i) if (Info(int(i)).hSubMenu == mOverflow) { + SetFocus(mButtons[i]); + if (Info(index).hSubMenu) { Open(index, true); } + else { PostMessageW(mWindow, WM_COMMAND, Info(index).wID, 0); RestoreFocus(); } + break; + } + } + return true; } } if (message->message != WM_KEYDOWN) return false; diff --git a/QVisionCore/Q1UiMenuWin.h b/QVisionCore/Q1UiMenuWin.h index 3db5731..51afc31 100644 --- a/QVisionCore/Q1UiMenuWin.h +++ b/QVisionCore/Q1UiMenuWin.h @@ -134,11 +134,12 @@ class WindowsUiMenus { SelectObject(draw->hDC, Font(item)); SetBkMode(draw->hDC, TRANSPARENT); const bool disabled = (draw->itemState & (ODS_DISABLED | ODS_GRAYED)) != 0; const bool selected = !disabled && (draw->itemState & (ODS_SELECTED | ODS_HOTLIGHT)) != 0; - const auto background = item->bar ? WindowsUiCommandBackground(!disabled, + const bool checkedCommand = item->bar && !disabled && (draw->itemState & ODS_CHECKED); + const auto background = checkedCommand ? WindowsUiColor::Selection : item->bar ? WindowsUiCommandBackground(!disabled, selected, (draw->itemState & ODS_SELECTED) != 0, WindowsUiColor::Window) : selected ? WindowsUiColor::Selection : WindowsUiColor::Surface; FillRect(draw->hDC, &draw->rcItem, WindowsUiColorBrush(background)); - const auto foreground = selected ? (WindowsUiAppearanceState().HighContrast() ? WindowsUiColor::OnAccent : WindowsUiColor::Accent) : + const auto foreground = selected || checkedCommand ? (WindowsUiAppearanceState().HighContrast() ? WindowsUiColor::OnAccent : WindowsUiColor::Accent) : disabled ? WindowsUiColor::Muted : WindowsUiColor::Text; SetTextColor(draw->hDC, WindowsUiColorValue(foreground)); if (disabled && WindowsUiAppearanceState().HighContrast()) SetTextColor(draw->hDC, GetSysColor(COLOR_GRAYTEXT)); @@ -177,13 +178,6 @@ class WindowsUiMenus { const auto label = item->text.substr(0, tab); const bool readOnlyStatus = item->bar && mReadOnlyStatus && GetMenuItemID(item->menu, item->position) == mReadOnlyStatus; const bool rightAlignedStatus = readOnlyStatus && (item->type & MFT_RIGHTJUSTIFY); - if (item->bar && disabled && !readOnlyStatus) { - RECT edge = draw->rcItem; InflateRect(&edge, -2, -2); - HPEN pen = CreatePen(PS_DASH, 1, GetTextColor(draw->hDC)); - auto oldPen = SelectObject(draw->hDC, pen), oldBrush = SelectObject(draw->hDC, GetStockObject(NULL_BRUSH)); - Rectangle(draw->hDC, edge.left, edge.top, edge.right, edge.bottom); - SelectObject(draw->hDC, oldPen); SelectObject(draw->hDC, oldBrush); DeleteObject(pen); - } DrawTextW(draw->hDC, label.c_str(), -1, &rect, flags | (rightAlignedStatus ? DT_RIGHT : DT_LEFT)); if (tab != std::wstring::npos) DrawTextW(draw->hDC, item->text.c_str() + tab + 1, -1, &rect, flags | DT_RIGHT | DT_NOPREFIX); diff --git a/Tests/ComparerTypographyIntegrationTests.cpp b/Tests/ComparerTypographyIntegrationTests.cpp index dfb9fff..73f97ae 100644 --- a/Tests/ComparerTypographyIntegrationTests.cpp +++ b/Tests/ComparerTypographyIntegrationTests.cpp @@ -256,6 +256,8 @@ void CheckSourceCommandButton() command.SendMessage(WM_MOUSEMOVE, 0, MAKELPARAM(20, 20)); Check(::GetCapture() != command.m_hWnd, "source-command hover does not capture the pointer"); command.SendMessage(WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(20, 20)); + Check((command.SendMessage(WM_QUERYUISTATE) & UISF_HIDEFOCUS)!=0, + "mouse activation hides the keyboard-only source focus cue"); Check(host.opened == 0, "source command does not activate on mouse-down"); host.SetTimer(119, 1500, nullptr); command.SendMessage(WM_LBUTTONUP, 0, MAKELPARAM(20, 20)); Pump(40); host.KillTimer(119); @@ -267,6 +269,8 @@ void CheckSourceCommandButton() host.SetTimer(119, 1500, nullptr); command.SendMessage(WM_KEYUP, VK_SPACE); Pump(40); host.KillTimer(119); Check(host.opened == 2 && host.executed == 2, "held Space activates once on release without duplicate command notification"); + Check((command.SendMessage(WM_QUERYUISTATE) & UISF_HIDEFOCUS)==0, + "keyboard activation exposes the native source focus cue"); host.SetTimer(119, 1500, nullptr); command.SendMessage(WM_KEYDOWN, VK_RETURN); Pump(40); host.KillTimer(119); Check(host.opened == 3 && host.executed == 3, "Enter activates the same existing popup command once"); @@ -276,6 +280,20 @@ void CheckSourceCommandButton() command.EnableWindow(FALSE); command.SendMessage(BM_CLICK); host.SendMessage(WM_COMMAND, MAKEWPARAM(0xffff, BN_CLICKED), reinterpret_cast(command.m_hWnd)); Pump(20); Check(host.opened == 3 && host.executed == 3, "disabled control rejects native clicks and forged notifications"); + { + ScopedWindowsUiPaintTestTheme restoreTheme; + for (auto theme : {q1view::WindowsUiAppearance::Light,q1view::WindowsUiAppearance::Dark}) { + q1view::WindowsUiAppearanceState().SetAppearance(theme); + command.Invalidate(FALSE); command.UpdateWindow(); + const auto pixels=Pixels(command.m_hWnd); + CRect bounds; command.GetClientRect(bounds); + const auto edge=q1view::WindowsUiColorValue(q1view::WindowsUiColor::Boundary); + const DWORD expected=(DWORD(GetRValue(edge))<<16)|(DWORD(GetGValue(edge))<<8)|GetBValue(edge); + bool solid=true; + for (int x=1;x(&accessible))), "source command exposes native accessibility metadata"); VARIANT self; VariantInit(&self); self.vt = VT_I4; self.lVal = CHILDID_SELF; @@ -424,8 +442,38 @@ int RunComparerTypographyTests() { const auto theme = q1view::WindowsUiAppearanceState().Appearance(); CRect themeBounds; frame->GetWindowRect(themeBounds); HMENU options = frame->mOptionsMenu.GetSafeHmenu(); - Check(frame->mUiMenus.Text(frame->GetMenu()->GetSafeHmenu(),5,true)==L"&Options" && GetMenuItemCount(options)==1 && - GetMenuItemID(options,0)==ID_OPTIONS_DIFF_RESOLUTION, "Comparer has no theme menu and retains its comparison-specific option"); + Check(frame->mUiMenus.Text(frame->GetMenu()->GetSafeHmenu(),5,true)==L"&Options" && GetMenuItemCount(options)==3 && + GetMenuItemID(options,0)==ID_OPTIONS_DIFF_RESOLUTION && GetMenuItemID(options,2)==ID_SELECTION_MODE, + "Comparer Options retains resolution policy and adds selection mode, without theme/sync/clear actions"); + { + const bool before=doc->mSelMode; + auto* active=frame->GetActiveView(); + auto checkSelection=[&] { + frame->SendMessage(WM_INITMENUPOPUP,reinterpret_cast(options),5); + Check(bool(GetMenuState(options,ID_SELECTION_MODE,MF_BYCOMMAND)&MF_CHECKED)==doc->mSelMode, + "Comparer Options selection check follows shared document state"); + for(int slot=0;slot<2;++slot) { + auto* paneView=doc->mPane[slot].pView; + CCmdUI state; state.m_nID=ID_SELECTION_MODE; state.m_nIndex=0; + state.m_nIndexMax=paneView->mMouseMenu.GetMenuItemCount(); state.m_pMenu=&paneView->mMouseMenu; + Check(static_cast(paneView)->OnCmdMsg(ID_SELECTION_MODE,CN_UPDATE_COMMAND_UI,&state,nullptr)!=FALSE, + "each pane routes selection command UI to the shared state"); + Check(bool(paneView->mMouseMenu.GetMenuState(ID_SELECTION_MODE,MF_BYCOMMAND)&MF_CHECKED)==doc->mSelMode, + "all pane context selection checks agree with Options"); + } + }; + frame->SetActiveView(timeline); timeline->SetFocus(); + frame->SendMessage(WM_COMMAND,ID_SELECTION_MODE); + Check(doc->mSelMode!=before,"Options selection toggles once even with Timeline active"); checkSelection(); + doc->mPane[1].pView->SendMessage(WM_COMMAND,ID_SELECTION_MODE); + Check(doc->mSelMode==before,"candidate pane context command toggles the same document mode"); checkSelection(); + frame->SetActiveView(active); Pump(50); + doc->mPane[0].pView->SendMessage(WM_KEYDOWN,'S',1); Pump(50); + Check(doc->mSelMode!=before,"Comparer S shortcut shares the Options/context mode"); checkSelection(); + doc->mPane[0].pView->SendMessage(WM_KEYDOWN,'S',1); Pump(50); + Check(doc->mSelMode==before && doc->GetSelectionRect(l,t,r,b) && l==2 && t==3 && r==10 && b==12, + "selection-mode changes preserve the shared ROI and restore the original mode"); + } frame->SendMessage(WM_COMMAND,q1view::ID_UI_APPEARANCE_SYSTEM+(UINT(theme)+1)%3); Pump(30); Check(q1view::WindowsUiAppearanceState().Appearance()==theme, "retired Comparer theme command cannot change shared preference"); auto SharedTheme = [&](unsigned choice) { diff --git a/Tests/GalleryIntegrationTests.cpp b/Tests/GalleryIntegrationTests.cpp index f108336..357da20 100644 --- a/Tests/GalleryIntegrationTests.cpp +++ b/Tests/GalleryIntegrationTests.cpp @@ -6,6 +6,7 @@ #include "../Viewer/ViewerDoc.h" #include "../Viewer/ViewerView.h" #include "../Viewer/FrmSrc.h" +#include "../Viewer/Y4mReaderWin.h" #include "../Viewer/ThumbnailPane.h" #include "../Viewer/GalleryGridCanvas.h" #include "QViewerCmn.h" @@ -98,6 +99,22 @@ struct GalleryIntegrationTests { CString singleRecycleLabel; singleMenu.GetMenuString(CThumbnailPane::CMD_RECYCLE, singleRecycleLabel, MF_BYCOMMAND); Require(singleMenu.GetMenuState(CThumbnailPane::CMD_COMPARE, MF_BYCOMMAND) == UINT(-1) && singleRecycleLabel == L"Move to Recycle Bin", "single-file menu uses singular Recycle Bin wording without Compare"); + { + q1view::WindowsUiMenus contextMenus; pane.mContextMenus=&contextMenus; + pane.SendMessage(WM_INITMENUPOPUP,reinterpret_cast(singleMenu.GetSafeHmenu())); + MENUITEMINFOW info={sizeof(info)}; info.fMask=MIIM_FTYPE|MIIM_DATA; + Require(GetMenuItemInfoW(singleMenu.GetSafeHmenu(),0,TRUE,&info) && (info.fType&MFT_OWNERDRAW), + "thumbnail context popup uses the same shared painting as Comparator menus"); + MEASUREITEMSTRUCT measure={}; measure.CtlType=ODT_MENU; measure.itemData=info.dwItemData; + Require(pane.SendMessage(WM_MEASUREITEM,0,reinterpret_cast(&measure)) && + measure.itemHeight>=UINT(q1view::WindowsUiPixels(32,q1view::WindowsUiDpi(pane.m_hWnd),q1view::WindowsUiSettings().Scale())), + "thumbnail context popup routes measurement through shared DPI-aware body-font sizing"); + HMENU names=GetSubMenu(singleMenu.GetSafeHmenu(),4); + pane.SendMessage(WM_INITMENUPOPUP,reinterpret_cast(names)); + Require(names && GetMenuItemInfoW(names,0,TRUE,&info) && (info.fType&MFT_OWNERDRAW), + "nested Copy path/name popup receives the same styling as its parent"); + pane.mContextMenus=nullptr; + } click(last, MK_CONTROL); Require(pane.SelectedMediaPaths().size() == 2, "MFC Ctrl-click selects disjoint media in both views"); CMenu compareTwo; pane.BuildContextMenu(compareTwo, first); @@ -301,7 +318,7 @@ struct GalleryIntegrationTests { CRect client; frame->GetClientRect(client); const SIZE available = { monitor.rcWork.right - monitor.rcWork.left - (initial.Width() - client.Width()), - monitor.rcWork.bottom - monitor.rcWork.top - (initial.Height() - client.Height()) - frame->mUiFrame.Height() + monitor.rcWork.bottom - monitor.rcWork.top - (initial.Height() - client.Height()) - frame->mUiFrame.Height() - frame->StatusHeight() }; const auto expected = q1view::ViewerDefaultContentSize(dpi, menuWidth, available); Require(content.Width() == expected.cx && content.Height() == expected.cy, @@ -320,7 +337,7 @@ struct GalleryIntegrationTests { frame->MoveWindow(initial.left, initial.top, q1view::WindowsUiPixels(320, dpi), initial.Height()); Pump(.1); bool wrapped = false; for (const auto& rect : frame->mUiFrame.MenuRects()) wrapped |= rect.top > 0; - Require(wrapped, "manual narrowing still wraps the application menu"); + Require(wrapped && frame->mUiFrame.OverflowPositions().empty(), "manual narrowing wraps the original menus without hiding commands in More"); frame->SizeDefaultWindow(); Pump(.1); CRect resizedContent; frame->GetContentRect(resizedContent); Require(resizedContent.Width() == expected.cx && resizedContent.Height() == expected.cy, @@ -356,9 +373,47 @@ struct GalleryIntegrationTests { Require(doc->mFrmSrc && !doc->mFrmSrc->isFixed() && doc->mW == 800 && doc->mH == 600 && doc->mOrigSceneSize == 720000 && doc->mFrames == 1, "unnamed RAW uses 800x600 pixels and correct YUV420 frame bytes/count"); + Require(AfxGetApp()->OpenDocumentFile(L"Tests\\fixtures\\sample_16x16.png") != nullptr, + "decoded photo opens before testing the return to editable RAW menus"); + frame->RefreshCommandStates(true); + Require(AfxGetApp()->OpenDocumentFile(named) != nullptr,"explicit RAW opens after a decoded photo"); + frame->RefreshCommandStates(); Pump(.05); + for(UINT position : {1u,2u}) { + Require(!(frame->GetMenu()->GetMenuState(position,MF_BYPOSITION)&(MF_DISABLED|MF_GRAYED)) && + IsWindowEnabled(::GetDlgItem(frame->mUiFrame.MenuHost(),position+1)), + "returning from a decoded photo enables the actual RAW root menu button"); + auto* popup=frame->GetMenu()->GetSubMenu(position); + frame->SendMessage(WM_INITMENUPOPUP,reinterpret_cast(popup->GetSafeHmenu()),position); + Require(!(popup->GetMenuState(0,MF_BYPOSITION)&(MF_DISABLED|MF_GRAYED)), + "opening a RAW popup retains enabled interpretation commands after native MFC updates"); + } + auto requireHostedLabel=[&](UINT position,const CString& expected) { + wchar_t label[256]={}; GetWindowTextW(::GetDlgItem(frame->mUiFrame.MenuHost(),position+1),label,_countof(label)); + Require(expected==label,"RAW menu command updates its actual hosted label as well as document state"); + }; + for(int i=0;iSendMessage(WM_COMMAND,ID_CS_START+i); Pump(.05); + Require(doc->mColorSpace==QIMAGE_CS_NV12,"RAW color-space menu command actually changes interpretation"); + requireHostedLabel(2,L"NV12"); + } + for(int i=0;iSendMessage(WM_COMMAND,ID_CS_START+i); Pump(.05); + Require(doc->mColorSpace==QIMAGE_CS_YUV420,"RAW color-space menu can restore YUV420"); + } Require(AfxGetApp()->OpenDocumentFile(named) != nullptr && doc->mW == 16 && doc->mH == 16 && doc->mFrames == 1, "explicit RAW filename dimensions override the new default"); - frame->ApplyResolution(320, 240); + frame->ApplyResolution(800,600); + Require(AfxGetApp()->OpenDocumentFile(unnamed)!=nullptr,"full-sized RAW fixture reopens before resolution command"); + for(int i=0;iSendMessage(WM_COMMAND,ID_RESOLUTION_START+i); + } + Require(doc->mW==320 && doc->mH==240,"RAW resolution menu command actually changes interpreted dimensions"); + Await([&]{return view->mStableRgbBufferInfo.addr && doc->mDocState!=DOC_JUSTLOAD;},"reinterpreted RAW frame is presented"); + Require(view->mW==320 && view->mH==240 && doc->mFrames==6, + "resolution command updates the displayed RAW dimensions and recalculates its frame count"); + requireHostedLabel(1,L"320&x240"); Require(AfxGetApp()->OpenDocumentFile(unnamed) != nullptr && doc->mW == 320 && doc->mH == 240, "unnamed RAW continues to honor a manually selected resolution"); frame->ApplyResolution(800, 600); @@ -422,16 +477,432 @@ struct GalleryIntegrationTests { CheckWindowsUiErase(view->m_hWnd,q1view::WindowsUiColor::Canvas,false,check); } } + void AutoplayNavigationTests(CMainFrame* frame) { + auto* view = static_cast(frame->GetActiveView()); + auto* doc = view->GetDocument(); + const CString previous = doc->mPathName; + CRect originalWindow; frame->GetWindowRect(originalWindow); + struct RestoreAutoplay { + bool enabled = theApp.IsVideoAutoplayEnabled(); + ~RestoreAutoplay() { theApp.SetVideoAutoplayEnabled(enabled); } + } restoreAutoplay; + wchar_t temp[MAX_PATH]; GetTempPathW(MAX_PATH, temp); + CString root; root.Format(L"%sQ1View-autoplay-nav-%lu-%llu\\", temp, GetCurrentProcessId(), GetTickCount64()); + Require(CreateDirectoryW(root, nullptr) != FALSE, "isolated autoplay navigation folder created"); + const CString photo = root + L"a.png", first = root + L"b.avi", second = root + L"c.avi"; + Require(q1::imwriteW(photo.GetString(), cv::Mat(64,96,CV_8UC3,cv::Scalar(40,80,120))), "autoplay navigation photo generated"); + { + cv::VideoWriter writer(std::string(CW2A(first,CP_UTF8)), cv::VideoWriter::fourcc('M','J','P','G'),30.0,cv::Size(96,64)); + Require(writer.isOpened(), "autoplay navigation video writer opened"); + for (int i=0;i<150;++i) writer.write(cv::Mat(64,96,CV_8UC3,cv::Scalar(i%256,(i*3)%256,(i*7)%256))); + } + Require(CopyFileW(first, second, TRUE) != FALSE, "second autoplay navigation video generated"); + const CString raw=root+L"d_96x64_yuv420_30fps.yuv", single=root+L"e_96x64_yuv420.yuv", + lastRaw=root+L"f_96x64_yuv420_30fps.yuv"; + auto writeRaw=[&](const CString& path,int frames) { + CFile file; Require(file.Open(path,CFile::modeCreate|CFile::modeWrite|CFile::typeBinary)!=FALSE, + "RAW autoplay navigation fixture opened for writing"); + std::vector pixels(96*64*3/2,128); + for(int i=0;iOpenDocumentFile(photo) != nullptr, "navigation starts on a still image"); + Await([&] { return view->mStableRgbBufferInfo.addr && doc->mDocState != DOC_JUSTLOAD; }, "navigation photo presented"); + auto sendKey = [&](UINT key) { + Await([&] { return !view->mKeyProcessing && doc->mDocState != DOC_JUSTLOAD; }, "view is ready for the next user navigation key"); + view->SendMessage(WM_KEYDOWN,key,1); + view->SendMessage(WM_KEYUP,key,1 | (1L<<30) | (1L<<31)); + }; + auto navigate = [&](UINT key, const CString& expected, bool playing) { + CRect before; frame->GetWindowRect(before); + sendKey(key); + Require(doc->mPathName.CompareNoCase(expected)==0, "file navigation selects the expected adjacent source"); + Await([&] { return view->mStableRgbBufferInfo.addr && doc->mDocState != DOC_JUSTLOAD; }, "adjacent source first frame presented"); + if (playing) Await([&] { return view->mIsPlaying && view->mStableRgbBufferInfo.ID>0; }, + "adjacent multi-frame source autoplays and advances when the setting is enabled"); + else { + Pump(.15); + Require(!view->mIsPlaying && view->mStableRgbBufferInfo.ID==0 && + !doc->mAutoplayAfterPresent && !doc->mAutoplayMessagePending, + "disabled autoplay or a single-frame source remains paused without a stale autoplay request"); + } + CRect after; frame->GetWindowRect(after); + Require(before==after, "user file navigation keeps the existing outer window size"); + Require(doc->mCurFrameID==view->mStableRgbBufferInfo.ID && (view->mHProgress>0)==(doc->mFrames>1) && + frame->mStatusBar.Text().find(L"Frame")==std::wstring::npos, + "multi-frame sources retain their progress bar without duplicating frame information in the status line"); + }; + navigate(VK_NEXT,first,true); // still -> video + navigate(VK_NEXT,second,true); // playing video -> next video + view->ToggleFullScreen(); Pump(.1); + Require(view->IsFullScreen() && !frame->GetMenu(), "full-screen navigation starts without menu chrome"); + navigate(VK_PRIOR,first,true); + Require(view->IsFullScreen() && !frame->GetMenu(), "previous-file autoplay retains full-screen mode and hidden menu"); + navigate(VK_NEXT,second,true); + Require(view->IsFullScreen() && !frame->GetMenu(), "next-file autoplay retains full-screen mode and hidden menu"); + view->ToggleFullScreen(); Pump(.1); + navigate(VK_PRIOR,first,true); // playing video -> previous video + view->KillPlayTimerSafe(); + navigate(VK_NEXT,second,true); // manual pause does not disable the preference + theApp.SetVideoAutoplayEnabled(false); + navigate(VK_PRIOR,first,false); + navigate(VK_NEXT,second,false); + theApp.SetVideoAutoplayEnabled(true); + Require(doc->ReloadDocument()!=FALSE, "internal reload succeeds with autoplay preference enabled"); + Pump(.2); + Require(!view->mIsPlaying && !doc->mAutoplayAfterPresent && !doc->mAutoplayMessagePending, + "internal reload does not turn a paused video into an explicit autoplay open"); + doc->Rotate90(); Pump(.2); + Require(!view->mIsPlaying && !doc->mAutoplayAfterPresent && !doc->mAutoplayMessagePending, + "rotating a paused video does not start autoplay"); + sendKey(VK_PRIOR); + Await([&] { return view->mIsPlaying; }, "previous video plays before moving back to a photo"); + sendKey(VK_PRIOR); + Require(doc->mPathName.CompareNoCase(photo)==0, "navigation returns to the still image"); + Pump(.2); + Require(!view->mIsPlaying && !doc->mAutoplayAfterPresent && !doc->mAutoplayMessagePending, + "moving from video to photo cannot consume a stale autoplay request"); + navigate(VK_NEXT,first,true); navigate(VK_NEXT,second,true); + navigate(VK_NEXT,raw,true); + Require(!doc->mFrmSrc->isFixed() && doc->mFrames==150,"navigation opens a multi-frame RAW source, not a fixed video decoder"); + navigate(VK_NEXT,single,false); navigate(VK_NEXT,lastRaw,true); + view->KillPlayTimerSafe(); navigate(VK_PRIOR,single,false); navigate(VK_PRIOR,raw,true); + view->ToggleFullScreen(); Pump(.05); + navigate(VK_PRIOR,second,true); navigate(VK_NEXT,raw,true); + view->ToggleFullScreen(); Pump(.05); + theApp.SetVideoAutoplayEnabled(false); + navigate(VK_PRIOR,second,false); navigate(VK_NEXT,raw,false); + theApp.SetVideoAutoplayEnabled(true); + Require(doc->ReloadDocument()!=FALSE,"RAW internal reload succeeds"); Pump(.1); + Require(!view->mIsPlaying && !doc->mAutoplayAfterPresent && !doc->mAutoplayMessagePending, + "RAW internal reload remains paused despite enabled autoplay"); + Require(AfxGetApp()->OpenDocumentFile(photo)!=nullptr,"a different source opens before the explicit RAW-open check"); + Require(AfxGetApp()->OpenDocumentFile(raw)!=nullptr,"explicit multi-frame RAW open succeeds"); + Await([&]{return view->mIsPlaying && view->mStableRgbBufferInfo.ID>0;},"explicit RAW open also autoplays after presentation"); + const CString reopen = previous.IsEmpty() ? CString(L"Tests\\fixtures\\sample_16x16.png") : previous; + Require(AfxGetApp()->OpenDocumentFile(reopen)!=nullptr, "original source restored after autoplay regression checks"); + frame->MoveWindow(originalWindow); + Pump(.1); + Require(DeleteFileW(photo) && DeleteFileW(first) && DeleteFileW(second) && + DeleteFileW(raw) && DeleteFileW(single) && DeleteFileW(lastRaw) && RemoveDirectoryW(root), + "autoplay fixtures cleaned up after releasing all decoder handles"); + } + + void Y4mInterpretationTests(CMainFrame* frame,CViewerView* view,CViewerDoc* doc) { + const CString previous=doc->mPathName; + CRect bounds; frame->GetWindowRect(bounds); + struct Restore { bool autoplay=theApp.IsVideoAutoplayEnabled(); ~Restore(){theApp.SetVideoAutoplayEnabled(autoplay);} } restore; + theApp.SetVideoAutoplayEnabled(false); + wchar_t temp[MAX_PATH]; GetTempPathW(MAX_PATH,temp); + CString root; root.Format(L"%sQ1View-y4m-%lu-%llu\\",temp,GetCurrentProcessId(),GetTickCount64()); + Require(CreateDirectoryW(root,nullptr)!=FALSE,"isolated Y4M test directory created"); + const CString path=root+L"framed_96x64.yuv", malformed=root+L"malformed.y4m"; + { + CFile file; Require(file.Open(path,CFile::modeCreate|CFile::modeWrite|CFile::typeBinary)!=FALSE,"Y4M fixture opened"); + const std::string header="YUV4MPEG2 W96 H64 F30000:1001 Ip A1:1\n"; + file.Write(header.data(),UINT(header.size())); + std::vector pixels(96*64*3/2,128); + for(int i=0;i<90;++i) { + const std::string marker=i%2 ? "FRAME Xtest="+std::to_string(i)+"\n" : "FRAME\n"; + file.Write(marker.data(),UINT(marker.size())); + std::fill(pixels.begin(),pixels.begin()+96*64,BYTE(40+i)); + file.Write(pixels.data(),UINT(pixels.size())); + } + } + auto verify=[&](const CString& input,bool generated) { + Require(AfxGetApp()->OpenDocumentFile(input)!=nullptr,"framed YUV opens through the ordinary document route"); + Await([&]{return view->mStableRgbBufferInfo.addr && doc->mDocState!=DOC_JUSTLOAD;},"framed YUV first payload is presented"); + Require(doc->mFrmSrc && !doc->mFrmSrc->isFixed() && doc->mFrmSrc->RawLayout() && doc->mFrames>1, + "playable Y4M is indexed RAW input rather than a locked decoded-video source"); + frame->RefreshCommandStates(); + Require(frame->IsCommandEnabled(ID_RESOLUTION_START) && frame->IsCommandEnabled(ID_CS_START) && + IsWindowEnabled(::GetDlgItem(frame->mUiFrame.MenuHost(),2)) && IsWindowEnabled(::GetDlgItem(frame->mUiFrame.MenuHost(),3)), + "playable framed RAW exposes enabled resolution and color-space buttons"); + const int width=doc->mW,height=doc->mH; const long count=doc->mFrames; + Require(doc->mColorSpace==QIMAGE_CS_YUV420 && std::abs(doc->mFps-30000.0/1001)<.00001, + "Y4M header supplies native YUV420 and rational timing instead of decoded BGR"); + if(generated) Require(count==90 && width==96 && height==64 && doc->mOrigBuf[0]==40, + "variable FRAME markers are excluded from native pixel data and frame count"); + Require(doc->SeekScene(17)>=0,"indexed framed RAW seeks to frame 17"); Pump(.05); + if(generated) Require(doc->mOrigBuf[0]==57,"seeking skips variable-length markers and reads the correct raw payload"); + auto format=[&](QIMAGE_CS cs) { + for(int i=0;iSendMessage(WM_COMMAND,ID_CS_START+i); + Pump(.05); Require(doc->mColorSpace==cs,"framed RAW color-space menu changes actual interpretation"); + }; + format(QIMAGE_CS_NV12); + view->SendMessage(WM_KEYDOWN,VK_SPACE,1); + Await([&]{return view->mIsPlaying && view->mStableRgbBufferInfo.ID>0;},"reinterpreted framed RAW plays through indexed worker reads"); + frame->SendMessage(WM_COMMAND,ID_RESOLUTION_START+1); Pump(.05); // 320x240 + Require(doc->mW==320 && doc->mH==240 && doc->mFrames==count && !view->mIsPlaying, + "resolution can change during RAW playback safely, retaining container frame boundaries"); + const auto bytes=doc->mFrmSrc->RawLayout()->payloadBytes; + Require(doc->mOrigSceneSize>bytes && doc->mOrigBuf[bytes]==0 && doc->mOrigBuf[doc->mOrigSceneSize-1]==0, + "oversized reinterpretation pads missing pixels instead of reading frame headers or the next image"); + frame->ApplyResolution(width,height); format(QIMAGE_CS_YUV420); + theApp.SetVideoAutoplayEnabled(true); + Require(AfxGetApp()->OpenDocumentFile(L"Tests\\fixtures\\sample_16x16.png")!=nullptr,"still source opens before framed RAW autoplay"); + Require(AfxGetApp()->OpenDocumentFile(input)!=nullptr,"framed RAW is opened again as a new source"); + Await([&]{return view->mIsPlaying && view->mStableRgbBufferInfo.ID>0;},"framed RAW autoplays with editable native interpretation"); + view->KillPlayTimerSafe(); theApp.SetVideoAutoplayEnabled(false); + }; + verify(path,true); + wchar_t real[32768]={}; + if(GetEnvironmentVariableW(L"Q1VIEW_Y4M_TEST_FILE",real,_countof(real))) verify(real,false); + for(const std::string& data : {std::string("YUV4MPEG2 W0 H64\nFRAME\n"),std::string("YUV4MPEG2 W2 H2 C444\nFRAME\n"), + std::string("YUV4MPEG2 W2 H2\nFRAME\n12"),std::string("YUV4MPEG2 W2147483647 H2147483647 C420p10\nFRAME\n")}) { + CFile file; Require(file.Open(malformed,CFile::modeCreate|CFile::modeReadWrite|CFile::typeBinary)!=FALSE,"parser boundary fixture opened"); + file.Write(data.data(),UINT(data.size())); file.Seek(0,CFile::begin); + q1view::Y4mInfo info; + Require(!q1view::ParseY4m(file,info),"invalid dimensions, unsupported layouts and truncated payloads are rejected safely"); + } + Require(AfxGetApp()->OpenDocumentFile(previous)!=nullptr,"original source restored after framed RAW checks"); + frame->MoveWindow(bounds); Pump(.05); + Require(DeleteFileW(path) && DeleteFileW(malformed) && RemoveDirectoryW(root),"generated framed RAW fixtures released and cleaned up"); + } + + void InteractionMenuTests(CMainFrame* frame,CViewerView* view) { + auto* options=frame->GetMenu()->GetSubMenu(5); + const bool selected=view->mSelMode, synced=frame->IsSyncInputEnabled(); + const auto path=view->GetDocument()->mPathName; + const auto generation=view->GetDocument()->mOpenGeneration; + const auto pixels=view->mStableRgbBufferInfo.addr; + Require(options->GetMenuState(ID_SELECTION_MODE,MF_BYCOMMAND)!=UINT(-1) && + options->GetMenuState(ID_SYNC_INPUT,MF_BYCOMMAND)!=UINT(-1) && view->mMouseMenu.GetMenuItemCount()==2, + "Viewer exposes selection/sync in Options while retaining both context shortcuts"); + auto check=[&] { + frame->SendMessage(WM_INITMENUPOPUP,reinterpret_cast(options->GetSafeHmenu()),5); + view->SendMessage(WM_INITMENUPOPUP,reinterpret_cast(view->mMouseMenu.GetSafeHmenu())); + for(auto* menu : {options,&view->mMouseMenu}) { + Require(bool(menu->GetMenuState(ID_SELECTION_MODE,MF_BYCOMMAND)&MF_CHECKED)==view->mSelMode, + "Options and context selection checks follow the one actual Viewer mode"); + Require(bool(menu->GetMenuState(ID_SYNC_INPUT,MF_BYCOMMAND)&MF_CHECKED)==frame->IsSyncInputEnabled(), + "Options and context sync checks follow the same per-window setting"); + } + }; + check(); + if(frame->mpDrawer) frame->mpDrawer->SetFocus(); + frame->SendMessage(WM_COMMAND,ID_SELECTION_MODE); + Require(view->mSelMode!=selected,"Viewer Options selection command toggles exactly once while browser has focus"); check(); + view->SendMessage(WM_COMMAND,ID_SELECTION_MODE); + Require(view->mSelMode==selected,"Viewer context selection command uses the same toggle"); check(); + view->SendMessage(WM_KEYDOWN,'S',1); view->SendMessage(WM_KEYUP,'S',1); + Require(view->mSelMode!=selected,"existing S shortcut shares the Options selection state"); check(); + view->SendMessage(WM_KEYDOWN,'S',1); view->SendMessage(WM_KEYUP,'S',1); + frame->SendMessage(WM_COMMAND,ID_SYNC_INPUT); + Require(frame->IsSyncInputEnabled()!=synced,"Options toggles Viewer window synchronization exactly once"); check(); + view->SendMessage(WM_COMMAND,ID_SYNC_INPUT); + Require(frame->IsSyncInputEnabled()==synced && view->mSelMode==selected,"context shortcut restores the shared interaction state"); check(); + Require(view->GetDocument()->mPathName==path && view->GetDocument()->mOpenGeneration==generation && + view->mStableRgbBufferInfo.addr==pixels,"interaction menu changes do not reopen or replace the current source"); + view->SetFocus(); + } + + void CommandRefreshTests(CMainFrame* frame) { + frame->RefreshCommandStates(true); + const auto applied = frame->mCommandMenuRefreshCount; + const double begin = CGalleryGridCanvas::Now(); + for (int i=0;i<2000;++i) frame->RefreshCommandStates(); + const double cached = CGalleryGridCanvas::Now()-begin; + Require(frame->mCommandMenuRefreshCount==applied, "unchanged idle state performs no full menu enumeration or hosted-button synchronization"); + const double forceBegin = CGalleryGridCanvas::Now(); + for (int i=0;i<2000;++i) frame->RefreshCommandStates(true); + fprintf(report,"Command refresh / 2000 calls: cached %.3f ms; forced full update %.3f ms\n", cached*1000,(CGalleryGridCanvas::Now()-forceBegin)*1000); + Require(frame->mCommandMenuRefreshCount==applied+2000, "explicit menu initialization can still force a complete availability refresh"); + auto* view = static_cast(frame->GetActiveView()); + auto* doc = view->GetDocument(); + frame->UpdateCsLabel(CString(q1::image_find_cs_name(doc->mSortedCscInfo,doc->mColorSpace)).MakeUpper()); + frame->RefreshCommandStates(); + Require(frame->mCommandMenuRefreshCount==applied+2001, "rebuilding a source-format menu label invalidates cached menu availability"); + const auto root = frame->GetMenu()->GetSafeHmenu(); + const bool enabled = !(GetMenuState(root,2,MF_BYPOSITION)&(MF_GRAYED|MF_DISABLED)); + Require(enabled==frame->CommandContext().rawInput, "cached refresh does not leave a rebuilt fixed-source RAW menu enabled"); + } + void ViewerShellTests(CMainFrame* frame, CViewerView* view, CViewerDoc* doc) { + // A source change must refresh hosted labels independently of zoom. + frame->UpdateResolutionLabel(800,600); + frame->UpdateCsLabel(L"YUV420"); + frame->UpdateFpsLabel(1.0); + frame->DrawMenuBar(); + doc->UpdateMenu(); + CString expectedLabels[3]; + expectedLabels[0].Format(L"%d&x%d",doc->mW,doc->mH); + expectedLabels[1]=CString(q1::image_find_cs_name(doc->mSortedCscInfo,doc->mColorSpace)).MakeUpper(); + expectedLabels[2].Format(L"%.2ff&ps",doc->mFps); + for(int i=0;i<3;++i) { + wchar_t label[256]={}; + GetWindowTextW(::GetDlgItem(frame->mUiFrame.MenuHost(),i+2),label,_countof(label)); + Require(expectedLabels[i]==label, + "opening a source refreshes the actual resolution, format and FPS buttons without a top zoom item"); + } + CRect original; frame->GetWindowRect(original); + const CString path = doc->mPathName; + const auto generation = doc->mOpenGeneration; + const auto pixels = view->mStableRgbBufferInfo.addr; + const auto presented = view->mStableRgbBufferInfo.ID; + const auto selection = view->mSelRegions.size(); + const UINT dpi = q1view::WindowsUiDpi(frame->m_hWnd); + for (auto size : {CSize(640,480), CSize(960,600), CSize(1440,900)}) { + // The specified client dimensions, not an outer-window estimate. + CRect outer, client; frame->GetWindowRect(outer); frame->GetClientRect(client); + frame->SetWindowPos(nullptr, 0, 0, q1view::WindowsUiPixels(size.cx,dpi) + outer.Width()-client.Width(), + q1view::WindowsUiPixels(size.cy,dpi) + outer.Height()-client.Height(), SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); + Pump(.08); + frame->GetClientRect(client); + const auto& rects = frame->mUiFrame.MenuRects(); + Require(frame->mUiFrame.OverflowPositions().empty(), "Viewer keeps all original input menus directly accessible without More"); + for (size_t i=0; i=0 && r.top>=0 && r.right<=client.right && r.right>r.left, "all Viewer command targets remain visible within the measured wrapped rows"); + for(size_t j=i+1; jmStatusBar.Window(),status); frame->ScreenToClient(status); + frame->GetContentRect(content); frame->mwndSplitter.GetWindowRect(splitter); frame->ScreenToClient(splitter); + Require(status.bottom==client.bottom && status.Height()==frame->StatusHeight() && content.bottom==status.top && content==splitter, + "read-only status reserves exactly its measured height without covering the image"); + } + frame->MoveWindow(original); Pump(.1); + frame->UpdateViewerStatus(); + wchar_t accessible[1024] = {}; GetWindowTextW(frame->mStatusBar.Window(),accessible,_countof(accessible)); + Require(frame->mStatusBar.Text()==accessible && frame->mStatusBar.Text().find(L"%")!=std::wstring::npos, + "native read-only status exposes the full actual zoom text, independent of visual ellipsis"); + const auto rects = frame->mUiFrame.MenuRects(); + const int height = frame->mUiFrame.Height(); + auto verifyZoom=[&] { + CString expected; expected.Format(L"%.1f%%",double(view->mN)*100.0); + Require(frame->mStatusBar.ZoomText()==expected.GetString(), + "bottom-right magnification reports the real zoom without a duplicate menu item"); + const std::wstring sampling=view->GetScalingStatusLabel().GetString(); + Require(frame->mStatusBar.SamplingText()==sampling && frame->mStatusBar.Text()==sampling+L" | "+expected.GetString(), + "status retains the actual sampling mode and zoom only, excluding dimensions, color space, Fit/Manual and frame text"); + q1view::WindowsUiFontCache fonts; HDC dc=CreateCompatibleDC(nullptr); + const auto old=SelectObject(dc,fonts.Get(q1view::WindowsUiFontRole::Supporting,frame->mStatusBar.Window())); + RECT bounds,left,right; ::GetClientRect(frame->mStatusBar.Window(),&bounds); + frame->mStatusBar.TextRects(dc,bounds,left,right); + const int padding=q1view::WindowsUiPixels(8,q1view::WindowsUiDpi(frame->mStatusBar.Window())); + SIZE size={}; GetTextExtentPoint32W(dc,expected,expected.GetLength(),&size); + Require(left.left==padding && right.right==bounds.right-padding && left.right<=right.left && right.right-right.left>=size.cx, + "sampling stays left and the entire measured zoom stays right without overlap or clipping"); + SelectObject(dc,old); DeleteDC(dc); + Require(frame->mUiFrame.Height()==height,"changing magnification never changes menu height"); + const auto& current=frame->mUiFrame.MenuRects(); + for(size_t i=0;iGetClientRect(client); + Require(current.back().right==client.right,"status changes do not move the right-aligned Help menu"); + for(size_t i=0;iApplySyncInput(input); + }; + for(float zoom : {.67f,1.f,1.13f,2.f,9.99f,10.f,99.99f,128.f}) { + setZoom(zoom); Pump(.02); verifyZoom(); + } + setZoom(.99f); + CPoint wheel(view->mWCanvas/2,view->mHCanvas/2); view->ClientToScreen(&wheel); + view->SendMessage(WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),MAKELPARAM(wheel.x,wheel.y)); Pump(.03); + Require(view->mN==1.f,"the existing mouse-wheel zoom can still reach exact 100 percent without a reset button"); + verifyZoom(); + view->SendMessage(WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),MAKELPARAM(wheel.x,wheel.y)); Pump(.03); + Require(view->mN>1.f,"mouse wheel actually zooms beyond 100 percent"); verifyZoom(); + view->FitToWindow(); view->Invalidate(FALSE); Pump(.03); verifyZoom(); + const auto originalMode=view->GetScalingMode(); + for(auto mode : {q1::ImageScalingMode::Smooth,q1::ImageScalingMode::PixelExact,q1::ImageScalingMode::Auto}) { + view->SetScalingMode(mode,false,false); Pump(.03); verifyZoom(); + } + view->SetScalingMode(originalMode,false,false); Pump(.03); + Require(frame->mUiFrame.Height()==height && frame->mUiFrame.MenuRects().size()==rects.size(), "zoom status never changes command row height or control count"); + for(size_t i=0;imUiFrame.MenuRects()[i])!=FALSE,"zoom leaves Help and every command hit target fixed"); + Require(doc->mPathName==path && doc->mOpenGeneration==generation && view->mStableRgbBufferInfo.addr==pixels && + view->mStableRgbBufferInfo.ID==presented && view->mSelRegions.size()==selection, + "Viewer shell changes preserve source, presented frame, decoder generation and ROI selection"); + // The full suite below separately exercises manual pan anchors, E, + // playback and GPU recreation. These checks exercise live zoom labels. + } + void Run() { auto frame = static_cast(AfxGetMainWnd()); auto view = static_cast(frame->GetActiveView()); auto doc = static_cast(frame->GetActiveDocument()); Require(frame && view && doc, "real Viewer document/view created"); + { + const UINT selectionState=view->mMouseMenu.GetMenuState(ID_MOUSEMENU_START,MF_BYCOMMAND); + view->SendMessage(WM_INITMENUPOPUP,reinterpret_cast(view->mMouseMenu.GetSafeHmenu())); + MENUITEMINFOW info={sizeof(info)}; info.fMask=MIIM_FTYPE|MIIM_DATA|MIIM_STATE; + Require(GetMenuItemInfoW(view->mMouseMenu.GetSafeHmenu(),0,TRUE,&info) && (info.fType&MFT_OWNERDRAW), + "Viewer canvas right-click menu uses Comparator's shared menu renderer"); + MEASUREITEMSTRUCT measure={}; measure.CtlType=ODT_MENU; measure.itemData=info.dwItemData; + Require(view->SendMessage(WM_MEASUREITEM,0,reinterpret_cast(&measure)) && + measure.itemHeight>=UINT(q1view::WindowsUiPixels(32,q1view::WindowsUiDpi(view->m_hWnd),q1view::WindowsUiSettings().Scale())), + "Viewer context menu uses the same DPI-aware body font and row spacing as Comparator"); + Require((view->mMouseMenu.GetMenuState(ID_MOUSEMENU_START,MF_BYCOMMAND)&MF_CHECKED)==(selectionState&MF_CHECKED), + "context-menu styling preserves selection mode's native checked state"); + } + for(int i=0;iGetMenu()->GetMenuItemCount();++i) + { + Require(frame->GetMenu()->GetMenuItemID(i)!=ID_FILE_OPEN, "Viewer has no duplicate top-level Open command"); + Require(frame->GetMenu()->GetMenuItemID(i)!=ID_MAGNIFY,"magnification appears only in the bottom status, never in the menu bar"); + const auto label=frame->mUiMenus.Text(frame->GetMenu()->GetSafeHmenu(),i,true); + Require(label!=L"&View" && label!=L"&More", "Viewer has no separate View or More menu"); + } + Require(frame->GetMenu()->GetSubMenu(1)->GetSafeHmenu()==frame->mResolutionMenu.GetSafeHmenu() && + frame->GetMenu()->GetSubMenu(2)->GetSafeHmenu()==frame->mCsMenu.GetSafeHmenu() && + frame->GetMenu()->GetSubMenu(3)->GetSafeHmenu()==frame->mFpsMenu.GetSafeHmenu(), + "resolution, pixel format and FPS retain their original top-level positions"); + const int rootCount=frame->GetMenu()->GetMenuItemCount(); + Require(frame->GetMenu()->GetMenuItemID(4)==ID_TOGGLE_DRAWER && + frame->mUiMenus.Text(frame->GetMenu()->GetSafeHmenu(),5,true)==L"&Options", + "Browse appears immediately left of Options in root and native button order"); + Require(rootCount>=8 && frame->GetMenu()->GetMenuItemID(rootCount-1)==ID_VIEWER_HELP, + "Help is the last root item, with no zoom, Fit or 100 percent menu controls"); + Require(frame->GetMenu()->GetSubMenu(0)->GetMenuState(ID_FILE_OPEN,MF_BYCOMMAND)!=UINT(-1), + "File menu retains its original Open command"); + CMenu* fileMenu=frame->GetMenu()->GetSubMenu(0); + CMenu* settingsMenu=frame->GetMenu()->GetSubMenu(5); + Require(fileMenu->GetMenuState(ID_TOGGLE_DRAWER,MF_BYCOMMAND)==UINT(-1) && + fileMenu->GetMenuState(ID_AUTOPLAY_VIDEOS,MF_BYCOMMAND)==UINT(-1) && + settingsMenu->GetMenuState(ID_AUTOPLAY_VIDEOS,MF_BYCOMMAND)!=UINT(-1), + "File has no duplicate browser or autoplay setting; autoplay is available under Options"); + const bool autoplayBefore=theApp.IsVideoAutoplayEnabled(); + auto verifyAutoplayCheck=[&] { + frame->SendMessage(WM_INITMENUPOPUP,reinterpret_cast(settingsMenu->GetSafeHmenu()),5); + Require(bool(settingsMenu->GetMenuState(ID_AUTOPLAY_VIDEOS,MF_BYCOMMAND)&MF_CHECKED)==theApp.IsVideoAutoplayEnabled(), + "Options autoplay check reflects the unchanged saved preference and existing command handler"); + }; + verifyAutoplayCheck(); frame->SendMessage(WM_COMMAND,ID_AUTOPLAY_VIDEOS); + Require(theApp.IsVideoAutoplayEnabled()!=autoplayBefore,"moved autoplay command still toggles its original preference"); + verifyAutoplayCheck(); frame->SendMessage(WM_COMMAND,ID_AUTOPLAY_VIDEOS); verifyAutoplayCheck(); + Require(theApp.IsVideoAutoplayEnabled()==autoplayBefore,"autoplay preference is restored after isolated menu checks"); + InteractionMenuTests(frame,view); + std::vector accelerators(CopyAcceleratorTable(frame->m_hAccelTable,nullptr,0)); + CopyAcceleratorTable(frame->m_hAccelTable,accelerators.data(),int(accelerators.size())); + Require(std::any_of(accelerators.begin(),accelerators.end(),[](const ACCEL& key) { + return key.cmd==ID_FILE_OPEN && key.key=='O' && (key.fVirt&(FVIRTKEY|FCONTROL))==(FVIRTKEY|FCONTROL); + }), "Ctrl+O retains the original file-open command binding"); + Require(std::any_of(accelerators.begin(),accelerators.end(),[](const ACCEL& key) { + return key.cmd==ID_TOGGLE_DRAWER && key.key=='E' && (key.fVirt&FVIRTKEY) && !(key.fVirt&(FCONTROL|FALT|FSHIFT)); + }),"E still toggles Browse after removing its duplicate File entry"); frame->RefreshCommandStates(); const auto commandContext = frame->CommandContext(); Require(frame->IsCommandEnabled(ID_EDIT_PASTE) == bool(IsClipboardFormatAvailable(CF_DIB) || IsClipboardFormatAvailable(CF_DIBV5)), "paste menu policy reflects supported clipboard formats without changing the clipboard"); - Require(frame->IsCommandEnabled(ID_COMPARE) == !doc->mPathName.IsEmpty(), "compare availability follows an actual source file"); + Require(frame->IsCommandEnabled(ID_COMPARE), "main Compare remains enabled with or without a current file"); + for (int i=0;iGetMenu()->GetMenuItemCount();++i) if(frame->GetMenu()->GetMenuItemID(i)==ID_COMPARE) { + Require((frame->GetMenu()->GetMenuState(i,MF_BYPOSITION)&(MF_GRAYED|MF_DISABLED))==0 && + IsWindowEnabled(::GetDlgItem(frame->mUiFrame.MenuHost(),i+1)), + "the actual Compare menu and native button are enabled, including the empty startup window"); + } + Require(!frame->OpenComparatorFiles({}), "selection-based comparison still rejects an empty batch instead of launching a blank workspace"); + Require(!frame->OpenComparatorFiles(std::vector(5,L"unused.png")), "selection-based comparison still rejects more than four files"); + if(!commandContext.pixels) Require(frame->mStatusBar.ZoomText()==L"--" && frame->mStatusBar.SamplingText().empty(), + "empty input does not pretend to have a loaded 100 percent zoom"); const auto clipboardSequence = GetClipboardSequenceNumber(); if (!commandContext.pixels) { frame->SendMessage(WM_COMMAND, ID_EDIT_COPY); frame->OnEditCopy(); @@ -478,6 +949,15 @@ struct GalleryIntegrationTests { if (!frame->mDrawerVisible) frame->OnToggleDrawer(); Pump(.4); ChromeTests(frame, view); + ViewerShellTests(frame, view, doc); + CommandRefreshTests(frame); + AutoplayNavigationTests(frame); + Y4mInterpretationTests(frame,view,doc); + wchar_t navigationOnly[8] = {}; + if (GetEnvironmentVariableW(L"Q1VIEW_AUTOPLAY_NAVIGATION_ONLY", navigationOnly, _countof(navigationOnly))) { + fprintf(report,"Autoplay navigation-only run; full gallery/GPU suite is separate\n"); + return; + } auto& pane = *frame->mpDrawer; Require(pane.mPrivateFontLoaded && pane.mFontFamily == L"Pretendard Variable", "bundled Pretendard typography loaded for the MFC drawer"); @@ -557,9 +1037,11 @@ struct GalleryIntegrationTests { if (!captured.empty()) captureBytes.assign(captureBuffer,captureBuffer+captureSize); CRect appearanceBounds; frame->GetWindowRect(appearanceBounds); HMENU options = GetSubMenu(frame->GetMenu()->GetSafeHmenu(),5); - Require(frame->mUiMenus.Text(frame->GetMenu()->GetSafeHmenu(),5,true)==L"&Options" && options && GetMenuItemCount(options)==1 && - frame->mUiMenus.Text(options,0,true)==L"&Theme" && GetMenuItemCount(GetSubMenu(frame->GetMenu()->GetSafeHmenu(),4))==1, - "Viewer exposes only Options > Theme; View retains image scaling without appearance/background duplicates"); + Require(frame->mUiMenus.Text(frame->GetMenu()->GetSafeHmenu(),5,true)==L"&Options" && options && GetMenuItemCount(options)==7 && + frame->mUiMenus.Text(options,0,true)==L"&Image Scaling" && GetMenuItemCount(GetSubMenu(options,0))==3 && + GetMenuItemID(options,1)==ID_AUTOPLAY_VIDEOS && GetMenuItemID(options,3)==ID_SELECTION_MODE && + GetMenuItemID(options,4)==ID_SYNC_INPUT && frame->mUiMenus.Text(options,6,true)==L"&Theme", + "Options contains scaling, autoplay, interaction modes and Theme without unwanted zoom commands"); for (UINT command : {q1view::ID_UI_APPEARANCE_LIGHT,q1view::ID_UI_APPEARANCE_DARK, q1view::ID_UI_APPEARANCE_LIGHT,q1view::ID_UI_APPEARANCE_SYSTEM}) { if (command==q1view::ID_UI_APPEARANCE_DARK || command==q1view::ID_UI_APPEARANCE_SYSTEM) diff --git a/Tests/WindowsUiTypographyTests.cpp b/Tests/WindowsUiTypographyTests.cpp index 4065339..97b454f 100644 --- a/Tests/WindowsUiTypographyTests.cpp +++ b/Tests/WindowsUiTypographyTests.cpp @@ -7,6 +7,7 @@ #include "../QVisionCore/Q1UiAppearanceMenuWin.h" #include "../Comparator/AnalysisLayout.h" #include +#include static void Require(bool value, const char* message) { if (!value) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); } } @@ -51,12 +52,13 @@ static void TestCommandStates() context.clipboardImage = (mask & 4) != 0; context.file = (mask & 8) != 0; context.selection = (mask & 16) != 0; for (const auto& item : {std::make_pair(WindowsUiCommand::RawInput, context.rawInput), std::make_pair(WindowsUiCommand::CopyPixels, context.pixels), std::make_pair(WindowsUiCommand::PasteImage, context.clipboardImage), - std::make_pair(WindowsUiCommand::CompareFile, context.file), std::make_pair(WindowsUiCommand::ClearSelection, context.selection)}) { + std::make_pair(WindowsUiCommand::OpenComparator, true), std::make_pair(WindowsUiCommand::ClearSelection, context.selection)}) { const auto state = WindowsUiResolveCommand(item.first, context); - Require(state.enabled == item.second && state.unavailable && *state.unavailable, "command applicability and help reason share a single policy"); + Require(state.enabled == item.second && state.unavailable && (state.enabled || *state.unavailable), "command applicability and help reason share a single policy"); } } Require(WindowsUiResolveCommand(WindowsUiCommand::RawInput, {}).enabled, "empty sessions retain RAW preconfiguration"); + Require(WindowsUiResolveCommand(WindowsUiCommand::OpenComparator, {}).enabled, "Compare always opens a workspace, even without an input file"); Require(WindowsUiCommandBackground(false, true, true) == WindowsUiColor::SurfaceAlt, "disabled controls cannot acquire hot or pressed colors"); Require(WindowsUiCommandBackground(true, true, false) == WindowsUiColor::Hover && WindowsUiCommandBackground(true, true, true) == WindowsUiColor::Pressed, "hover and pressed states remain distinct"); @@ -246,7 +248,7 @@ static void TestMenus() "disabled top-level actions and status ignore stale hover/pressed colors"); bool boundary=false; for (int x=2;x<318;++x) boundary |= GetPixel(dc,x,2)!=WindowsUiColorValue(WindowsUiColor::Window); - Require(boundary == (id==105), "right-aligned disabled Compare has a non-color cue; read-only zoom does not"); + Require(!boundary, "disabled actions and read-only status have no invented focus-like dashed boundary"); } } WindowsUiAppearanceState().Initialize(0); @@ -269,6 +271,10 @@ static void TestMenus() struct FrameTestHost { q1view::WindowsUiMenus menus; q1view::WindowsUiFrame frame; + HMENU testPopup = nullptr; + int executed = 0, popupInitializations = 0; + bool queued = false, popupTimedOut = false, copiedDisabled = false; + std::function menuProbe; static LRESULT CALLBACK Proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { auto* host = reinterpret_cast(GetWindowLongPtrW(window, GWLP_USERDATA)); if (message == WM_NCCREATE) { @@ -277,6 +283,18 @@ struct FrameTestHost { } LRESULT result = 0; if (host && host->frame.Before(message, wp, lp, result)) return result; + if (host && message == WM_INITMENUPOPUP && reinterpret_cast(wp) == host->testPopup) { + ++host->popupInitializations; host->queued=false; + host->copiedDisabled = (GetMenuState(host->testPopup,503,MF_BYCOMMAND)&MF_GRAYED)!=0; + SetTimer(window,120,1500,nullptr); + } + if (host && host->testPopup && message == WM_ENTERIDLE && wp == MSGF_MENU && !host->queued) { + host->queued=true; + if (host->menuProbe) host->menuProbe(reinterpret_cast(lp)); + else PostMessageW(reinterpret_cast(lp),WM_CHAR,L'x',1); + } + if (host && message == WM_TIMER && wp == 120) { host->popupTimedOut=true; KillTimer(window,120); EndMenu(); return 0; } + if (host && message == WM_COMMAND && LOWORD(wp) == 504) { ++host->executed; return 0; } 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); @@ -385,6 +403,7 @@ static void TestFrames() -20000, -20000, 640, 480, nullptr, root, cls.hInstance, &host); Require(window != nullptr, "isolated custom-frame host created"); host.frame.ReserveMenuWidth(102, L"888.88x"); + host.menus.SetReadOnlyStatus(102); 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"); @@ -474,24 +493,136 @@ static void TestFrames() std::puts("Windows custom-frame tests passed (native menu retention, accessible buttons, wrapping, full screen, caption GDI lifetime)."); } +static void TestDirectMenuActivation() +{ + FrameTestHost host; HMENU root=CreateMenu(), popup=CreatePopupMenu(); + AppendMenuW(root,MF_POPUP,reinterpret_cast(popup),L"&File"); + AppendMenuW(popup,MF_STRING,501,L"&Open"); + AppendMenuW(root,MF_STRING,504,L"Browse"); + HWND window=CreateWindowExW(0,L"Q1View.FrameRegressionTest",L"Direct command activation",WS_OVERLAPPEDWINDOW, + -20000,-20000,800,600,nullptr,root,GetModuleHandleW(nullptr),&host); + host.frame.Initialize(window,host.menus); + if(host.frame.Custom()) { + ShowWindow(window,SW_SHOWNOACTIVATE); host.testPopup=popup; + const HWND browse=GetDlgItem(host.frame.MenuHost(),2); + for(int probe : {0,1,2,3}) { + bool observed=false; + host.menuProbe=[&](HWND menuWindow) { + observed=true; RECT rect; GetWindowRect(browse,&rect); + MSG input={}; input.hwnd=menuWindow; input.pt={(rect.left+rect.right)/2,(rect.top+rect.bottom)/2}; + if(probe==0) { input.message=WM_MOUSEMOVE; CallMsgFilterW(&input,MSGF_MENU); } + if(probe==1) { input.message=WM_KEYDOWN; input.wParam=VK_RIGHT; CallMsgFilterW(&input,MSGF_MENU); } + if(probe>=2) { + input.message=WM_LBUTTONDOWN; CallMsgFilterW(&input,MSGF_MENU); + Require(host.executed==0,"pressing a direct command during popup tracking does not execute before release"); + input.message=WM_LBUTTONUP; + if(probe==2) input.pt={-30000,-30000}; + CallMsgFilterW(&input,MSGF_MENU); + } + EndMenu(); + }; + MSG key={}; key.hwnd=window; key.message=WM_KEYDOWN; key.wParam=VK_F10; + SetFocus(window); host.frame.Translate(&key); + key.wParam=VK_DOWN; Require(host.frame.Translate(&key),"real native popup opens for direct-command regression"); + KillTimer(window,120); + MSG pending; while(PeekMessageW(&pending,nullptr,0,0,PM_REMOVE)) { TranslateMessage(&pending); DispatchMessageW(&pending); } + Require(observed && !host.popupTimedOut,"direct-command probe ran inside actual native menu tracking without timing out"); + Require(host.executed==(probe==3?1:0),"hover, arrow navigation and cancelled clicks never execute; a complete direct click executes exactly once"); + if(probe==1) { + Require(GetFocus()==browse,"arrow navigation focuses Browse without opening the drawer"); + key.wParam=VK_RETURN; host.frame.Translate(&key); + while(PeekMessageW(&pending,nullptr,0,0,PM_REMOVE)) { TranslateMessage(&pending); DispatchMessageW(&pending); } + Require(host.executed==1,"Enter explicitly executes the focused direct command exactly once"); + } + host.executed=0; key.wParam=VK_ESCAPE; host.frame.Translate(&key); + } + } + DestroyWindow(window); + std::puts("Direct menu activation tests passed (hover/arrow inert, release activation, cancelled click, Enter)."); +} + +static void TestOptionalCommandOverflow() +{ + using namespace q1view; + const std::vector labels = {L"&File",L"Browse",L"Fit",L"100%",L"&View",L"&Options",L"&Compare",L"&Help",L"8192x4320",L"RGBA1010102",L"120.00fps",L"&More"}; + const int priorities[] = {0,2,2,3,4,5,6,7,100,100,100,0}; + const int moreIndex=int(labels.size())-1; + HDC dc = CreateCompatibleDC(nullptr); WindowsUiFontCache fonts; + for(UINT dpi : {96u,120u,144u,192u}) for(double scale : {1.0,1.25,1.5,2.0,2.25}) { + WindowsUiDcState saved(dc); SelectObject(dc,fonts.Get(WindowsUiFontRole::Body,dpi,scale)); + std::vector items; + for(size_t i=0;i counts(items.size()); int used=0; + for(int i:plan.visible) { ++counts[i]; used+=items[i].width; } + for(int i:plan.overflow) ++counts[i]; + for(int count:counts) Require(count==1,"all measured commands are reachable exactly once in the row or overflow"); + Require(used<=width || (plan.visible.size()==1 && plan.visible[0]==moreIndex),"measured row overflows before clipping other commands or adding a row"); + Require(!plan.visible.empty() && plan.visible.back()==moreIndex,"More remains the final accessible command even with enlarged text"); + for(int i:{8,9,10}) Require(std::find(plan.overflow.begin(),plan.overflow.end(),i)!=plan.overflow.end(),"advanced input controls remain available in More without duplicate status buttons"); + } + } + DeleteDC(dc); + // Test the same policy on real child HWNDs, retaining disabled state and + // keyboard traversal while the control's position moves into overflow. + FrameTestHost host; HMENU root=CreateMenu(), file=CreatePopupMenu(), more=CreatePopupMenu(); + AppendMenuW(root,MF_POPUP,reinterpret_cast(file),L"&File"); + AppendMenuW(file,MF_STRING,501,L"&Open"); + AppendMenuW(root,MF_STRING,502,L"Browse"); + AppendMenuW(root,MF_STRING|MF_GRAYED,503,L"&Compare selected files"); + AppendMenuW(root,MF_STRING,504,L"E&xecute once"); + AppendMenuW(root,MF_POPUP,reinterpret_cast(more),L"&More"); + HWND window=CreateWindowExW(0,L"Q1View.FrameRegressionTest",L"Viewer single row",WS_OVERLAPPEDWINDOW, + -20000,-20000,640,480,nullptr,root,GetModuleHandleW(nullptr),&host); + host.frame.ConfigureSingleRow(more,{{file,0,0,0,{}},{nullptr,502,1,2,{}},{nullptr,503,2,100,{}},{nullptr,504,3,100,{}}}); + host.frame.Initialize(window,host.menus); + if(host.frame.Custom()) for(int width:{160,320,640,1440}) { + SetWindowPos(window,nullptr,0,0,width,480,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE); host.frame.Layout(); + const auto& rects=host.frame.MenuRects(); + Require(!IsWindowEnabled(GetDlgItem(host.frame.MenuHost(),3)),"overflow never enables a disabled native command"); + Require(GetMenuItemCount(file)==1 && GetMenuItemID(file,0)==501,"layout preserves original popup lifetime and command IDs"); + for(const auto& rect:rects) Require(rect.top==0,"real Viewer command HWNDs never gain a second row"); + } + if(host.frame.Custom()) { + ShowWindow(window,SW_SHOWNOACTIVATE); host.testPopup=more; + MSG key={}; key.hwnd=window; key.message=WM_KEYDOWN; key.wParam=VK_F10; + Require(host.frame.Translate(&key),"F10 enters the real Viewer-style menu row"); + key.wParam=VK_LEFT; Require(host.frame.Translate(&key),"Left traverses visual order to More, skipping hidden roots"); + Require(GetFocus()==GetDlgItem(host.frame.MenuHost(),5),"More receives actual native keyboard focus"); + key.wParam=VK_DOWN; Require(host.frame.Translate(&key),"Down opens the real overflow popup"); + KillTimer(window,120); + MSG pending; while(PeekMessageW(&pending,nullptr,0,0,PM_REMOVE)) { TranslateMessage(&pending); DispatchMessageW(&pending); } + Require(!host.popupTimedOut && host.popupInitializations==1 && host.executed==1,"one native overflow selection executes its original command exactly once"); + Require(host.copiedDisabled,"overflow popup preserves actual disabled state"); + Require(GetMenuItemCount(more)==0 && GetMenuItemCount(file)==1,"temporary overflow entries detach without destroying borrowed submenus"); + } + DestroyWindow(window); +} + static void TestViewerDefaultMenu() { FrameTestHost host; HMENU root = CreateMenu(); - const wchar_t* labels[] = {L"&File", L"800&x600", L"YUV420", L"30.00f&ps", L"&View", - L"&Options", L"&Compare", L"&Help", L"800x600 (1.00x) \x00b7 Auto"}; + const wchar_t* labels[] = {L"&File", L"800&x600", L"YUV420", L"30.00f&ps", L"Browse", + L"&Options", L"&Compare", L"&Help"}; for (UINT i = 0; i < _countof(labels); ++i) AppendMenuW(root, MF_STRING | (i >= 6 ? MF_RIGHTJUSTIFY : 0), 200+i, labels[i]); HWND window = CreateWindowExW(0, L"Q1View.FrameRegressionTest", L"Viewer startup measurement", WS_OVERLAPPEDWINDOW, -20000, -20000, 900, 700, nullptr, root, GetModuleHandleW(nullptr), &host); Require(window != nullptr, "realistic Viewer menu measurement host created"); + host.frame.KeepTrailingMenuGroupTogether(); host.frame.Initialize(window, host.menus); for (int update = 0; update < 2; ++update) { if (update) { InsertMenuW(root, 7, MF_BYPOSITION | MF_STRING | MF_RIGHTJUSTIFY, 299, L"&Update"); host.frame.Sync(); } for (UINT dpi : {96u, 120u, 144u}) { const int measured = host.frame.SingleRowMenuWidth(dpi, 1.0); const auto size = q1view::ViewerDefaultContentSize(dpi, measured, {1920,1200}); - Require(size.cx >= measured, "realistic Viewer menu, detailed zoom and optional Update fit chosen default width"); + Require(size.cx >= measured, "restored Viewer input menus and optional Update fit chosen default width without a zoom field"); std::printf("Viewer normal menu DPI %u, Update %d: %d px; default width %ld px\n", dpi, update, measured, size.cx); } if (host.frame.Custom()) { @@ -503,6 +634,19 @@ static void TestViewerDefaultMenu() host.frame.Layout(); if (q1view::WindowsUiSettings().Scale() == 1.0) for (const auto& rect : host.frame.MenuRects()) Require(rect.top == 0, "realistic Viewer menu renders on one row at default width"); + for (int dip : {320,640,960,1440}) { + SetWindowPos(window,nullptr,0,0,q1view::WindowsUiPixels(dip,dpi)+outer.right-outer.left-client.right,700, + SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE); + host.frame.Layout(); RECT bounds; GetClientRect(window,&bounds); + const auto& rects=host.frame.MenuRects(); + Require(host.frame.OverflowPositions().empty(),"restored Viewer menu never hides an original command in More"); + for(size_t i=0;i=0 && rects[i].right<=bounds.right && rects[i].right>rects[i].left,"every restored command stays visible inside its row"); + for(size_t j=i+1;j *pBufferQueue, @@ -30,7 +31,9 @@ bool CscThread::setupDetail(CViewerDoc *pDoc) return false; } - mFrames = long(mFile.GetLength() / mOrigSceneSize); + const auto* layout = pDoc->mFrmSrc ? pDoc->mFrmSrc->RawLayout() : nullptr; + mLayout = layout ? *layout : q1view::RawFrameLayout{}; + mFrames = layout ? long(mLayout.offsets.size()) : long(mFile.GetLength() / mOrigSceneSize); return true; } @@ -51,6 +54,7 @@ bool CscThread::loadOrigBuf(long frameID, BYTE *buf) { if (frameID >= mFrames) return false; + if (!mLayout.offsets.empty()) return mLayout.Read(mFile, frameID, buf, mOrigSceneSize); ULONGLONG pos = ULONGLONG(frameID) * mOrigSceneSize; mFile.Seek(pos, CFile::begin); @@ -62,6 +66,7 @@ bool CscThread::loadOrigBuf(long frameID, BYTE *buf) void CscThread::deinit() { + mLayout = {}; if (mFile.m_hFile != CFile::hFileNull) mFile.Close(); } diff --git a/Viewer/CscThread.h b/Viewer/CscThread.h index c48c467..7dd7f32 100644 --- a/Viewer/CscThread.h +++ b/Viewer/CscThread.h @@ -2,6 +2,7 @@ #include "FrmProvideThread.h" #include "QOcv.h" +#include "RawFrameLayout.h" class CscThread : public FrmProvideThread { public: @@ -20,4 +21,5 @@ class CscThread : public FrmProvideThread { private: CFile mFile; + q1view::RawFrameLayout mLayout; }; diff --git a/Viewer/FrmSrc.h b/Viewer/FrmSrc.h index e75e20b..fc8dbf1 100644 --- a/Viewer/FrmSrc.h +++ b/Viewer/FrmSrc.h @@ -1,6 +1,7 @@ #pragma once #include "stdafx.h" +#include "RawFrameLayout.h" class CViewerDoc; @@ -22,6 +23,7 @@ class FrmSrc inline bool isFixed() { return mFixedResolution; } virtual bool isVideo() const { return false; } virtual bool usesHlgToneMapping() const { return false; } + virtual const q1view::RawFrameLayout* RawLayout() const { return nullptr; } private: bool mFixedResolution; diff --git a/Viewer/MainFrm.cpp b/Viewer/MainFrm.cpp index 602aa87..06a4d6b 100644 --- a/Viewer/MainFrm.cpp +++ b/Viewer/MainFrm.cpp @@ -126,6 +126,10 @@ BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_UPDATE_COMMAND_UI(ID_TOGGLE_DRAWER, &CMainFrame::OnUpdateToggleDrawer) ON_COMMAND(ID_AUTOPLAY_VIDEOS, &CMainFrame::OnToggleAutoplayVideos) ON_UPDATE_COMMAND_UI(ID_AUTOPLAY_VIDEOS, &CMainFrame::OnUpdateAutoplayVideos) + ON_COMMAND(ID_SELECTION_MODE, &CMainFrame::OnToggleSelectionMode) + ON_UPDATE_COMMAND_UI(ID_SELECTION_MODE, &CMainFrame::OnUpdateSelectionMode) + ON_COMMAND(ID_SYNC_INPUT, &CMainFrame::ToggleSyncInput) + ON_UPDATE_COMMAND_UI(ID_SYNC_INPUT, &CMainFrame::OnUpdateSyncInput) ON_COMMAND(ID_UPDATE, &CMainFrame::OnUpdateNow) ON_MESSAGE(WM_STORE_UPDATE_AVAILABLE, &CMainFrame::OnStoreUpdateAvailable) ON_MESSAGE(WM_STORE_UPDATE_DONE, &CMainFrame::OnStoreUpdateDone) @@ -466,7 +470,7 @@ LRESULT CMainFrame::OnAppearanceChanged(WPARAM, LPARAM) const COLORREF canvas = Q1UI_COLOR_CANVAS_BG; const bool canvasChanged = canvas != mAppliedCanvasColor; mAppliedCanvasColor = canvas; - mUiFrame.RefreshSettings(); mUiFrame.Sync(); + mUiFrame.RefreshSettings(); mUiFrame.Sync(); RecalcLayout(); SendMessageToDescendants(q1view::WM_UI_APPEARANCE_CHANGED, canvasChanged); mHelpOverlay.Relayout(); RedrawWindow(nullptr, nullptr, RDW_INVALIDATE | RDW_ALLCHILDREN); @@ -488,7 +492,7 @@ void CMainFrame::OnDrawItem(int id, LPDRAWITEMSTRUCT item) void CMainFrame::OnInitMenuPopup(CMenu* menu, UINT index, BOOL system) { - if (!system) RefreshCommandStates(); + if (!system) RefreshCommandStates(true); CFrameWnd::OnInitMenuPopup(menu, index, system); if (!system) mUiMenus.Sync(m_hWnd, GetMenu() ? GetMenu()->GetSafeHmenu() : nullptr, mUiFrame.Custom()); } @@ -617,7 +621,6 @@ CMainFrame::CMainFrame() mResolutionMenu.CreatePopupMenu(); mCsMenu.CreatePopupMenu(); mFpsMenu.CreatePopupMenu(); - mOptionsMenu.CreatePopupMenu(); BITMAPINFOHEADER &bmiHeader = mCopyBmi.bmiHeader; bmiHeader.biSize = (DWORD)sizeof(BITMAPINFOHEADER); @@ -632,7 +635,6 @@ CMainFrame::CMainFrame() CMainFrame::~CMainFrame() { mFpsMenu.DestroyMenu(); - mOptionsMenu.DestroyMenu(); mCsMenu.DestroyMenu(); mResolutionMenu.DestroyMenu(); @@ -665,8 +667,9 @@ LRESULT CMainFrame::WindowProc(UINT message, WPARAM wp, LPARAM lp) result = CFrameWnd::WindowProc(message, wp, lp); if (message == WM_DESTROY) { mUiFrame.Destroy(); return result; } if (message == WM_CREATE && result != -1) { - mUiMenus.SetReadOnlyStatus(ID_MAGNIFY); - mUiFrame.Initialize(m_hWnd, mUiMenus); RecalcLayout(); + mUiFrame.KeepTrailingMenuGroupTogether(); + mUiFrame.Initialize(m_hWnd, mUiMenus); + mStatusBar.Initialize(m_hWnd); UpdateViewerStatus(); RecalcLayout(); } mUiFrame.After(message); if (message == WM_ACTIVATE && LOWORD(wp) != WA_INACTIVE && q1view::ReloadSharedWindowsUiTheme()) @@ -682,6 +685,7 @@ CMenu* CMainFrame::GetMenu() const BOOL CMainFrame::SetMenu(CMenu* menu) { + mCommandMenu = nullptr; if (!mUiFrame.Initialized()) return CFrameWnd::SetMenu(menu); mUiFrame.SetMenu(menu ? menu->GetSafeHmenu() : nullptr); RecalcLayout(); return TRUE; } @@ -696,12 +700,16 @@ void CMainFrame::OnUpdateFrameMenu(HMENU menu) } void CMainFrame::GetContentRect(CRect& rect) const -{ GetClientRect(&rect); rect.top = std::min(rect.bottom, LONG(mUiFrame.Height())); } +{ GetClientRect(&rect); rect.top = std::min(rect.bottom, LONG(mUiFrame.Height())); rect.bottom = std::max(rect.top, rect.bottom - StatusHeight()); } + +int CMainFrame::StatusHeight() const +{ return GetMenu() && (GetStyle() & WS_CAPTION) == WS_CAPTION ? mStatusBar.Height() : 0; } void CMainFrame::RecalcLayout(BOOL notify) { if (mUiFrame.Initialized()) mUiFrame.Layout(); - m_rectBorder.SetRect(0, mUiFrame.Height(), 0, 0); + mStatusBar.Layout(m_hWnd, StatusHeight() > 0); + m_rectBorder.SetRect(0, mUiFrame.Height(), 0, StatusHeight()); CFrameWnd::RecalcLayout(notify); } @@ -739,7 +747,7 @@ void CMainFrame::SizeDefaultWindow() const int borderHeight = outer.Height() - client.Height(); SIZE available = { monitor.rcWork.right - monitor.rcWork.left - borderWidth, - monitor.rcWork.bottom - monitor.rcWork.top - borderHeight - mUiFrame.Height() + monitor.rcWork.bottom - monitor.rcWork.top - borderHeight - mUiFrame.Height() - StatusHeight() }; // Keep the normal default roomy; enlarged accessibility text still wraps // rather than forcing a very wide launch or resizing the user's window. @@ -750,7 +758,7 @@ void CMainFrame::SizeDefaultWindow() if (width != outer.Width()) SetWindowPos(nullptr, 0, 0, width, outer.Height(), SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); RecalcLayout(); - const int chrome = mUiFrame.Height(); + const int chrome = mUiFrame.Height() + StatusHeight(); available.cy = monitor.rcWork.bottom - monitor.rcWork.top - borderHeight - chrome; const SIZE content = q1view::ViewerDefaultContentSize(dpi, menuWidth, available); const int height = content.cy + chrome + borderHeight; @@ -848,7 +856,6 @@ void CMainFrame::OnFileOpen() #define MENU_POS_RESOLUTION 1 #define MENU_POS_COLORSPACE 2 #define MENU_POS_FPS 3 -#define MENU_POS_VIEW 4 #define MENU_POS_OPTIONS 5 void CMainFrame::UpdateResolutionLabel(int w, int h) @@ -869,6 +876,7 @@ void CMainFrame::UpdateResolutionLabel(int w, int h) str.Format(_T("%d&x%d"), w, h); GetMenu()->ModifyMenu(MENU_POS_RESOLUTION, nFlags, (UINT_PTR)mResolutionMenu.m_hMenu, str); + mCommandMenuSignature = ~0u; } void CMainFrame::UpdateCsLabel(const TCHAR *csLabel) @@ -878,6 +886,7 @@ void CMainFrame::UpdateCsLabel(const TCHAR *csLabel) GetMenu()->ModifyMenu(MENU_POS_COLORSPACE, MF_BYPOSITION | MF_POPUP, (UINT_PTR)mCsMenu.m_hMenu, csLabel); + mCommandMenuSignature = ~0u; } void CMainFrame::UpdateFpsLabel(double fps) @@ -959,8 +968,8 @@ void CMainFrame::CheckScalingRadio(int mode) return; UINT id = ID_SCALING_AUTO + static_cast(mode); - CMenu *viewMenu = GetMenu()->GetSubMenu(MENU_POS_VIEW); - CMenu *scalingMenu = viewMenu ? viewMenu->GetSubMenu(0) : NULL; + CMenu *optionsMenu = GetMenu()->GetSubMenu(MENU_POS_OPTIONS); + CMenu *scalingMenu = optionsMenu ? optionsMenu->GetSubMenu(0) : NULL; if (!scalingMenu) return; scalingMenu->CheckMenuRadioItem(ID_SCALING_AUTO, ID_SCALING_PIXEL_EXACT, @@ -1021,9 +1030,10 @@ int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct) mFpsMenu.AppendMenu(MF_STRING, ID_FPS_START + i, CA2W(qfps_info_table[i])); AddMainMenu(); - q1view::AppendWindowsUiThemeMenu(mOptionsMenu.GetSafeHmenu()); - GetMenu()->InsertMenu(MENU_POS_OPTIONS, MF_BYPOSITION | MF_POPUP, - (UINT_PTR)mOptionsMenu.GetSafeHmenu(), _T("&Options")); + GetMenu()->InsertMenu(MENU_POS_OPTIONS - 1, MF_BYPOSITION | MF_STRING, ID_TOGGLE_DRAWER, L"Browse"); + CMenu* optionsMenu = GetMenu()->GetSubMenu(MENU_POS_OPTIONS); + q1view::AppendWindowsUiThemeMenu(optionsMenu->GetSafeHmenu()); + // Keep input settings directly accessible in their original positions. mAppliedCanvasColor = Q1UI_COLOR_CANVAS_BG; CheckResolutionRadio(q1view::ViewerDefaultImageWidth, q1view::ViewerDefaultImageHeight); @@ -1226,6 +1236,23 @@ void CMainFrame::OnUpdateAutoplayVideos(CCmdUI *pCmdUI) pCmdUI->SetCheck(theApp.IsVideoAutoplayEnabled() ? 1 : 0); } +void CMainFrame::OnToggleSelectionMode() +{ + if (auto* view = DYNAMIC_DOWNCAST(CViewerView, GetActiveView())) view->ToggleSelMode(); +} + +void CMainFrame::OnUpdateSelectionMode(CCmdUI *pCmdUI) +{ + auto* view = DYNAMIC_DOWNCAST(CViewerView, GetActiveView()); + pCmdUI->Enable(view != nullptr); + pCmdUI->SetCheck(view && view->mSelMode); +} + +void CMainFrame::OnUpdateSyncInput(CCmdUI *pCmdUI) +{ + pCmdUI->SetCheck(IsSyncInputEnabled()); +} + int CMainFrame::GetDrawerReservedWidth() const { return mDrawerVisible ? (mDrawerWidth + DRAWER_SPLIT_BAR) : 0; @@ -1330,6 +1357,10 @@ void CMainFrame::RefreshView() pView->AdjustWindowSize(); } + // RAW interpretation changes also replace root menu labels. Refresh these + // once here, independently of the read-only zoom/status update path. + RefreshCommandStates(); + DrawMenuBar(); } namespace { @@ -1338,7 +1369,7 @@ bool ViewerCommandKind(UINT id, q1view::WindowsUiCommand& kind) if ((id >= ID_RESOLUTION_START && id <= ID_RESOLUTION_END) || (id >= ID_CS_START && id <= ID_CS_END)) kind = q1view::WindowsUiCommand::RawInput; else if (id == ID_EDIT_COPY) kind = q1view::WindowsUiCommand::CopyPixels; else if (id == ID_EDIT_PASTE) kind = q1view::WindowsUiCommand::PasteImage; - else if (id == ID_COMPARE) kind = q1view::WindowsUiCommand::CompareFile; + else if (id == ID_COMPARE) kind = q1view::WindowsUiCommand::OpenComparator; else return false; return true; } @@ -1375,17 +1406,28 @@ BOOL CMainFrame::OnCmdMsg(UINT id, int code, void* extra, AFX_CMDHANDLERINFO* ha return CFrameWnd::OnCmdMsg(id, code, extra, handler); } -void CMainFrame::RefreshCommandStates() +void CMainFrame::RefreshCommandStates(bool force) { const auto context = CommandContext(); const unsigned signature = unsigned(context.rawInput) | (unsigned(context.pixels) << 1) | - (unsigned(context.clipboardImage) << 2) | (unsigned(context.file) << 3); - if (signature != mCommandStateSignature) { - mCommandStateSignature = signature; + (unsigned(context.clipboardImage) << 2) | (unsigned(context.file) << 3) | + (unsigned(mDrawerVisible) << 5); + const unsigned helpSignature = signature & 15u; + if (helpSignature != mCommandStateSignature) { + mCommandStateSignature = helpSignature; if (mHelpOverlay.GetSafeHwnd() && mHelpOverlay.IsShown()) mHelpOverlay.Relayout(); } // Full screen hides the menu, not the context-sensitive help. CMenu* menu = GetMenu(); if (!menu) return; + // Clipboard/source availability is cheap to sample. Do not enumerate all + // menu items or hosted buttons again on every idle message when unchanged. + const HMENU handle = menu->GetSafeHmenu(); + if (!force && mCommandMenu == handle && mCommandMenuSignature == signature) return; + mCommandMenu = handle; + mCommandMenuSignature = signature; +#ifdef Q1VIEW_GALLERY_TESTS + ++mCommandMenuRefreshCount; +#endif bool nativeBarChanged = false; auto enable = [](CMenu* target, UINT item, UINT by, bool available) { const UINT old = target->GetMenuState(item, by); @@ -1408,6 +1450,17 @@ void CMainFrame::RefreshCommandStates() nativeBarChanged |= changed && id == ID_COMPARE; } } + for (int i = 0; i < menu->GetMenuItemCount(); ++i) { + const UINT id = menu->GetMenuItemID(i); + if (id != ID_TOGGLE_DRAWER) continue; + const bool checked = mDrawerVisible; + const bool old = (menu->GetMenuState(i, MF_BYPOSITION) & MF_CHECKED) != 0; + if (old != checked) { + menu->CheckMenuItem(i, MF_BYPOSITION | (checked ? MF_CHECKED : MF_UNCHECKED)); + if (mUiFrame.MenuHost()) ::InvalidateRect(::GetDlgItem(mUiFrame.MenuHost(), i + 1), nullptr, FALSE); + nativeBarChanged = true; + } + } mUiFrame.SyncEnabledStates(); if (nativeBarChanged && !mUiFrame.Custom()) ::DrawMenuBar(m_hWnd); } @@ -1416,6 +1469,7 @@ void CMainFrame::OnIdleUpdateCmdUI() { CFrameWnd::OnIdleUpdateCmdUI(); RefreshCommandStates(); + UpdateViewerStatus(); } void CMainFrame::OnResolutionChange(UINT nID) @@ -1579,30 +1633,20 @@ void CMainFrame::OnUpdateScalingMode(CCmdUI *pCmdUI) void CMainFrame::UpdateMagnication(float n, int wDst, int hDst) { - if (GetMenu() == NULL) - return; + UpdateViewerStatus(); +} - CString str; - str.Format(_T("%dx%d (%.2fx)"), wDst, hDst, n); - CViewerView *pView = DYNAMIC_DOWNCAST(CViewerView, GetActiveView()); - if (pView) { - str += _T(" "); - str.AppendChar(0x00b7); - str += _T(" "); - str += pView->GetScalingStatusLabel(); +void CMainFrame::UpdateViewerStatus() +{ + auto* view = DYNAMIC_DOWNCAST(CViewerView, GetActiveView()); + auto* doc = view ? view->GetDocument() : nullptr; + CString sampling; + CString zoom = L"--"; + if (doc && view->mStableRgbBufferInfo.addr && doc->mDocState != DOC_JUSTLOAD) { + zoom.Format(L"%.1f%%", double(view->mN) * 100.0); + sampling = view->GetScalingStatusLabel(); } - - CMenu *pMenu = GetMenu(); - if (pMenu == NULL) - return; - - pMenu->ModifyMenu(ID_MAGNIFY, MF_BYCOMMAND | MF_RIGHTJUSTIFY | MF_GRAYED, ID_MAGNIFY, str); - - // While the divider is dragged, DrawMenuBar would repaint the whole - // non-buffered menu bar each time. Update the label text now but defer the - // repaint until the drag settles. - if (!mDrawerResizing) - DrawMenuBar(); + mStatusBar.SetText(sampling.GetString(), zoom.GetString()); } void CMainFrame::ToggleSyncInput() @@ -1801,13 +1845,21 @@ void CMainFrame::OnExecComparator() std::vector paths; if (pDoc != NULL && !pDoc->mPathName.IsEmpty()) paths.push_back(pDoc->mPathName); - OpenComparatorFiles(paths); + // Compare is an application launcher, not an image-only operation. With + // no current file, open the same empty workspace as Comparator.exe itself. + StartComparator(paths); } BOOL CMainFrame::OpenComparatorFiles(const std::vector& filePaths) { if (filePaths.empty() || filePaths.size() > 4) return FALSE; + return StartComparator(filePaths); +} + +BOOL CMainFrame::StartComparator(const std::vector& filePaths) +{ + if (filePaths.size() > 4) return FALSE; CString cmperPath; if (!ResolveComparatorPath(cmperPath)) { diff --git a/Viewer/MainFrm.h b/Viewer/MainFrm.h index 7f9dfb3..2cbce52 100644 --- a/Viewer/MainFrm.h +++ b/Viewer/MainFrm.h @@ -9,6 +9,7 @@ #include "Q1UiFontWin.h" #include "Q1UiMenuWin.h" #include "Q1UiFrameWin.h" +#include "ViewerStatusBar.h" #define WM_RELOAD (WM_USER + 100) #define WM_APPLY_SYNC_INPUT (WM_APP + 100) @@ -155,7 +156,7 @@ class CMainFrame : public CFrameWnd // Attributes private: - CMenu mResolutionMenu, mCsMenu, mFpsMenu, mOptionsMenu; + CMenu mResolutionMenu, mCsMenu, mFpsMenu; BITMAPINFO mCopyBmi; bool mSyncInput; bool mSyncViewStatePending; @@ -177,8 +178,14 @@ class CMainFrame : public CFrameWnd CHelpOverlay mHelpOverlay; q1view::WindowsUiMenus mUiMenus; q1view::WindowsUiFrame mUiFrame; + ViewerStatusBar mStatusBar; CDrawerTransitionOverlay mDrawerTransitionOverlay; unsigned mCommandStateSignature = ~0u; + unsigned mCommandMenuSignature = ~0u; + HMENU mCommandMenu = nullptr; +#ifdef Q1VIEW_GALLERY_TESTS + unsigned mCommandMenuRefreshCount = 0; +#endif // Operations public: @@ -197,7 +204,9 @@ class CMainFrame : public CFrameWnd virtual BOOL OnCmdMsg(UINT id, int code, void* extra, AFX_CMDHANDLERINFO* handler); q1view::WindowsUiCommandContext CommandContext() const; bool IsCommandEnabled(UINT id) const; - void RefreshCommandStates(); + void RefreshCommandStates(bool force = false); + void UpdateViewerStatus(); + int StatusHeight() const; afx_msg void OnIdleUpdateCmdUI(); virtual void OnUpdateFrameTitle(BOOL bAddToTitle); virtual BOOL OnCreateClient(LPCREATESTRUCT lpcs, CCreateContext* pContext); @@ -277,6 +286,9 @@ class CMainFrame : public CFrameWnd afx_msg void OnUpdateToggleDrawer(CCmdUI *pCmdUI); afx_msg void OnToggleAutoplayVideos(); afx_msg void OnUpdateAutoplayVideos(CCmdUI *pCmdUI); + afx_msg void OnToggleSelectionMode(); + afx_msg void OnUpdateSelectionMode(CCmdUI *pCmdUI); + afx_msg void OnUpdateSyncInput(CCmdUI *pCmdUI); afx_msg void OnTimer(UINT_PTR nIDEvent); // "Update" menu command: download and install the available Store update. afx_msg void OnUpdateNow(); @@ -287,6 +299,7 @@ class CMainFrame : public CFrameWnd private: BOOL ResolveComparatorPath(CString &cmperPath); BOOL LaunchComparator(const CString &cmperPath, const CString "edArgs); + BOOL StartComparator(const std::vector& filePaths); void PinDrawerColumn(); void SetDrawerVisibleImmediately(bool visible); // Settle the image after a drawer resize. Active fit mode is recomputed once diff --git a/Viewer/RawFrameLayout.h b/Viewer/RawFrameLayout.h new file mode 100644 index 0000000..0e2e262 --- /dev/null +++ b/Viewer/RawFrameLayout.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace q1view { +// Container framing is immutable even when the user reinterprets its pixels. +struct RawFrameLayout { + std::vector offsets; + UINT payloadBytes = 0; + bool Read(CFile& file, long frame, BYTE* target, size_t requested) const { + if (frame < 0 || size_t(frame) >= offsets.size() || requested > UINT_MAX) return false; + file.Seek(offsets[size_t(frame)], CFile::begin); + const UINT count = UINT((std::min)(requested, size_t(payloadBytes))); + const UINT read = file.Read(target, count); + if (read != count) return false; + // Larger interpretations must not consume the next FRAME marker/payload. + if (requested > read) memset(target + read, 0, requested - read); + return true; + } +}; +} diff --git a/Viewer/RawFrmSrc.h b/Viewer/RawFrmSrc.h index b165e10..fd335d2 100644 --- a/Viewer/RawFrmSrc.h +++ b/Viewer/RawFrmSrc.h @@ -4,13 +4,14 @@ #include "CscThread.h" #include "QHeifUtil.h" +#include "Y4mReaderWin.h" #include class RawFrmSrc : public FrmSrc { public: RawFrmSrc(CViewerDoc *pDoc) - : FrmSrc(false), mNextFrame(-1) + : FrmSrc(false), mNextFrame(-1), mFrames(0) { for (int i = 0; i < NUM_THREADS; i++) { mCscRequesters[i] = new CscThread(pDoc->mBufferPool, pDoc->mBufferQueue, @@ -43,12 +44,27 @@ class RawFrmSrc : public FrmSrc { mNextFrame = 0; mFilePath = filePath; + mY4m = {}; + if (q1view::HasY4mSignature(mFile) && !q1view::ParseY4m(mFile, mY4m)) { + mFile.Close(); return false; + } return true; } virtual inline void ConfigureDoc(CViewerDoc *pDoc) { + if (!mY4m.layout.offsets.empty()) { + pDoc->mW = mY4m.width; pDoc->mH = mY4m.height; + for (const auto& info : qcsc_info_table) if (info.cs == mY4m.colorSpace) { + pDoc->mColorSpace = info.cs; pDoc->mCsc2Rgb888 = info.csc2rgb888; + pDoc->mCsLoadInfo = info.cs_load_info; pDoc->mSampleNativePixel = info.sample_native_pixel; + break; + } + pDoc->mHasTimingFps = mY4m.fps > 0; + if (pDoc->mHasTimingFps) pDoc->mFps = mY4m.fps; + return; + } int ret; CString fileName = mFilePath.Mid(mFilePath.ReverseFind('\\') + 1); fileName.MakeLower(); @@ -85,6 +101,7 @@ class RawFrmSrc : public FrmSrc { virtual inline bool LoadOrigBuf(CViewerDoc *pDoc, BYTE *buf) { + if (RawLayout()) return mY4m.layout.Read(mFile, pDoc->mCurFrameID, buf, pDoc->mOrigSceneSize); if (mFile.m_hFile == CFile::hFileNull) { LOGERR("File is not available"); return false; @@ -110,14 +127,15 @@ class RawFrmSrc : public FrmSrc { virtual inline long CalNumFrame(CViewerDoc *pDoc) { - mFrames = long(mFile.GetLength() / pDoc->mOrigSceneSize); + mFrames = RawLayout() ? long(mY4m.layout.offsets.size()) : long(mFile.GetLength() / pDoc->mOrigSceneSize); return mFrames; } virtual inline bool SetFramePos(CViewerDoc *pDoc, long id) { - if (id >= mFrames) + if (id < 0 || id >= mFrames) return false; + if (RawLayout()) { mNextFrame = id; return true; } mNextFrame = id; @@ -167,10 +185,15 @@ class RawFrmSrc : public FrmSrc { if (mFile.m_hFile != CFile::hFileNull) mFile.Close(); mNextFrame = -1; + mY4m = {}; + } + const q1view::RawFrameLayout* RawLayout() const override { + return mY4m.layout.offsets.empty() ? nullptr : &mY4m.layout; } private: CFile mFile; + q1view::Y4mInfo mY4m; long mNextFrame; int mFrames; CString mFilePath; diff --git a/Viewer/ThumbnailPane.cpp b/Viewer/ThumbnailPane.cpp index 7ff1bb3..32d14d4 100644 --- a/Viewer/ThumbnailPane.cpp +++ b/Viewer/ThumbnailPane.cpp @@ -1173,6 +1173,23 @@ void CThumbnailPane::BuildContextMenu(CMenu& menu, int index) } } +LRESULT CThumbnailPane::WindowProc(UINT message, WPARAM wp, LPARAM lp) +{ + if (mContextMenus) { + if (message == WM_INITMENUPOPUP && !HIWORD(lp)) { + mContextMenus->SyncPopup(m_hWnd, reinterpret_cast(wp)); + return 0; + } + if (message == WM_MEASUREITEM && mContextMenus->Measure(reinterpret_cast(lp))) return TRUE; + if (message == WM_DRAWITEM && mContextMenus->Draw(reinterpret_cast(lp))) return TRUE; + if (message == WM_MENUCHAR) { + LRESULT result; + if (mContextMenus->MenuChar(LOWORD(wp), reinterpret_cast(lp), result)) return result; + } + } + return CListCtrl::WindowProc(message, wp, lp); +} + void CThumbnailPane::ShowContextMenu(int index, CPoint screenPoint) { const bool item = index >= 0 && index < int(mEntries.size()); @@ -1185,8 +1202,12 @@ void CThumbnailPane::ShowContextMenu(int index, CPoint screenPoint) const std::wstring path = q1view::TrimDirectorySeparator(entry.path.GetString()); CMenu menu; BuildContextMenu(menu, index); if (menu.GetMenuItemCount() == 0) return; + // Own style metadata only for this popup's lifetime, including its submenus. + q1view::WindowsUiMenus contextMenus; + mContextMenus = &contextMenus; const UINT command = menu.TrackPopupMenu(TPM_RETURNCMD | TPM_RIGHTBUTTON, screenPoint.x, screenPoint.y, this); + mContextMenus = nullptr; // The popup runs a nested message loop: a watcher/new document may have // changed the entries. Never act on a recycled index or a different folder. if (!command || generation != mGen.load()) return; diff --git a/Viewer/ThumbnailPane.h b/Viewer/ThumbnailPane.h index 77f7578..76e0dcf 100644 --- a/Viewer/ThumbnailPane.h +++ b/Viewer/ThumbnailPane.h @@ -21,6 +21,7 @@ #include #include #include "QBrowserSelection.h" +#include "Q1UiMenuWin.h" class CGalleryGridCanvas; @@ -40,6 +41,7 @@ class CThumbnailPane : public CListCtrl // Create the list control as a child of pParent with control id nID. BOOL CreatePane(CWnd *pParent, UINT nID); virtual BOOL PreTranslateMessage(MSG* message); + virtual LRESULT WindowProc(UINT message, WPARAM wp, LPARAM lp) override; bool GoToParent(); bool CanGoToParent() const; void ShowContextMenu(int index, CPoint screenPoint); @@ -82,6 +84,7 @@ class CThumbnailPane : public CListCtrl afx_msg LRESULT OnDirectoryChanged(WPARAM wParam, LPARAM lParam); private: + q1view::WindowsUiMenus* mContextMenus = nullptr; friend class CGalleryGridCanvas; friend struct GalleryIntegrationTests; enum ContextCommand { CMD_OPEN = 1, CMD_EXPLORER, CMD_COPY, CMD_PATH, CMD_NAME, CMD_PROPERTIES, CMD_RECYCLE, CMD_COMPARE }; diff --git a/Viewer/Viewer.rc b/Viewer/Viewer.rc index fb2bebb..0f470d3 100644 --- a/Viewer/Viewer.rc +++ b/Viewer/Viewer.rc @@ -94,13 +94,10 @@ BEGIN MENUITEM "&Open...\tCtrl+O", ID_FILE_OPEN,MFT_STRING,MFS_ENABLED MENUITEM "Save &As\tCtrl+Alt+S", ID_FILE_SAVE_AS,MFT_STRING,MFS_ENABLED MENUITEM MFT_SEPARATOR - MENUITEM "Thumbnail &Browser\tE", ID_TOGGLE_DRAWER,MFT_STRING,MFS_ENABLED - MENUITEM "Autoplay &Videos When Opened", ID_AUTOPLAY_VIDEOS,MFT_STRING,MFS_ENABLED - MENUITEM MFT_SEPARATOR MENUITEM "Recent File", ID_FILE_MRU_FILE1,MFT_STRING,MFS_GRAYED MENUITEM MFT_SEPARATOR END - POPUP "&View", 65535,MFT_STRING,MFS_ENABLED + POPUP "&Options", 65535,MFT_STRING,MFS_ENABLED BEGIN POPUP "&Image Scaling", 65535,MFT_STRING,MFS_ENABLED BEGIN @@ -108,10 +105,13 @@ BEGIN MENUITEM "&Smooth (Bilinear)", ID_SCALING_SMOOTH,MFT_STRING,MFS_ENABLED MENUITEM "&Pixel Exact (Nearest)", ID_SCALING_PIXEL_EXACT,MFT_STRING,MFS_ENABLED END + MENUITEM "Autoplay &Multi-frame Files", ID_AUTOPLAY_VIDEOS,MFT_STRING,MFS_ENABLED + MENUITEM MFT_SEPARATOR + MENUITEM "&Selection Mode\tS", ID_SELECTION_MODE,MFT_STRING,MFS_ENABLED + MENUITEM "Sync Input Across Viewer &Windows", ID_SYNC_INPUT,MFT_STRING,MFS_ENABLED END MENUITEM "&Compare", ID_COMPARE,MFT_STRING | MFT_RIGHTJUSTIFY,MFS_ENABLED MENUITEM "&Help", ID_VIEWER_HELP,MFT_STRING | MFT_RIGHTJUSTIFY,MFS_ENABLED - MENUITEM "1.00x", ID_MAGNIFY,MFT_STRING | MFT_RIGHTJUSTIFY,MFS_GRAYED END diff --git a/Viewer/ViewerDoc.cpp b/Viewer/ViewerDoc.cpp index d250196..b6cfc26 100644 --- a/Viewer/ViewerDoc.cpp +++ b/Viewer/ViewerDoc.cpp @@ -367,12 +367,25 @@ void CViewerDoc::UpdateMenu() pMainFrm->UpdateFpsLabel(mFps); pMainFrm->CheckFpsRadio(mFps); pMainFrm->UpdateMagnication(pView->mN, pView->mWDst, pView->mHDst); + // Source labels change together on open, not on every zoom/status update. + pMainFrm->DrawMenuBar(); } // CViewerDoc commands BOOL CViewerDoc::OnOpenDocument(LPCTSTR lpszPathName) { - bool explicitOpen = theApp.IsExplicitFileOpenInProgress(); + return OpenDocumentImpl(lpszPathName, theApp.IsExplicitFileOpenInProgress()); +} + +BOOL CViewerDoc::OpenNavigatedDocument(LPCTSTR path) +{ + // User file movement is an explicit open for autoplay, but must not + // reinitialize or reactivate the entire MFC frame via the document manager. + return OpenDocumentImpl(path, true); +} + +BOOL CViewerDoc::OpenDocumentImpl(LPCTSTR lpszPathName, bool explicitOpen) +{ mAutoplayAfterPresent = false; mAutoplayMessagePending = false; ++mOpenGeneration; @@ -419,17 +432,21 @@ BOOL CViewerDoc::OnOpenDocument(LPCTSTR lpszPathName) if (mFrmSrc) mFrmSrc->Release(); - auto it = std::begin(mFrmSrcs); - for (; it != std::end(mFrmSrcs); it++) { - bool ok = (*it)->Open(mPathName); + // Y4M contains raw pixel payloads plus frame markers. Prefer the indexed + // RAW reader so interpretation controls stay editable, regardless of suffix. + const size_t preferred = q1view::HasY4mSignature(mPathName) ? mFrmSrcs.size() - 1 : 0; + size_t attempted = 0; + for (; attempted < mFrmSrcs.size(); ++attempted) { + auto* source = mFrmSrcs[(preferred + attempted) % mFrmSrcs.size()]; + bool ok = source->Open(mPathName); if (ok) { - mFrmSrc = *it; + mFrmSrc = source; break; } } // If this fires, even the raw fallback could not open the file. - ASSERT(it != std::end(mFrmSrcs)); - if (it == std::end(mFrmSrcs)) { + ASSERT(attempted != mFrmSrcs.size()); + if (attempted == mFrmSrcs.size()) { AfxMessageBox(_T("Unsupported or unreadable file.")); return FALSE; } @@ -438,10 +455,12 @@ BOOL CViewerDoc::OnOpenDocument(LPCTSTR lpszPathName) mOrigW = mOrigH = -1; mFrmSrc->ConfigureDoc(this); - mAutoplayAfterPresent = explicitOpen && mFrmSrc->isVideo() && - theApp.IsVideoAutoplayEnabled(); - LoadSourceImage(mLoadLayout); + // The actual frame count is known only after loading. Raw byte streams are + // playable sequences too; decoded stills and internal reloads remain paused. + mAutoplayAfterPresent = explicitOpen && mFrames > 1 && + (mFrmSrc->isVideo() || !mFrmSrc->isFixed()) && theApp.IsVideoAutoplayEnabled(); + if (mAutoplayAfterPresent) pView->Invalidate(FALSE); UpdateMenu(); diff --git a/Viewer/ViewerDoc.h b/Viewer/ViewerDoc.h index 629638a..cfa1729 100644 --- a/Viewer/ViewerDoc.h +++ b/Viewer/ViewerDoc.h @@ -128,6 +128,7 @@ class CViewerDoc : public CDocument int FirstScene(); int LastScene(); void LoadSourceImage(LoadLayout layout = LOAD_RESIZE_WINDOW); + BOOL OpenNavigatedDocument(LPCTSTR path); BOOL ReloadDocument(); void CloseMediaForRecycle(); void UpdateMenu(); @@ -147,6 +148,7 @@ class CViewerDoc : public CDocument #endif protected: + BOOL OpenDocumentImpl(LPCTSTR path, bool explicitOpen); int QueueScene(int frameID); // Generated message map functions diff --git a/Viewer/ViewerStatusBar.h b/Viewer/ViewerStatusBar.h new file mode 100644 index 0000000..ce77f58 --- /dev/null +++ b/Viewer/ViewerStatusBar.h @@ -0,0 +1,94 @@ +#pragma once + +#include "Q1UiBufferedPaintWin.h" +#include "Q1UiFontWin.h" +#include + +// A native read-only Static (MSAA/UIA name is the full text), not a disabled +// command. A tooltip preserves full information if the single line ellipsizes. +class ViewerStatusBar { + HWND mWindow = nullptr, mTip = nullptr; + q1view::WindowsUiFontCache mFonts; + std::wstring mText; + std::wstring mSampling, mZoom; + static LRESULT CALLBACK Proc(HWND window, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR data) { + auto* self = reinterpret_cast(data); + if (message == WM_ERASEBKGND) return TRUE; + if (message == WM_PAINT) { + PAINTSTRUCT paint; HDC dc = BeginPaint(window, &paint); + RECT rect; GetClientRect(window, &rect); + { + q1view::WindowsUiBufferedPaint buffer(dc, rect, q1view::WindowsUiColor::Surface); + { + q1view::WindowsUiDcState saved(buffer.Dc()); + SelectObject(buffer.Dc(), self->mFonts.Get(q1view::WindowsUiFontRole::Supporting, window)); + SetBkMode(buffer.Dc(), TRANSPARENT); + SetTextColor(buffer.Dc(), q1view::WindowsUiColorValue(q1view::WindowsUiColor::Muted)); + RECT edge = rect; edge.bottom = 1; + FillRect(buffer.Dc(), &edge, q1view::WindowsUiColorBrush(q1view::WindowsUiColor::Border)); + RECT sampling, zoom; + self->TextRects(buffer.Dc(), rect, sampling, zoom); + DrawTextW(buffer.Dc(), self->mSampling.c_str(), -1, &sampling, DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX); + DrawTextW(buffer.Dc(), self->mZoom.c_str(), -1, &zoom, DT_SINGLELINE | DT_VCENTER | DT_RIGHT | DT_END_ELLIPSIS | DT_NOPREFIX); + } + } + EndPaint(window, &paint); return 0; + } + if (message == WM_NCDESTROY) { self->mWindow = nullptr; self->mTip = nullptr; RemoveWindowSubclass(window, Proc, 1); } + return DefSubclassProc(window, message, wp, lp); + } +public: + bool Initialize(HWND parent) { + mWindow = CreateWindowExW(0, L"STATIC", L"", WS_CHILD | WS_VISIBLE | SS_LEFT | SS_NOPREFIX, + 0, 0, 0, 0, parent, nullptr, GetModuleHandleW(nullptr), nullptr); + if (!mWindow) return false; + SetWindowSubclass(mWindow, Proc, 1, reinterpret_cast(this)); + mTip = CreateWindowExW(WS_EX_TOPMOST, TOOLTIPS_CLASSW, nullptr, WS_POPUP | TTS_NOPREFIX | TTS_ALWAYSTIP, + CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, mWindow, nullptr, GetModuleHandleW(nullptr), nullptr); + if (mTip) { + TOOLINFOW tool = {sizeof(tool)}; tool.uFlags = TTF_IDISHWND | TTF_SUBCLASS; + tool.hwnd = mWindow; tool.uId = reinterpret_cast(mWindow); tool.lpszText = const_cast(L""); + SendMessageW(mTip, TTM_ADDTOOLW, 0, reinterpret_cast(&tool)); + SendMessageW(mTip, TTM_SETMAXTIPWIDTH, 0, 900); + } + return true; + } + HWND Window() const { return mWindow; } + const std::wstring& Text() const { return mText; } + const std::wstring& SamplingText() const { return mSampling; } + const std::wstring& ZoomText() const { return mZoom; } + // The caller selects the same status font used by painting. Keep zoom at + // the right edge and let the left field ellipsize before the fields overlap. + void TextRects(HDC dc, RECT rect, RECT& sampling, RECT& zoom) const { + const int padding = q1view::WindowsUiPixels(8, q1view::WindowsUiDpi(mWindow)); + rect.left = (std::min)(rect.right, rect.left + padding); + rect.right = (std::max)(rect.left, rect.right - padding); + SIZE size = {}; GetTextExtentPoint32W(dc, mZoom.c_str(), int(mZoom.size()), &size); + zoom = rect; zoom.left = (std::max)(rect.left, rect.right - size.cx); + sampling = rect; sampling.right = (std::max)(rect.left, zoom.left - padding); + } + int Height() const { + if (!mWindow) return 0; + const auto dpi = q1view::WindowsUiDpi(mWindow); + return (std::max)(q1view::WindowsUiPixels(24, dpi), + q1view::WindowsUiPixels(16, dpi, q1view::WindowsUiSettings().Scale()) + q1view::WindowsUiPixels(8, dpi)); + } + void Layout(HWND parent, bool visible) { + if (!mWindow) return; + RECT rect; GetClientRect(parent, &rect); + SetWindowPos(mWindow, HWND_TOP, 0, (std::max)(0L, rect.bottom - Height()), rect.right, Height(), + SWP_NOACTIVATE | (visible ? SWP_SHOWWINDOW : SWP_HIDEWINDOW)); + } + void SetText(const std::wstring& sampling, const std::wstring& zoom) { + if ((mSampling == sampling && mZoom == zoom) || !mWindow) return; + mSampling = sampling; mZoom = zoom; + mText = sampling.empty() ? zoom : sampling + L" | " + zoom; + SetWindowTextW(mWindow, mText.c_str()); + if (mTip) { + TOOLINFOW tool = {sizeof(tool)}; tool.hwnd = mWindow; + tool.uId = reinterpret_cast(mWindow); tool.lpszText = const_cast(mText.c_str()); + SendMessageW(mTip, TTM_UPDATETIPTEXTW, 0, reinterpret_cast(&tool)); + } + InvalidateRect(mWindow, nullptr, FALSE); + } +}; diff --git a/Viewer/ViewerView.cpp b/Viewer/ViewerView.cpp index 0f4e668..e58bbb1 100644 --- a/Viewer/ViewerView.cpp +++ b/Viewer/ViewerView.cpp @@ -50,23 +50,6 @@ static bool IsPlaybackTraceEnabled() #define WM_VIEWER_AUTOPLAY_VIDEO (WM_APP + 2) -enum QMouseMenuID -{ - QMouseMenuStart = 0x10000000, - QMouseMenuSel = QMouseMenuStart, - QMouseMenuSyncInput, -}; - -struct QMouseMenuInfo { - const char *name; - const QMouseMenuID id; -}; - -static const QMouseMenuInfo QMouseMenu[] = { - { "Sel Mode", QMouseMenuSel }, - { "Sync Input", QMouseMenuSyncInput }, -}; - class CTransitionSnapshotCover { public: CTransitionSnapshotCover() : mHwnd(NULL), mBitmap(NULL) {} @@ -285,11 +268,8 @@ CViewerView::CViewerView() mVolumeDragging = false; mMouseMenu.CreatePopupMenu(); - CString str; - for (int i = 0; i < ARRAY_SIZE(QMouseMenu); i++) { - str = QMouseMenu[i].name; - mMouseMenu.AppendMenu(MF_STRING, ID_MOUSEMENU_START + i, str); - } + mMouseMenu.AppendMenu(MF_STRING, ID_SELECTION_MODE, L"&Selection Mode\tS"); + mMouseMenu.AppendMenu(MF_STRING, ID_SYNC_INPUT, L"Sync Input Across Viewer &Windows"); mDeltaRect.SetRectEmpty(); } @@ -453,7 +433,7 @@ void CViewerView::AdjustWindowSize() } } -void CViewerView::FitToWindow() +void CViewerView::FitToWindow(bool broadcast) { mFitToWindow = true; CRect rcClient; @@ -482,6 +462,7 @@ void CViewerView::FitToWindow() CMainFrame *pMainFrm = static_cast(AfxGetMainWnd()); pMainFrm->UpdateMagnication(mN, mWDst, mHDst); + if (broadcast) BroadcastViewState(true); } void CViewerView::RestoreImageScreenOrigin(const CPoint &screenOrigin) @@ -918,7 +899,7 @@ LRESULT CViewerView::OnAutoplayVideo(WPARAM wParam, LPARAM lParam) pDoc->mAutoplayMessagePending = false; if (pDoc->mDocState == DOC_JUSTLOAD || mIsPlaying || - !pDoc->mFrmSrc || !pDoc->mFrmSrc->isVideo() || + !pDoc->mFrmSrc || (!pDoc->mFrmSrc->isVideo() && pDoc->mFrmSrc->isFixed()) || pDoc->mFrames <= 1 || !theApp.IsVideoAutoplayEnabled()) { return 0; } @@ -1447,6 +1428,7 @@ void CViewerView::OnDraw(CDC *pDC) } pDoc->mCurFrameID = mStableRgbBufferInfo.ID; + if (!pDC->IsPrinting()) static_cast(GetParentFrame())->UpdateViewerStatus(); // Defer autoplay until the first valid RGB buffer has been fully presented. // Posting a generation-tagged UI message avoids starting decoder/audio work @@ -2013,7 +1995,9 @@ bool CViewerView::FindFile(CViewerDoc* pDoc, UINT nChar) // the frame to the image. LoadLayout prevLayout = pDoc->mLoadLayout; pDoc->mLoadLayout = LOAD_FIT_TO_WINDOW; - BOOL opened = pDoc->OnOpenDocument(candidate); + // Keep the existing in-place navigation path, but distinguish this + // explicit user choice from an internal reload for autoplay purposes. + BOOL opened = pDoc->OpenNavigatedDocument(candidate); pDoc->mLoadLayout = prevLayout; if (opened) { pDoc->SetPathName(candidate, TRUE); @@ -2596,6 +2580,26 @@ void CViewerView::OnTimer(UINT_PTR nIDEvent) CView::OnTimer(nIDEvent); } +LRESULT CViewerView::WindowProc(UINT message, WPARAM wp, LPARAM lp) +{ + if (message == WM_INITMENUPOPUP && !HIWORD(lp)) { + if (reinterpret_cast(wp) == mMouseMenu.GetSafeHmenu()) { + mMouseMenu.CheckMenuItem(ID_SELECTION_MODE, MF_BYCOMMAND | (mSelMode ? MF_CHECKED : MF_UNCHECKED)); + auto* frame = static_cast(GetParentFrame()); + mMouseMenu.CheckMenuItem(ID_SYNC_INPUT, MF_BYCOMMAND | (frame->IsSyncInputEnabled() ? MF_CHECKED : MF_UNCHECKED)); + } + mUiPopupMenus.SyncPopup(m_hWnd, reinterpret_cast(wp)); + return 0; + } + 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 CView::WindowProc(message, wp, lp); +} + void CViewerView::OnRButtonUp(UINT nFlags, CPoint point) { if ((!mRcVolume.IsRectEmpty() && mRcVolume.PtInRect(point)) || @@ -2623,18 +2627,16 @@ void CViewerView::ToggleSelMode() Invalidate(FALSE); } nFlags |= mSelMode ? MF_CHECKED : MF_UNCHECKED; - mMouseMenu.CheckMenuItem(QMouseMenuSel - QMouseMenuStart + ID_MOUSEMENU_START, nFlags); + mMouseMenu.CheckMenuItem(ID_SELECTION_MODE, nFlags); } void CViewerView::OnMouseMenu(UINT nID) { - int idx = nID - ID_MOUSEMENU_START; - if (idx < 0 || idx >= ARRAY_SIZE(QMouseMenu)) return; - switch (QMouseMenu[idx].id) { - case QMouseMenuSel: + switch (nID) { + case ID_SELECTION_MODE: ToggleSelMode(); break; - case QMouseMenuSyncInput: { + case ID_SYNC_INPUT: { CMainFrame *pMainFrm = static_cast(AfxGetMainWnd()); pMainFrm->ToggleSyncInput(); UINT nFlags = MF_BYCOMMAND | @@ -2643,6 +2645,6 @@ void CViewerView::OnMouseMenu(UINT nID) break; } default: - QASSERT(0); + break; } } diff --git a/Viewer/ViewerView.h b/Viewer/ViewerView.h index f28329d..9302948 100644 --- a/Viewer/ViewerView.h +++ b/Viewer/ViewerView.h @@ -14,6 +14,7 @@ class DxgiPresenter; #include #include "Q1UiFontWin.h" +#include "Q1UiMenuWin.h" #include #include @@ -176,6 +177,7 @@ class CViewerView : public CView bool mVolumeMuted; bool mVolumeDragging; CMenu mMouseMenu; + q1view::WindowsUiMenus mUiPopupMenus; bool mSelMode; bool mYMode; @@ -199,7 +201,7 @@ class CViewerView : public CView // Operations public: void AdjustWindowSize(); - void FitToWindow(); + void FitToWindow(bool broadcast = false); bool IsFitToWindow() const { return mFitToWindow; } // Keep the displayed image origin fixed in desktop coordinates when the // hosting viewport moves during a drawer layout change in manual zoom mode. @@ -270,6 +272,7 @@ class CViewerView : public CView public: virtual void OnDraw(CDC* pDC); // overridden to draw this view virtual BOOL PreCreateWindow(CREATESTRUCT& cs); + virtual LRESULT WindowProc(UINT message, WPARAM wp, LPARAM lp) override; protected: // Implementation diff --git a/Viewer/Y4mReaderWin.h b/Viewer/Y4mReaderWin.h new file mode 100644 index 0000000..ca15bf4 --- /dev/null +++ b/Viewer/Y4mReaderWin.h @@ -0,0 +1,99 @@ +#pragma once + +#include "RawFrameLayout.h" +#include "qimage_cs.h" +#include +#include +#include +#include +#include + +namespace q1view { +struct Y4mInfo { + int width = 0, height = 0; + QIMAGE_CS colorSpace = QIMAGE_CS_YUV420; + double fps = 0; + RawFrameLayout layout; +}; + +inline bool HasY4mSignature(CFile& file) { + const auto position = file.GetPosition(); + char signature[9] = {}; + const bool result = file.Read(signature, sizeof(signature)) == sizeof(signature) && + memcmp(signature, "YUV4MPEG2", sizeof(signature)) == 0; + file.Seek(position, CFile::begin); + return result; +} + +inline bool HasY4mSignature(const CString& path) { + CFile file; CFileException error; + if (!file.Open(path, CFile::modeRead | CFile::shareDenyNone | CFile::typeBinary, &error)) return false; + return HasY4mSignature(file); +} + +inline bool ReadY4mLine(CFile& file, std::string& line) { + const auto start = file.GetPosition(); + char buffer[4096]; + const UINT count = file.Read(buffer, sizeof(buffer)); + const char* end = static_cast(memchr(buffer, '\n', count)); + if (!end) return false; + line.assign(buffer, size_t(end - buffer)); + file.Seek(start + (end - buffer) + 1, CFile::begin); + if (!line.empty() && line.back() == '\r') line.pop_back(); + return true; +} + +inline bool ParseY4m(CFile& file, Y4mInfo& result) { + file.Seek(0, CFile::begin); + std::string line, token, color; + if (!ReadY4mLine(file, line)) return false; + std::istringstream header(line); + header >> token; + if (token != "YUV4MPEG2") return false; + Y4mInfo info; + auto positive = [](const std::string& value, int& output) { + char* end = nullptr; errno = 0; + const long number = strtol(value.c_str(), &end, 10); + if (errno || end == value.c_str() || *end || number <= 0 || number > INT_MAX) return false; + output = int(number); return true; + }; + while (header >> token) { + if (token.size() < 2) continue; + const auto value = token.substr(1); + if (token[0] == 'W' && !positive(value, info.width)) return false; + if (token[0] == 'H' && !positive(value, info.height)) return false; + if (token[0] == 'C') color = value; + if (token[0] == 'F') { + int numerator = 0, denominator = 0; + const auto colon = value.find(':'); + if (colon == std::string::npos || !positive(value.substr(0, colon), numerator) || + !positive(value.substr(colon + 1), denominator)) return false; + info.fps = double(numerator) / denominator; + } + } + if (info.width <= 0 || info.height <= 0) return false; + const ULONGLONG pixels = ULONGLONG(info.width) * info.height; + const ULONGLONG chroma = ULONGLONG((info.width + 1LL) / 2) * ((info.height + 1LL) / 2); + ULONGLONG bytes = 0; + if (color.empty() || color == "420" || color == "420jpeg" || color == "420mpeg2" || color == "420paldv") + bytes = pixels + 2 * chroma; + else if (color == "420p10") { info.colorSpace = QIMAGE_CS_YUV420P10LE; bytes = 2 * (pixels + 2 * chroma); } + else if (color == "mono") { info.colorSpace = QIMAGE_CS_GRAYSCALE; bytes = pixels; } + else return false; // Other Y4M formats retain the existing decoded-video fallback. + if (!bytes || bytes > INT_MAX || pixels > INT_MAX / 6) return false; + info.layout.payloadBytes = UINT(bytes); + const auto length = file.GetLength(); + while (file.GetPosition() < length) { + if (!ReadY4mLine(file, line) || (line != "FRAME" && line.compare(0, 6, "FRAME ") != 0)) return false; + const auto offset = file.GetPosition(); + if (length - offset < bytes) break; // Ignore an incomplete trailing frame while a file is being written. + if (info.layout.offsets.size() >= LONG_MAX) return false; + info.layout.offsets.push_back(offset); + file.Seek(offset + bytes, CFile::begin); + } + if (info.layout.offsets.empty()) return false; + result = std::move(info); + file.Seek(0, CFile::begin); + return true; +} +} diff --git a/Viewer/resource.h b/Viewer/resource.h index dbe2ffa..b07f8da 100644 --- a/Viewer/resource.h +++ b/Viewer/resource.h @@ -16,6 +16,8 @@ #define ID_CS_END 0x71ff #define ID_MOUSEMENU_START 0x7200 #define ID_MOUSEMENU_END 0x72ff +#define ID_SELECTION_MODE ID_MOUSEMENU_START +#define ID_SYNC_INPUT (ID_MOUSEMENU_START + 1) #define ID_FPS_START 0x7300 #define ID_FPS_END 0x73ff #define ID_COMPARE 32771 @@ -33,7 +35,7 @@ #ifdef APSTUDIO_INVOKED #ifndef APSTUDIO_READONLY_SYMBOLS #define _APS_NEXT_RESOURCE_VALUE 140 -#define _APS_NEXT_COMMAND_VALUE 32780 +#define _APS_NEXT_COMMAND_VALUE 32782 #define _APS_NEXT_CONTROL_VALUE 1004 #define _APS_NEXT_SYMED_VALUE 101 #endif diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md index 64a2aef..ae1e070 100644 --- a/docs/STORE_LISTING.md +++ b/docs/STORE_LISTING.md @@ -25,10 +25,16 @@ 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. +Viewer and Comparator use coordinated Pretendard typography for app-drawn Korean and English text, window titles, application menus and app-owned context menus. Text follows Windows text-size and display scaling settings. Viewer keeps resolution, pixel-format and FPS menus directly accessible and places Browse immediately left of Options, without a duplicate Thumbnail Browser entry in File. Options groups image scaling, video autoplay and theme settings without separate View or More menus. Narrow windows wrap the menus; open files through File > Open or Ctrl+O without a duplicate Open button. Hovering or moving keyboard focus never executes direct commands. Comparator retains its existing menu layout. Viewer's read-only status line keeps the sampling mode on the left and actual magnification on the far right, without duplicate top-level zoom or Fit/100% controls. Video frame/time progress remains available in its existing playback display. Help descriptions wrap and scroll for access to the full shortcut list. Native Windows window buttons and system dialogs retain their familiar behavior. Windows RAW interpretation, copying/pasting image pixels, opening a file in Comparator and clearing a selection use shared availability checks for their existing entry points. Comparator source-format controls are native keyboard-accessible buttons for RAW input, with visible focus and disabled states; decoded-source format controls are disabled instead of offering an inapplicable action. +Viewer's autoplay preference applies to videos and multi-frame RAW files, both when opening directly and when moving to the previous or next file, without resizing the current window during navigation. Single-frame inputs, internal refreshes and rotating a paused source do not start playback. RAW resolution and color-space changes update the interpreted image and the visible menu labels. Unavailable commands use muted text rather than decorative dashed outlines; keyboard focus remains distinct from mouse activation. + +Selection Mode is available in Options in both Windows apps, as well as through S and image context menus, with matching check states. Viewer's Sync Input Across Viewer Windows toggle is also available in Options and its context menu. Comparator retains shared pane interaction and the contextual Clear Selection/Esc action. + +Windows Viewer supports native Y4M RAW-frame interpretation even for .yuv filenames: supported layouts retain editable resolution and color-space controls, frame-accurate seeking and autoplay, without treating container headers as pixels or changing the source file. + An empty Windows Viewer session opens with a roomier, DPI-scaled 800×600-class viewing area and measured menu clearance, limited to the monitor work area. The thumbnail drawer stays inside that window. The initial resolution for RAW inputs without recognized dimensions is 800×600 pixels; filename dimensions and a manually selected resolution remain supported. Viewer's Options > Theme menu offers shared System, Light, and Dark choices for both Viewer and Comparator. Comparator follows automatically without a duplicate theme menu, including its Timeline scrollbar. One saved choice updates application colors and image-surround brightness together and respects Windows high contrast. Theme changes keep open media, zoom, selections, playback, and comparison results intact; image pixels and captured regions are not tinted. Windows-owned dialogs and popup chrome retain their OS appearance. @@ -51,7 +57,7 @@ BT.2020 HLG videos are tone-mapped for accurate color and contrast on standard S In the Windows Viewer, right-click a single thumbnail to open it, copy the file or its path, reveal it in File Explorer, view Windows properties, or move it to the Recycle Bin; right-click a selection of two to four media files and choose Compare selected files to open them in Comparator, or move a larger selection to the Recycle Bin. Delete performs the same confirmed recycle operation. Gallery grids focus on useful image and video previews, while compact mode keeps the complete folder list. Directories use clean **[Folder name]** and **[..]** notation with balanced Pretendard typography instead of decorative folder artwork; long names wrap across the card and use an ellipsis only on the final visible line. Every non-empty folder, including a drive root, starts with a visible **[..]** entry; activating it from `D:\` opens the top-level logical-drive chooser so another volume such as `C:\` can be selected. Backspace and Alt+Up provide the same navigation without interrupting the photo or video in the main view, while Viewer shortcuts remain available when the drawer has focus. -Choose Auto, Smooth (Bilinear), or Pixel Exact (Nearest) image scaling from the View menu. Auto keeps scanned text clear at moderate reductions, suppresses aliasing when large photos are reduced substantially, and preserves exact pixels during close inspection. +Choose Auto, Smooth (Bilinear), or Pixel Exact (Nearest) image scaling from Options > Image Scaling. Auto keeps scanned text clear at moderate reductions, suppresses aliasing when large photos are reduced substantially, and preserves exact pixels during close inspection. COMPARATOR diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 369d8be..cfaaef4 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -36,13 +36,23 @@ identifies the active pixel-value mode and row order (`Y/U/V` or `R/G/B`) without adding labels to every pixel. Decoded images and video show RGB values only. +### Windows Viewer commands and status + +The top menu keeps **File, resolution, pixel format, FPS, Browse, Options**, followed by the right-aligned **Compare, Help** group. **Browse** is immediately left of **Options** and is the single menu entry for the thumbnail drawer; `E` performs the same toggle. There are no top-level zoom, Fit or fixed 100% controls. Open files through **File → Open** or **Ctrl+O**, without a duplicate top-level Open button. **Browse** highlights when open. **Compare** is always available and can open an empty Comparator workspace. + +Hovering over a direct command such as Browse or Compare never executes it, including while another menu is open. Arrow keys move keyboard focus; click, Enter or Space explicitly activates the focused command. Viewer canvas and thumbnail-browser context menus use the same app font, palette and row spacing as Comparator's menus, retaining native check states and commands. + +Menus use measured text widths at the current DPI and Windows text size, wrapping into additional rows when necessary rather than hiding commands or reducing font size. Resolution, pixel format and FPS remain directly visible as before; fixed decoded sources keep RAW-only settings unavailable. Image scaling, video autoplay and theme settings are grouped in **Options**, with no separate **View** or **More** menu. File contains file operations rather than duplicate drawer or autoplay settings. Existing mouse-wheel zoom, automatic fitting on opening a source, Alt menu access and shortcuts remain available. Windows high-contrast/non-DWM fallback retains the OS-managed native menu. + +The bottom status line is read-only and has two fields: the current sampling label (**Smooth**, **Pixel**, or **Auto**, including **Auto→Pixel** at high zoom) on the left, and the actual zoom percentage on the far right. Resolution, color space, Fit/Manual and duplicate frame text are not shown here. Empty/loading input shows **--** in the zoom field rather than a fabricated percentage. The fields use the shared app font and palette; long text can ellipsize without overlapping, and the full text remains available through the tooltip and native accessibility name. Status updates do not resize the image area or move command buttons. The video's existing frame/time progress display is unchanged. Full screen hides both command and status areas. + ### Image Scaling -Choose **View → Image Scaling** or press `I` to cycle through **Auto**, **Smooth (Bilinear)**, and **Pixel Exact (Nearest)**. Auto uses bilinear sampling for moderate still-image reductions so thin document text remains legible, area sampling for reductions of 2x or more to suppress aliasing in large photos, and nearest sampling for pixel inspection and timed sources. The active choice appears beside the magnification readout and in the Qt status bar; at high zoom, `Auto→Pixel` confirms that Auto is displaying exact source pixels. +Choose **Options → Image Scaling** in Windows Viewer (**View → Image Scaling** in Qt) or press `I` to cycle through **Auto**, **Smooth (Bilinear)**, and **Pixel Exact (Nearest)**. Auto uses bilinear sampling for moderate still-image reductions so thin document text remains legible, area sampling for reductions of 2x or more to suppress aliasing in large photos, and nearest sampling for pixel inspection and timed sources. The active choice appears in the read-only Windows status line and in the Qt status bar; at high zoom, `Auto→Pixel` confirms that Auto is displaying exact source pixels. ### Theme (Windows Viewer and Comparator) -In Viewer choose **Options → Theme**. This is the shared theme for both Viewer and Comparator; Comparator has no separate theme menu. **System** is the default and follows the Windows app theme. **Light** applies bright application colors and a light gray image surround (`#ECECEC`); **Dark** applies dark application colors and a dark gray image surround (`#181818`). One selection updates titles, application menus, panels, help, text, selection, metric graph colors and the exposed background around images together, including other running instances, without reopening sources or resetting zoom, pan, ROI, playback or comparison results. To change the theme while using Comparator alone, open Viewer. Viewer's **View** menu retains image-scaling controls, and Comparator retains its comparison-specific Options. Comparator's Timeline scrollbar matches the application palette, reverting to native rendering in Windows high contrast. Windows-owned file dialogs, system menus, other native scrollbars and popup chrome retain their OS appearance. +In Viewer choose **Options → Theme**. This is the shared theme for both Viewer and Comparator; Comparator has no separate theme menu. **System** is the default and follows the Windows app theme. **Light** applies bright application colors and a light gray image surround (`#ECECEC`); **Dark** applies dark application colors and a dark gray image surround (`#181818`). One selection updates titles, application menus, panels, help, text, selection, metric graph colors and the exposed background around images together, including other running instances, without reopening sources or resetting zoom, pan, ROI, playback or comparison results. To change the theme while using Comparator alone, open Viewer. Viewer's **Options** also contains image-scaling controls, and Comparator retains its comparison-specific Options. Comparator's Timeline scrollbar matches the application palette, reverting to native rendering in Windows high contrast. Windows-owned file dialogs, system menus, other native scrollbars and popup chrome retain their OS appearance. The Timeline scrollbar appears only when video frame rows overflow the panel, not for empty or still-image comparisons. Scroll with the wheel, arrow/page buttons or thumb drag; when the Timeline or scrollbar has focus, Up/Down, Page Up/Page Down and Home/End scroll its list without seeking the source media. Clicking a frame row still selects that frame, and the Timeline's existing Space and Left/Right playback shortcuts remain available. @@ -50,13 +60,17 @@ Theme changes do not tint image pixels or alter color conversion, captured regio ### Controls -Windows command availability follows the same rules in menus, keyboard command handlers and contextual help. Viewer RAW resolution/color-space controls are available for RAW inputs and empty-session setup, but not for decoded images or videos. Copy needs presented image pixels; Paste needs a supported image on the clipboard; the main Compare command needs an open file. The browser's separate Compare selected files command still applies to its own selection. Unavailable commands do not execute through alternate entry points. +Windows command availability follows the same rules in menus, keyboard command handlers and contextual help. Viewer RAW resolution/color-space controls are available for RAW inputs and empty-session setup, but not for decoded images or videos. Copy needs presented image pixels; Paste needs a supported image on the clipboard. The main Compare command is always available: with an open file it passes that file to Comparator, and without one it opens an empty Comparator workspace. The browser's separate Compare selected files command still applies to its own selection. Unavailable commands do not execute through alternate entry points. + +Windows Viewer also reads supported **YUV4MPEG2/Y4M** containers as native RAW frames, even when their extension is `.yuv`. The header initializes dimensions, native color space and FPS; resolution and Color Space remain editable instead of being locked as decoded BGR video. Current native layouts are 8-bit planar 4:2:0 (including the default/420jpeg/420mpeg2/420paldv tags), 10-bit planar 4:2:0 and 8-bit monochrome. Unsupported Y4M layouts retain the existing decoded-video fallback. Frame markers never become pixels: the container's original payload boundaries/frame count are retained during reinterpretation, smaller interpretations read only the requested bytes, and larger ones are zero-padded within each frame. Changing interpretation pauses playback safely while rebuilding; Space resumes playback. The source file and its header are never edited. Ordinary headerless RAW retains its existing size-dependent frame count; MP4/JPEG and other decoded sources retain intrinsic-format restrictions. -In Comparator, each source's format menu is a native keyboard-accessible button for RAW input; decoded-source format buttons are disabled and read-only filenames are not buttons. Tab from an eligible source canvas focuses its format button, Space/Enter opens it, and Tab returns to the canvas. Normal, hover and pressed states differ; keyboard focus has a visible outline and disabled custom buttons have a dashed boundary. Native Alt menus and Windows high-contrast behavior are retained. This does not add a new toolbar or change global shortcut routing. +In Comparator, each source's format menu is a native keyboard-accessible button for RAW input; decoded-source format buttons are disabled and read-only filenames are not buttons. Tab from an eligible source canvas focuses its format button, Space/Enter opens it, and Tab returns to the canvas. Normal, hover and pressed states differ; keyboard focus has a visible outline, while unavailable commands use muted text without an extra dashed outline. Native Alt menus and Windows high-contrast behavior are retained. This does not add a new toolbar or change global shortcut routing. + +In Windows Viewer, **Options > Autoplay Multi-frame Files** applies both to opening a video or multi-frame RAW file directly and to moving to another file with Page Up/Page Down. With the option enabled (the default), playback starts after the first frame is presented; with it disabled, the source waits at its first frame. This retains the existing saved autoplay preference. Single-frame RAW and decoded still images never autoplay. File navigation keeps the current outer window size and full-screen mode. Internal reloads and rotating a paused source do not start autoplay. RAW resolution and Color Space remain editable in their existing top-level menus; changes update both the interpreted pixels/frame count and the displayed menu labels, while decoded images/videos retain their intrinsic format. 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 32 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. +In the Windows Viewer and Comparator, window titles, help descriptions, main application menus and app-owned context 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 32 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; Windows-owned system menus and dialogs retain native typography. | Action | Control | | --- | --- | @@ -85,7 +99,7 @@ In the Windows Viewer and Comparator, window titles, help descriptions and main ### Thumbnail Browser -Press `E` (or **File ▸ Thumbnail Browser**) to slide out a thumbnail drawer on +Press `E` (or the top-level **Browse** command) to slide out a thumbnail drawer on the left. It is hidden by default, so the window is unchanged until you open it. The compact list is a complete view of the current folder, with thumbnails or labeled placeholders as appropriate. In the Windows Viewer, Ctrl+wheel over @@ -108,10 +122,12 @@ In the Windows Viewer, opening or closing the docked drawer changes the image vi ### Synchronized Viewer Windows To inspect related sources interactively, open more than one Viewer window, -right-click each image area, and enable **Sync Input**. Navigation, zoom, pan, +and enable **Options → Sync Input Across Viewer Windows** in each window. The same toggle remains available by right-clicking each image area. Navigation, zoom, pan, rotation, playback, FPS, and display mode changes are relayed between enabled Viewer windows. +In both Windows Viewer and Comparator, **Options → Selection Mode**, the image's **Selection Mode** context command and the existing `S` shortcut control the same mode and show matching checks. This changes left-drag between selection and panning without adding a separate preference or reopening sources. Viewer's mode belongs to its window; Comparator's mode is shared across all comparison panes and remains accessible from Options when Timeline has focus. Comparator's **Clear Selection** stays in the image context menu and on `Esc`, rather than being moved into Options. Pane-specific Color Space controls remain with their panes. + ## Comparator Comparator opens two to four images, raw dumps, or video/frame sources in aligned diff --git a/docs/verification/issue-120-viewer-command-status.md b/docs/verification/issue-120-viewer-command-status.md new file mode 100644 index 0000000..307829b --- /dev/null +++ b/docs/verification/issue-120-viewer-command-status.md @@ -0,0 +1,136 @@ +# Issue #120: Viewer command row and read-only status + +## User acceptance and release preparation — 2026-10-19 + +The user approved clearing the completed related issue and releasing after the latest preview. Prepare v2.8.2 for the completed #120 scope and its documented post-v2.8.1 follow-ups; keep future redesign jobs and their coordination epics open. Prior statements below that prohibit merge/release describe the earlier review stage, not this new authorization. Production evidence is the final 1,790 Viewer / 131 startup-RAW checks and the earlier 779 Comparator checks (Comparator production code is unchanged by the Y4M follow-up), plus common UI/core tests, both ordinary Windows Release builds and actual latest-preview inspection. CoreRegressionTests was rebuilt/rerun for release preparation; only changelog/verification documentation changed afterward. + +Curated v2.8.2 notes are 1,173 characters, below the Store pipeline's 1,500-character limit, with one logical line per bullet. Local version remains 0.0.0-dev; the direct version script is machine-policy-blocked, and no policy bypass is used. The tagged Actions workflow owns release versioning, final builds, packaging/signing and GitHub/Store publication. PR source-build checks still running at approval are not represented as completed; do not wait/poll for release completion after pushing the release tag. Tag/run links and the actual queued/running state are the handoff, not a claim that binaries or Store publication already finished. The user's unrelated untracked design files remain outside the release commits. + +## Playable RAW/Y4M controls and actual latest preview — 2026-10-19 + +The user's running Viewer was still Issue120RawPlaybackReview, predating the already committed Options additions in 609649a; the new menu was not missing from the source/build. The reported .yuv contains a YUV4MPEG2 header and had been opened by VidCapFrmSrc as intrinsically fixed decoded BGR, so merely enabling the existing buttons would have fed mismatched dimensions/format into that decoder. Implement an indexed native-RAW path for supported Y4M layouts instead, selected by signature before the normal image/video/raw order. Other inputs retain their existing order and unsupported Y4M layouts retain decoded-video fallback. Qt and Comparator production code are unchanged. + +The Viewer-only bounded reader parses width/height, rational FPS and supported native color space, then indexes actual FRAME payload offsets, allowing variable-length marker tags. It never decodes all frames during discovery. Source reads and per-worker snapshots use those immutable payload boundaries; current resolution/color-space settings can change without reading subsequent marker bytes or the next frame. Missing bytes in larger interpretations are zero-filled; frame count remains the container count. This is different from headerless RAW, whose frame count depends on interpreted frame size. Existing queue/worker lifecycle, explicit autoplay, seek, RAW menu command routing and file reload machinery are reused. Headerless RAW behavior and decoded MP4/JPEG restrictions are preserved. Supported native layouts: 8-bit planar 420 variants/default, planar 420p10 and mono. Other layouts fall back to the existing video reader. Format reference reviewed: [FFmpeg Y4M demuxer](https://raw.githubusercontent.com/FFmpeg/FFmpeg/master/libavformat/yuv4mpegdec.c); no FFmpeg implementation code is copied into this reader. + +Final verification passed **1,790 full Viewer checks** (`issue-120-y4m-gallery.txt`) and **131 startup/RAW checks** (`issue-120-y4m-startup.txt`), exit 0, with CoreRegressionTests, WindowsUiTypographyTests, ordinary/instrumented Release/x64/v143 builds and git diff --check. Generated coverage checks native payload bytes, variable marker lengths, rational timing, seeking, actual format/resolution commands during playback, safe oversized padding, restored interpretation and autoplay, plus malformed dimensions/unsupported layouts/truncation/overflow-sized headers. The full suite also runs the new framed-RAW flow against the user-designated local 300-frame source using Q1VIEW_Y4M_TEST_FILE, reading it without changing its contents. The same existing menu/ROI/E/theme/playback/GPU regressions remain green. A C++ pointer-iterator type mismatch in the new line reader was fixed before the successful builds/runs. No new Comparator full-suite run is claimed for this Viewer-only follow-up. + +Computer Use launched the actual latest `Viewer/x64/Issue120RawEditableReview/Viewer.exe`, opened Options and directly confirmed Selection Mode and Sync Input Across Viewer Windows. It opened the user's requested YUV through the observed MRU entry and confirmed native YUV420 at 176x144 / 29.97fps with automatic frame advancement. Both resolution and Color Space popups opened with selectable entries; no interpretation value was changed during this ordinary UI inspection (the actual command changes are covered by the integration run). Options is left open so the user can see both additions. The older running window is untouched. No user media, Windows setting, version, merge or release was changed. + +## Shared interaction settings in Options and context shortcuts — 2026-10-18 + +The user approved the earlier menu proposal. Windows Viewer now exposes Selection Mode (S) and Sync Input Across Viewer Windows in Options, retaining both image-context shortcuts. Comparator adds Selection Mode (S) to its existing comparison-specific Options, retaining its context toggle and Clear Selection/Esc. Clear is not a new Options setting; no Comparator Sync Input or pane-format relocation is added. Both menu surfaces use the existing shared font/theme renderer and legacy context command IDs through named aliases. Existing autoplay preference and source handling are unchanged. + +Viewer Options/context/S all use the existing view selection toggle and frame synchronization flag. Popup initialization refreshes checks from those actual states. Frame fallback handlers make Options independent of browser focus. Comparator centralizes the toggle in its shared document, with command/update routing available even when Timeline is the active view; image context and S reuse that toggle. Selection-mode changes preserve its existing ROI. No new persisted/global preference or duplicate mode flag is introduced. Viewer Options has scaling, autoplay, separator, selection, sync, separator, Theme; Comparator Options has the existing different-resolution policy, separator, selection. + +Added actual-app checks for Options/context/S toggles, exact-once execution, matching native menu checks, both Comparator panes, Timeline routing, retained ROI/source identity and exact Options structure. A test compilation error from calling a protected derived OnCmdMsg directly was corrected to use its public CCmdTarget interface; no production API was exposed for the test. The hand-built CCmdUI fixture initially omitted m_nIndexMax, triggering MFC's Enable/SetCheck precondition; it now supplies the actual menu count, and the suite is rerun rather than waiving the failure. Earlier descriptions below that call this proposal pending are historical. + +Final-code verification passed: **1,748 full Viewer checks** (`issue-120-interaction-gallery.txt`), **131 startup/RAW checks** (`issue-120-interaction-startup.txt`) and **779 Comparator checks** (`issue-120-interaction-comparer.txt`), exit 0, including actual ONNX LPIPS inference, PSNR/SSIM/ROI and two/three/four-source regressions. WindowsUiTypographyTests and CoreRegressionTests passed, as did both ordinary and instrumented Release/x64/v143 builds and git diff --check. New ordinary binaries are `Viewer/x64/Issue120InteractionReview/Viewer.exe` and `Comparator/x64/Issue120InteractionReview/Comparator.exe`, paired for the normal Compare launcher/runtime layout. Existing user windows were left running and were not replaced; no separate ordinary-window UI automation or OS setting change is claimed for this revision. No merge, version bump or release. + +## RAW interpretation and multi-frame playback follow-up — 2026-10-18 + +The user reported RAW resolution/color-space editing and required Page Up/Page Down autoplay for multi-frame RAW as well as video. Before the fix, the added actual-app regression confirmed RAW root buttons and popup commands remained enabled after a decoded photo, and an NV12 command changed document interpretation, but failed because the hosted color-space label remained unchanged. RefreshView now updates command availability and synchronizes source labels once after interpretation changes, including incoming synchronized changes, not on each frame/zoom/idle update. + +Explicit file open/navigation now decides autoplay after the actual frame count is known, accepting video and non-fixed RAW sequences with more than one frame. It preserves the existing saved preference/default and first-presented-frame/generation-tagged UI-message safeguards. Rename only the visible setting to Autoplay Multi-frame Files; keep its ID/profile key and handlers. Single-frame RAW/stills and internal reloads remain paused. Existing manual playback, progress display, navigation size and full screen are unchanged. Added real Page Up/Page Down coverage through photo/video/RAW/single-frame RAW/RAW, both directions, manual pause, disabled autoplay, full screen and explicit RAW open; generated files use an isolated test profile/directory. + +Menu review only, pending user approval: add Selection Mode to Options in both apps and Sync Input to Viewer Options, sharing existing state/handlers with retained context shortcuts. Keep Comparator Clear Selection as an action in the context menu/Esc, not a persistent option; retain per-pane format controls. Viewer synchronization is per window and opt-in; Comparator already shares selection/view interaction across panes, so do not invent an extra Sync Input setting there. The main menu improves discoverability; context shortcuts retain canvas locality and full-screen access, consistent with Microsoft's menu/context guidance (https://learn.microsoft.com/en-us/windows/apps/develop/ui/controls/menus-and-context-menus). No context/menu relocation is implemented by this follow-up. + +During test development, the decoded-photo-to-unnamed-RAW fixture initially assumed YUV420 defaults after opening a BGR photo; it was corrected to validate untouched defaults first and use explicitly named RAW for the decoder transition. A later explicit-open test selected the already open paused RAW document, which MFC activates without reopening; it now switches to a different photo first to exercise a genuine new RAW open. Neither test assumption required a product behavior change. The separate stale hosted-label failure above was a reproduced product regression, fixed in RefreshView. + +Final instrumented code passed **1,717 full Viewer integration checks** (`issue-120-raw-autoplay-gallery.txt`) and **100 startup/RAW checks** (`issue-120-raw-autoplay-startup.txt`), exit 0. RAW checks use actual MFC popup initialization, native root-button enabled state, WM_COMMAND format/resolution changes, hosted labels and displayed dimensions/recalculated frame count. CoreRegressionTests, WindowsUiTypographyTests and ordinary/instrumented Viewer Release/x64/v143 builds passed, as did git diff --check. Synthetic playback observed 59.999 fps baseline / 59.780 during grid zoom (max pump gap 10.132 ms), passing the existing smoke threshold, not a controlled historical benchmark. The ordinary build is `Viewer/x64/Issue120RawPlaybackReview/Viewer.exe`, paired with the unchanged Comparator/runtime; existing user windows were not replaced or controlled. No new ordinary loaded-window visual run, OS change, Comparator production change, merge or release is claimed. + +## Agreed final status fields and menu ownership — 2026-10-18 + +The user clarified that sampling must remain visible. Current Viewer status is exactly sampling on the left and actual magnification on the far right, in the existing status row. Keep Smooth/Pixel/Auto (including the existing Auto→Pixel label), remove status dimensions/color-space/Fit/Manual/frame text, and remove the duplicate top-level magnification entry and its reserved menu width. Preserve the video's separate frame/time progress, wheel zoom and internal automatic fitting. Empty/loading input shows -- on the right with no fabricated metadata. Both fields retain the existing shared status font/palette, buffered painting, full native accessibility name and tooltip; measurement prevents overlap. No slider, reset button, extra row or overlay is added. + +Remove File > Thumbnail Browser while retaining top-level Browse and E. Move Autoplay Videos When Opened from File into Options without changing its command ID, preference, check-state handler or playback behavior. Image Scaling remains Options child zero; Options now contains Image Scaling, autoplay, separator and Theme. All earlier conflicting status/menu descriptions below are historical. + +Ordinary-app inspection exposed stale hosted resolution/color-space labels after opening a source once the incidental top-zoom synchronization was removed. CViewerDoc::UpdateMenu now refreshes the command bar once after updating all source labels; idle/frame/zoom status updates still do not rebuild it. Added a regression that seeds stale resolution/format/FPS labels and asserts that all three actual native buttons match the current document after UpdateMenu. + +Final-code verification passed: **86 startup/RAW checks** (`issue-120-status-layout-startup.txt`) and **1,642 full Viewer integration checks** (`issue-120-status-layout-gallery.txt`), exit 0. Checks cover sampling mode changes, right-aligned measured zoom, absence of duplicate metadata/top zoom/File browser, original E/Ctrl+O bindings, autoplay command/check restoration and the new source-label regression, alongside the existing playback/theme/E/ROI/GPU suite. Ordinary and instrumented Viewer Release/x64/v143 builds, WindowsUiTypographyTests, CoreRegressionTests and git diff --check passed. Synthetic playback observed 59.986 fps baseline / 59.989 during grid zoom (max pump gap 9.232 ms); this is a smoke observation, not a historical benchmark. Shared/Comparator production code is unchanged by this revision; no new Comparator full-suite run is claimed. + +Computer Use launched `Viewer/x64/Issue120StatusFinalReview/Viewer.exe` without replacing the user's running preview, confirmed the empty status has -- only at the right and no top zoom, and inspected File without browser/autoplay duplicates. Further loaded-image visual input was interrupted by detected user input; automation stopped, so the final loaded-source/sampling/zoom evidence is the actual-app integration suite above, not a completed extra ordinary-window wheel check. The corrected preview is left running. Existing windows, user media and OS settings were not changed by automation. No version bump, merge or release. + +## Compact magnification spacing — 2026-10-18 + +At the user's request, reduce only Viewer's magnification reservation from 88888.8% to 888.8%. The measured normal menu width decreases from 588 to 570 px at 96 DPI, 745 to 723 at 120 DPI and 877 to 851 at 144 DPI. Normal values below 1000% keep menu geometry fixed; exceptionally large values use their actual measured width without clipping or overlap. Live percentage updates, read-only semantics, Browse/Options order and other command behavior are unchanged. The previous all-values geometry reservation below is superseded by this compact policy. + +Verification passed: WindowsUiTypographyTests (including explicit before/after width measurements at all three DPIs), **74 startup/RAW checks** (`issue-120-compact-zoom-startup.txt`) and **798 focused loaded-source/shell/wheel/navigation checks** (`issue-120-compact-zoom-focused.txt`), exit 0. Normal zoom values retain their rectangles; large values remain far-right and do not overlap other labels. Ordinary and instrumented Viewer Release/x64/v143 builds and `git diff --check` passed. This one-line Viewer production adjustment did not repeat the earlier full GPU/Comparator suites or modify their sources. The updated ordinary binary is `Viewer/x64/Issue120ZoomReview/Viewer.exe`; no new ordinary UI session, OS change, merge or release occurred for this spacing correction. + +## Remove zoom commands; restore real magnification — 2026-10-18 + +The user rejected the added Fit button and fixed 100% action. Remove both top-level controls and their added Options entries/command handlers; keep existing mouse-wheel zoom and automatic fitting unchanged. Retain the original ID_MAGNIFY as a read-only, far-right current percentage, refreshed from the actual presented view zoom. Empty/loading input shows --. Update only changed text and reserve measured width at the active font/DPI/text scale so Help and menu/content geometry do not move as digits change. The retained HMENU also updates during full screen. Browse order, context-menu styling, direct-command activation fixes and Comparator layout are unchanged. Earlier Fit/100% control descriptions below are historical, not the current UX. + +Verification passed: **74 startup/empty/RAW checks** (`issue-120-live-zoom-startup.txt`), **1,212 full Viewer integration checks** (`issue-120-live-zoom-gallery.txt`), WindowsUiTypographyTests and CoreRegressionTests, exit 0. Viewer ordinary and instrumented Release/x64/v143 builds passed. Exact native HMENU and hosted accessibility names follow actual zoom across 67%, 100%, 113%, 200%, 999%, 1000%, 9999% and 12800%; the real WM_MOUSEWHEEL path reaches 100% from 99% and continues above 100%. The readout remains non-executable, far-right and geometrically stable, with no menu-height or Help-target changes. Options has only Image Scaling, separator and Theme. Full source/ROI, E/manual anchors, theme, autoplay and GPU checks remain green. Synthetic playback observed 59.962 fps baseline / 59.959 during grid zoom, passing the existing relative threshold; this is a smoke observation, not a controlled benchmark. A test-only private-method access compile error was corrected by using the existing public sync-input route; the instrumented build and full suite were then rerun successfully. Comparator production/shared source is unchanged by this correction; its previously verified binary is paired beside the review Viewer. + +Computer Use launched `Viewer/x64/Issue120ZoomReview/Viewer.exe` and confirmed the empty window has no Fit/fixed 100% buttons and shows a read-only -- at the far right. The ordinary file dialog exposed a settable filename edit but the helper rejected setting it; focus was reported on Search rather than the filename. No text was typed into an uncertain target and the dialog was cancelled. Therefore loaded-image/zoom verification is the actual-app in-process integration result above, not an additional successful ordinary file-dialog/physical-wheel claim. The corrected ordinary Viewer is left open for the user. No OS settings, version bump, merge, tag or release occurred. `git diff --check` passed. + +## Explicit activation, Browse order and context menus — 2026-10-18 + +The user reported Browse opening on hover. The shared frame's native menu filter switched to every hovered root, including direct commands, and Open immediately dispatched a root without a submenu. Fix: hover may switch popups but cannot select a direct command for execution; arrow navigation may focus a direct command but requires explicit activation. A direct click during native popup tracking is consumed through press/release and executes once only when released on the same enabled target. A release outside cancels. The regression uses the real TrackPopupMenuEx loop and WH_MSGFILTER hook for hover, Right, cancelled press/release, complete click and subsequent Enter, not just a pure policy fixture. + +Viewer order is now File, resolution, pixel format, FPS, Browse, Options, Compare, Help, Fit, 100%. Browse is immediately left of Options. Viewer canvas and thumbnail context popups (including nested Copy path/name) now use the exact WindowsUiMenus renderer already used by Comparator: Pretendard Body, measured DPI/text-size-aware rows, shared theme palette and native check/command state. Temporary thumbnail style metadata lives only for that popup, avoiding accumulation across newly created menus. Earlier Browse-after-Options descriptions below are historical. + +Verification passed: **77 startup/empty/RAW checks** (`issue-120-browse-context-startup.txt`), **1,114 full Viewer integration checks** (`issue-120-browse-context-gallery.txt`), CoreRegressionTests and WindowsUiTypographyTests, exit 0. Shared native-menu regression covers inert hover and Right navigation, activation on release/Enter and cancellation after release outside. Actual Viewer checks assert Browse immediately before Options, the retained rightmost zoom actions, canvas/pane owner-draw measurement routing, nested context styling and preserved native check states. The full source/ROI, E/manual zoom, autoplay, theme and GPU suite remains green. Synthetic playback observed 59.982 fps baseline / 59.784 during grid zoom and passed the existing relative threshold; these are smoke measurements, not a controlled historical benchmark. Both ordinary Release/x64/v143 builds and the instrumented Viewer build passed; `git diff --check` passed. + +Computer Use launched the corrected ordinary Viewer from `Viewer/x64/Issue120BrowseReview/Viewer.exe` without replacing existing review windows. With Options open, moving the pointer over Browse using a one-pixel wheel gesture did not open the drawer; an explicit Browse click then opened it once. Pure WM_MOUSEMOVE is separately exercised inside the actual native tracking regression. Direct UI inspection confirmed the canvas Sel Mode/Sync Input popup and the browser's drive context popup use the shared font, themed rows and spacing. No context action was executed; popups were dismissed and the corrected Viewer was left open with the drawer visible. The current Comparator and ONNX runtime are paired beside it. No user files, OS settings, release version, merge or release were changed. + +## Restore direct menus and rightmost zoom actions — 2026-10-18 + +The user explicitly rejected moving existing input menus into More and placing Fit/100% beside File. Current Viewer order is File, resolution, pixel format, FPS, Options, Browse, then the right-aligned Compare, Help, Fit, 100% group. More is removed; resolution/pixel-format/FPS are restored at their original root positions. View is renamed to Options and retains Image Scaling, with the existing Theme and Fit/Actual size entries consolidated there. File > Open, Ctrl+O, always-enabled Compare, the read-only status line, source handling and keyboard shortcuts are preserved. + +Viewer no longer opts into the single-row overflow layout. Narrow windows wrap visible commands as before. A Viewer-only opt-in keeps the trailing right-aligned group together when it fits a row, so the two final commands remain at the far right; Comparator's wrapping behavior is unchanged. Existing optional overflow helper tests remain as library regressions, not as a statement of the current Viewer UX. All descriptions of More and separate View in earlier delivery sections below are historical. + +Verification passed: **73 startup/empty/RAW checks** (`issue-120-restored-menus-startup.txt`), **1,104 full Viewer integration checks** (`issue-120-restored-menus-gallery.txt`), CoreRegressionTests and WindowsUiTypographyTests, all exit 0. Both ordinary Release/x64/v143 builds passed, as did the instrumented Viewer build. Tests assert original input-menu positions, no View/More/duplicate Open, actual Ctrl+O binding, always-enabled Compare, exact consolidated Options contents, visible nonoverlapping wrapped commands, and far-right Fit/100% at actual 640/960/1440-DIP client widths. Source/ROI, E/manual zoom, autoplay, theme and GPU recreation checks remain green. The synthetic playback observation was 39.701 fps baseline / 39.163 during grid zoom and passed the existing relative threshold; builds were running concurrently, so this is not a controlled performance comparison or 60-fps certification. + +Computer Use launched the corrected ordinary Viewer, confirmed the actual top-level control order and far-right Fit/100%, opened Options and inspected Image Scaling, Theme, Fit to window and Actual size, then closed the popup without changing settings. The updated Viewer is left open at `Viewer/x64/Issue120NoOpenReview/Viewer.exe`; the freshly built Comparator and ONNX runtime are paired beside it. No user media was opened through UI automation, no OS settings changed, and no merge or release occurred. The earlier full Comparator metric-suite result below is historical; this correction rebuilt Comparator and reran the common default-wrapping tests rather than claiming a new full metric-suite run. + +## Remove duplicate Open — 2026-10-18 + +The user requested removal of only the redundant top-level Open button. Production changes remove its AppendMenu entry and its single-row rule; File > Open, Ctrl+O, the file-open handler, Compare and other command policies are unchanged. The remaining relative command order is unchanged. Integration checks explicitly reject a top-level ID_FILE_OPEN, retain File's original command and verify Ctrl+O in the actual accelerator table. The measured layout fixture now reflects the shorter row. Earlier descriptions of a separate top-level Open below are historical. + +Verification passed: startup/empty/RAW **65 checks** (`issue-120-no-open-startup.txt`), loaded-source shell/navigation **407 checks** (`issue-120-no-open-focused.txt`), WindowsUiTypographyTests (including the updated measured overflow matrix) and CoreRegressionTests. Viewer ordinary and instrumented Release/x64/v143 builds passed; `git diff --check` passed. A test-only local variable name collision was corrected and the typography build/test rerun successfully. This is a focused verification of the two-line Viewer-only production change, not a newly repeated full Comparator/GPU suite. + +Existing ordinary review windows were left intact; the new ordinary build uses `Viewer/x64/Issue120NoOpenReview/Viewer.exe` so no running executable or user session is replaced implicitly. The previously verified Comparator and ONNX runtime were copied beside it so the existing Compare launcher remains usable. No UI input, OS changes, merge or release occurred for this correction. The only production diff is removal of the duplicate root entry and its layout rule; resource definitions, file-open implementation, accelerator bindings and shared layout code are unchanged. + +## Compare launcher correction — 2026-10-18 + +The user corrected the earlier availability decision: the main Compare command launches Comparator, even without a current Viewer file. Requiring a file and dimming the command was an incorrect agent assumption, not an approved product concept. The correction changes the shared command role to OpenComparator, which is enabled in every input context, and permits the main launcher to start an empty workspace. Existing-file forwarding is retained. The browser's separate selection API still rejects empty or more-than-four batches, and its 2–4 selected-media menu policy is unchanged. No overflow priorities or layout are changed by this correction; the interrupted interpretation about pinning Compare was not implemented. + +The earlier inspection notes below describing an empty-window disabled Compare are historical, not the corrected behavior. The updated regression checks require both the actual native Compare button and menu to remain enabled, including startup without media. Correction verification passed: startup/empty/RAW **52 checks**, source-loaded shell/navigation/autoplay **436 checks**, common Windows UI tests (all 32 applicability contexts) and CoreRegressionTests, plus both ordinary Release/x64/v143 builds. Reports: `issue-120-compare-launcher-startup.txt` and `issue-120-compare-launcher-focused.txt` under `Tests/bin/x64/Release`. The earlier full-suite counts below belong to the prior implementation; this narrow launcher correction used the focused suites, not a new full metric/GPU run. + +Computer Use relaunched the ordinary Viewer, confirmed its native Compare button was enabled in the empty Ready window, clicked Compare once and observed exactly one new Comparator with empty A/B drop targets and Reference not loaded. Both windows remain open for the user. The verified ordinary Comparator and its ONNX runtime were copied beside the review Viewer to match the normal installed layout; the executable copy hash was checked. No overflow priority, OS setting, installed Store package, merge or release was changed. + +Date: 2026-10-18 (Asia/Seoul). Windows MFC Viewer only for the new layout; the shared menu implementation remains opt-in and Comparator retains its existing wrapping layout. Branch: `codex/issue-120-viewer-command-status`, based on current `origin/master` `ea10a53` with the separately committed, previously verified follow-up `eb5a5aa` (navigation autoplay, disabled feedback, idle menu caching). The user's unrelated untracked design assets are not part of these commits. No merge, version bump, release tag or shutdown is authorized by this implementation request. + +## Current decisions + +- Reuse the existing menu bar, not an additional toolbar. Current wide order: File, resolution, pixel format, FPS, Browse, Options, Compare, Help. Browse is immediately left of Options. Remove top-level magnification and the added Fit/fixed 100% controls. File > Open and Ctrl+O provide file opening without a duplicate button. File has no duplicate Thumbnail Browser entry; Image Scaling, video autoplay and Theme are under Options. View and More are removed. Comparator keeps its existing layout. +- Use actual font/DPI/text-scale measurements, retaining text instead of shrinking it. Narrow windows wrap visible commands as before; no original input menu is hidden. Decoded sources retain disabled RAW interpretation. Viewer keeps its trailing right-aligned group together when that group fits one row. +- Reuse the root HMENU, popup HMENUs, existing command IDs, native menu tracking and execution guards. Resource order, native button order and visual order agree. Existing Alt access keys and shortcuts are preserved except the explicitly removed View and More roots. +- Browse shows its active state through the shared selection palette, never a disabled dashed outline. Mouse-wheel zoom and internal automatic fitting retain their existing transforms and synchronization; the live magnification is information, not an executable command. +- Keep sampling mode at the left and actual magnification at the far right of the existing native read-only Static status line with a 24-DIP minimum height. Remove dimensions, color space, Fit/Manual and duplicate frame text from this row, retaining the separate video frame/time progress display. Empty/loading input has no sampling label and shows -- on the right. Long text ellipsizes visually without overlapping and retains its full tooltip and native accessibility name. +- Cache unchanged status text so it does not invalidate repeatedly on idle. Zoom/frame text updates do not rebuild the command row or resize the content splitter. Full screen hides both rows. MFC reserves status height exactly once; the DPI-scaled empty startup retains its existing 800x600-class viewing area. Native high-contrast/non-DWM menu fallback remains OS-managed and can wrap. + +## Earlier implementation verification (before the user corrections above) + +- CoreRegressionTests passed, exit 0. +- WindowsUiTypographyTests passed, exit 0, including the existing font/theme/contrast/background/Snap hit-test matrices, bounded fonts and buffered drawing. The new measured overflow policy is checked at 96/120/144/192 DPI, five text factors (1.0 through 2.25) and five widths (160/320/640/960/1440 DIP). Actual child HWND tests confirm no wrapping, disabled state and original menu lifetime. A real TrackPopupMenuEx overflow selection via F10/Left/Down and a native mnemonic executes its original command exactly once, retains disabled items and detaches borrowed submenus safely. Warm GDI count is 53 before / 53 after. +- Initial final-code Viewer full integration passed 1,212 checks, exit 0 (`Tests/bin/x64/Release/issue-120-gallery-final.txt`). New shell checks use actual 640x480, 960x600 and 1440x900 DIP client areas; every root command is either visible or in More, hit targets never overlap, and status/content/splitter bounds agree. Native read-only status exposes full zoom text. Fit/100% preserve the source, stable frame, decoder generation and ROI count, and leave every command rectangle fixed. Existing E/manual-anchor/playback/theme/shared-settings/GPU recreation and safe batch-file regressions remain green. +- Startup/empty/RAW passed 48 checks, exit 0 (`issue-120-startup-final.txt`). Default startup dimensions and RAW interpretation remain separate; narrowing now asserts overflow rather than the superseded wrapping policy. Comparator full integration passed 760 checks, exit 0 (`issue-120-comparer.txt`), including native source controls, two/three/four-source geometry, ROI/current results, PSNR/SSIM and actual ONNX LPIPS paths. Tests run with isolated profiles and generated/repository media, not the user's photos. +- Both ordinary Viewer and Comparator Release/x64/v143 solutions built successfully into `Viewer/x64/Issue120Review/Viewer.exe` and `Comparator/x64/Issue120Review/Comparator.exe`. The final Fit synchronization routing and additional presented-frame assertion are followed by a final Viewer rerun, recorded below. Local version remains `0.0.0-dev`. The requested direct version-script invocation is still blocked by machine execution policy; no policy or other OS setting was changed. + +One earlier Viewer run stopped at a pre-existing theme fixture's exact assumption that View contained only one item. The new approved Fit/100% menu entries legitimately change that count. The test now verifies the exact four-item structure, Fit/100% IDs and unchanged unique Options > Theme; the subsequent full run passed. Earlier compile errors (private view helper access and an include being compiled while a patch was in progress) were corrected, then builds/tests were repeated. No failing product assertion is waived. + +The first full run measured 59.798 presented fps normally and 59.987 during thumbnail-grid zoom, passing the existing relative playback threshold. 2,000 unchanged command refreshes took 6.101 ms versus 11.277 ms forced; no full menu passes occur for unchanged applicability. These are bounded smoke observations, not a controlled old-release whole-app performance comparison or a promise to resolve every subjective slowdown. + +## Native inspection and limits + +Computer Use inspected the ordinary build at actual 96 DPI / text factor 1.000: one Viewer command row, disabled empty-image Fit/100%/Compare, Ready in a distinct bottom status line, and the native More popup with existing RAW settings. That inspection exposed an accessibility-order mismatch with the visual row; HWND sibling order was corrected and the final native control tests rerun. The native file dialog opened, but attempts to focus its filename edit reported the search edit as focused instead. No path was typed into an uncertain target; the dialog was cancelled and the preview closed normally. Loaded-image/status behavior is covered by the real-app in-process integration suite, not claimed as an additional successful ordinary file-dialog automation check. + +No global DPI, Windows text size, high-contrast or monitor layout was changed. Calculated/mapped DPI matrices are not claimed as live Windows Settings tests or exhaustive mixed-monitor/high-contrast acceptance. User visual acceptance of the revised layout remains pending; keep #120 open and the review PR draft. #111 was separately closed for its agreed completed jobs #117/#118/#119, retaining documented waivers and distinguishing these later follow-up commits from the already shipped code. + +## Final delivery-code rerun + +The final production-code Viewer run passed **1,221 checks**, exit 0 (`Tests/bin/x64/Release/issue-120-gallery-delivery.txt`), including the explicit-Fit synchronization routing, Fit/actual-size applicability guard and actual-presented-frame status assertion during previous/next autoplay. Presented playback was 59.991 fps normally and 59.968 during grid zoom; the existing relative playback threshold passed. The final command cache probe measured 6.600 ms for 2,000 cached calls versus 12.916 ms forced. These remain smoke observations, not a controlled whole-app historical baseline. + +WindowsUiTypographyTests and CoreRegressionTests were rerun successfully after the full Viewer pass; both ordinary Release binaries are built from the final production sources. Final startup verification passed **49 checks**, exit 0 (`issue-120-startup-delivery.txt`). The final ordinary Viewer was relaunched and its native accessibility tree now matches the visual command order. Down/Right navigation from More opened the original RAW-resolution submenu with 800x600 still checked; no value was changed. The review window is left open for the user. `git diff --check` passed. The version script remains policy-blocked and the existing local header remains `0.0.0-dev`; no policy bypass, global OS change, merge, tag, release or shutdown occurred. A Draft PR is prepared for user review; #120 remains open. diff --git a/docs/verification/viewer-autoplay-command-feedback.md b/docs/verification/viewer-autoplay-command-feedback.md new file mode 100644 index 0000000..a6dbdd5 --- /dev/null +++ b/docs/verification/viewer-autoplay-command-feedback.md @@ -0,0 +1,31 @@ +# Viewer file-navigation autoplay and command feedback follow-up + +Date: 2026-10-18 (Asia/Seoul). Branch: `codex/viewer-autoplay-command-feedback`, based on `origin/master` `ea10a53`. Windows MFC only. The user's reported autoplay case is moving to the previous/next file in Viewer. This follow-up also implements the agreed removal of disabled-command dashed outlines and the identified redundant idle menu scan. No merge, release, version bump or computer shutdown is part of this request; unrelated untracked design assets remain untouched. + +## Implementation + +- File navigation keeps its existing in-place document-open, fit-to-window, path/MRU and synchronization behavior. `OpenNavigatedDocument` conveys explicit user intent to the same `OpenDocumentImpl` used by the ordinary override, without invoking MFC document-manager frame initialization/activation for every file movement. The saved autoplay preference, video-only check, first-frame presentation and generation-tagged autoplay message remain authoritative. Internal reloads do not acquire explicit-open intent. +- Disabled top-level commands no longer acquire a custom dashed rectangle. Source-format buttons retain an ordinary solid edge and muted disabled text; native enabled/accessibility state and execution guards remain unchanged. Hosted menu focus is shown in keyboard mode, not after mouse activation. Source controls use native UI-state focus cues, clearing hidden focus on keyboard navigation and hiding it on mouse activation. +- Viewer samples the small applicability context on idle but caches the applied menu handle/signature. An unchanged context skips full menu enumeration and hosted-button synchronization. Menu replacement, resolution/format-label rebuilding and popup initialization invalidate or force the cache. The help signature remains separate so hidden menus do not suppress full-screen help updates. Execution still validates current state, independently of cached presentation. + +## Verification + +The tests run the actual MFC document/view, decoder, renderer and native controls with isolated application profiles. Fixtures use generated images and short MJPEG clips, not the user's media. No global DPI, text-size, theme or security setting was changed. No live manual-input review or every-Windows-version coverage is claimed. + +- Autoplay coverage: photo to video, playing video to next/previous video, movement after pausing, autoplay disabled in both directions, internal reload and rotation while paused, video back to photo with stale requests rejected, current outer-window size retained, and previous/next movement with hidden menu/full-screen mode retained. The initial focused run passed 78 checks; the full-screen-expanded run passed 91 checks before the final in-place routing refinement. Final full-suite results are recorded below after rerunning the final code. +- Menu cache coverage: 2,000 unchanged refresh calls execute no full menu-application passes; forced popup refresh remains available; rebuilding a fixed-source format label invalidates the cache and cannot leave its RAW command enabled. Timings compare cached and forced-full refresh within the same build, not whole-app performance against an older release. +- WindowsUiTypographyTests passed, including Light/Dark disabled-boundary pixel checks, native hosted menus, layout/font matrices and stable warmed GDI count 46/46. Removing the old disabled rectangle exposed a test fixture that had not declared its zoom status role; the fixture now explicitly marks it, so the actual right-aligned text, rather than an unrelated border pixel, satisfies the assertion. +- Comparator integration passed 760 checks, including mouse-hidden/keyboard-visible focus state, solid disabled source-button edge in Light/Dark, actual disabled accessibility, single activation, four-source layouts and existing PSNR/LPIPS paths. Report: `Tests/bin/x64/Release/autoplay-command-feedback-comparer.txt`. +- CoreRegressionTests passed. Both ordinary Release/x64/v143 solutions built successfully; ordinary review executables are `Viewer/x64/Issue119Review/Viewer.exe` and `Comparator/x64/Issue119Review/Comparator.exe`. The final Viewer routing refinement was rebuilt separately for ordinary and instrumented outputs. Local version remains `0.0.0-dev`. + +The first complete Viewer pass, before the final routing refinement/full-screen additions, passed 786 checks and startup passed 44 checks. Its playback smoke test measured 59.989 presented fps normally and 59.980 fps during grid zoom, passing the existing relative regression threshold. This does not establish the cause of the user's perceived slowdown or promise that all app workflows are faster. Reports: `autoplay-navigation-gallery-full.txt` and `autoplay-navigation-startup.txt` under `Tests/bin/x64/Release/`. + +## Final-code result + +The final complete Viewer run passed **899 checks**, exit 0, including previous/next autoplay in full screen and restoration of the original test window after fixture cleanup. Report: `Tests/bin/x64/Release/autoplay-navigation-final-gallery.txt`. All checks use the final in-place document routing, not the earlier application-document-manager variant. No failing assertion is waived; compilation/test-fixture corrections were followed by reruns. + +The final startup/empty-session/RAW run passed **44 checks**, exit 0 (`autoplay-navigation-final-startup.txt`); WindowsUiTypographyTests and CoreRegressionTests were also rerun successfully after the final complete Viewer pass. Both ordinary binaries were built from the final production sources. `git diff --check` passed; changes remain local and uncommitted, with no merge, tag or release action. + +The final cache probe measured 6.981 ms for 2,000 unchanged refreshes versus 9.070 ms for 2,000 forced full refreshes; the test counter verifies zero full menu scans in the unchanged case. This is a small, bounded reduction in command-state bookkeeping, not evidence that it explains the reported subjective slowdown. The final video smoke run measured 42.482 presented fps normally and 47.442 fps during grid zoom and passed the existing relative threshold. These rates varied from the earlier run; no controlled v2.8.0-versus-current whole-app performance claim is made. + +Design references: [Windows unavailable menu-item convention](https://learn.microsoft.com/en-us/windows/win32/menurc/about-menus#enabled-grayed-and-disabled-menu-items) and [keyboard focus visuals](https://learn.microsoft.com/en-us/windows/apps/design/input/keyboard-interactions). These inform the removal of invented disabled outlines without removing actual disabled behavior or keyboard access.