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