From 6160390913d49496acfd115f8953213e7bc1b129 Mon Sep 17 00:00:00 2001 From: SKProCH Date: Fri, 17 Jul 2026 18:17:48 +0300 Subject: [PATCH 1/5] 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 +} From 9a6a8f40a402fa74fd88fba2ac0a134c87127aaa Mon Sep 17 00:00:00 2001 From: SKProCH Date: Fri, 14 Aug 2026 17:36:58 +0300 Subject: [PATCH 2/5] Move the settings into advanced section --- Src/FlyPhotos/UI/Views/Settings.xaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Src/FlyPhotos/UI/Views/Settings.xaml b/Src/FlyPhotos/UI/Views/Settings.xaml index 0b0ea1e..8fb694e 100644 --- a/Src/FlyPhotos/UI/Views/Settings.xaml +++ b/Src/FlyPhotos/UI/Views/Settings.xaml @@ -279,15 +279,6 @@ - - - - + + + + Date: Fri, 14 Aug 2026 17:48:39 +0300 Subject: [PATCH 3/5] Fixes merge mistakes --- .../UI/Views/PhotoDisplayWindow.xaml.cs | 378 +++--------------- 1 file changed, 46 insertions(+), 332 deletions(-) diff --git a/Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs b/Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs index a4af53f..808a8f2 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; @@ -28,9 +29,7 @@ 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; @@ -355,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(); @@ -612,12 +653,7 @@ private void D2dCanvas_PointerReleased(object sender, PointerRoutedEventArgs e) !(currentPoint.Position.Y < AppTitlebar.ActualHeight) && !_canvasController.IsPressedOnImage(dpiAdjustedPosition) && _windFullScreenManager.IsMaximizedOrFullScreen) - { - if (AppConfig.Settings.SizeWindowToImageOnRestore) - RestoreWindowToImage(); - else - _windFullScreenManager.Restore(ButtonFullScreenClose); - } + _windFullScreenManager.Restore(ButtonFullScreenClose); break; case PointerUpdateKind.MiddleButtonReleased: @@ -1121,332 +1157,10 @@ public async Task RunAsync(Func action) { if (_isRunning) return; _isRunning = true; - try - { await action(); } - finally - { _isRunning = false; } + try { await action(); } + finally { _isRunning = false; } } } - private const string DefaultAppIconGlyph = "\uED35"; - - private static IconElement BuildAppIcon(InstalledApp app, double? size = null) - { - if (app.Icon != null) - { - var icon = new ImageIcon { Source = app.Icon }; - if (size.HasValue) { icon.Width = size.Value; icon.Height = size.Value; } - return icon; - } - - var fallback = new FontIcon { Glyph = DefaultAppIconGlyph, FontFamily = App.FluentIconFont }; - if (size.HasValue) fallback.FontSize = size.Value; - return fallback; - } - - private void LaunchExternalAppFromSender(object sender) - { - var filePathArgument = _photoController.GetFullPathCurrentFile(); - if (sender is FrameworkElement { Tag: InstalledApp app }) - _ = app.LaunchAsync(filePathArgument); // Fire and forget the launch, we don't need to await it here - } - - /// - /// Shows a Flyout of configured external-app shortcuts, anchored to the bottom toolbar - /// panel (always visible/laid-out, unlike the zoom-percentage OSD). A plain Flyout gives - /// light-dismiss (click-outside/Escape) and keyboard navigation for free, unlike a - /// hand-rolled overlay. - /// - private Task ShowShortcutsPanelAsync() => _shortcutsGate.RunAsync(async () => - { - if (!File.Exists(_photoController.GetFullPathCurrentFile())) return; - - var stackPanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 }; - foreach (var app in await GetConfiguredExternalAppsAsync()) - { - if (app == null) continue; - - var flyoutButton = new Button { Width = 60, Height = 50, Tag = app, Content = BuildAppIcon(app, 32) }; - ToolTipService.SetToolTip(flyoutButton, app.DisplayName); - flyoutButton.Click += ShortcutsFlyoutButton_OnClick; - stackPanel.Children.Add(flyoutButton); - } - - UIElement content = stackPanel.Children.Count != 0 - ? stackPanel - : new TextBlock { Text = L.Get("NoShortcutsCreated/Message") }; - - _shortcutsFlyout = new Flyout { Content = content, Placement = FlyoutPlacementMode.Top }; - _shortcutsFlyout.ShowAt(BorderButtonPanel); - }); - - private Task ShowMoreMenuAsync() => _moreMenuGate.RunAsync(async () => - { - if (!File.Exists(_photoController.GetFullPathCurrentFile())) return; - await PopulateOpenWithSectionAsync(); - FlyoutBase.ShowAttachedFlyout(ButtonMore); - }); - - /// - /// Resolves the 4 configured external-app shortcut slots (Settings > External apps) in - /// parallel and caches the result by position (an empty/unresolved slot is null at that - /// index), since resolving an app involves off-UI-thread work (icon extraction for Win32 - /// apps, native lookups for Store apps) and callers like Ctrl+1..4 depend on slot identity. - /// - private async Task GetConfiguredExternalAppsAsync() - { - var shortCuts = new[] - { - AppConfig.Settings.ExternalApp1, AppConfig.Settings.ExternalApp2, - AppConfig.Settings.ExternalApp3, AppConfig.Settings.ExternalApp4 - }; - var key = (shortCuts[0], shortCuts[1], shortCuts[2], shortCuts[3]); - if (_cachedExternalApps != null && _cachedExternalAppKey.Equals(key)) - return _cachedExternalApps; - - var resolveTasks = new Task[shortCuts.Length]; - for (var i = 0; i < shortCuts.Length; i++) - resolveTasks[i] = string.IsNullOrEmpty(shortCuts[i]) - ? Task.FromResult(null) - : ShellAppProvider.GetAppAsync(shortCuts[i]); - - var apps = await Task.WhenAll(resolveTasks); - - _cachedExternalAppKey = key; - _cachedExternalApps = apps; - return apps; - } - - private async Task PopulateOpenWithSectionAsync() - { - while (ButtonMoreMenuFlyout.Items[0] != SeparatorAfterOpenWith) - ButtonMoreMenuFlyout.Items.RemoveAt(0); - - if (!AppConfig.Settings.ShowExternalAppShortcuts) - { - SeparatorAfterOpenWith.Visibility = Visibility.Collapsed; - return; - } - SeparatorAfterOpenWith.Visibility = Visibility.Visible; - - var insertAt = 0; - ButtonMoreMenuFlyout.Items.Insert(insertAt++, - new MenuFlyoutItem { Text = L.Get("MenuItemOpenWithHeader/Text"), IsEnabled = false }); - - var apps = await GetConfiguredExternalAppsAsync(); - var anyAdded = false; - foreach (var app in apps) - { - if (app == null) continue; - - var item = new MenuFlyoutItem - { - Text = TruncateAppName(app.DisplayName), - Tag = app, - Icon = BuildAppIcon(app) - }; - ToolTipService.SetToolTip(item, app.DisplayName); - item.Click += MenuItemOpenWithApp_OnClick; - ButtonMoreMenuFlyout.Items.Insert(insertAt++, item); - anyAdded = true; - } - - if (!anyAdded) - ButtonMoreMenuFlyout.Items.Insert(insertAt, - new MenuFlyoutItem { Text = L.Get("NoShortcutsCreated/Message"), IsEnabled = false }); - } - - private static string TruncateAppName(string name, int maxLength = 24) => - name.Length > maxLength ? name[..maxLength] + "…" : name; - - /// - /// Activates the window and applies the configured launch mode (maximized, full-screen, - /// or last window state). Activate() is always called first so that - /// ExtendsContentIntoTitleBar is stable before any maximize, avoiding the - /// top-edge position jag that occurs when SW_SHOWMAXIMIZED is applied inside - /// WM_SHOWWINDOW before the title-bar geometry has settled. - /// - internal void ActivateForStartup() - { - Activate(); - switch (AppConfig.Settings.WindowLaunchMode) - { - case WindowLaunchMode.Maximized: - this.Maximize(); - break; - case WindowLaunchMode.FullScreen: - _windFullScreenManager.ToggleFullScreen(ButtonFullScreenClose); - break; - case WindowLaunchMode.LastWindowState: - if (_windPlacementManager.WasMaximized) - this.Maximize(); - break; - } - } - - private async Task HandleMouseWheelNavigation(int delta, bool isHorizontalScroll) - { - if (_photoController.IsSinglePhoto()) return; - - ref int accumulator = ref (isHorizontalScroll ? ref _horizontalDeltaAccumulator : ref _verticalDeltaAccumulator); - accumulator += delta; - - if (Math.Abs(accumulator) < AppConfig.Settings.ScrollThreshold) return; - - var direction = isHorizontalScroll ? - (accumulator > 0 ? NavDirection.Next : NavDirection.Prev) : - (accumulator > 0 ? NavDirection.Prev : NavDirection.Next); - - accumulator = 0; - await _photoController.Fly(direction); - RestartBrakeTimer(); - } - - private void HandleMouseWheelZoom(int delta, Point point) - { - var adjustedPoint = point.AdjustForDpi(D2dCanvas); - - if (IsPrecisionTouchpad(delta)) - _canvasController.ZoomAtPointPrecision(delta, adjustedPoint); - else - _canvasController.ZoomAtPoint(delta > 0 ? ZoomDirection.In : ZoomDirection.Out, adjustedPoint); - } - - /// - /// Heuristic to detect precision touchpad / smooth pinch scroll. - /// Returns true if delta is not a multiple of standard WHEEL_DELTA (120), - /// which usually indicates a touchpad gesture. - /// - private static bool IsPrecisionTouchpad(int delta) => Math.Abs(delta) % 120 != 0; - - private void RestartBrakeTimer() - { - _wheelScrollBrakeTimer.Stop(); - _wheelScrollBrakeTimer.Start(); - } - - private void ToggleMaximizeRestore() - { - if (_windFullScreenManager.IsMaximizedOrFullScreen) - _windFullScreenManager.Restore(ButtonFullScreenClose); - else - _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(); - - if (AppConfig.Settings.OpenExitZoom) - { - // Arm before starting so the W2D subscribe is enqueued (FIFO) ahead of ZoomOutOnExit's - // start action, then wait for the actual animation to finish instead of a fixed delay. - var exitAnimation = _canvasController.WaitForPanZoomAnimationAsync( - Constants.PanZoomAnimationDurationForExit * 2); - _canvasController.ZoomOutOnExit(Constants.PanZoomAnimationDurationForExit); - await exitAnimation; - } - SaveLastWindowState(); - this.Hide(); - Close(); - } - - private void SaveLastWindowState() - { - if (AppConfig.Settings.WindowLaunchMode == WindowLaunchMode.LastWindowState) - { - AppConfig.Settings.WindowState = _windPlacementManager.Data; - AppConfig.Save(); - } - } - - private void OnFirstPhotoLoaded() => DispatcherQueue.TryEnqueue(() => - { - _firstPhotoLoaded = true; - if (_licenseCheckDone) CheckLicense(); - }); - - private async void MainLayout_Loaded(object sender, RoutedEventArgs e) - { - await LicenseService.Instance.RefreshLicenseStateAsync(); - _licenseCheckDone = true; - if (_firstPhotoLoaded) CheckLicense(); - } - - private async void CheckLicense() - { - if (LicenseService.Instance.State != LicenseState.TrialExpired) return; - if (Content?.XamlRoot == null) return; // window may be closing - var dialog = new ContentDialog - { - Title = L.Get("TrialExpiredMessage/Title"), - Content = L.Get("TrialExpiredMessage/Content"), - CloseButtonText = L.Get("TrialExpiredMessage/CloseButton"), - XamlRoot = Content.XamlRoot - }; - await dialog.ShowAsync(); - await AnimatePhotoDisplayWindowClose(); - } - - private void OpenFileInExplorer() - { - var filePath = _photoController.GetFullPathCurrentFile(); - if (File.Exists(filePath)) - Process.Start("explorer.exe", $"/select,\"{filePath}\""); - } - - private async Task DeleteCurrentlyDisplayedPhoto() - { - if (AppConfig.Volatile.IsSecondaryInstance) return; - if (!_photoController.CanDeleteCurrentPhoto()) - { - TxtZoom.Text = L.Get("LoadingHighQuality/Message"); - _inactivityFader.ReportActivity(); - _canvasController.Shrug(); - return; - } - #endregion } From 098a092982cda204161f988ff98a51f50b499dbb Mon Sep 17 00:00:00 2001 From: SKProCH Date: Fri, 14 Aug 2026 17:51:14 +0300 Subject: [PATCH 4/5] Fix merge mistakes again --- Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs b/Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs index 808a8f2..0a5cd7a 100644 --- a/Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs +++ b/Src/FlyPhotos/UI/Views/PhotoDisplayWindow.xaml.cs @@ -653,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: From 4795b0ce1fb045fcda8fee797f2df1276fd8e25c Mon Sep 17 00:00:00 2001 From: SKProCH Date: Fri, 14 Aug 2026 21:26:47 +0300 Subject: [PATCH 5/5] Fixes the gap in the bottom and window reposition when exiting the fullscreen --- Src/FlyPhotos/Infra/Interop/Win32Methods.cs | 16 ++++++ .../UI/Behaviors/WindowFullScreenManager.cs | 56 +++++++++++++++---- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/Src/FlyPhotos/Infra/Interop/Win32Methods.cs b/Src/FlyPhotos/Infra/Interop/Win32Methods.cs index f7fb43b..dbbede3 100644 --- a/Src/FlyPhotos/Infra/Interop/Win32Methods.cs +++ b/Src/FlyPhotos/Infra/Interop/Win32Methods.cs @@ -209,6 +209,22 @@ public struct SHELLEXECUTEINFO [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)] diff --git a/Src/FlyPhotos/UI/Behaviors/WindowFullScreenManager.cs b/Src/FlyPhotos/UI/Behaviors/WindowFullScreenManager.cs index 5ea7b9b..6196382 100644 --- a/Src/FlyPhotos/UI/Behaviors/WindowFullScreenManager.cs +++ b/Src/FlyPhotos/UI/Behaviors/WindowFullScreenManager.cs @@ -90,26 +90,25 @@ internal void Restore(UIElement? exitFullScreenButton = null) 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); + // 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 - frameX, - Top = clientRect.Y - frameY, - Right = clientRect.X + clientRect.Width + frameX, - Bottom = clientRect.Y + clientRect.Height + frameY + 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; - // 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) @@ -119,6 +118,41 @@ internal void RestoreToClientRect(RectInt32 clientRect, UIElement? exitFullScree _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); } ///