diff --git a/QuickView/AppContext.h b/QuickView/AppContext.h index cf290db..28ef0c9 100644 --- a/QuickView/AppContext.h +++ b/QuickView/AppContext.h @@ -126,6 +126,18 @@ struct CompareState { bool showDividerHandle = false; }; +// --- Loupe Definitions --- +// A press-and-hold magnifier that pops up under the cursor and shows the local +// region at actual pixels (e.g. for quickly confirming focus while culling). +// Activated by holding the HotkeyAction::Loupe key (rebindable, default 'L'); +// follows the cursor while held and disappears on release. Works in Compare +// mode too (the same image location is magnified on both panes). +struct LoupeState { + bool active = false; + POINT cursorClient = { 0, 0 }; // current cursor position in client coords + bool sizeChanged = false; // wheel resized the loupe this session -> persist on release +}; + // --- Global App Context --- // Using a Singleton for stage 1 refactoring, easy to migrate to DI later. #include @@ -141,6 +153,7 @@ class AppContext { SmoothZoomState SmoothZoom; SmoothWindowZoomState SmoothWindowZoom; CompareState Compare; + LoupeState Loupe; std::unique_ptr CompareCtrl; std::unique_ptr DialogCtrl; diff --git a/QuickView/AppStrings.cpp b/QuickView/AppStrings.cpp index 0b58160..f951493 100644 --- a/QuickView/AppStrings.cpp +++ b/QuickView/AppStrings.cpp @@ -5512,6 +5512,20 @@ std::wstring GetHotkeyActionName(HotkeyAction action) { } break; } + case HotkeyAction::Loupe: { + needsCleaning = false; + switch (GetActiveLanguage()) { + case AppStrings::Language::ChineseSimplified: raw = L"放大镜 (按住)"; break; + case AppStrings::Language::ChineseTraditional: raw = L"放大鏡 (按住)"; break; + case AppStrings::Language::Japanese: raw = L"ルーペ (長押し)"; break; + case AppStrings::Language::Russian: raw = L"Лупа (удерживать)"; break; + case AppStrings::Language::German: raw = L"Lupe (halten)"; break; + case AppStrings::Language::Spanish: raw = L"Lupa (mantener)"; break; + case AppStrings::Language::French: raw = L"Loupe (maintenir)"; break; + default: raw = L"Loupe (Hold)"; break; + } + break; + } case HotkeyAction::NavNext: raw = AppStrings::Toolbar_Tooltip_Next; break; diff --git a/QuickView/EditState.h b/QuickView/EditState.h index 6ba8748..bcc1873 100644 --- a/QuickView/EditState.h +++ b/QuickView/EditState.h @@ -130,6 +130,7 @@ enum class HotkeyAction : uint8_t { ZoomOutFine, // Zoom Out Fine Zoom100, // Zoom 100% / Restore ZoomFit, // Zoom Fit / Restore + Loupe, // Hold to show the magnifier (loupe) RotateCW, // Rotate 90 CW RotateCCW, // Rotate 90 CCW FlipH, // Flip Horizontal @@ -205,6 +206,7 @@ inline std::wstring_view HotkeyActionToString(HotkeyAction action) noexcept { case HotkeyAction::Help: return L"Help"; case HotkeyAction::ToggleSlideshow: return L"ToggleSlideshow"; case HotkeyAction::Exit: return L"Exit"; + case HotkeyAction::Loupe: return L"Loupe"; default: return L"None"; } } @@ -249,6 +251,7 @@ inline HotkeyAction StringToHotkeyAction(std::wstring_view sv) noexcept { if (sv == L"Help") return HotkeyAction::Help; if (sv == L"ToggleSlideshow") return HotkeyAction::ToggleSlideshow; if (sv == L"Exit") return HotkeyAction::Exit; + if (sv == L"Loupe") return HotkeyAction::Loupe; return HotkeyAction::None; } @@ -553,6 +556,12 @@ struct AppConfig { bool RightButtonDragZoom = true; // Hold right button and drag vertically to zoom float WheelZoomSpeed = 10.0f; // 5.0f to 50.0f (percentage) float RightDragZoomSpeed = 1.0f; // 0.1f to 3.0f (multiplier) + + // --- Loupe (hold-key magnifier; e.g. for checking focus while culling) --- + // The activation key is the rebindable HotkeyAction::Loupe binding. + bool LoupeEnabled = true; // Master toggle for the loupe + float LoupeSizeRatio = 0.25f; // Loupe box edge as a fraction of the viewport's short side (resolution-adaptive) + float LoupeZoom = 1.0f; // Magnification vs actual image pixels (1.0 = 100%) MouseAction LeftDragAction = MouseAction::WindowDrag; MouseAction MiddleDragAction = MouseAction::PanImage; MouseAction MiddleClickAction = MouseAction::ExitApp; diff --git a/QuickView/UIRenderer.cpp b/QuickView/UIRenderer.cpp index 14f29e4..a6b528f 100644 --- a/QuickView/UIRenderer.cpp +++ b/QuickView/UIRenderer.cpp @@ -16,6 +16,8 @@ #include "ImageEngine.h" // [v3.1] Access for HasEmbeddedThumb #include "GeekIconRenderer.h" +#include "AppContext.h" // [Loupe] loupe + compare state +#include "PaneContext.h" // [Loupe] per-pane image + view transform // External globals (retained - these are global state needed by overlays) extern Toolbar g_toolbar; @@ -754,6 +756,154 @@ void UIRenderer::RenderStaticLayer(ID2D1DeviceContext* dc, HWND hwnd) { // Dynamic Layer: Debug HUD, OSD, Tooltip, Dialog // ============================================================================ +// [Loupe] Press-and-hold magnifier. Maps the cursor to an image pixel via the +// inverse of the on-screen draw transform, then renders a crisp (nearest- +// neighbour) magnified patch of the source bitmap in a box at the cursor. In +// Compare mode the same image location is magnified on both panes at once. +void UIRenderer::DrawLoupe(ID2D1DeviceContext* dc, HWND hwnd) { + AppContext& app = AppContext::GetInstance(); + if (!app.Loupe.active || !g_config.LoupeEnabled) return; + + RECT rcClient; GetClientRect(hwnd, &rcClient); + const float winW = (float)(rcClient.right - rcClient.left); + const float winH = (float)(rcClient.bottom - rcClient.top); + if (winW < 2.0f || winH < 2.0f) return; + + const float cursorX = (float)app.Loupe.cursorClient.x; + const float cursorY = (float)app.Loupe.cursorClient.y; + + const float uiScale = (m_uiScale > 0.0f) ? m_uiScale : 1.0f; + const float loupeRatio = std::clamp(g_config.LoupeSizeRatio, 0.1f, 0.5f); + const float loupeZoom = std::clamp(g_config.LoupeZoom, 1.0f, 8.0f); + + // Oriented (display) size: EXIF 5-8 swap width/height. + auto orientedSize = [](const D2D1_SIZE_F& raw, int exif) -> D2D1_SIZE_F { + if (exif >= 5 && exif <= 8) return D2D1::SizeF(raw.height, raw.width); + return raw; + }; + // Forward transform (native bitmap pixels -> screen), mirroring + // DrawResourceIntoViewport() so our inverse matches what is actually drawn. + auto buildForward = [](const D2D1_SIZE_F& raw, int exif, float scale, + float centerX, float centerY) -> D2D1::Matrix3x2F { + D2D1::Matrix3x2F m = D2D1::Matrix3x2F::Translation(-raw.width * 0.5f, -raw.height * 0.5f); + switch (exif) { + case 2: m = m * D2D1::Matrix3x2F::Scale(-1.0f, 1.0f); break; + case 3: m = m * D2D1::Matrix3x2F::Rotation(180.0f); break; + case 4: m = m * D2D1::Matrix3x2F::Scale(1.0f, -1.0f); break; + case 5: m = m * D2D1::Matrix3x2F::Scale(-1.0f, 1.0f) * D2D1::Matrix3x2F::Rotation(270.0f); break; + case 6: m = m * D2D1::Matrix3x2F::Rotation(90.0f); break; + case 7: m = m * D2D1::Matrix3x2F::Scale(-1.0f, 1.0f) * D2D1::Matrix3x2F::Rotation(90.0f); break; + case 8: m = m * D2D1::Matrix3x2F::Rotation(270.0f); break; + default: break; + } + m = m * D2D1::Matrix3x2F::Scale(scale, scale); + m = m * D2D1::Matrix3x2F::Translation(centerX, centerY); + return m; + }; + + struct LoupeTarget { PaneSlot slot; D2D1_RECT_F viewport; }; + LoupeTarget targets[2]; + int targetCount = 0; + const bool compare = (app.Compare.mode != ViewMode::Single) && app.CompareCtrl; + if (compare) { + targets[targetCount++] = { PaneSlot::Left, app.CompareCtrl->GetViewport(hwnd, ComparePane::Left) }; + targets[targetCount++] = { PaneSlot::Primary, app.CompareCtrl->GetViewport(hwnd, ComparePane::Right) }; + } else { + targets[targetCount++] = { PaneSlot::Primary, D2D1::RectF(0.0f, 0.0f, winW, winH) }; + } + + // On-screen forward transform for a target at its true zoom (for inverse-mapping). + auto computeForward = [&](const LoupeTarget& t, D2D1::Matrix3x2F& outM, D2D1_SIZE_F& outRaw) -> bool { + PaneContext& pane = GetPaneContext(t.slot); + if (!pane.resource.bitmap) return false; + outRaw = pane.resource.GetSize(); + if (outRaw.width <= 0.0f || outRaw.height <= 0.0f) return false; + const D2D1_SIZE_F osz = orientedSize(outRaw, pane.view.ExifOrientation); + const float vpW = t.viewport.right - t.viewport.left; + const float vpH = t.viewport.bottom - t.viewport.top; + if (vpW < 1.0f || vpH < 1.0f) return false; + float fitScale = std::min(vpW / osz.width, vpH / osz.height); + if (osz.width < 200.0f && osz.height < 200.0f && fitScale > 1.0f) fitScale = 1.0f; + const float totalScale = fitScale * (std::max)(0.02f, pane.view.Zoom); + const float centerX = (t.viewport.left + t.viewport.right) * 0.5f + pane.view.PanX; + const float centerY = (t.viewport.top + t.viewport.bottom) * 0.5f + pane.view.PanY; + outM = buildForward(outRaw, pane.view.ExifOrientation, totalScale, centerX, centerY); + return true; + }; + + // Which pane is the cursor over? Map it to a normalized image location there. + int hoveredIdx = 0; + for (int i = 0; i < targetCount; ++i) { + const D2D1_RECT_F& vp = targets[i].viewport; + if (cursorX >= vp.left && cursorX < vp.right && cursorY >= vp.top && cursorY < vp.bottom) { + hoveredIdx = i; break; + } + } + D2D1::Matrix3x2F hovM; D2D1_SIZE_F hovRaw; + if (!computeForward(targets[hoveredIdx], hovM, hovRaw)) return; + if (!hovM.Invert()) return; // hovM becomes screen->image + const D2D1_POINT_2F imgPt = hovM.TransformPoint(D2D1::Point2F(cursorX, cursorY)); + const float fracX = imgPt.x / hovRaw.width; + const float fracY = imgPt.y / hovRaw.height; + + // Reusable brushes (crisp white border, dark backing). + ComPtr borderBrush, backBrush; + dc->CreateSolidColorBrush(D2D1::ColorF(1.0f, 1.0f, 1.0f, 0.9f), &borderBrush); + dc->CreateSolidColorBrush(D2D1::ColorF(0.0f, 0.0f, 0.0f, 0.85f), &backBrush); + + D2D1_MATRIX_3X2_F identity; dc->GetTransform(&identity); + + for (int i = 0; i < targetCount; ++i) { + PaneContext& pane = GetPaneContext(targets[i].slot); + if (!pane.resource.bitmap) continue; + const D2D1_SIZE_F rawT = pane.resource.GetSize(); + if (rawT.width <= 0.0f || rawT.height <= 0.0f) continue; + const D2D1_RECT_F& vp = targets[i].viewport; + const float vpW = vp.right - vp.left; + const float vpH = vp.bottom - vp.top; + if (vpW < 1.0f || vpH < 1.0f) continue; + + // Resolution-adaptive box size: a fraction of this viewport's short side. + const float shortSide = std::min(vpW, vpH); + const float boxSize = std::clamp(shortSide * loupeRatio, 80.0f, shortSide * 0.9f); + + // Same scene location in this pane's native pixels. + const D2D1_POINT_2F tImgPt = D2D1::Point2F(fracX * rawT.width, fracY * rawT.height); + + // Where that pixel currently appears on screen in this pane -> box center. + D2D1::Matrix3x2F fwdT; D2D1_SIZE_F rawTmp; + if (!computeForward(targets[i], fwdT, rawTmp)) continue; + const D2D1_POINT_2F screenPt = fwdT.TransformPoint(tImgPt); + + const float half = boxSize * 0.5f; + float cx = screenPt.x, cy = screenPt.y; + cx = (vpW <= boxSize) ? (vp.left + vp.right) * 0.5f : std::clamp(cx, vp.left + half, vp.right - half); + cy = (vpH <= boxSize) ? (vp.top + vp.bottom) * 0.5f : std::clamp(cy, vp.top + half, vp.bottom - half); + const D2D1_RECT_F box = D2D1::RectF(cx - half, cy - half, cx + half, cy + half); + + // Loupe transform: native bitmap -> magnified, centered so tImgPt lands at box center. + D2D1::Matrix3x2F L0 = buildForward(rawT, pane.view.ExifOrientation, loupeZoom, cx, cy); + const D2D1_POINT_2F p = L0.TransformPoint(tImgPt); + D2D1::Matrix3x2F L = L0 * D2D1::Matrix3x2F::Translation(cx - p.x, cy - p.y); + + // Dark backing (covers regions outside the image near edges). + dc->FillRectangle(box, backBrush.Get()); + + // Magnified patch, clipped to the box, drawn with the loupe transform. + dc->PushAxisAlignedClip(box, D2D1_ANTIALIAS_MODE_ALIASED); + dc->SetTransform(L); + dc->DrawBitmap(pane.resource.bitmap.Get(), + D2D1::RectF(0.0f, 0.0f, rawT.width, rawT.height), + 1.0f, + D2D1_INTERPOLATION_MODE_NEAREST_NEIGHBOR); + dc->SetTransform(identity); + dc->PopAxisAlignedClip(); + + // Border. + dc->DrawRectangle(box, borderBrush.Get(), 1.5f * uiScale); + } +} + void UIRenderer::RenderDynamicLayer(ID2D1DeviceContext* dc, HWND hwnd) { // 创建画刷 ComPtr whiteBrush, blackBrush, accentBrush; @@ -767,7 +917,10 @@ void UIRenderer::RenderDynamicLayer(ID2D1DeviceContext* dc, HWND hwnd) { // OSD DrawOSD(dc, hwnd); - + + // [Loupe] press-and-hold magnifier (drawn above OSD, below dialogs) + DrawLoupe(dc, hwnd); + // [Edge Focus] Tile decode status line DrawDecodingStatus(dc, hwnd); diff --git a/QuickView/UIRenderer.h b/QuickView/UIRenderer.h index f2c9748..fb365dd 100644 --- a/QuickView/UIRenderer.h +++ b/QuickView/UIRenderer.h @@ -226,6 +226,8 @@ class UIRenderer { // 绘制函数 void DrawOSD(ID2D1DeviceContext* dc, HWND hwnd); + // [Loupe] press-and-hold magnifier overlay (shows the region at actual pixels) + void DrawLoupe(ID2D1DeviceContext* dc, HWND hwnd); void DrawWindowControls(ID2D1DeviceContext* dc, HWND hwnd); void DrawBorderIndicators(ID2D1DeviceContext* dc); void DrawDebugHUD(ID2D1DeviceContext* dc); diff --git a/QuickView/main.cpp b/QuickView/main.cpp index 2e37d40..d6cc13a 100644 --- a/QuickView/main.cpp +++ b/QuickView/main.cpp @@ -296,6 +296,7 @@ std::array(HotkeyAction::Count)> g_hotkeys = HotkeyBinding{ HotkeyAction::ZoomOutFine, KeyCombo{ VK_OEM_MINUS, 1 }, KeyCombo{ VK_OEM_MINUS, 1 } }, // Ctrl + - HotkeyBinding{ HotkeyAction::Zoom100, KeyCombo{ 'Z', 0 }, KeyCombo{ 'Z', 0 } }, HotkeyBinding{ HotkeyAction::ZoomFit, KeyCombo{ 'F', 0 }, KeyCombo{ 'F', 0 } }, + HotkeyBinding{ HotkeyAction::Loupe, KeyCombo{ 'L', 0 }, KeyCombo{ 'L', 0 } }, // Hold to magnify HotkeyBinding{ HotkeyAction::RotateCW, KeyCombo{ 'R', 0 }, KeyCombo{ 'R', 0 } }, HotkeyBinding{ HotkeyAction::RotateCCW, KeyCombo{ 'R', 2 }, KeyCombo{ 'R', 2 } }, // Shift + R HotkeyBinding{ HotkeyAction::FlipH, KeyCombo{ 'H', 0 }, KeyCombo{ 'H', 0 } }, @@ -3949,6 +3950,11 @@ void SaveConfig() { WritePrivateProfileStringW(L"Controls", L"GalleryTriggerMode", std::to_wstring(g_config.GalleryTriggerMode).c_str(), iniPath.c_str()); // NavIndicator moved to View section + // Loupe (activation key lives in the [Hotkeys] Loupe binding) + WritePrivateProfileStringW(L"Controls", L"LoupeEnabled", g_config.LoupeEnabled ? L"1" : L"0", iniPath.c_str()); + WritePrivateProfileStringW(L"Controls", L"LoupeSizeRatio", std::to_wstring(g_config.LoupeSizeRatio).c_str(), iniPath.c_str()); + WritePrivateProfileStringW(L"Controls", L"LoupeZoom", std::to_wstring(g_config.LoupeZoom).c_str(), iniPath.c_str()); + // Image WritePrivateProfileStringW(L"Image", L"AutoRotate", std::to_wstring(g_config.AutoRotate).c_str(), iniPath.c_str()); WritePrivateProfileStringW(L"Image", L"ColorManagement", g_config.ColorManagement ? L"1" : L"0", iniPath.c_str()); @@ -4203,6 +4209,13 @@ void LoadConfig() { g_config.EdgeNavClick = GetPrivateProfileIntW(L"Controls", L"EdgeNavClick", 1, iniPath.c_str()) != 0; g_config.GalleryTriggerMode = GetPrivateProfileIntW(L"Controls", L"GalleryTriggerMode", 1, iniPath.c_str()); // NavIndicator moved to View section + + // Loupe (activation key lives in the [Hotkeys] Loupe binding) + g_config.LoupeEnabled = GetPrivateProfileIntW(L"Controls", L"LoupeEnabled", 1, iniPath.c_str()) != 0; + GetPrivateProfileStringW(L"Controls", L"LoupeSizeRatio", L"0.25", buf, 64, iniPath.c_str()); + g_config.LoupeSizeRatio = std::clamp((float)_wtof(buf), 0.1f, 0.5f); + GetPrivateProfileStringW(L"Controls", L"LoupeZoom", L"1.0", buf, 64, iniPath.c_str()); + g_config.LoupeZoom = std::clamp((float)_wtof(buf), 1.0f, 8.0f); // Image g_config.AutoRotate = GetPrivateProfileIntW(L"Image", L"AutoRotate", 1, iniPath.c_str()) != 0; @@ -6007,6 +6020,13 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) } if (message == WM_SETCURSOR) { + // [Loupe] Hide the cursor while the loupe is active so it does not + // obscure the magnified region it sits on. + if (AppContext::GetInstance().Loupe.active && LOWORD(lParam) == HTCLIENT) { + SetCursor(nullptr); + return TRUE; + } + // Don't show Wait cursor when gallery is visible if (g_isLoading && !g_gallery.IsVisible()) { SetCursor(LoadCursor(nullptr, IDC_WAIT)); @@ -6864,6 +6884,16 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) float winH = (float)(rcClient.bottom - rcClient.top); bool hasGallery = g_gallery.IsVisible() && g_gallery.HitTestArea(pt.x, pt.y, winW, winH); + // [Loupe] While the loupe key is held, the magnifier follows the + // cursor. Update its position, keep the cursor hidden, and skip the + // normal pan/hover handling (which would re-show the cursor below). + if (AppContext::GetInstance().Loupe.active) { + AppContext::GetInstance().Loupe.cursorClient = pt; + SetCursor(nullptr); + RequestRepaint(PaintLayer::Dynamic); + return 0; + } + if (GetPaneContext(PaneSlot::Primary).view.IsDraggingInfoPanel) { int dx = pt.x - GetPaneContext(PaneSlot::Primary).view.InfoPanelDragAnchor.x; int dy = pt.y - GetPaneContext(PaneSlot::Primary).view.InfoPanelDragAnchor.y; @@ -8481,7 +8511,19 @@ SKIP_EDGE_NAV:; case WM_MOUSEWHEEL: { float wheelDelta = (float)GET_WHEEL_DELTA_WPARAM(wParam) / (float)WHEEL_DELTA; - + + // [Loupe] While the loupe is held, the wheel resizes the loupe box + // (a fraction of the viewport's short side) instead of zooming the image. + if (AppContext::GetInstance().Loupe.active) { + const float before = g_config.LoupeSizeRatio; + g_config.LoupeSizeRatio = std::clamp(g_config.LoupeSizeRatio + wheelDelta * 0.02f, 0.1f, 0.5f); + if (g_config.LoupeSizeRatio != before) { + AppContext::GetInstance().Loupe.sizeChanged = true; + RequestRepaint(PaintLayer::Dynamic); + } + return 0; + } + if (g_helpOverlay.IsVisible()) { if (g_helpOverlay.OnMouseWheel(wheelDelta * 120.0f)) { // HelpOverlay expects raw delta RequestRepaint(PaintLayer::Static); @@ -8631,6 +8673,20 @@ SKIP_EDGE_NAV:; case WM_SYSKEYUP: case WM_KEYUP: + // [Loupe] Releasing the loupe key hides the magnifier. The key is the + // rebindable HotkeyAction::Loupe binding (default 'L'), so this honours + // user remapping rather than a hardcoded key. + if (AppContext::GetInstance().Loupe.active + && wParam == g_hotkeys[static_cast(HotkeyAction::Loupe)].combo.virtualKey) { + AppContext::GetInstance().Loupe.active = false; + SetCursor(g_currentCursor ? g_currentCursor : LoadCursor(nullptr, IDC_ARROW)); // restore now + if (AppContext::GetInstance().Loupe.sizeChanged) { + AppContext::GetInstance().Loupe.sizeChanged = false; + SaveConfig(); // persist the wheel-adjusted loupe size + } + RequestRepaint(PaintLayer::Dynamic); + return 0; + } if (wParam == VK_MENU) return 0; // 拦截 Alt 释放,防止进入菜单循环导致焦点丢失 break; @@ -8742,7 +8798,7 @@ SKIP_EDGE_NAV:; bool ctrl = (GetKeyState(VK_CONTROL) & 0x8000) != 0; bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; bool alt = (GetKeyState(VK_MENU) & 0x8000) != 0; - + // [Fix] 增加对 VK_MENU 的排除,防止 Alt 键交给 DefWindowProc 触发菜单系统 if (message == WM_SYSKEYDOWN && wParam != VK_F10 && wParam != VK_LEFT && wParam != VK_RIGHT && wParam != VK_UP && wParam != VK_DOWN && wParam != VK_MENU) { break; // 其他系统键交给默认处理 @@ -12718,6 +12774,22 @@ bool HandleHotkeyAction(HWND hwnd, HotkeyAction action) { } return true; + case HotkeyAction::Loupe: + // Press-and-hold magnifier: activate on key-down (this dispatch); the + // matching key-up in WndProc hides it. Auto-repeat re-enters here and is + // idempotent. Only fires with an image loaded, the feature enabled, and + // the image shown below 100% — at/above actual size the loupe (which + // magnifies to actual pixels) would add nothing. + if (g_config.LoupeEnabled && GetPaneContext(PaneSlot::Primary).resource + && GetCurrentZoomPercent() < 100 + && !AppContext::GetInstance().Loupe.active) { + POINT cp; GetCursorPos(&cp); ScreenToClient(hwnd, &cp); + AppContext::GetInstance().Loupe.active = true; + AppContext::GetInstance().Loupe.cursorClient = cp; + RequestRepaint(PaintLayer::Dynamic); + } + return true; + default: break; }