feat(ui): let resizable modals snap to the screen edges
ModalShell moved its window by assigning Window.Position on every pointer-move, which bypasses the OS move loop entirely - so Windows never saw a drag gesture and no snap was ever recognised. Dragging the diff viewer to the top edge did nothing. The titlebar now calls Window.BeginMoveDrag, which hands the drag to the window manager and brings back maximise-on-top-edge, half-screen side snap, snap layouts and the preview overlay. No opt-in flag is needed: the OS only snaps resizable windows, so the non-resizable modals stay as they are. The stale comment claiming BeginMoveDrag does not work under Avalonia 12 was wrong - the method is there in 12.0.4. The VisualRoot cast was the actual problem, and the window still has to come from TopLevel.GetTopLevel. Also inset the shell by the window's OffScreenMargin, mirroring what MainWindow already does for itself. The modals share MainWindow's extended-client-area flags, so without it a maximised modal overhangs the screen by the invisible resize border and gets its edges and close button clipped.
This commit is contained in:
@@ -23,48 +23,57 @@ public class ModalShell : ContentControl
|
||||
public ICommand? CloseCommand { get => GetValue(CloseCommandProperty); set => SetValue(CloseCommandProperty, value); }
|
||||
|
||||
private Window? _window;
|
||||
private PixelPoint _dragStartScreen;
|
||||
private PixelPoint _dragStartPos;
|
||||
private bool _dragging;
|
||||
|
||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
{
|
||||
base.OnApplyTemplate(e);
|
||||
if (e.NameScope.Find<Border>("PART_TitleBar") is { } bar)
|
||||
{
|
||||
bar.PointerPressed += OnTitleBarPressed;
|
||||
bar.PointerMoved += OnTitleBarMoved;
|
||||
bar.PointerReleased += OnTitleBarReleased;
|
||||
}
|
||||
}
|
||||
|
||||
// VisualRoot is a TopLevelHost (not the Window) in Avalonia 12, so resolve the
|
||||
// owning Window via TopLevel.GetTopLevel and drive the move manually — BeginMoveDrag
|
||||
// and a VisualRoot-as-Window cast both fail here.
|
||||
/// Mirrors what MainWindow does for itself: with an extended client area a maximised window
|
||||
/// overhangs the screen by the invisible resize border, so the chrome has to be inset by
|
||||
/// OffScreenMargin or its edges (and the close button) get clipped. Doing it here covers
|
||||
/// every modal at once instead of per code-behind.
|
||||
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnAttachedToVisualTree(e);
|
||||
_window = TopLevel.GetTopLevel(this) as Window;
|
||||
if (_window is null) return;
|
||||
Margin = _window.OffScreenMargin;
|
||||
_window.PropertyChanged += OnWindowPropertyChanged;
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
if (_window is not null) _window.PropertyChanged -= OnWindowPropertyChanged;
|
||||
_window = null;
|
||||
}
|
||||
|
||||
private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.Property == Window.OffScreenMarginProperty && _window is not null)
|
||||
Margin = _window.OffScreenMargin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands the drag to the window manager instead of moving the window by hand.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is what makes Windows snap work — drag to the top edge to maximise, to a side for
|
||||
/// half the screen, with the usual preview overlay. Assigning <c>Window.Position</c> per
|
||||
/// pointer-move (what this used to do) bypasses the OS move loop entirely, so no snap
|
||||
/// gesture is ever recognised. The OS only snaps resizable windows, so the non-resizable
|
||||
/// modals opt out on their own without a flag here.
|
||||
///
|
||||
/// Note: <c>VisualRoot</c> is a TopLevelHost rather than the Window in Avalonia 12, so the
|
||||
/// window has to come from <see cref="TopLevel.GetTopLevel"/>. Do not capture the pointer —
|
||||
/// <see cref="Window.BeginMoveDrag"/> runs its own modal loop and needs the input.
|
||||
/// </remarks>
|
||||
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
|
||||
_window = TopLevel.GetTopLevel(this) as Window;
|
||||
if (_window is null) return;
|
||||
_dragStartScreen = _window.PointToScreen(e.GetPosition(_window));
|
||||
_dragStartPos = _window.Position;
|
||||
_dragging = true;
|
||||
e.Pointer.Capture(sender as IInputElement);
|
||||
}
|
||||
|
||||
private void OnTitleBarMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (!_dragging || _window is null
|
||||
|| !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
|
||||
var cur = _window.PointToScreen(e.GetPosition(_window));
|
||||
_window.Position = new PixelPoint(
|
||||
_dragStartPos.X + (cur.X - _dragStartScreen.X),
|
||||
_dragStartPos.Y + (cur.Y - _dragStartScreen.Y));
|
||||
}
|
||||
|
||||
private void OnTitleBarReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
_dragging = false;
|
||||
e.Pointer.Capture(null);
|
||||
(TopLevel.GetTopLevel(this) as Window)?.BeginMoveDrag(e);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user