From 6160390913d49496acfd115f8953213e7bc1b129 Mon Sep 17 00:00:00 2001 From: SKProCH Date: Fri, 17 Jul 2026 18:17:48 +0300 Subject: [PATCH] Add image-sized window restoration feature --- .../Display/Controllers/CanvasController.cs | 56 ++++++++++++++++++- .../Display/Controllers/CanvasViewManager.cs | 12 ++++ .../Infra/Configuration/AppSettings.cs | 1 + Src/FlyPhotos/Infra/Interop/Win32Methods.cs | 30 ++++++++++ Src/FlyPhotos/Strings/en-US/Resources.resw | 8 ++- Src/FlyPhotos/Strings/ru-RU/Resources.resw | 6 ++ .../UI/Behaviors/WindowFullScreenManager.cs | 40 +++++++++++++ .../UI/Views/PhotoDisplayWindow.xaml.cs | 51 ++++++++++++++++- Src/FlyPhotos/UI/Views/Settings.xaml | 11 +++- Src/FlyPhotos/UI/Views/Settings.xaml.cs | 11 +++- 10 files changed, 221 insertions(+), 5 deletions(-) diff --git a/Src/FlyPhotos/Display/Controllers/CanvasController.cs b/Src/FlyPhotos/Display/Controllers/CanvasController.cs index 4912196..93a952f 100644 --- a/Src/FlyPhotos/Display/Controllers/CanvasController.cs +++ b/Src/FlyPhotos/Display/Controllers/CanvasController.cs @@ -77,9 +77,15 @@ internal partial class CanvasController : ICanvasController // A Lock is used because Matrix3x2 (6 floats) and Rect (4 doubles) are not atomically writable, // making volatile inadequate. Contention is negligible: pointer events are rare vs. 144 Hz Update. private Matrix3x2 _hitTestMatInv = Matrix3x2.Identity; + + /// The latest canvas transform published for UI-thread bounds calculations. + private Matrix3x2 _hitTestMat = Matrix3x2.Identity; private Rect _hitTestImageRect; private readonly Lock _hitTestLock = new(); + /// The image origin to preserve during the next image-sized window resize. + private Point? _imageSizedResizeOrigin; + private int _zoomPercentUiUpdatePending; private int _pendingZoomPercent; private int _lastDispatchedZoomPercent = -1; @@ -429,6 +435,7 @@ private void D2dCanvas_Update(ICanvasAnimatedControl sender, CanvasAnimatedUpdat // ④ Publish the current transform for UI-thread hit-testing (IsPressedOnImage). lock (_hitTestLock) { + _hitTestMat = _canvasViewState.Mat; _hitTestMatInv = _canvasViewState.MatInv; _hitTestImageRect = _canvasViewState.ImageRect; } @@ -460,7 +467,13 @@ private void D2dCanvas_SizeChanged(object sender, SizeChangedEventArgs args) { var newSize = args.NewSize.AdjustForDpi(_d2dCanvas); var previousSize = args.PreviousSize.AdjustForDpi(_d2dCanvas); - SafeEnqueue(v => v.HandleSizeChange(newSize, previousSize)); + if (_imageSizedResizeOrigin is { } imageOrigin) + { + _imageSizedResizeOrigin = null; + SafeEnqueue(v => v.HandleImageSizedWindowResize(imageOrigin)); + } + else + SafeEnqueue(v => v.HandleSizeChange(newSize, previousSize)); } /// @@ -507,6 +520,47 @@ public bool IsPressedOnImage(Point position) && tp.X <= imageRect.Right && tp.Y <= imageRect.Bottom; } + /// + /// Tries to get the axis-aligned bounds of the displayed image in physical canvas pixels. + /// + /// The displayed image bounds when available. + /// when valid image bounds are available; otherwise, . + public bool TryGetDisplayedImageBounds(out Rect bounds) + { + Matrix3x2 transform; + Rect imageRect; + lock (_hitTestLock) + { + transform = _hitTestMat; + imageRect = _hitTestImageRect; + } + + if (imageRect.Width <= 0 || imageRect.Height <= 0) + { + bounds = default; + return false; + } + + var topLeft = Vector2.Transform(new Vector2((float)imageRect.Left, (float)imageRect.Top), transform); + var topRight = Vector2.Transform(new Vector2((float)imageRect.Right, (float)imageRect.Top), transform); + var bottomLeft = Vector2.Transform(new Vector2((float)imageRect.Left, (float)imageRect.Bottom), transform); + var bottomRight = Vector2.Transform(new Vector2((float)imageRect.Right, (float)imageRect.Bottom), transform); + + var left = MathF.Min(MathF.Min(topLeft.X, topRight.X), MathF.Min(bottomLeft.X, bottomRight.X)); + var top = MathF.Min(MathF.Min(topLeft.Y, topRight.Y), MathF.Min(bottomLeft.Y, bottomRight.Y)); + var right = MathF.Max(MathF.Max(topLeft.X, topRight.X), MathF.Max(bottomLeft.X, bottomRight.X)); + var bottom = MathF.Max(MathF.Max(topLeft.Y, topRight.Y), MathF.Max(bottomLeft.Y, bottomRight.Y)); + bounds = new Rect(left, top, right - left, bottom - top); + return true; + } + + /// + /// Marks the next canvas resize as an image-sized window resize and preserves the image's screen position. + /// + /// The displayed image bounds before the window is resized. + public void PrepareForImageSizedWindow(Rect imageBounds) => + _imageSizedResizeOrigin = new Point(imageBounds.Left, imageBounds.Top); + // --- Settings --- public void HandleCheckeredBackgroundChange() => _pump.Wake(); // wake the canvas; Draw() reads the setting live diff --git a/Src/FlyPhotos/Display/Controllers/CanvasViewManager.cs b/Src/FlyPhotos/Display/Controllers/CanvasViewManager.cs index 6ade5fd..86f7bd0 100644 --- a/Src/FlyPhotos/Display/Controllers/CanvasViewManager.cs +++ b/Src/FlyPhotos/Display/Controllers/CanvasViewManager.cs @@ -436,6 +436,18 @@ public void HandleSizeChange(Size newSize, Size previousSize) } } + /// + /// Keeps the current scale and moves the displayed image bounds to the new canvas origin. + /// + public void HandleImageSizedWindowResize(Point previousImageOrigin) + { + ClearActiveAnimation(); + _canvasViewState.ImagePos.X -= previousImageOrigin.X; + _canvasViewState.ImagePos.Y -= previousImageOrigin.Y; + _canvasViewState.UpdateTransform(); + ViewChanged?.Invoke(); + } + /// /// Saves the current view for if "RememberPerPhoto" is enabled and the /// user has actually modified the view (panned, zoomed, or rotated). Pan is stored normalized to the diff --git a/Src/FlyPhotos/Infra/Configuration/AppSettings.cs b/Src/FlyPhotos/Infra/Configuration/AppSettings.cs index 4d6d4ee..2378229 100644 --- a/Src/FlyPhotos/Infra/Configuration/AppSettings.cs +++ b/Src/FlyPhotos/Infra/Configuration/AppSettings.cs @@ -50,6 +50,7 @@ public class AppSettings public bool AutoHideMouse { get; set; } = false; public bool AutoHideCaptionButtons { get; set; } = false; public bool ClickOutsideImageToRestoreWindow { get; set; } = true; + public bool SizeWindowToImageOnRestore { get; set; } = false; public bool CtrlDragToMoveWindow { get; set; } = true; public bool UseExternalExeForContextMenu { get; set; } = false; public bool ShowExternalAppShortcuts { get; set; } = false; diff --git a/Src/FlyPhotos/Infra/Interop/Win32Methods.cs b/Src/FlyPhotos/Infra/Interop/Win32Methods.cs index 1b35e57..dd17df9 100644 --- a/Src/FlyPhotos/Infra/Interop/Win32Methods.cs +++ b/Src/FlyPhotos/Infra/Interop/Win32Methods.cs @@ -204,6 +204,33 @@ public struct SHELLEXECUTEINFO #region Window placement (user32.dll) + /// Retrieves the dimensions of a window's client area. + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetClientRect(nint hWnd, out RECT lpRect); + + /// Converts client-area coordinates to screen coordinates. + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool ClientToScreen(nint hWnd, ref POINT lpPoint); + + /// Retrieves the DPI value for a window. + [LibraryImport("user32.dll")] + internal static partial uint GetDpiForWindow(nint hWnd); + + /// Retrieves a system metric for the specified DPI. + [LibraryImport("user32.dll")] + internal static partial int GetSystemMetricsForDpi(int nIndex, uint dpi); + + /// Width of a sizing window frame. + internal const int SM_CXSIZEFRAME = 32; + + /// Height of a sizing window frame. + internal const int SM_CYSIZEFRAME = 33; + + /// Thickness of the padded border around a resizable window. + internal const int SM_CXPADDEDBORDER = 92; + #pragma warning disable SYSLIB1054 /// /// Retrieves the show state and the restored, minimized, and maximized positions of the specified window. @@ -293,6 +320,9 @@ internal struct WINDOWPLACEMENT /// internal const uint SW_SHOWMAXIMIZED = 3; + /// Activates and displays a window in its normal position and size. + internal const uint SW_SHOWNORMAL = 1; + #endregion #region Native stream access — bypasses Windows Storage Broker (shcore.dll) diff --git a/Src/FlyPhotos/Strings/en-US/Resources.resw b/Src/FlyPhotos/Strings/en-US/Resources.resw index 7995347..1a15abd 100644 --- a/Src/FlyPhotos/Strings/en-US/Resources.resw +++ b/Src/FlyPhotos/Strings/en-US/Resources.resw @@ -762,6 +762,12 @@ Esc : Close Settings or Exit App Click outside image to restore window + + When restoring the window by clicking outside the image, resize it to match the displayed image. + + + Size restored window to image + The minimize, maximize, and close buttons are shown only when the mouse is near the top of the window. @@ -819,4 +825,4 @@ High Quality Cubic – Highest-quality scaling for photos. Delete - \ No newline at end of file + diff --git a/Src/FlyPhotos/Strings/ru-RU/Resources.resw b/Src/FlyPhotos/Strings/ru-RU/Resources.resw index 9d1c9ca..e7c82bb 100644 --- a/Src/FlyPhotos/Strings/ru-RU/Resources.resw +++ b/Src/FlyPhotos/Strings/ru-RU/Resources.resw @@ -763,6 +763,12 @@ Esc : Закрыть параметры или выйти из приложен Клик вне изображения для восстановления окна + + При восстановлении окна кликом вне изображения изменять его размер под отображаемое изображение. + + + Размер окна по изображению + Кнопки свертывания, развертывания и закрытия отображаются только тогда, когда мышь находится у верхнего края окна. diff --git a/Src/FlyPhotos/UI/Behaviors/WindowFullScreenManager.cs b/Src/FlyPhotos/UI/Behaviors/WindowFullScreenManager.cs index aea7930..5ea7b9b 100644 --- a/Src/FlyPhotos/UI/Behaviors/WindowFullScreenManager.cs +++ b/Src/FlyPhotos/UI/Behaviors/WindowFullScreenManager.cs @@ -3,6 +3,7 @@ using FlyPhotos.Infra.Interop; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; +using Windows.Graphics; using WinRT.Interop; namespace FlyPhotos.UI.Behaviors; @@ -81,6 +82,45 @@ internal void Restore(UIElement? exitFullScreenButton = null) } } + /// + /// Restores the window and makes its client area match the requested screen-space rectangle. + /// + /// The desired client-area rectangle in physical screen pixels. + /// The optional button to hide when leaving full-screen mode. + internal void RestoreToClientRect(RectInt32 clientRect, UIElement? exitFullScreenButton = null) + { + var hwnd = WindowNative.GetWindowHandle(_window); + var dpi = Win32Methods.GetDpiForWindow(hwnd); + var frameX = Win32Methods.GetSystemMetricsForDpi(Win32Methods.SM_CXSIZEFRAME, dpi) + + Win32Methods.GetSystemMetricsForDpi(Win32Methods.SM_CXPADDEDBORDER, dpi); + var frameY = Win32Methods.GetSystemMetricsForDpi(Win32Methods.SM_CYSIZEFRAME, dpi) + + Win32Methods.GetSystemMetricsForDpi(Win32Methods.SM_CXPADDEDBORDER, dpi); + + Win32Methods.GetWindowPlacement(hwnd, out var placement); + placement.rcNormalPosition = new Win32Methods.RECT + { + Left = clientRect.X - frameX, + Top = clientRect.Y - frameY, + Right = clientRect.X + clientRect.Width + frameX, + Bottom = clientRect.Y + clientRect.Height + frameY + }; + placement.showCmd = Win32Methods.SW_SHOWNORMAL; + + var wasFullScreen = AppWindow.Presenter.Kind == AppWindowPresenterKind.FullScreen; + + // While full-screen, update the hidden normal placement first. Switching presenters then + // reveals the window directly at its destination instead of briefly showing the old bounds. + Win32Methods.SetWindowPlacement(hwnd, in placement); + + if (wasFullScreen) + { + exitFullScreenButton?.Visibility = Visibility.Collapsed; + AppWindow.SetPresenter(AppWindowPresenterKind.Overlapped); + _wasMaximizedBeforeFullScreen = false; + FullScreenToggled?.Invoke(false); + } + } + /// /// Toggles the window between full-screen mode and the normal overlapped state. /// Tracks previous maximized state to avoid flickering when returning from full-screen. diff --git a/Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs b/Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs index cefd62d..0b4c118 100644 --- a/Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs +++ b/Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs @@ -26,7 +26,9 @@ using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Input; using NLog; +using Windows.Graphics; using WinUIEx; +using WinRT.Interop; namespace FlyPhotos.UI.Views; @@ -563,7 +565,12 @@ private void D2dCanvas_PointerReleased(object sender, PointerRoutedEventArgs e) !(currentPoint.Position.Y < AppTitlebar.ActualHeight) && !_canvasController.IsPressedOnImage(dpiAdjustedPosition) && _windFullScreenManager.IsMaximizedOrFullScreen) - _windFullScreenManager.Restore(ButtonFullScreenClose); + { + if (AppConfig.Settings.SizeWindowToImageOnRestore) + RestoreWindowToImage(); + else + _windFullScreenManager.Restore(ButtonFullScreenClose); + } break; case PointerUpdateKind.MiddleButtonReleased: @@ -1069,6 +1076,48 @@ private void ToggleMaximizeRestore() _windFullScreenManager.Maximize(); } + /// + /// Restores the window with its client area sized around the currently displayed image. + /// + private void RestoreWindowToImage() + { + if (!_canvasController.TryGetDisplayedImageBounds(out var imageBounds)) + { + _windFullScreenManager.Restore(ButtonFullScreenClose); + return; + } + + var hwnd = WindowNative.GetWindowHandle(this); + var clientOrigin = new Win32Methods.POINT(); + if (!Win32Methods.ClientToScreen(hwnd, ref clientOrigin) || + !Win32Methods.GetClientRect(hwnd, out var clientRect)) + { + _windFullScreenManager.Restore(ButtonFullScreenClose); + return; + } + + var dpiScale = D2dCanvas.Dpi / 96.0; + var canvasOffset = D2dCanvas.TransformToVisual(MainLayout).TransformPoint(default); + var canvasOffsetX = (int)Math.Round(canvasOffset.X * dpiScale); + var canvasOffsetY = (int)Math.Round(canvasOffset.Y * dpiScale); + var nonCanvasWidth = clientRect.Right - clientRect.Left - (int)Math.Round(D2dCanvas.ActualWidth * dpiScale); + var nonCanvasHeight = clientRect.Bottom - clientRect.Top - (int)Math.Round(D2dCanvas.ActualHeight * dpiScale); + + var imageLeft = clientOrigin.X + canvasOffsetX + (int)Math.Floor(imageBounds.Left); + var imageTop = clientOrigin.Y + canvasOffsetY + (int)Math.Floor(imageBounds.Top); + var imageWidth = (int)Math.Ceiling(imageBounds.Right) - (int)Math.Floor(imageBounds.Left); + var imageHeight = (int)Math.Ceiling(imageBounds.Bottom) - (int)Math.Floor(imageBounds.Top); + + _canvasController.PrepareForImageSizedWindow(imageBounds); + _windFullScreenManager.RestoreToClientRect( + new RectInt32( + imageLeft - canvasOffsetX, + imageTop - canvasOffsetY, + Math.Max(1, imageWidth + nonCanvasWidth), + Math.Max(1, imageHeight + nonCanvasHeight)), + ButtonFullScreenClose); + } + private async Task AnimatePhotoDisplayWindowClose() { _settingWindow?.Close(); diff --git a/Src/FlyPhotos/UI/Views/Settings.xaml b/Src/FlyPhotos/UI/Views/Settings.xaml index 9d518fa..0b0ea1e 100644 --- a/Src/FlyPhotos/UI/Views/Settings.xaml +++ b/Src/FlyPhotos/UI/Views/Settings.xaml @@ -279,6 +279,15 @@ + + + + - \ No newline at end of file + diff --git a/Src/FlyPhotos/UI/Views/Settings.xaml.cs b/Src/FlyPhotos/UI/Views/Settings.xaml.cs index c44d687..947ec6f 100644 --- a/Src/FlyPhotos/UI/Views/Settings.xaml.cs +++ b/Src/FlyPhotos/UI/Views/Settings.xaml.cs @@ -107,6 +107,7 @@ internal Settings() ButtonEnableAutoHideCaptionButtons.IsOn = AppConfig.Settings.AutoHideCaptionButtons; ButtonCtrlDragToMoveWindow.IsOn = AppConfig.Settings.CtrlDragToMoveWindow; ButtonClickOutsideImageToRestoreWindow.IsOn = AppConfig.Settings.ClickOutsideImageToRestoreWindow; + ButtonSizeWindowToImageOnRestore.IsOn = AppConfig.Settings.SizeWindowToImageOnRestore; ButtonEnableExternalShortcut.IsOn = AppConfig.Settings.ShowExternalAppShortcuts; ButtonDecodeRawData.IsOn = AppConfig.Settings.DecodeRawData; @@ -140,6 +141,7 @@ internal Settings() ButtonEnableAutoHideCaptionButtons.Toggled += ButtonEnableAutoHideCaptionButtons_OnToggled; ButtonCtrlDragToMoveWindow.Toggled += ButtonCtrlDragToMoveWindow_OnToggled; ButtonClickOutsideImageToRestoreWindow.Toggled += ButtonClickOutsideImageToRestoreWindow_OnToggled; + ButtonSizeWindowToImageOnRestore.Toggled += ButtonSizeWindowToImageOnRestore_OnToggled; ButtonEnableExternalShortcut.Toggled += ButtonEnableExternalShortcut_OnToggled; ButtonDecodeRawData.Toggled += ButtonDecodeRawData_OnToggled; AppConfig.Settings.RawDecoderPriority.CollectionChanged += RawDecoderPriority_CollectionChanged; @@ -251,6 +253,13 @@ private async void ButtonClickOutsideImageToRestoreWindow_OnToggled(object sende await AppConfig.SaveAsync(); } + /// Persists whether image-sized restoration is enabled. + private async void ButtonSizeWindowToImageOnRestore_OnToggled(object sender, RoutedEventArgs e) + { + AppConfig.Settings.SizeWindowToImageOnRestore = ButtonSizeWindowToImageOnRestore.IsOn; + await AppConfig.SaveAsync(); + } + private async void ComboPanZoomNavBehaviour_OnSelectionChanged(object sender, SelectionChangedEventArgs e) { var panZoomEnum = GetPanZoomForIndex(ComboPanZoomNavBehaviour.SelectedIndex); @@ -783,4 +792,4 @@ public static Windows.UI.Color FromHex(string hex) } return Windows.UI.Color.FromArgb(a, r, g, b); } -} \ No newline at end of file +}