Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion Src/FlyPhotos/Display/Controllers/CanvasController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>The latest canvas transform published for UI-thread bounds calculations.</summary>
private Matrix3x2 _hitTestMat = Matrix3x2.Identity;
private Rect _hitTestImageRect;
private readonly Lock _hitTestLock = new();

/// <summary>The image origin to preserve during the next image-sized window resize.</summary>
private Point? _imageSizedResizeOrigin;

private int _zoomPercentUiUpdatePending;
private int _pendingZoomPercent;
private int _lastDispatchedZoomPercent = -1;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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));
}

/// <summary>
Expand Down Expand Up @@ -507,6 +520,47 @@ public bool IsPressedOnImage(Point position)
&& tp.X <= imageRect.Right && tp.Y <= imageRect.Bottom;
}

/// <summary>
/// Tries to get the axis-aligned bounds of the displayed image in physical canvas pixels.
/// </summary>
/// <param name="bounds">The displayed image bounds when available.</param>
/// <returns><see langword="true"/> when valid image bounds are available; otherwise, <see langword="false"/>.</returns>
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;
}

/// <summary>
/// Marks the next canvas resize as an image-sized window resize and preserves the image's screen position.
/// </summary>
/// <param name="imageBounds">The displayed image bounds before the window is resized.</param>
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
Expand Down
12 changes: 12 additions & 0 deletions Src/FlyPhotos/Display/Controllers/CanvasViewManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,18 @@ public void HandleSizeChange(Size newSize, Size previousSize)
}
}

/// <summary>
/// Keeps the current scale and moves the displayed image bounds to the new canvas origin.
/// </summary>
public void HandleImageSizedWindowResize(Point previousImageOrigin)
{
ClearActiveAnimation();
_canvasViewState.ImagePos.X -= previousImageOrigin.X;
_canvasViewState.ImagePos.Y -= previousImageOrigin.Y;
_canvasViewState.UpdateTransform();
ViewChanged?.Invoke();
}

/// <summary>
/// Saves the current view for <paramref name="photoPath"/> if "RememberPerPhoto" is enabled and the
/// user has actually modified the view (panned, zoomed, or rotated). Pan is stored normalized to the
Expand Down
1 change: 1 addition & 0 deletions Src/FlyPhotos/Infra/Configuration/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
30 changes: 30 additions & 0 deletions Src/FlyPhotos/Infra/Interop/Win32Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,33 @@ public struct SHELLEXECUTEINFO

#region Window placement (user32.dll)

/// <summary>Retrieves the dimensions of a window's client area.</summary>
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool GetClientRect(nint hWnd, out RECT lpRect);

/// <summary>Converts client-area coordinates to screen coordinates.</summary>
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool ClientToScreen(nint hWnd, ref POINT lpPoint);

/// <summary>Retrieves the DPI value for a window.</summary>
[LibraryImport("user32.dll")]
internal static partial uint GetDpiForWindow(nint hWnd);

/// <summary>Retrieves a system metric for the specified DPI.</summary>
[LibraryImport("user32.dll")]
internal static partial int GetSystemMetricsForDpi(int nIndex, uint dpi);

/// <summary>Width of a sizing window frame.</summary>
internal const int SM_CXSIZEFRAME = 32;

/// <summary>Height of a sizing window frame.</summary>
internal const int SM_CYSIZEFRAME = 33;

/// <summary>Thickness of the padded border around a resizable window.</summary>
internal const int SM_CXPADDEDBORDER = 92;

#pragma warning disable SYSLIB1054
/// <summary>
/// Retrieves the show state and the restored, minimized, and maximized positions of the specified window.
Expand Down Expand Up @@ -293,6 +320,9 @@ internal struct WINDOWPLACEMENT
/// </summary>
internal const uint SW_SHOWMAXIMIZED = 3;

/// <summary>Activates and displays a window in its normal position and size.</summary>
internal const uint SW_SHOWNORMAL = 1;

#endregion

