diff --git a/src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml.cs index 9ae35f36..19b17b2a 100644 --- a/src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml.cs @@ -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("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; + } + + /// + /// Hands the drag to the window manager instead of moving the window by hand. + /// + /// + /// 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 Window.Position 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: VisualRoot is a TopLevelHost rather than the Window in Avalonia 12, so the + /// window has to come from . Do not capture the pointer — + /// runs its own modal loop and needs the input. + /// 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); } }