diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index f4d81418ac..edf3d41bf5 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -11,8 +11,7 @@ Spacing="16"> (Enumerable.Range(1, 10000).Select(i => GenerateItem())); - SelectedItems = new ObservableCollection(); - - AddItemCommand = ReactiveCommand.Create(() => Items.Add(GenerateItem())); - - RemoveItemCommand = ReactiveCommand.Create(() => - { - while (SelectedItems.Count > 0) - { - Items.Remove(SelectedItems[0]); - } - }); - - SelectRandomItemCommand = ReactiveCommand.Create(() => - { - var random = new Random(); - - SelectedItem = Items[random.Next(Items.Count - 1)]; - }); - } - - public ObservableCollection Items { get; } - - private string _selectedItem; - - public string SelectedItem - { - get { return _selectedItem; } - set { this.RaiseAndSetIfChanged(ref _selectedItem, value); } - } - - - public ObservableCollection SelectedItems { get; } - - public ReactiveCommand AddItemCommand { get; } - - public ReactiveCommand RemoveItemCommand { get; } - - public ReactiveCommand SelectRandomItemCommand { get; } - - public SelectionMode SelectionMode - { - get => _selectionMode; - set - { - SelectedItems.Clear(); - this.RaiseAndSetIfChanged(ref _selectionMode, value); - } - } - - private string GenerateItem() => $"Item {_counter++.ToString()}"; - } } } diff --git a/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs new file mode 100644 index 0000000000..6bdb5c0103 --- /dev/null +++ b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using Avalonia.Controls; +using ReactiveUI; + +namespace ControlCatalog.ViewModels +{ + public class ListBoxPageViewModel : ReactiveObject + { + private int _counter; + private SelectionMode _selectionMode; + + public ListBoxPageViewModel() + { + Items = new ObservableCollection(Enumerable.Range(1, 10000).Select(i => GenerateItem())); + Selection = new SelectionModel(); + Selection.Select(1); + + AddItemCommand = ReactiveCommand.Create(() => Items.Add(GenerateItem())); + + RemoveItemCommand = ReactiveCommand.Create(() => + { + while (Selection.SelectedItems.Count > 0) + { + Items.Remove((string)Selection.SelectedItems.First()); + } + }); + + SelectRandomItemCommand = ReactiveCommand.Create(() => + { + var random = new Random(); + + using (Selection.Update()) + { + Selection.ClearSelection(); + Selection.Select(random.Next(Items.Count - 1)); + } + }); + } + + public ObservableCollection Items { get; } + + public SelectionModel Selection { get; } + + public ReactiveCommand AddItemCommand { get; } + + public ReactiveCommand RemoveItemCommand { get; } + + public ReactiveCommand SelectRandomItemCommand { get; } + + public SelectionMode SelectionMode + { + get => _selectionMode; + set + { + Selection.ClearSelection(); + this.RaiseAndSetIfChanged(ref _selectionMode, value); + } + } + + private string GenerateItem() => $"Item {_counter++.ToString()}"; + } +} diff --git a/src/Avalonia.Controls/ApiCompatBaseline.txt b/src/Avalonia.Controls/ApiCompatBaseline.txt new file mode 100644 index 0000000000..0a2415a263 --- /dev/null +++ b/src/Avalonia.Controls/ApiCompatBaseline.txt @@ -0,0 +1,7 @@ +Compat issues with assembly Avalonia.Controls: +MembersMustExist : Member 'protected void Avalonia.Controls.ComboBox.PopupClosedOverride(Avalonia.Controls.Primitives.PopupClosedEventArgs)' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public Avalonia.StyledProperty Avalonia.StyledProperty Avalonia.Controls.Primitives.Popup.StaysOpenProperty' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public void Avalonia.Controls.Primitives.Popup.add_Closed(System.EventHandler)' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public void Avalonia.Controls.Primitives.Popup.remove_Closed(System.EventHandler)' does not exist in the implementation but it does exist in the contract. +TypesMustExist : Type 'Avalonia.Controls.Primitives.PopupClosedEventArgs' does not exist in the implementation but it does exist in the contract. +Total Issues: 5 diff --git a/src/Avalonia.Controls/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox.cs index 31101dc0f1..c164f282e8 100644 --- a/src/Avalonia.Controls/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox.cs @@ -1647,7 +1647,7 @@ namespace Avalonia.Controls /// /// The source object. /// The event data. - private void DropDownPopup_Closed(object sender, PopupClosedEventArgs e) + private void DropDownPopup_Closed(object sender, EventArgs e) { // Force the drop down dependency property to be false. if (IsDropDownOpen) @@ -1655,11 +1655,6 @@ namespace Avalonia.Controls IsDropDownOpen = false; } - if (e.CloseEvent is PointerEventArgs pointerEvent) - { - pointerEvent.Handled = true; - } - // Fire the DropDownClosed event if (_popupHasOpened) { diff --git a/src/Avalonia.Controls/Calendar/CalendarDatePicker.cs b/src/Avalonia.Controls/Calendar/CalendarDatePicker.cs index b987f065be..046b55d49a 100644 --- a/src/Avalonia.Controls/Calendar/CalendarDatePicker.cs +++ b/src/Avalonia.Controls/Calendar/CalendarDatePicker.cs @@ -889,17 +889,12 @@ namespace Avalonia.Controls _ignoreButtonClick = false; } } - private void PopUp_Closed(object sender, PopupClosedEventArgs e) + private void PopUp_Closed(object sender, EventArgs e) { IsDropDownOpen = false; if(!_isPopupClosing) { - if (e.CloseEvent is PointerEventArgs pointerEvent) - { - pointerEvent.Handled = true; - } - _isPopupClosing = true; Threading.Dispatcher.UIThread.InvokeAsync(() => _isPopupClosing = false); } diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index 75a0e41e7b..27313b0b4c 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -290,24 +290,6 @@ namespace Avalonia.Controls _popup = e.NameScope.Get("PART_Popup"); _popup.Opened += PopupOpened; - _popup.Closed += PopupClosed; - } - - /// - /// Called when the ComboBox popup is closed, with the - /// that caused the popup to close. - /// - /// The event args. - /// - /// This method can be overridden to control whether the event that caused the popup to close - /// is swallowed or passed through. - /// - protected virtual void PopupClosedOverride(PopupClosedEventArgs e) - { - if (e.CloseEvent is PointerEventArgs pointerEvent) - { - pointerEvent.Handled = true; - } } internal void ItemFocused(ComboBoxItem dropDownItem) @@ -318,13 +300,11 @@ namespace Avalonia.Controls } } - private void PopupClosed(object sender, PopupClosedEventArgs e) + private void PopupClosed(object sender, EventArgs e) { _subscriptionsOnOpen?.Dispose(); _subscriptionsOnOpen = null; - PopupClosedOverride(e); - if (CanFocus(this)) { Focus(); diff --git a/src/Avalonia.Controls/ContextMenu.cs b/src/Avalonia.Controls/ContextMenu.cs index 5929dd39d4..b4e4dd5071 100644 --- a/src/Avalonia.Controls/ContextMenu.cs +++ b/src/Avalonia.Controls/ContextMenu.cs @@ -265,7 +265,8 @@ namespace Avalonia.Controls PlacementMode = PlacementMode, PlacementRect = PlacementRect, PlacementTarget = PlacementTarget ?? control, - StaysOpen = false + IsLightDismissEnabled = true, + OverlayDismissEventPassThrough = true, }; _popup.Opened += PopupOpened; diff --git a/src/Avalonia.Controls/IMenuElement.cs b/src/Avalonia.Controls/IMenuElement.cs index ee9d0fd6b6..426f265084 100644 --- a/src/Avalonia.Controls/IMenuElement.cs +++ b/src/Avalonia.Controls/IMenuElement.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; using Avalonia.Input; +#nullable enable + namespace Avalonia.Controls { /// @@ -11,7 +13,7 @@ namespace Avalonia.Controls /// /// Gets or sets the currently selected submenu item. /// - IMenuItem SelectedItem { get; set; } + IMenuItem? SelectedItem { get; set; } /// /// Gets the submenu items. diff --git a/src/Avalonia.Controls/IMenuItem.cs b/src/Avalonia.Controls/IMenuItem.cs index 132d565cb7..94d761f725 100644 --- a/src/Avalonia.Controls/IMenuItem.cs +++ b/src/Avalonia.Controls/IMenuItem.cs @@ -1,4 +1,6 @@ -namespace Avalonia.Controls +#nullable enable + +namespace Avalonia.Controls { /// /// Represents a . @@ -29,7 +31,7 @@ /// /// Gets the parent . /// - new IMenuElement Parent { get; } + new IMenuElement? Parent { get; } /// /// Raises a click event on the menu item. diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 6e0ad66699..1aa7945901 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -295,7 +295,7 @@ namespace Avalonia.Controls if (next != null) { - focus.Focus(next, NavigationMethod.Directional); + focus.Focus(next, NavigationMethod.Directional, e.KeyModifiers); e.Handled = true; } diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index 3c21cd2c38..a085bfb6bc 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -136,7 +136,8 @@ namespace Avalonia.Controls e.Handled = UpdateSelectionFromEventSource( e.Source, true, - (e.KeyModifiers & KeyModifiers.Shift) != 0); + (e.KeyModifiers & KeyModifiers.Shift) != 0, + (e.KeyModifiers & KeyModifiers.Control) != 0); } } diff --git a/src/Avalonia.Controls/Menu.cs b/src/Avalonia.Controls/Menu.cs index 7205af0e75..4da044fec1 100644 --- a/src/Avalonia.Controls/Menu.cs +++ b/src/Avalonia.Controls/Menu.cs @@ -1,9 +1,12 @@ using Avalonia.Controls.Platform; +using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; +#nullable enable + namespace Avalonia.Controls { /// @@ -14,6 +17,8 @@ namespace Avalonia.Controls private static readonly ITemplate DefaultPanel = new FuncTemplate(() => new StackPanel { Orientation = Orientation.Horizontal }); + private LightDismissOverlayLayer? _overlay; + /// /// Initializes a new instance of the class. /// diff --git a/src/Avalonia.Controls/MenuBase.cs b/src/Avalonia.Controls/MenuBase.cs index 4554cb2bcf..0434928280 100644 --- a/src/Avalonia.Controls/MenuBase.cs +++ b/src/Avalonia.Controls/MenuBase.cs @@ -8,6 +8,8 @@ using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.LogicalTree; +#nullable enable + namespace Avalonia.Controls { /// @@ -51,9 +53,7 @@ namespace Avalonia.Controls /// The menu interaction handler. public MenuBase(IMenuInteractionHandler interactionHandler) { - Contract.Requires(interactionHandler != null); - - InteractionHandler = interactionHandler; + InteractionHandler = interactionHandler ?? throw new ArgumentNullException(nameof(interactionHandler)); } /// @@ -77,7 +77,7 @@ namespace Avalonia.Controls IMenuInteractionHandler IMenu.InteractionHandler => InteractionHandler; /// - IMenuItem IMenuElement.SelectedItem + IMenuItem? IMenuElement.SelectedItem { get { diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index bfa2e81745..b4d3272471 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -13,6 +13,8 @@ using Avalonia.Interactivity; using Avalonia.LogicalTree; using Avalonia.VisualTree; +#nullable enable + namespace Avalonia.Controls { /// @@ -23,7 +25,7 @@ namespace Avalonia.Controls /// /// Defines the property. /// - public static readonly DirectProperty CommandProperty = + public static readonly DirectProperty CommandProperty = Button.CommandProperty.AddOwner( menuItem => menuItem.Command, (menuItem, command) => menuItem.Command = command, @@ -95,7 +97,7 @@ namespace Avalonia.Controls private static readonly ITemplate DefaultPanel = new FuncTemplate(() => new StackPanel()); - private ICommand _command; + private ICommand? _command; private bool _commandCanExecute = true; private Popup _popup; @@ -192,7 +194,7 @@ namespace Avalonia.Controls /// /// Gets or sets the command associated with the menu item. /// - public ICommand Command + public ICommand? Command { get { return _command; } set { SetAndRaise(CommandProperty, ref _command, value); } @@ -272,7 +274,7 @@ namespace Avalonia.Controls bool IMenuItem.IsPointerOverSubMenu => _popup?.IsPointerOverPopup ?? false; /// - IMenuElement IMenuItem.Parent => Parent as IMenuElement; + IMenuElement? IMenuItem.Parent => Parent as IMenuElement; protected override bool IsEnabledCore => base.IsEnabledCore && _commandCanExecute; @@ -280,7 +282,7 @@ namespace Avalonia.Controls bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap); /// - IMenuItem IMenuElement.SelectedItem + IMenuItem? IMenuElement.SelectedItem { get { @@ -551,7 +553,7 @@ namespace Avalonia.Controls /// The property change event. private void IsSelectedChanged(AvaloniaPropertyChangedEventArgs e) { - if ((bool)e.NewValue) + if ((bool)e.NewValue!) { Focus(); } @@ -563,7 +565,7 @@ namespace Avalonia.Controls /// The property change event. private void SubMenuOpenChanged(AvaloniaPropertyChangedEventArgs e) { - var value = (bool)e.NewValue; + var value = (bool)e.NewValue!; if (value) { diff --git a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs index 2a7ea12d79..6d6398bcda 100644 --- a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -3,7 +3,6 @@ using Avalonia.Input; using Avalonia.Input.Raw; using Avalonia.Interactivity; using Avalonia.LogicalTree; -using Avalonia.Platform; using Avalonia.Rendering; using Avalonia.Threading; using Avalonia.VisualTree; @@ -235,7 +234,9 @@ namespace Avalonia.Controls.Platform // If the the parent is an IMenu which successfully moved its selection, // and the current menu is open then close the current menu and open the // new menu. - if (item.IsSubMenuOpen && item.Parent is IMenu) + if (item.IsSubMenuOpen && + item.Parent is IMenu && + item.Parent.SelectedItem is object) { item.Close(); Open(item.Parent.SelectedItem, true); @@ -363,6 +364,11 @@ namespace Avalonia.Controls.Platform } else { + if (item.IsTopLevel && item.Parent is IMainMenu mainMenu) + { + mainMenu.Open(); + } + Open(item, false); } @@ -385,7 +391,7 @@ namespace Avalonia.Controls.Platform { if (e.Source == Menu) { - Menu.MoveSelection(NavigationDirection.First, true); + Menu?.MoveSelection(NavigationDirection.First, true); } } diff --git a/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs b/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs new file mode 100644 index 0000000000..752eedb68a --- /dev/null +++ b/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs @@ -0,0 +1,61 @@ +using System; +using System.Linq; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Rendering; +using Avalonia.Styling; +using Avalonia.VisualTree; + +#nullable enable + +namespace Avalonia.Controls.Primitives +{ + /// + /// A layer that is used to dismiss a when the user clicks outside. + /// + public class LightDismissOverlayLayer : Border, ICustomHitTest + { + public IInputElement? InputPassThroughElement { get; set; } + + /// + /// Returns the light dismiss overlay for a specified visual. + /// + /// The visual. + /// The light dismiss overlay, or null if none found. + public static LightDismissOverlayLayer? GetLightDismissOverlayLayer(IVisual visual) + { + visual = visual ?? throw new ArgumentNullException(nameof(visual)); + + VisualLayerManager? manager; + + if (visual is TopLevel topLevel) + { + manager = topLevel.GetTemplateChildren() + .OfType() + .FirstOrDefault(); + } + else + { + manager = visual.FindAncestorOfType(); + } + + return manager?.LightDismissOverlayLayer; + } + + public bool HitTest(Point point) + { + if (InputPassThroughElement is object) + { + var p = point.Transform(this.TransformToVisual(VisualRoot)!.Value); + var hit = VisualRoot.GetVisualAt(p, x => x != this); + + if (hit is object) + { + return !InputPassThroughElement.IsVisualAncestorOf(hit); + } + } + + return true; + } + } +} diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index 1fcf8d61bc..a676892384 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -1,12 +1,10 @@ using System; -using System.Diagnostics; using System.Linq; using System.Reactive.Disposables; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Input; using Avalonia.Input.Raw; -using Avalonia.Interactivity; using Avalonia.LogicalTree; using Avalonia.Metadata; using Avalonia.Platform; @@ -86,12 +84,27 @@ namespace Avalonia.Controls.Primitives AvaloniaProperty.Register(nameof(ObeyScreenEdges), true); #pragma warning restore 618 + public static readonly StyledProperty OverlayDismissEventPassThroughProperty = + AvaloniaProperty.Register(nameof(OverlayDismissEventPassThrough)); + + public static readonly DirectProperty OverlayInputPassThroughElementProperty = + AvaloniaProperty.RegisterDirect( + nameof(OverlayInputPassThroughElement), + o => o.OverlayInputPassThroughElement, + (o, v) => o.OverlayInputPassThroughElement = v); + /// /// Defines the property. /// public static readonly StyledProperty HorizontalOffsetProperty = AvaloniaProperty.Register(nameof(HorizontalOffset)); + /// + /// Defines the property. + /// + public static readonly StyledProperty IsLightDismissEnabledProperty = + AvaloniaProperty.Register(nameof(IsLightDismissEnabled)); + /// /// Defines the property. /// @@ -101,8 +114,13 @@ namespace Avalonia.Controls.Primitives /// /// Defines the property. /// - public static readonly StyledProperty StaysOpenProperty = - AvaloniaProperty.Register(nameof(StaysOpen), true); + [Obsolete("Use IsLightDismissEnabledProperty")] + public static readonly DirectProperty StaysOpenProperty = + AvaloniaProperty.RegisterDirect( + nameof(StaysOpen), + o => o.StaysOpen, + (o, v) => o.StaysOpen = v, + true); /// /// Defines the property. @@ -113,6 +131,7 @@ namespace Avalonia.Controls.Primitives private bool _isOpen; private bool _ignoreIsOpenChanged; private PopupOpenState? _openState; + private IInputElement _overlayInputPassThroughElement; /// /// Initializes static members of the class. @@ -127,7 +146,7 @@ namespace Avalonia.Controls.Primitives /// /// Raised when the popup closes. /// - public event EventHandler? Closed; + public event EventHandler? Closed; /// /// Raised when the popup opens. @@ -165,6 +184,18 @@ namespace Avalonia.Controls.Primitives set; } + /// + /// Gets or sets a value that determines how the can be dismissed. + /// + /// + /// Light dismiss is when the user taps on any area other than the popup. + /// + public bool IsLightDismissEnabled + { + get => GetValue(IsLightDismissEnabledProperty); + set => SetValue(IsLightDismissEnabledProperty, value); + } + /// /// Gets or sets a value indicating whether the popup is currently open. /// @@ -246,6 +277,32 @@ namespace Avalonia.Controls.Primitives set => SetValue(ObeyScreenEdgesProperty, value); } + /// + /// Gets or sets a value indicating whether the event that closes the popup is passed + /// through to the parent window. + /// + /// + /// When is set to true, clicks outside the the popup + /// cause the popup to close. When is set to + /// false, these clicks will be handled by the popup and not be registered by the parent + /// window. When set to true, the events will be passed through to the parent window. + /// + public bool OverlayDismissEventPassThrough + { + get => GetValue(OverlayDismissEventPassThroughProperty); + set => SetValue(OverlayDismissEventPassThroughProperty, value); + } + + /// + /// Gets or sets an element that should receive pointer input events even when underneath + /// the popup's overlay. + /// + public IInputElement OverlayInputPassThroughElement + { + get => _overlayInputPassThroughElement; + set => SetAndRaise(OverlayInputPassThroughElementProperty, ref _overlayInputPassThroughElement, value); + } + /// /// Gets or sets the Horizontal offset of the popup in relation to the . /// @@ -268,10 +325,11 @@ namespace Avalonia.Controls.Primitives /// Gets or sets a value indicating whether the popup should stay open when the popup is /// pressed or loses focus. /// + [Obsolete("Use IsLightDismissEnabled")] public bool StaysOpen { - get { return GetValue(StaysOpenProperty); } - set { SetValue(StaysOpenProperty, value); } + get => !IsLightDismissEnabled; + set => IsLightDismissEnabled = !value; } /// @@ -363,14 +421,12 @@ namespace Avalonia.Controls.Primitives if (parentPopupRoot?.Parent is Popup popup) { - DeferCleanup(SubscribeToEventHandler>(popup, ParentClosed, + DeferCleanup(SubscribeToEventHandler>(popup, ParentClosed, (x, handler) => x.Closed += handler, (x, handler) => x.Closed -= handler)); } } - DeferCleanup(topLevel.AddDisposableHandler(PointerPressedEvent, PointerPressedOutside, RoutingStrategies.Tunnel)); - DeferCleanup(InputManager.Instance?.Process.Subscribe(ListenForNonClientClick)); var cleanupPopup = Disposable.Create((popupHost, handlerCleanup), state => @@ -384,6 +440,29 @@ namespace Avalonia.Controls.Primitives state.popupHost.Dispose(); }); + if (IsLightDismissEnabled) + { + var dismissLayer = LightDismissOverlayLayer.GetLightDismissOverlayLayer(placementTarget); + + if (dismissLayer != null) + { + dismissLayer.IsVisible = true; + dismissLayer.InputPassThroughElement = _overlayInputPassThroughElement; + + DeferCleanup(Disposable.Create(() => + { + dismissLayer.IsVisible = false; + dismissLayer.InputPassThroughElement = null; + })); + + DeferCleanup(SubscribeToEventHandler>( + dismissLayer, + PointerPressedDismissOverlay, + (x, handler) => x.PointerPressed += handler, + (x, handler) => x.PointerPressed -= handler)); + } + } + _openState = new PopupOpenState(topLevel, popupHost, cleanupPopup); WindowManagerAddShadowHintChanged(popupHost, WindowManagerAddShadowHint); @@ -401,7 +480,7 @@ namespace Avalonia.Controls.Primitives /// /// Closes the popup. /// - public void Close() => CloseCore(null); + public void Close() => CloseCore(); /// /// Measures the control. @@ -471,7 +550,7 @@ namespace Avalonia.Controls.Primitives } } - private void CloseCore(EventArgs? closeEvent) + private void CloseCore() { if (_openState is null) { @@ -491,24 +570,46 @@ namespace Avalonia.Controls.Primitives IsOpen = false; } - Closed?.Invoke(this, new PopupClosedEventArgs(closeEvent)); + Closed?.Invoke(this, EventArgs.Empty); } private void ListenForNonClientClick(RawInputEventArgs e) { var mouse = e as RawPointerEventArgs; - if (!StaysOpen && mouse?.Type == RawPointerEventType.NonClientLeftButtonDown) + if (IsLightDismissEnabled && mouse?.Type == RawPointerEventType.NonClientLeftButtonDown) { - CloseCore(e); + CloseCore(); } } - private void PointerPressedOutside(object sender, PointerPressedEventArgs e) + private void PointerPressedDismissOverlay(object sender, PointerPressedEventArgs e) { - if (!StaysOpen && e.Source is IVisual v && !IsChildOrThis(v)) + if (IsLightDismissEnabled && e.Source is IVisual v && !IsChildOrThis(v)) { - CloseCore(e); + CloseCore(); + + if (OverlayDismissEventPassThrough) + { + PassThroughEvent(e); + } + } + } + + private void PassThroughEvent(PointerPressedEventArgs e) + { + if (e.Source is LightDismissOverlayLayer layer && + layer.GetVisualRoot() is IInputElement root) + { + var p = e.GetCurrentPoint(root); + var hit = root.InputHitTest(p.Position, x => x != layer); + + if (hit != null) + { + e.Pointer.Capture(hit); + hit.RaiseEvent(e); + e.Handled = true; + } } } @@ -602,7 +703,7 @@ namespace Avalonia.Controls.Primitives private void WindowDeactivated(object sender, EventArgs e) { - if (!StaysOpen) + if (IsLightDismissEnabled) { Close(); } @@ -610,7 +711,7 @@ namespace Avalonia.Controls.Primitives private void ParentClosed(object sender, EventArgs e) { - if (!StaysOpen) + if (IsLightDismissEnabled) { Close(); } @@ -618,7 +719,7 @@ namespace Avalonia.Controls.Primitives private void WindowLostFocus() { - if(!StaysOpen) + if (IsLightDismissEnabled) Close(); } diff --git a/src/Avalonia.Controls/Primitives/PopupClosedEventArgs.cs b/src/Avalonia.Controls/Primitives/PopupClosedEventArgs.cs deleted file mode 100644 index c51543438c..0000000000 --- a/src/Avalonia.Controls/Primitives/PopupClosedEventArgs.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using Avalonia.Interactivity; - -#nullable enable - -namespace Avalonia.Controls.Primitives -{ - /// - /// Holds data for the event. - /// - public class PopupClosedEventArgs : EventArgs - { - /// - /// Initializes a new instance of the class. - /// - /// - public PopupClosedEventArgs(EventArgs? closeEvent) - { - CloseEvent = closeEvent; - } - - /// - /// Gets the event that closed the popup, if any. - /// - /// - /// If is false, then this property will hold details of the - /// interaction that caused the popup to close if the close was caused by e.g. a pointer press - /// outside the popup. It can be used to mark the event as handled if the event should not - /// be propagated. - /// - public EventArgs? CloseEvent { get; } - } -} diff --git a/src/Avalonia.Controls/Primitives/VisualLayerManager.cs b/src/Avalonia.Controls/Primitives/VisualLayerManager.cs index ff83351190..d8d3450c6f 100644 --- a/src/Avalonia.Controls/Primitives/VisualLayerManager.cs +++ b/src/Avalonia.Controls/Primitives/VisualLayerManager.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using Avalonia.LogicalTree; -using Avalonia.Metadata; +using Avalonia.Media; namespace Avalonia.Controls.Primitives { @@ -8,7 +8,8 @@ namespace Avalonia.Controls.Primitives { private const int AdornerZIndex = int.MaxValue - 100; private const int ChromeZIndex = int.MaxValue - 99; - private const int OverlayZIndex = int.MaxValue - 98; + private const int LightDismissOverlayZIndex = int.MaxValue - 98; + private const int OverlayZIndex = int.MaxValue - 97; private ILogicalRoot _logicalRoot; private readonly List _layers = new List(); @@ -62,6 +63,27 @@ namespace Avalonia.Controls.Primitives } } + public LightDismissOverlayLayer LightDismissOverlayLayer + { + get + { + if (IsPopup) + return null; + var rv = FindLayer(); + if (rv == null) + { + rv = new LightDismissOverlayLayer + { + Background = Brushes.Transparent, + IsVisible = false + }; + + AddLayer(rv, LightDismissOverlayZIndex); + } + return rv; + } + } + T FindLayer() where T : class { foreach (var layer in _layers) diff --git a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs index c127771990..91cef9fe64 100644 --- a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs +++ b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Specialized; +using System.ComponentModel; using System.Linq; using Avalonia.Collections; @@ -16,6 +17,7 @@ namespace Avalonia.Controls.Utils private IList? _items; private bool _updatingItems; private bool _updatingModel; + private bool _initializeOnSourceAssignment; public SelectedItemsSync(ISelectionModel model) { @@ -63,10 +65,18 @@ namespace Avalonia.Controls.Utils _updatingModel = true; _items = items; - using (Model.Update()) + if (Model.Source is object) { - Model.ClearSelection(); - Add(items); + using (Model.Update()) + { + Model.ClearSelection(); + Add(items); + } + } + else if (!_initializeOnSourceAssignment) + { + Model.PropertyChanged += SelectionModelPropertyChanged; + _initializeOnSourceAssignment = true; } if (_items is INotifyCollectionChanged incc2) @@ -86,9 +96,11 @@ namespace Avalonia.Controls.Utils if (_items != null) { + Model.PropertyChanged -= SelectionModelPropertyChanged; Model.SelectionChanged -= SelectionModelSelectionChanged; Model = model; Model.SelectionChanged += SelectionModelSelectionChanged; + _initializeOnSourceAssignment = false; try { @@ -175,6 +187,25 @@ namespace Avalonia.Controls.Utils } } + private void SelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (_initializeOnSourceAssignment && + _items != null && + e.PropertyName == nameof(SelectionModel.Source)) + { + try + { + _updatingModel = true; + Add(_items); + _initializeOnSourceAssignment = false; + } + finally + { + _updatingModel = false; + } + } + } + private void SelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) { if (_updatingModel) diff --git a/src/Avalonia.DesignerSupport/DesignWindowLoader.cs b/src/Avalonia.DesignerSupport/DesignWindowLoader.cs index 59862da230..b009778f97 100644 --- a/src/Avalonia.DesignerSupport/DesignWindowLoader.cs +++ b/src/Avalonia.DesignerSupport/DesignWindowLoader.cs @@ -69,6 +69,8 @@ namespace Avalonia.DesignerSupport window = new Window() {Content = (Control)control}; } + Design.ApplyDesignModeProperties(window, control); + if (!window.IsSet(Window.SizeToContentProperty)) { if (double.IsNaN(window.Width)) @@ -83,7 +85,6 @@ namespace Avalonia.DesignerSupport } } window.Show(); - Design.ApplyDesignModeProperties(window, control); return window; } } diff --git a/src/Avalonia.Input/InputExtensions.cs b/src/Avalonia.Input/InputExtensions.cs index 4babe711f2..cbe36583e6 100644 --- a/src/Avalonia.Input/InputExtensions.cs +++ b/src/Avalonia.Input/InputExtensions.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; using Avalonia.VisualTree; +#nullable enable + namespace Avalonia.Input { /// @@ -22,7 +24,7 @@ namespace Avalonia.Input /// public static IEnumerable GetInputElementsAt(this IInputElement element, Point p) { - Contract.Requires(element != null); + element = element ?? throw new ArgumentNullException(nameof(element)); return element.GetVisualsAt(p, s_hitTestDelegate).Cast(); } @@ -33,13 +35,34 @@ namespace Avalonia.Input /// The element to test. /// The point on . /// The topmost at the specified position. - public static IInputElement InputHitTest(this IInputElement element, Point p) + public static IInputElement? InputHitTest(this IInputElement element, Point p) { - Contract.Requires(element != null); + element = element ?? throw new ArgumentNullException(nameof(element)); return element.GetVisualAt(p, s_hitTestDelegate) as IInputElement; } + /// + /// Returns the topmost active input element at a point on an . + /// + /// The element to test. + /// The point on . + /// + /// A filter predicate. If the predicate returns false then the visual and all its + /// children will be excluded from the results. + /// + /// The topmost at the specified position. + public static IInputElement? InputHitTest( + this IInputElement element, + Point p, + Func filter) + { + element = element ?? throw new ArgumentNullException(nameof(element)); + filter = filter ?? throw new ArgumentNullException(nameof(filter)); + + return element.GetVisualAt(p, x => s_hitTestDelegate(x) && filter(x)) as IInputElement; + } + private static bool IsHitTestVisible(IVisual visual) { var element = visual as IInputElement; diff --git a/src/Avalonia.Themes.Default/Accents/BaseDark.xaml b/src/Avalonia.Themes.Default/Accents/BaseDark.xaml index 4274f7ee4d..44dfb9ea48 100644 --- a/src/Avalonia.Themes.Default/Accents/BaseDark.xaml +++ b/src/Avalonia.Themes.Default/Accents/BaseDark.xaml @@ -58,6 +58,9 @@ + + + 1,1,1,1 diff --git a/src/Avalonia.Themes.Default/Accents/BaseLight.xaml b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml index b1c0946d0f..9ed3207235 100644 --- a/src/Avalonia.Themes.Default/Accents/BaseLight.xaml +++ b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml @@ -61,6 +61,9 @@ + + + 1 diff --git a/src/Avalonia.Themes.Default/AutoCompleteBox.xaml b/src/Avalonia.Themes.Default/AutoCompleteBox.xaml index 788b60892b..66d0f17ede 100644 --- a/src/Avalonia.Themes.Default/AutoCompleteBox.xaml +++ b/src/Avalonia.Themes.Default/AutoCompleteBox.xaml @@ -19,7 +19,7 @@ MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}" MaxHeight="{TemplateBinding MaxDropDownHeight}" PlacementTarget="{TemplateBinding}" - StaysOpen="False"> + IsLightDismissEnabled="True"> + IsLightDismissEnabled="True"> diff --git a/src/Avalonia.Themes.Default/DatePicker.xaml b/src/Avalonia.Themes.Default/DatePicker.xaml new file mode 100644 index 0000000000..da878c88e2 --- /dev/null +++ b/src/Avalonia.Themes.Default/DatePicker.xaml @@ -0,0 +1,334 @@ + + + + + 0,0,0,4 + 40 + 40 + 41 + 296 + 456 + 0,3,0,6 + 9,3,0,6 + 0,3,0,6 + 9,3,0,6 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Default/DefaultTheme.xaml b/src/Avalonia.Themes.Default/DefaultTheme.xaml index 4e63d1d223..e5b654b490 100644 --- a/src/Avalonia.Themes.Default/DefaultTheme.xaml +++ b/src/Avalonia.Themes.Default/DefaultTheme.xaml @@ -55,4 +55,7 @@ + + + diff --git a/src/Avalonia.Themes.Default/GridSplitter.xaml b/src/Avalonia.Themes.Default/GridSplitter.xaml index dc5cd002dc..6d9cb4f31f 100644 --- a/src/Avalonia.Themes.Default/GridSplitter.xaml +++ b/src/Avalonia.Themes.Default/GridSplitter.xaml @@ -2,8 +2,8 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Default/TimePicker.xaml b/src/Avalonia.Themes.Default/TimePicker.xaml new file mode 100644 index 0000000000..6c4d0c97bd --- /dev/null +++ b/src/Avalonia.Themes.Default/TimePicker.xaml @@ -0,0 +1,283 @@ + + + + + 40 + 1 + 1 + 0,0,0,4 + 40 + 41 + 242 + 456 + 0,3,0,6 + 0,3,0,6 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Fluent/AutoCompleteBox.xaml b/src/Avalonia.Themes.Fluent/AutoCompleteBox.xaml index 7dc3bda133..532826c500 100644 --- a/src/Avalonia.Themes.Fluent/AutoCompleteBox.xaml +++ b/src/Avalonia.Themes.Fluent/AutoCompleteBox.xaml @@ -47,7 +47,7 @@ WindowManagerAddShadowHint="False" MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}" MaxHeight="{TemplateBinding MaxDropDownHeight}" - StaysOpen="False" + IsLightDismissEnabled="True" PlacementTarget="{TemplateBinding}"> + IsLightDismissEnabled="True"> diff --git a/src/Avalonia.Themes.Fluent/ComboBox.xaml b/src/Avalonia.Themes.Fluent/ComboBox.xaml index 97f36169ed..a89b18b272 100644 --- a/src/Avalonia.Themes.Fluent/ComboBox.xaml +++ b/src/Avalonia.Themes.Fluent/ComboBox.xaml @@ -124,7 +124,7 @@ MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}" MaxHeight="{TemplateBinding MaxDropDownHeight}" PlacementTarget="{TemplateBinding}" - StaysOpen="False"> + IsLightDismissEnabled="True"> diff --git a/src/Avalonia.Themes.Fluent/MenuItem.xaml b/src/Avalonia.Themes.Fluent/MenuItem.xaml index e1f7ea2279..2115319c3c 100644 --- a/src/Avalonia.Themes.Fluent/MenuItem.xaml +++ b/src/Avalonia.Themes.Fluent/MenuItem.xaml @@ -112,7 +112,7 @@ WindowManagerAddShadowHint="True" PlacementMode="Right" HorizontalOffset="{StaticResource MenuFlyoutSubItemPopupHorizontalOffset}" - StaysOpen="True" + IsLightDismissEnabled="True" IsOpen="{TemplateBinding IsSubMenuOpen, Mode=TwoWay}"> + OverlayInputPassThroughElement="{Binding $parent[Menu]}"> diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs index 9318fcc68e..b116249fd4 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs @@ -31,7 +31,8 @@ namespace Avalonia.Media.TextFormatting case TextWrapping.WrapWithOverflow: case TextWrapping.Wrap: { - textLine = PerformTextWrapping(textRuns, textRange, paragraphWidth, paragraphProperties); + textLine = PerformTextWrapping(textRuns, textRange, paragraphWidth, paragraphProperties, + nextLineBreak); break; } default: @@ -118,7 +119,7 @@ namespace Avalonia.Media.TextFormatting /// The text run's. /// The length to split at. /// The split text runs. - internal static SplitTextRunsResult SplitTextRuns(IReadOnlyList textRuns, int length) + internal static SplitTextRunsResult SplitTextRuns(List textRuns, int length) { var currentLength = 0; @@ -134,13 +135,13 @@ namespace Avalonia.Media.TextFormatting var firstCount = currentRun.GlyphRun.Characters.Length >= 1 ? i + 1 : i; - var first = new ShapedTextCharacters[firstCount]; + var first = new List(firstCount); if (firstCount > 1) { for (var j = 0; j < i; j++) { - first[j] = textRuns[j]; + first.Add(textRuns[j]); } } @@ -148,7 +149,7 @@ namespace Avalonia.Media.TextFormatting if (currentLength + currentRun.GlyphRun.Characters.Length == length) { - var second = new ShapedTextCharacters[secondCount]; + var second = new List(secondCount); var offset = currentRun.GlyphRun.Characters.Length > 1 ? 1 : 0; @@ -156,11 +157,11 @@ namespace Avalonia.Media.TextFormatting { for (var j = 0; j < secondCount; j++) { - second[j] = textRuns[i + j + offset]; + second.Add(textRuns[i + j + offset]); } } - first[i] = currentRun; + first.Add(currentRun); return new SplitTextRunsResult(first, second); } @@ -168,22 +169,22 @@ namespace Avalonia.Media.TextFormatting { secondCount++; - var second = new ShapedTextCharacters[secondCount]; + var second = new List(secondCount); + + var split = currentRun.Split(length - currentLength); + + first.Add(split.First); + + second.Add(split.Second); if (secondCount > 0) { for (var j = 1; j < secondCount; j++) { - second[j] = textRuns[i + j]; + second.Add(textRuns[i + j]); } } - var split = currentRun.Split(length - currentLength); - - first[i] = split.First; - - second[0] = split.Second; - return new SplitTextRunsResult(first, second); } } @@ -201,7 +202,7 @@ namespace Avalonia.Media.TextFormatting /// /// The formatted text runs. /// - private static IReadOnlyList FetchTextRuns(ITextSource textSource, + private static List FetchTextRuns(ITextSource textSource, int firstTextSourceIndex, TextLineBreak previousLineBreak, out TextLineBreak nextLineBreak) { nextLineBreak = default; @@ -212,8 +213,10 @@ namespace Avalonia.Media.TextFormatting if (previousLineBreak != null) { - foreach (var shapedCharacters in previousLineBreak.RemainingCharacters) + for (var index = 0; index < previousLineBreak.RemainingCharacters.Count; index++) { + var shapedCharacters = previousLineBreak.RemainingCharacters[index]; + if (shapedCharacters == null) { continue; @@ -225,6 +228,14 @@ namespace Avalonia.Media.TextFormatting { var splitResult = SplitTextRuns(textRuns, currentLength + runLineBreak.PositionWrap); + if (++index < previousLineBreak.RemainingCharacters.Count) + { + for (; index < previousLineBreak.RemainingCharacters.Count; index++) + { + splitResult.Second.Add(previousLineBreak.RemainingCharacters[index]); + } + } + nextLineBreak = new TextLineBreak(splitResult.Second); return splitResult.First; @@ -323,9 +334,10 @@ namespace Avalonia.Media.TextFormatting /// The text range that is covered by the text runs. /// The paragraph width. /// The text paragraph properties. + /// The current line break if the line was explicitly broken. /// The wrapped text line. - private static TextLine PerformTextWrapping(IReadOnlyList textRuns, TextRange textRange, - double paragraphWidth, TextParagraphProperties paragraphProperties) + private static TextLine PerformTextWrapping(List textRuns, TextRange textRange, + double paragraphWidth, TextParagraphProperties paragraphProperties, TextLineBreak currentLineBreak) { var availableWidth = paragraphWidth; var currentWidth = 0.0; @@ -388,8 +400,22 @@ namespace Avalonia.Media.TextFormatting var textLineMetrics = TextLineMetrics.Create(splitResult.First, new TextRange(textRange.Start, currentLength), paragraphWidth, paragraphProperties); - var lineBreak = splitResult.Second != null && splitResult.Second.Count > 0 ? - new TextLineBreak(splitResult.Second) : + var remainingCharacters = splitResult.Second; + + if (currentLineBreak?.RemainingCharacters != null) + { + if (remainingCharacters != null) + { + remainingCharacters.AddRange(currentLineBreak.RemainingCharacters); + } + else + { + remainingCharacters = new List(currentLineBreak.RemainingCharacters); + } + } + + var lineBreak = remainingCharacters != null && remainingCharacters.Count > 0 ? + new TextLineBreak(remainingCharacters) : null; return new TextLineImpl(splitResult.First, textLineMetrics, lineBreak); @@ -403,7 +429,10 @@ namespace Avalonia.Media.TextFormatting } return new TextLineImpl(textRuns, - TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties)); + TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties), + currentLineBreak?.RemainingCharacters != null ? + new TextLineBreak(currentLineBreak.RemainingCharacters) : + null); } /// @@ -434,7 +463,7 @@ namespace Avalonia.Media.TextFormatting internal readonly struct SplitTextRunsResult { - public SplitTextRunsResult(IReadOnlyList first, IReadOnlyList second) + public SplitTextRunsResult(List first, List second) { First = first; @@ -447,7 +476,7 @@ namespace Avalonia.Media.TextFormatting /// /// The first text runs. /// - public IReadOnlyList First { get; } + public List First { get; } /// /// Gets the second text runs. @@ -455,7 +484,7 @@ namespace Avalonia.Media.TextFormatting /// /// The second text runs. /// - public IReadOnlyList Second { get; } + public List Second { get; } } private struct TextRunEnumerator diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs index 14602a2560..fa7d6cb4bf 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs @@ -183,7 +183,10 @@ namespace Avalonia.Media.TextFormatting var glyphRun = TextShaper.Current.ShapeText(new ReadOnlySlice(s_empty, startingIndex, 1), properties.Typeface, properties.FontRenderingEmSize, properties.CultureInfo); - var textRuns = new[] { new ShapedTextCharacters(glyphRun, _paragraphProperties.DefaultTextRunProperties) }; + var textRuns = new List + { + new ShapedTextCharacters(glyphRun, _paragraphProperties.DefaultTextRunProperties) + }; return new TextLineImpl(textRuns, TextLineMetrics.Create(textRuns, new TextRange(startingIndex, 1), MaxWidth, _paragraphProperties)); diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs index 435752160e..8b44e32c48 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs @@ -6,9 +6,9 @@ namespace Avalonia.Media.TextFormatting { internal class TextLineImpl : TextLine { - private readonly IReadOnlyList _textRuns; + private readonly List _textRuns; - public TextLineImpl(IReadOnlyList textRuns, TextLineMetrics lineMetrics, + public TextLineImpl(List textRuns, TextLineMetrics lineMetrics, TextLineBreak lineBreak = null, bool hasCollapsed = false) { _textRuns = textRuns; diff --git a/src/Avalonia.Visuals/Rendering/ICustomSimpleHitTest.cs b/src/Avalonia.Visuals/Rendering/ICustomSimpleHitTest.cs index 4c15de0312..245888a351 100644 --- a/src/Avalonia.Visuals/Rendering/ICustomSimpleHitTest.cs +++ b/src/Avalonia.Visuals/Rendering/ICustomSimpleHitTest.cs @@ -14,6 +14,17 @@ namespace Avalonia.Rendering bool HitTest(Point point); } + /// + /// Allows customization of hit-testing for all renderers. + /// + /// + /// Note that this interface can only used to make a portion of a control non-hittable, it + /// cannot expand the hittable area of a control. + /// + public interface ICustomHitTest : ICustomSimpleHitTest + { + } + public static class CustomSimpleHitTestExtensions { public static bool HitTestCustom(this IVisual visual, Point point) diff --git a/src/Avalonia.Visuals/Rendering/SceneGraph/Scene.cs b/src/Avalonia.Visuals/Rendering/SceneGraph/Scene.cs index 0f6001516d..4f5c97cdff 100644 --- a/src/Avalonia.Visuals/Rendering/SceneGraph/Scene.cs +++ b/src/Avalonia.Visuals/Rendering/SceneGraph/Scene.cs @@ -304,6 +304,12 @@ namespace Avalonia.Rendering.SceneGraph clipped = !node.GeometryClip.FillContains(controlPoint.Value); } + if (!clipped && node.Visual is ICustomHitTest custom) + { + var controlPoint = _sceneRoot.Visual.TranslatePoint(_point, node.Visual); + clipped = !custom.HitTest(controlPoint.Value); + } + return !clipped; } diff --git a/src/Skia/Avalonia.Skia/TextShaperImpl.cs b/src/Skia/Avalonia.Skia/TextShaperImpl.cs index b0384a1fdf..61075171fe 100644 --- a/src/Skia/Avalonia.Skia/TextShaperImpl.cs +++ b/src/Skia/Avalonia.Skia/TextShaperImpl.cs @@ -123,10 +123,7 @@ namespace Avalonia.Skia return; } - if (offsetBuffer == null) - { - offsetBuffer = new Vector[glyphPositions.Length]; - } + offsetBuffer ??= new Vector[glyphPositions.Length]; var offsetX = position.XOffset * textScale; @@ -138,10 +135,7 @@ namespace Avalonia.Skia private static void SetAdvance(ReadOnlySpan glyphPositions, int index, double textScale, ref double[] advanceBuffer) { - if (advanceBuffer == null) - { - advanceBuffer = new double[glyphPositions.Length]; - } + advanceBuffer ??= new double[glyphPositions.Length]; // Depends on direction of layout // advanceBuffer[index] = buffer.GlyphPositions[index].YAdvance * textScale; diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs new file mode 100644 index 0000000000..7c7cdd08db --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs @@ -0,0 +1,126 @@ +using System.Linq; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Styling; +using Avalonia.UnitTests; +using Avalonia.VisualTree; +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class ListBoxTests_Multiple + { + [Fact] + public void Focusing_Item_With_Shift_And_Arrow_Key_Should_Add_To_Selection() + { + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + Items = new[] { "Foo", "Bar", "Baz " }, + SelectionMode = SelectionMode.Multiple + }; + + ApplyTemplate(target); + + target.SelectedItem = "Foo"; + + target.Presenter.Panel.Children[1].RaiseEvent(new GotFocusEventArgs + { + RoutedEvent = InputElement.GotFocusEvent, + NavigationMethod = NavigationMethod.Directional, + KeyModifiers = KeyModifiers.Shift + }); + + Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems); + } + + [Fact] + public void Focusing_Item_With_Ctrl_And_Arrow_Key_Should_Add_To_Selection() + { + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + Items = new[] { "Foo", "Bar", "Baz " }, + SelectionMode = SelectionMode.Multiple + }; + + ApplyTemplate(target); + + target.SelectedItem = "Foo"; + + target.Presenter.Panel.Children[1].RaiseEvent(new GotFocusEventArgs + { + RoutedEvent = InputElement.GotFocusEvent, + NavigationMethod = NavigationMethod.Directional, + KeyModifiers = KeyModifiers.Control + }); + + Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems); + } + + [Fact] + public void Focusing_Selected_Item_With_Ctrl_And_Arrow_Key_Should_Remove_From_Selection() + { + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + Items = new[] { "Foo", "Bar", "Baz " }, + SelectionMode = SelectionMode.Multiple + }; + + ApplyTemplate(target); + + target.SelectedItems.Add("Foo"); + target.SelectedItems.Add("Bar"); + + target.Presenter.Panel.Children[0].RaiseEvent(new GotFocusEventArgs + { + RoutedEvent = InputElement.GotFocusEvent, + NavigationMethod = NavigationMethod.Directional, + KeyModifiers = KeyModifiers.Control + }); + + Assert.Equal(new[] { "Bar" }, target.SelectedItems); + } + + private Control CreateListBoxTemplate(ITemplatedControl parent, INameScope scope) + { + return new ScrollViewer + { + Template = new FuncControlTemplate(CreateScrollViewerTemplate), + Content = new ItemsPresenter + { + Name = "PART_ItemsPresenter", + [~ItemsPresenter.ItemsProperty] = parent.GetObservable(ItemsControl.ItemsProperty).ToBinding(), + }.RegisterInNameScope(scope) + }; + } + + private Control CreateScrollViewerTemplate(ITemplatedControl parent, INameScope scope) + { + return new ScrollContentPresenter + { + Name = "PART_ContentPresenter", + [~ContentPresenter.ContentProperty] = + parent.GetObservable(ContentControl.ContentProperty).ToBinding(), + }.RegisterInNameScope(scope); + } + + private void ApplyTemplate(ListBox target) + { + // Apply the template to the ListBox itself. + target.ApplyTemplate(); + + // Then to its inner ScrollViewer. + var scrollViewer = (ScrollViewer)target.GetVisualChildren().Single(); + scrollViewer.ApplyTemplate(); + + // Then make the ScrollViewer create its child. + ((ContentPresenter)scrollViewer.Presenter).UpdateChild(); + + // Now the ItemsPresenter should be reigstered, so apply its template. + target.Presenter.ApplyTemplate(); + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs b/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs index 97b6bf9d24..64f35049ce 100644 --- a/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs @@ -124,6 +124,24 @@ namespace Avalonia.Controls.UnitTests.Platform Assert.True(e.Handled); } + [Fact] + public void Click_On_TopLevel_Calls_MainMenu_Open() + { + var target = new DefaultMenuInteractionHandler(false); + var menu = new Mock(); + menu.As(); + + var item = Mock.Of(x => + x.IsTopLevel == true && + x.HasSubMenu == true && + x.Parent == menu.Object); + + var e = CreatePressed(item); + + target.PointerPressed(item, e); + menu.Verify(x => x.Open()); + } + [Fact] public void Click_On_Open_TopLevel_Menu_Closes_Menu() { diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs index fd06ba295b..d9176ca55d 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs @@ -14,6 +14,7 @@ using Avalonia.UnitTests; using Avalonia.VisualTree; using Xunit; using Avalonia.Input; +using Avalonia.Rendering; namespace Avalonia.Controls.UnitTests.Primitives { @@ -349,52 +350,48 @@ namespace Avalonia.Controls.UnitTests.Primitives } [Fact] - public void StaysOpen_False_Should_Not_Handle_Closing_Click() + public void OverlayDismissEventPassThrough_Should_Pass_Event_To_Window_Contents() { using (CreateServices()) { - var window = PreparedWindow(); + var renderer = new Mock(); + var platform = AvaloniaLocator.Current.GetService(); + var windowImpl = Mock.Get(platform.CreateWindow()); + windowImpl.Setup(x => x.CreateRenderer(It.IsAny())).Returns(renderer.Object); + + var window = new Window(windowImpl.Object); + window.ApplyTemplate(); + var target = new Popup() { PlacementTarget = window , - StaysOpen = false, + IsLightDismissEnabled = true, + OverlayDismissEventPassThrough = true, }; - target.Open(); - - var e = CreatePointerPressedEventArgs(window); - window.RaiseEvent(e); + var raised = 0; + var border = new Border(); + window.Content = border; - Assert.False(e.Handled); - } - } + renderer.Setup(x => + x.HitTestFirst(new Point(10, 15), window, It.IsAny>())) + .Returns(border); - [Fact] - public void Should_Pass_Closing_Click_To_Closed_Event() - { - using (CreateServices()) - { - var window = PreparedWindow(); - var target = new Popup() + border.PointerPressed += (s, e) => { - PlacementTarget = window, - StaysOpen = false, + Assert.Same(border, e.Source); + ++raised; }; target.Open(); + Assert.True(target.IsOpen); - var press = CreatePointerPressedEventArgs(window); - var raised = 0; - - target.Closed += (s, e) => - { - Assert.Same(press, e.CloseEvent); - ++raised; - }; - - window.RaiseEvent(press); + var e = CreatePointerPressedEventArgs(window, new Point(10, 15)); + var overlay = LightDismissOverlayLayer.GetLightDismissOverlayLayer(window); + overlay.RaiseEvent(e); Assert.Equal(1, raised); + Assert.False(target.IsOpen); } } @@ -410,14 +407,14 @@ namespace Avalonia.Controls.UnitTests.Primitives }))); } - private PointerPressedEventArgs CreatePointerPressedEventArgs(Window source) + private PointerPressedEventArgs CreatePointerPressedEventArgs(Window source, Point p) { var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Mouse, true); return new PointerPressedEventArgs( source, pointer, source, - default, + p, 0, new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.LeftButtonPressed), KeyModifiers.None); diff --git a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs index 3ab5950974..4aa7e24aa7 100644 --- a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs @@ -208,6 +208,20 @@ namespace Avalonia.Controls.UnitTests.Utils target.SetItems(new[] { "foo", "bar", "baz" })); } + [Fact] + public void Selected_Items_Can_Be_Set_Before_SelectionModel_Source() + { + var model = new SelectionModel(); + var target = new SelectedItemsSync(model); + var items = new AvaloniaList { "foo", "bar", "baz" }; + var selectedItems = new AvaloniaList { "bar" }; + + target.SetItems(selectedItems); + model.Source = items; + + Assert.Equal(new IndexPath(1), model.SelectedIndex); + } + private static SelectedItemsSync CreateTarget( IEnumerable items = null) { diff --git a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs index 67503ef0d0..9ec8ca2d36 100644 --- a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs +++ b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs @@ -3,6 +3,7 @@ using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Input; using Moq; using Avalonia.Platform; +using Avalonia.Rendering; namespace Avalonia.UnitTests {