133 lines
6.0 KiB
C#
133 lines
6.0 KiB
C#
using System.Globalization;
|
|
using System.Windows.Input;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Media;
|
|
using ClaudeDo.Ui.Converters;
|
|
|
|
namespace ClaudeDo.Ui.Views.Controls;
|
|
|
|
/// <summary>Reusable modal chrome: titlebar (drag + close) wrapping a body and optional footer.</summary>
|
|
public class ModalShell : ContentControl
|
|
{
|
|
public static readonly StyledProperty<string?> TitleProperty =
|
|
AvaloniaProperty.Register<ModalShell, string?>(nameof(Title));
|
|
|
|
public static readonly StyledProperty<object?> FooterProperty =
|
|
AvaloniaProperty.Register<ModalShell, object?>(nameof(Footer));
|
|
|
|
// Window-level actions rendered in the titlebar, left of maximise/close. Keeps a single
|
|
// always-visible action (e.g. the usage monitor's refresh) out of the body's vertical flow.
|
|
public static readonly StyledProperty<object?> HeaderActionsProperty =
|
|
AvaloniaProperty.Register<ModalShell, object?>(nameof(HeaderActions));
|
|
|
|
public static readonly StyledProperty<ICommand?> CloseCommandProperty =
|
|
AvaloniaProperty.Register<ModalShell, ICommand?>(nameof(CloseCommand));
|
|
|
|
// Opt-in: most modals are fixed-purpose dialogs that don't need maximise. Set true only
|
|
// on windows (e.g. the diff viewer) that benefit from a maximised full-screen layout.
|
|
public static readonly StyledProperty<bool> ShowMaximizeButtonProperty =
|
|
AvaloniaProperty.Register<ModalShell, bool>(nameof(ShowMaximizeButton));
|
|
|
|
public string? Title { get => GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
|
|
public object? Footer { get => GetValue(FooterProperty); set => SetValue(FooterProperty, value); }
|
|
public object? HeaderActions { get => GetValue(HeaderActionsProperty); set => SetValue(HeaderActionsProperty, value); }
|
|
public ICommand? CloseCommand { get => GetValue(CloseCommandProperty); set => SetValue(CloseCommandProperty, value); }
|
|
public bool ShowMaximizeButton { get => GetValue(ShowMaximizeButtonProperty); set => SetValue(ShowMaximizeButtonProperty, value); }
|
|
|
|
private Window? _window;
|
|
private PathIcon? _maxIcon;
|
|
private Button? _maxButton;
|
|
|
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
|
{
|
|
base.OnApplyTemplate(e);
|
|
if (e.NameScope.Find<Border>("PART_TitleBar") is { } bar)
|
|
bar.PointerPressed += OnTitleBarPressed;
|
|
_maxButton = e.NameScope.Find<Button>("PART_MaxButton");
|
|
if (_maxButton is not null) _maxButton.Click += OnMaxButtonClick;
|
|
_maxIcon = e.NameScope.Find<PathIcon>("PART_MaxIcon");
|
|
UpdateMaxIcon();
|
|
}
|
|
|
|
/// 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;
|
|
UpdateMaxIcon();
|
|
}
|
|
|
|
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;
|
|
if (e.Property == Window.WindowStateProperty)
|
|
UpdateMaxIcon();
|
|
}
|
|
|
|
private void UpdateMaxIcon()
|
|
{
|
|
if (_maxIcon is null || _window is null) return;
|
|
var maximized = _window.WindowState == WindowState.Maximized;
|
|
_maxIcon.Data = (Geometry?)IconKeyConverter.Instance.Convert(
|
|
maximized ? "mdi:WindowRestore" : "mdi:WindowMaximize", typeof(Geometry), null, CultureInfo.InvariantCulture);
|
|
if (_maxButton is not null)
|
|
{
|
|
var tipKey = maximized ? "modals.diff.restore" : "modals.diff.maximize";
|
|
ToolTip.SetTip(_maxButton, ClaudeDo.Ui.Localization.Loc.T(tipKey));
|
|
}
|
|
}
|
|
|
|
private void OnMaxButtonClick(object? sender, RoutedEventArgs e) => ToggleMaximize();
|
|
|
|
private void ToggleMaximize()
|
|
{
|
|
if (_window is null) return;
|
|
_window.WindowState = _window.WindowState == WindowState.Maximized
|
|
? WindowState.Normal
|
|
: WindowState.Maximized;
|
|
}
|
|
|
|
/// <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;
|
|
if (ShowMaximizeButton && e.ClickCount == 2)
|
|
{
|
|
ToggleMaximize();
|
|
return;
|
|
}
|
|
(TopLevel.GetTopLevel(this) as Window)?.BeginMoveDrag(e);
|
|
}
|
|
}
|