fix(ImagePreviewer): route title bar toolbar requests from CSD overlay

This commit is contained in:
CHINBOY
2026-07-28 09:47:11 +08:00
parent 93ee22353e
commit ab9ca473ce
3 changed files with 108 additions and 96 deletions
@@ -2,6 +2,8 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.LogicalTree;
using Avalonia.VisualTree;
namespace AtomUI.Desktop.Controls;
@@ -300,7 +302,7 @@ internal class ImagePreviewBaseToolbar : TemplatedControl
}
else if (sender == _fitToWindowButton)
{
RaiseEvent(new ImageFitToWindowEventArgs(_fitToWindowButton?.IsChecked == true)
RaiseToolbarEvent(new ImageFitToWindowEventArgs(_fitToWindowButton?.IsChecked == true)
{
RoutedEvent = FitToWindowRequestEvent
});
@@ -309,9 +311,33 @@ internal class ImagePreviewBaseToolbar : TemplatedControl
private void RaiseRequestEvent(RoutedEvent<ImagePreviewToolbarRequestEventArgs> routedEvent)
{
RaiseEvent(new ImagePreviewToolbarRequestEventArgs(ToolbarSource)
RaiseToolbarEvent(new ImagePreviewToolbarRequestEventArgs(ToolbarSource)
{
RoutedEvent = routedEvent
});
}
private void RaiseToolbarEvent(RoutedEventArgs args)
{
if (ResolveCsdTitleBarHostWindow() is { } hostWindow)
{
hostWindow.RaiseRoutedEventFromOverlay(this, args);
return;
}
RaiseEvent(args);
}
private Window? ResolveCsdTitleBarHostWindow()
{
if (ToolbarSource != ImagePreviewToolbarSource.TitleBar)
{
return null;
}
var titleBar = this.FindLogicalAncestorOfType<WindowTitleBar>() ??
this.FindAncestorOfType<WindowTitleBar>();
var hostWindow = titleBar?.HostWindow;
return hostWindow is { IsCsdEnabled: true } ? hostWindow : null;
}
}
@@ -904,100 +904,6 @@ public class WindowDialogPresenterTests
}
}
[Fact]
public void Native_User_Client_Size_Changes_Update_The_Window_Dialog_Normal_Size()
{
var owner = new AtomUI.Desktop.Controls.Window
{
Width = 800,
Height = 600
};
var dialog = new AtomUI.Desktop.Controls.Dialog
{
IsModal = false,
IsMotionEnabled = false,
IsResizable = true,
HostWidth = 360,
HostHeight = 220
};
var presenter = new WindowDialogPresenter(dialog, owner);
try
{
owner.Show();
WaitWithDispatcherPump(presenter.ShowAsync(CancellationToken.None).AsTask());
var surface = GetSurface(presenter);
var chrome = GetWindowChromeSize(presenter.HostWindow);
var platformSurfaceSize = new Size(500, 280);
presenter.HostWindow.BeginNativeUserResize();
presenter.HostWindow.SetPlatformChromeClientSize(platformSurfaceSize + chrome);
Dispatcher.UIThread.RunJobs();
presenter.HostWindow.CompleteNativeUserResize();
Dispatcher.UIThread.RunJobs();
surface.Bounds.Size.ShouldBe(platformSurfaceSize);
presenter.HostWindow.Padding = new Thickness(3, 4, 5, 6);
Dispatcher.UIThread.RunJobs();
var updatedChrome = GetWindowChromeSize(presenter.HostWindow);
surface.Bounds.Size.ShouldBe(platformSurfaceSize);
presenter.HostWindow.ClientSize.ShouldBe(platformSurfaceSize + updatedChrome);
}
finally
{
WaitWithDispatcherPump(presenter.CloseAsync().AsTask());
WaitWithDispatcherPump(presenter.DisposeAsync().AsTask());
owner.Close();
}
}
[Fact]
public void Sizing_Refresh_Does_Not_Treat_Stale_Client_Size_As_User_Resize()
{
var owner = new AtomUI.Desktop.Controls.Window
{
Width = 800,
Height = 600
};
var dialog = new AtomUI.Desktop.Controls.Dialog
{
IsModal = false,
IsMotionEnabled = false,
IsResizable = true,
HostWidth = 360,
HostHeight = 220
};
var presenter = new WindowDialogPresenter(dialog, owner);
try
{
owner.Show();
WaitWithDispatcherPump(presenter.ShowAsync(CancellationToken.None).AsTask());
var surface = GetSurface(presenter);
var normalSurfaceSize = surface.Bounds.Size;
var chrome = GetWindowChromeSize(presenter.HostWindow);
var staleSurfaceSize = normalSurfaceSize + new Size(120, 80);
presenter.HostWindow.SetPlatformChromeClientSize(staleSurfaceSize + chrome);
Dispatcher.UIThread.RunJobs();
presenter.HostWindow.Padding = new Thickness(3, 4, 5, 6);
Dispatcher.UIThread.RunJobs();
var updatedChrome = GetWindowChromeSize(presenter.HostWindow);
surface.Bounds.Size.ShouldBe(normalSurfaceSize);
presenter.HostWindow.ClientSize.ShouldBe(normalSurfaceSize + updatedChrome);
}
finally
{
WaitWithDispatcherPump(presenter.CloseAsync().AsTask());
WaitWithDispatcherPump(presenter.DisposeAsync().AsTask());
owner.Close();
}
}
[Fact]
public void Csd_Constraints_Ignore_The_Managed_TitleBar_Minimum_Width()
{
@@ -183,6 +183,73 @@ public class ImagePreviewerTitleTests
});
}
[Fact]
public void ImagePreviewerDialog_Handles_TitleBar_Toolbar_Requests_Relayed_From_Csd_Overlay()
{
Dispatcher.UIThread.Invoke(() =>
{
var dialog = new TestImagePreviewerDialog
{
ItemsSource =
[
new ImagePreviewItem(new UriImagePreviewSource("avares://AtomUI.Tests/Assets/first.png")),
new ImagePreviewItem(new UriImagePreviewSource("avares://AtomUI.Tests/Assets/second.png"))
],
CurrentImage = LoadedImageSource.CreateSvg("<svg />", new Avalonia.Size(10, 10)),
ImageScaleX = 1.0,
ImageScaleY = 1.0
};
var titleBar = new ImagePreviewerTitleBar();
dialog.ConfigureTitleBar(titleBar);
var toolbar = titleBar.LeftAddOn.ShouldNotBeNull()
.ShouldBeAssignableTo<ImagePreviewToolbar>();
var rotateArgs = RelayToolbarRequest(dialog, toolbar, ImagePreviewBaseToolbar.RotateRightRequestEvent);
rotateArgs.Handled.ShouldBeTrue();
dialog.ImageRotate.ShouldBe(Math.PI / 2, 0.000001);
var flipArgs = RelayToolbarRequest(dialog, toolbar, ImagePreviewBaseToolbar.HorizontalFlipRequestEvent);
flipArgs.Handled.ShouldBeTrue();
dialog.ImageScaleX.ShouldBe(-1.0);
var fitArgs = new ImageFitToWindowEventArgs(false)
{
RoutedEvent = ImagePreviewBaseToolbar.FitToWindowRequestEvent
};
dialog.RaiseRoutedEventFromOverlay(toolbar, fitArgs);
fitArgs.Handled.ShouldBeTrue();
dialog.IsImageFitToWindow.ShouldBeFalse();
var nextArgs = RelayToolbarRequest(dialog, toolbar, ImagePreviewBaseToolbar.NextRequestEvent);
nextArgs.Handled.ShouldBeTrue();
dialog.CurrentIndex.ShouldBe(1);
});
}
[Fact]
public void ImagePreviewToolbar_TitleBar_Requests_Use_Host_Window_Route_In_Csd()
{
var toolbarSource = File.ReadAllText(GetRepoFile(
"src/AtomUI.Desktop.Controls/ImagePreviewer/ImagePreviewBaseToolbar.cs"));
var titleBarSource = File.ReadAllText(GetRepoFile(
"src/AtomUI.Desktop.Controls/ImagePreviewer/ImagePreviewerTitleBar.cs"));
var dialogSource = File.ReadAllText(GetRepoFile(
"src/AtomUI.Desktop.Controls/ImagePreviewer/ImagePreviewerDialog.cs"));
toolbarSource.ShouldContain("ResolveCsdTitleBarHostWindow()");
toolbarSource.ShouldContain("ToolbarSource != ImagePreviewToolbarSource.TitleBar");
toolbarSource.ShouldContain("hostWindow.RaiseRoutedEventFromOverlay(this, args);");
toolbarSource.ShouldContain("RaiseEvent(args);");
toolbarSource.IndexOf("hostWindow.RaiseRoutedEventFromOverlay(this, args);", StringComparison.Ordinal)
.ShouldBeLessThan(toolbarSource.IndexOf("RaiseEvent(args);", StringComparison.Ordinal));
titleBarSource.ShouldNotContain("ToolbarHorizontalFlipRequest");
dialogSource.ShouldNotContain("_titleBarRequestSource");
dialogSource.ShouldContain("ImagePreviewBaseToolbar.NextRequestEvent.AddClassHandler<ImagePreviewerDialog>");
}
[Fact]
public void ImagePreviewerOverlayHost_ItemsSource_Change_Preserves_CurrentIndex()
{
@@ -268,6 +335,19 @@ public class ImagePreviewerTitleTests
throw new FileNotFoundException($"Could not find repository file: {relativePath}");
}
private static ImagePreviewToolbarRequestEventArgs RelayToolbarRequest(
ImagePreviewerDialog dialog,
ImagePreviewToolbar toolbar,
Avalonia.Interactivity.RoutedEvent<ImagePreviewToolbarRequestEventArgs> routedEvent)
{
var args = new ImagePreviewToolbarRequestEventArgs(ImagePreviewToolbarSource.TitleBar)
{
RoutedEvent = routedEvent
};
dialog.RaiseRoutedEventFromOverlay(toolbar, args);
return args;
}
private sealed class PrefixTitleResolver : IImagePreviewTitleResolver
{
private readonly string _prefix;