#region Native stream access — bypasses Windows Storage Broker (shcore.dll)
Expand Down
8 changes: 7 additions & 1 deletion Src/FlyPhotos/Strings/en-US/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,12 @@ Esc : Close Settings or Exit App</value>
<data name="SettingsCardClickOutsideImageToRestoreWindow.Header" xml:space="preserve">
<value>Click outside image to restore window</value>
</data>
<data name="SettingsCardSizeWindowToImageOnRestore.Description" xml:space="preserve">
<value>When restoring the window by clicking outside the image, resize it to match the displayed image.</value>
</data>
<data name="SettingsCardSizeWindowToImageOnRestore.Header" xml:space="preserve">
<value>Size restored window to image</value>
</data>
<data name="SettingsCardAutoHideCaptionButtons.Description" xml:space="preserve">
<value>The minimize, maximize, and close buttons are shown only when the mouse is near the top of the window.</value>
</data>
Expand Down Expand Up @@ -819,4 +825,4 @@ High Quality Cubic – Highest-quality scaling for photos.</value>
<data name="MenuItemDelete.Text" xml:space="preserve">
<value>Delete</value>
</data>
</root>
</root>
6 changes: 6 additions & 0 deletions Src/FlyPhotos/Strings/ru-RU/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,12 @@ Esc : Закрыть параметры или выйти из приложен
<data name="SettingsCardClickOutsideImageToRestoreWindow.Header" xml:space="preserve">
<value>Клик вне изображения для восстановления окна</value>
</data>
<data name="SettingsCardSizeWindowToImageOnRestore.Description" xml:space="preserve">
<value>При восстановлении окна кликом вне изображения изменять его размер под отображаемое изображение.</value>
</data>
<data name="SettingsCardSizeWindowToImageOnRestore.Header" xml:space="preserve">
<value>Размер окна по изображению</value>
</data>
<data name="SettingsCardAutoHideCaptionButtons.Description" xml:space="preserve">
<value>Кнопки свертывания, развертывания и закрытия отображаются только тогда, когда мышь находится у верхнего края окна.</value>
</data>
Expand Down
40 changes: 40 additions & 0 deletions Src/FlyPhotos/UI/Behaviors/WindowFullScreenManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -81,6 +82,45 @@ internal void Restore(UIElement? exitFullScreenButton = null)
}
}

/// <summary>
/// Restores the window and makes its client area match the requested screen-space rectangle.
/// </summary>
/// <param name="clientRect">The desired client-area rectangle in physical screen pixels.</param>
/// <param name="exitFullScreenButton">The optional button to hide when leaving full-screen mode.</param>
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);
}
}

/// <summary>
/// Toggles the window between full-screen mode and the normal overlapped state.
/// Tracks previous maximized state to avoid flickering when returning from full-screen.
Expand Down
51 changes: 50 additions & 1 deletion Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1069,6 +1076,48 @@ private void ToggleMaximizeRestore()
_windFullScreenManager.Maximize();
}

/// <summary>
/// Restores the window with its client area sized around the currently displayed image.
/// </summary>
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();
Expand Down
11 changes: 10 additions & 1 deletion Src/FlyPhotos/UI/Views/Settings.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,15 @@
<ToggleSwitch x:Name="ButtonClickOutsideImageToRestoreWindow" />
</controls:SettingsCard>

<controls:SettingsCard
x:Name="SettingsCardSizeWindowToImageOnRestore"
x:Uid="SettingsCardSizeWindowToImageOnRestore"
Margin="0,5,0,0"
HeaderIcon="{ui:FontIcon Glyph=&#xE8A7;, FontFamily={StaticResource FluentIcons}}"
IsEnabled="{x:Bind ButtonClickOutsideImageToRestoreWindow.IsOn, Mode=OneWay}">
<ToggleSwitch x:Name="ButtonSizeWindowToImageOnRestore" />
</controls:SettingsCard>

<controls:SettingsCard
x:Name="SettingsCardCtrlDragToMoveWindow"
x:Uid="SettingsCardCtrlDragToMoveWindow"
Expand Down Expand Up @@ -685,4 +694,4 @@
</PivotItem>
</Pivot>
</Grid>
</Window>
</Window>
11 changes: 10 additions & 1 deletion Src/FlyPhotos/UI/Views/Settings.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -251,6 +253,13 @@ private async void ButtonClickOutsideImageToRestoreWindow_OnToggled(object sende
await AppConfig.SaveAsync();
}

/// <summary>Persists whether image-sized restoration is enabled.</summary>
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);
Expand Down Expand Up @@ -783,4 +792,4 @@ public static Windows.UI.Color FromHex(string hex)
}
return Windows.UI.Color.FromArgb(a, r, g, b);
}
}
}