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
+}