diff --git a/Src/FlyPhotos/Display/Controllers/CanvasController.cs b/Src/FlyPhotos/Display/Controllers/CanvasController.cs index 2e08e87..6b71adf 100644 --- a/Src/FlyPhotos/Display/Controllers/CanvasController.cs +++ b/Src/FlyPhotos/Display/Controllers/CanvasController.cs @@ -81,9 +81,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; @@ -480,6 +486,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; } @@ -511,7 +518,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)); } /// @@ -558,6 +571,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 ae16cdb..deca8f7 100644 --- a/Src/FlyPhotos/Display/Controllers/CanvasViewManager.cs +++ b/Src/FlyPhotos/Display/Controllers/CanvasViewManager.cs @@ -460,6 +460,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 1f91370..dbbede3 100644 --- a/Src/FlyPhotos/Infra/Interop/Win32Methods.cs +++ b/Src/FlyPhotos/Infra/Interop/Win32Methods.cs @@ -204,6 +204,49 @@ 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); + + /// Changes the size and position of a window. + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetWindowPos(nint hWnd, nint hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); + + /// Does not activate the window when repositioning it. + internal const uint SWP_NOACTIVATE = 0x0010; + + /// Keeps the current Z order. + internal const uint SWP_NOZORDER = 0x0004; + + /// Retrieves the bounding rectangle of a window in screen coordinates. + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetWindowRect(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 +336,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 7d479fd..8261803 100644 --- a/Src/FlyPhotos/Strings/en-US/Resources.resw +++ b/Src/FlyPhotos/Strings/en-US/Resources.resw @@ -774,6 +774,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. @@ -852,4 +858,4 @@ High Quality Cubic – Highest-quality scaling for photos. OK - \ 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 c6aa0a1..f6b1327 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..6196382 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,79 @@ 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); + + // Seed the hidden normal placement with the requested rectangle so the presenter switch + // reveals the window near its destination instead of at the old bounds. This is only a + // flicker hint — the exact geometry is applied by AlignClientRect below, because the real + // non-client offsets cannot be derived from system metrics on a window that extends its + // content into the title bar (they are asymmetric: ~0 at the top, a resize border + // elsewhere) and cannot be measured at all while the full-screen presenter is live. + Win32Methods.GetWindowPlacement(hwnd, out var placement); + placement.rcNormalPosition = new Win32Methods.RECT + { + Left = clientRect.X, + Top = clientRect.Y, + Right = clientRect.X + clientRect.Width, + Bottom = clientRect.Y + clientRect.Height + }; + placement.showCmd = Win32Methods.SW_SHOWNORMAL; + + var wasFullScreen = AppWindow.Presenter.Kind == AppWindowPresenterKind.FullScreen; + + Win32Methods.SetWindowPlacement(hwnd, in placement); + + if (wasFullScreen) + { + exitFullScreenButton?.Visibility = Visibility.Collapsed; + AppWindow.SetPresenter(AppWindowPresenterKind.Overlapped); + _wasMaximizedBeforeFullScreen = false; + FullScreenToggled?.Invoke(false); + } + + AlignClientRect(hwnd, clientRect); + } + + /// + /// Positions the window so its client area matches exactly, + /// using measured (not estimated) non-client offsets. + /// + private static void AlignClientRect(nint hwnd, RectInt32 targetClientRect) + { + if (!Win32Methods.GetWindowRect(hwnd, out var windowRect) || + !Win32Methods.GetClientRect(hwnd, out var clientRect)) + return; + + var clientOrigin = new Win32Methods.POINT(); + if (!Win32Methods.ClientToScreen(hwnd, ref clientOrigin)) + return; + + var extraLeft = clientOrigin.X - windowRect.Left; + var extraTop = clientOrigin.Y - windowRect.Top; + var extraWidth = windowRect.Right - windowRect.Left - clientRect.Right; + var extraHeight = windowRect.Bottom - windowRect.Top - clientRect.Bottom; + + var desiredLeft = targetClientRect.X - extraLeft; + var desiredTop = targetClientRect.Y - extraTop; + var desiredWidth = targetClientRect.Width + extraWidth; + var desiredHeight = targetClientRect.Height + extraHeight; + + if (desiredLeft == windowRect.Left && desiredTop == windowRect.Top && + desiredWidth == windowRect.Right - windowRect.Left && + desiredHeight == windowRect.Bottom - windowRect.Top) + return; + + Win32Methods.SetWindowPos(hwnd, 0, desiredLeft, desiredTop, desiredWidth, desiredHeight, + Win32Methods.SWP_NOACTIVATE | Win32Methods.SWP_NOZORDER); + } + /// /// 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 dd79aaa..0a5cd7a 100644 --- a/Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs +++ b/Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs @@ -5,6 +5,7 @@ using System.IO; using System.Threading.Tasks; using Windows.Foundation; +using Windows.Graphics; using Windows.System; using FlyPhotos.Core; using FlyPhotos.Core.Model; @@ -353,6 +354,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 = WinRT.Interop.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(); @@ -610,7 +653,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: diff --git a/Src/FlyPhotos/UI/Views/Settings.xaml b/Src/FlyPhotos/UI/Views/Settings.xaml index 9d518fa..8fb694e 100644 --- a/Src/FlyPhotos/UI/Views/Settings.xaml +++ b/Src/FlyPhotos/UI/Views/Settings.xaml @@ -570,6 +570,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 b1ae49c..f6774b7 100644 --- a/Src/FlyPhotos/UI/Views/Settings.xaml.cs +++ b/Src/FlyPhotos/UI/Views/Settings.xaml.cs @@ -108,6 +108,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; @@ -141,6 +142,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; @@ -260,6 +262,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); @@ -785,4 +794,4 @@ public static Windows.UI.Color FromHex(string hex) } return Windows.UI.Color.FromArgb(a, r, g, b); } -} \ No newline at end of file +}