diff --git a/src/Avalonia.Base/Input/FocusManager.cs b/src/Avalonia.Base/Input/FocusManager.cs index 2bf666af44..c8de7267ca 100644 --- a/src/Avalonia.Base/Input/FocusManager.cs +++ b/src/Avalonia.Base/Input/FocusManager.cs @@ -122,6 +122,11 @@ namespace Avalonia.Input { scope = scope ?? throw new ArgumentNullException(nameof(scope)); + if (element is not null && !CanFocus(element)) + { + return; + } + if (_focusScopes.TryGetValue(scope, out var existingElement)) { if (element != existingElement) @@ -242,6 +247,6 @@ namespace Avalonia.Input } } - private static bool IsVisible(IInputElement e) => (e as Visual)?.IsVisible ?? true; + private static bool IsVisible(IInputElement e) => (e as Visual)?.IsEffectivelyVisible ?? true; } } diff --git a/src/Avalonia.Base/Input/InputElement.cs b/src/Avalonia.Base/Input/InputElement.cs index 962c7aa334..33ddbaedf9 100644 --- a/src/Avalonia.Base/Input/InputElement.cs +++ b/src/Avalonia.Base/Input/InputElement.cs @@ -647,6 +647,10 @@ namespace Avalonia.Input { PseudoClasses.Set(":focus-within", change.GetNewValue()); } + else if (change.Property == IsVisibleProperty && !change.GetNewValue() && IsFocused) + { + FocusManager.Instance?.Focus(null); + } } /// diff --git a/src/Avalonia.Base/Input/TextInput/InputMethodManager.cs b/src/Avalonia.Base/Input/TextInput/InputMethodManager.cs index 9b5668bf98..1c61334888 100644 --- a/src/Avalonia.Base/Input/TextInput/InputMethodManager.cs +++ b/src/Avalonia.Base/Input/TextInput/InputMethodManager.cs @@ -48,9 +48,9 @@ namespace Avalonia.Input.TextInput } _transformTracker.SetVisual(_client?.TextViewVisual); - UpdateCursorRect(); _im?.SetClient(_client); + UpdateCursorRect(); } else { diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs b/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs index 608f924808..55aac6f3fa 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs @@ -50,7 +50,8 @@ internal static class StorageProviderHelpers } } - public static string NameWithExtension(string path, string? defaultExtension, FilePickerFileType? filter) + [return: NotNullIfNotNull(nameof(path))] + public static string? NameWithExtension(string? path, string? defaultExtension, FilePickerFileType? filter) { var name = Path.GetFileName(path); if (name != null && !Path.HasExtension(name)) diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index dadda4e0ec..f41c00662d 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -170,8 +170,15 @@ namespace Avalonia.Controls UpdateFlowDirection(); } - protected internal override Control CreateContainerForItemOverride() => new ComboBoxItem(); - protected internal override bool IsItemItsOwnContainerOverride(Control item) => item is ComboBoxItem; + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return new ComboBoxItem(); + } + + protected internal override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + return NeedsContainer(item, out recycleKey); + } /// protected override void OnKeyDown(KeyEventArgs e) diff --git a/src/Avalonia.Controls/Flyouts/MenuFlyout.cs b/src/Avalonia.Controls/Flyouts/MenuFlyout.cs index a2ce93ee6d..1e5f43cf41 100644 --- a/src/Avalonia.Controls/Flyouts/MenuFlyout.cs +++ b/src/Avalonia.Controls/Flyouts/MenuFlyout.cs @@ -24,9 +24,8 @@ namespace Avalonia.Controls /// /// Defines the property /// - public static readonly DirectProperty ItemTemplateProperty = - AvaloniaProperty.RegisterDirect(nameof(ItemTemplate), - x => x.ItemTemplate, (x, v) => x.ItemTemplate = v); + public static readonly StyledProperty ItemTemplateProperty = + AvaloniaProperty.Register(nameof(ItemTemplate)); /// /// Defines the property. @@ -59,8 +58,8 @@ namespace Avalonia.Controls /// public IDataTemplate? ItemTemplate { - get => _itemTemplate; - set => SetAndRaise(ItemTemplateProperty, ref _itemTemplate, value); + get => GetValue(ItemTemplateProperty); + set => SetValue(ItemTemplateProperty, value); } /// @@ -82,7 +81,6 @@ namespace Avalonia.Controls } private Classes? _classes; - private IDataTemplate? _itemTemplate; protected override Control CreatePresenter() { diff --git a/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs b/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs index d27479af18..91e8dae0a1 100644 --- a/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs +++ b/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs @@ -10,34 +10,44 @@ namespace Avalonia.Controls.Generators /// When creating a container for an item from a , the following /// process should be followed: /// - /// - should first be called if the item is - /// derived from the class. If this method returns true then the - /// item itself should be used as the container. - /// - If returns false then - /// should be called to create a new container. + /// - should first be called to + /// determine whether the item needs a container. This method will return true if the item + /// should be wrapped in a container control, or false if the item itself can be used as a + /// container. + /// - If returns true then the + /// method should be called to create a new container, passing + /// the recycle key returned from . + /// - If the panel supports recycling and the recycle key is non-null then the recycle key + /// should be recorded for the container (e.g. in an attached property or the realized + /// container list). /// - method should be called for the /// container. /// - The container should then be added to the panel using /// /// - Finally, should be called. /// - /// NOTE: If in the first step above returns true - /// then the above steps should be carried out a single time; the first time the item is - /// displayed. Otherwise the steps should be carried out each time a new container is realized - /// for an item. + /// NOTE: If in the first step above + /// returns false then the above steps should be carried out a single time: the first time the + /// item is displayed. Otherwise the steps should be carried out each time a new container is + /// realized for an item. /// /// When unrealizing a container, the following process should be followed: /// - /// - If for the item returned true then the item - /// cannot be unrealized or recycled. + /// - If for the item returned false + /// then the item cannot be unrealized or recycled. /// - Otherwise, should be called for the container - /// - If recycling is supported then the container should be added to a recycle pool. - /// - It is assumed that recyclable containers will not be removed from the panel but instead - /// hidden from view using e.g. `container.IsVisible = false`. + /// - If recycling is supported by the panel and the container then the container should be + /// added to a recycle pool keyed on the recycle key returned from + /// . It is assumed that recycled + /// containers will not be removed from the panel but instead hidden from view using + /// e.g. `container.IsVisible = false`. + /// - If recycling is not supported then the container should be removed from the panel. /// /// When recycling an unrealized container, the following process should be followed: /// - /// - An element should be taken from the recycle pool. + /// - should be called to determine + /// whether the item needs a container, and if so, the recycle key. + /// - A container should be taken from the recycle pool keyed on the returned recycle key. /// - The container should be made visible. /// - method should be called for the /// container. @@ -55,28 +65,43 @@ namespace Avalonia.Controls.Generators internal ItemContainerGenerator(ItemsControl owner) => _owner = owner; /// - /// Creates a new container control. + /// Determines whether the specified item needs to be wrapped in a container control. /// - /// The newly created container control. - /// - /// Before calling this method, should be - /// called to determine whether the item itself should be used as a container. After - /// calling this method, should - /// be called to prepare the container to display the specified item. - /// - public Control CreateContainer() => _owner.CreateContainerForItemOverride(); + /// The item to display. + /// The index of the item. + /// + /// When the method returns, contains a key that can be used to locate a previously + /// recycled container of the correct type, or null if the item cannot be recycled. + /// + /// + /// true if the item needs a container; otherwise false if the item can itself be used + /// as a container. + /// + public bool NeedsContainer(object? item, int index, out object? recycleKey) => + _owner.NeedsContainerOverride(item, index, out recycleKey); /// - /// Determines whether the specified item is (or is eligible to be) its own container. + /// Creates a new container control. /// - /// The item. - /// true if the item is its own container, otherwise false. + /// The item to display. + /// The index of the item. + /// + /// The recycle key returned from + /// + /// The newly created container control. /// - /// Whereas in WPF/UWP, non-control items can be their own container, in Avalonia only - /// control items may be; the caller is responsible for checking if each item is a control - /// and calling this method before creating a new container. + /// Before calling this method, + /// should be called to determine whether the item itself should be used as a container. + /// After calling this method, + /// must be called to prepare the container to display the specified item. + /// + /// If the panel supports recycling then the returned recycle key should be stored alongside + /// the container and when container becomes eligible for recycling the container should + /// be placed in a recycle pool using this key. If the returned recycle key is null then + /// the container cannot be recycled. /// - public bool IsItemItsOwnContainer(Control container) => _owner.IsItemItsOwnContainerOverride(container); + public Control CreateContainer(object? item, int index, object? recycleKey) + => _owner.CreateContainerForItemOverride(item, index, recycleKey); /// /// Prepares the specified element as the container for the corresponding item. @@ -85,10 +110,10 @@ namespace Avalonia.Controls.Generators /// The item to display. /// The index of the item to display. /// - /// If is true for an item, then this method - /// must only be called a single time, otherwise this method must be called after the - /// container is created, and each subsequent time the container is recycled to display a - /// new item. + /// If is false for an + /// item, then this method must only be called a single time; otherwise this method must + /// be called after the container is created, and each subsequent time the container is + /// recycled to display a new item. /// public void PrepareItemContainer(Control container, object? item, int index) => _owner.PrepareItemContainer(container, item, index); @@ -104,8 +129,8 @@ namespace Avalonia.Controls.Generators /// This method must be called when a container has been fully prepared and added /// to the logical and visual trees, but may be called before a layout pass has completed. /// It must be called regardless of the result of - /// but if that method returned true then - /// must be called only a single time. + /// but if that method returned + /// false then must be called only a single time. /// public void ItemContainerPrepared(Control container, object? item, int index) => _owner.ItemContainerPrepared(container, item, index); @@ -128,7 +153,7 @@ namespace Avalonia.Controls.Generators /// This method must be called when a container is unrealized. The container must have /// already have been removed from the virtualizing panel's list of realized containers before /// this method is called. This method must not be called if - /// returned true for the item. + /// returned false for the item. /// public void ClearItemContainer(Control container) => _owner.ClearItemContainer(container); diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 4a0b3c367e..064716fa9b 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -307,6 +307,12 @@ namespace Avalonia.Controls set => SetValue(AreVerticalSnapPointsRegularProperty, value); } + /// + /// Gets a default recycle key that can be used when an supports + /// a single container type. + /// + protected static object DefaultRecycleKey { get; } = new object(); + /// /// Returns the container for the item at the specified index. /// @@ -362,7 +368,10 @@ namespace Avalonia.Controls /// /// Creates or a container that can be used to display an item. /// - protected internal virtual Control CreateContainerForItemOverride() => new ContentPresenter(); + protected internal virtual Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return new ContentPresenter(); + } /// /// Prepares the specified element to display the specified item. @@ -495,11 +504,52 @@ namespace Avalonia.Controls } /// - /// Determines whether the specified item is (or is eligible to be) its own container. + /// Determines whether the specified item can be its own container. /// /// The item to check. - /// true if the item is (or is eligible to be) its own container; otherwise, false. - protected internal virtual bool IsItemItsOwnContainerOverride(Control item) => true; + /// The index of the item. + /// + /// When the method returns, contains a key that can be used to locate a previously + /// recycled container of the correct type, or null if the item cannot be recycled. + /// If the item is its own container then by definition it cannot be recycled, so + /// shoud be set to null. + /// + /// + /// true if the item needs a container; otherwise false if the item can itself be used + /// as a container. + /// + protected internal virtual bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + return NeedsContainer(item, out recycleKey); + } + + /// + /// A default implementation of + /// that returns true and sets the recycle key to if the item + /// is not a . + /// + /// The container type. + /// The item. + /// + /// When the method returns, contains if + /// is not of type ; otherwise null. + /// + /// + /// true if is of type ; otherwise false. + /// + protected bool NeedsContainer(object? item, out object? recycleKey) where T : Control + { + if (item is T) + { + recycleKey = null; + return false; + } + else + { + recycleKey = DefaultRecycleKey; + return true; + } + } /// protected override void OnApplyTemplate(TemplateAppliedEventArgs e) diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index e5f0b50555..2bdc37537a 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -108,8 +108,15 @@ namespace Avalonia.Controls /// public void UnselectAll() => Selection.Clear(); - protected internal override Control CreateContainerForItemOverride() => new ListBoxItem(); - protected internal override bool IsItemItsOwnContainerOverride(Control item) => item is ListBoxItem; + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return new ListBoxItem(); + } + + protected internal override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + return NeedsContainer(item, out recycleKey); + } /// protected override void OnGotFocus(GotFocusEventArgs e) diff --git a/src/Avalonia.Controls/MenuBase.cs b/src/Avalonia.Controls/MenuBase.cs index da7a36fa73..7fc804a338 100644 --- a/src/Avalonia.Controls/MenuBase.cs +++ b/src/Avalonia.Controls/MenuBase.cs @@ -133,8 +133,22 @@ namespace Avalonia.Controls /// bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap); - protected internal override Control CreateContainerForItemOverride() => new MenuItem(); - protected internal override bool IsItemItsOwnContainerOverride(Control item) => item is MenuItem or Separator; + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return new MenuItem(); + } + + protected internal override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + if (item is MenuItem or Separator) + { + recycleKey = null; + return false; + } + + recycleKey = DefaultRecycleKey; + return true; + } /// protected override void OnKeyDown(KeyEventArgs e) diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index 72febcfedb..1bb53f90a8 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -339,8 +339,22 @@ namespace Avalonia.Controls /// void IMenuItem.RaiseClick() => RaiseEvent(new RoutedEventArgs(ClickEvent)); - protected internal override Control CreateContainerForItemOverride() => new MenuItem(); - protected internal override bool IsItemItsOwnContainerOverride(Control item) => item is MenuItem or Separator; + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return new MenuItem(); + } + + protected internal override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + if (item is MenuItem or Separator) + { + recycleKey = null; + return false; + } + + recycleKey = DefaultRecycleKey; + return true; + } protected override void OnPointerReleased(PointerReleasedEventArgs e) { diff --git a/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs b/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs index 5a6f9fc4f9..999c6db1bd 100644 --- a/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs +++ b/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs @@ -113,14 +113,14 @@ namespace Avalonia.Controls.Presenters var generator = itemsControl.ItemContainerGenerator; Control container; - if (item is Control c && generator.IsItemItsOwnContainer(c)) + if (generator.NeedsContainer(item, index, out var recycleKey)) { - container = c; - container.SetValue(ItemIsOwnContainerProperty, true); + container = generator.CreateContainer(item, index, recycleKey); } else { - container = generator.CreateContainer(); + container = (Control)item!; + container.SetValue(ItemIsOwnContainerProperty, true); } generator.PrepareItemContainer(container, item, index); diff --git a/src/Avalonia.Controls/Primitives/TabStrip.cs b/src/Avalonia.Controls/Primitives/TabStrip.cs index 25cd1cd65b..ac64b827d5 100644 --- a/src/Avalonia.Controls/Primitives/TabStrip.cs +++ b/src/Avalonia.Controls/Primitives/TabStrip.cs @@ -16,8 +16,15 @@ namespace Avalonia.Controls.Primitives ItemsPanelProperty.OverrideDefaultValue(DefaultPanel); } - protected internal override Control CreateContainerForItemOverride() => new TabStripItem(); - protected internal override bool IsItemItsOwnContainerOverride(Control item) => item is TabStripItem; + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return new TabStripItem(); + } + + protected internal override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + return NeedsContainer(item, out recycleKey); + } /// protected override void OnGotFocus(GotFocusEventArgs e) diff --git a/src/Avalonia.Controls/TabControl.cs b/src/Avalonia.Controls/TabControl.cs index 310dd34382..0f06294d25 100644 --- a/src/Avalonia.Controls/TabControl.cs +++ b/src/Avalonia.Controls/TabControl.cs @@ -148,8 +148,15 @@ namespace Avalonia.Controls return RegisterContentPresenter(presenter); } - protected internal override Control CreateContainerForItemOverride() => new TabItem(); - protected internal override bool IsItemItsOwnContainerOverride(Control item) => item is TabItem; + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return new TabItem(); + } + + protected internal override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + return NeedsContainer(item, out recycleKey); + } protected internal override void PrepareContainerForItemOverride(Control element, object? item, int index) { diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index 5122b4aebd..0d5a453141 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -486,8 +486,15 @@ namespace Avalonia.Controls return (false, null); } - protected internal override Control CreateContainerForItemOverride() => new TreeViewItem(); - protected internal override bool IsItemItsOwnContainerOverride(Control item) => item is TreeViewItem; + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return new TreeViewItem(); + } + + protected internal override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + return NeedsContainer(item, out recycleKey); + } protected internal override void ContainerForItemPreparedOverride(Control container, object? item, int index) { diff --git a/src/Avalonia.Controls/TreeViewItem.cs b/src/Avalonia.Controls/TreeViewItem.cs index a61d9f75cd..ccd4f89204 100644 --- a/src/Avalonia.Controls/TreeViewItem.cs +++ b/src/Avalonia.Controls/TreeViewItem.cs @@ -91,14 +91,14 @@ namespace Avalonia.Controls internal TreeView? TreeViewOwner => _treeView; - protected internal override Control CreateContainerForItemOverride() + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) { - return EnsureTreeView().CreateContainerForItemOverride(); + return EnsureTreeView().CreateContainerForItemOverride(item, index, recycleKey); } - protected internal override bool IsItemItsOwnContainerOverride(Control item) + protected internal override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) { - return EnsureTreeView().IsItemItsOwnContainerOverride(item); + return EnsureTreeView().NeedsContainerOverride(item, index, out recycleKey); } protected internal override void PrepareContainerForItemOverride(Control container, object? item, int index) diff --git a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs index 28d6a83309..958c19826d 100644 --- a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs +++ b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs @@ -15,13 +15,14 @@ namespace Avalonia.Controls /// public class VirtualizingCarouselPanel : VirtualizingPanel, ILogicalScrollable { - private static readonly AttachedProperty ItemIsOwnContainerProperty = - AvaloniaProperty.RegisterAttached("ItemIsOwnContainer"); + private static readonly AttachedProperty RecycleKeyProperty = + AvaloniaProperty.RegisterAttached("RecycleKey"); + private static readonly object s_itemIsItsOwnContainer = new object(); private Size _extent; private Vector _offset; private Size _viewport; - private Stack? _recyclePool; + private Dictionary>? _recyclePool; private Control? _realized; private int _realizedIndex = -1; private Control? _transitionFrom; @@ -172,7 +173,7 @@ namespace Avalonia.Controls return null; if (index == _realizedIndex) return _realized; - if (Items[index] is Control c && c.GetValue(ItemIsOwnContainerProperty)) + if (Items[index] is Control c && c.GetValue(RecycleKeyProperty) == s_itemIsItsOwnContainer) return c; return null; } @@ -246,10 +247,27 @@ namespace Avalonia.Controls private Control GetOrCreateElement(IReadOnlyList items, int index) { - return GetRealizedElement(index) ?? - GetItemIsOwnContainer(items, index) ?? - GetRecycledElement(items, index) ?? - CreateElement(items, index); + Debug.Assert(ItemContainerGenerator is not null); + + var e = GetRealizedElement(index); + + if (e is null) + { + var item = items[index]; + var generator = ItemContainerGenerator!; + + if (generator.NeedsContainer(item, index, out var recycleKey)) + { + e = GetRecycledElement(item, index, recycleKey) ?? + CreateElement(item, index, recycleKey); + } + else + { + e = GetItemAsOwnContainer(item, index); + } + } + + return e; } private Control? GetRealizedElement(int index) @@ -257,44 +275,37 @@ namespace Avalonia.Controls return _realizedIndex == index ? _realized : null; } - private Control? GetItemIsOwnContainer(IReadOnlyList items, int index) + private Control GetItemAsOwnContainer(object? item, int index) { Debug.Assert(ItemContainerGenerator is not null); - var item = items[index]; + var controlItem = (Control)item!; + var generator = ItemContainerGenerator!; - if (item is Control controlItem) + if (!controlItem.IsSet(RecycleKeyProperty)) { - var generator = ItemContainerGenerator; - - if (controlItem.IsSet(ItemIsOwnContainerProperty)) - { - controlItem.IsVisible = true; - return controlItem; - } - else if (generator.IsItemItsOwnContainer(controlItem)) - { - generator.PrepareItemContainer(controlItem, controlItem, index); - AddInternalChild(controlItem); - controlItem.SetValue(ItemIsOwnContainerProperty, true); - generator.ItemContainerPrepared(controlItem, item, index); - return controlItem; - } + generator.PrepareItemContainer(controlItem, controlItem, index); + AddInternalChild(controlItem); + controlItem.SetValue(RecycleKeyProperty, s_itemIsItsOwnContainer); + generator.ItemContainerPrepared(controlItem, item, index); } - return null; + controlItem.IsVisible = true; + return controlItem; } - private Control? GetRecycledElement(IReadOnlyList items, int index) + private Control? GetRecycledElement(object? item, int index, object? recycleKey) { Debug.Assert(ItemContainerGenerator is not null); - var generator = ItemContainerGenerator; - var item = items[index]; + if (recycleKey is null) + return null; + + var generator = ItemContainerGenerator!; - if (_recyclePool?.Count > 0) + if (_recyclePool?.TryGetValue(recycleKey, out var recyclePool) == true && recyclePool.Count > 0) { - var recycled = _recyclePool.Pop(); + var recycled = recyclePool.Pop(); recycled.IsVisible = true; generator.PrepareItemContainer(recycled, item, index); generator.ItemContainerPrepared(recycled, item, index); @@ -304,14 +315,14 @@ namespace Avalonia.Controls return null; } - private Control CreateElement(IReadOnlyList items, int index) + private Control CreateElement(object? item, int index, object? recycleKey) { Debug.Assert(ItemContainerGenerator is not null); - var generator = ItemContainerGenerator; - var item = items[index]; - var container = generator.CreateContainer(); + var generator = ItemContainerGenerator!; + var container = generator.CreateContainer(item, index, recycleKey); + container.SetValue(RecycleKeyProperty, recycleKey); generator.PrepareItemContainer(container, item, index); AddInternalChild(container); generator.ItemContainerPrepared(container, item, index); @@ -323,7 +334,10 @@ namespace Avalonia.Controls { Debug.Assert(ItemContainerGenerator is not null); - if (element.IsSet(ItemIsOwnContainerProperty)) + var recycleKey = element.GetValue(RecycleKeyProperty); + Debug.Assert(recycleKey is not null); + + if (recycleKey == s_itemIsItsOwnContainer) { element.IsVisible = false; } @@ -331,7 +345,14 @@ namespace Avalonia.Controls { ItemContainerGenerator.ClearItemContainer(element); _recyclePool ??= new(); - _recyclePool.Push(element); + + if (!_recyclePool.TryGetValue(recycleKey, out var pool)) + { + pool = new(); + _recyclePool.Add(recycleKey, pool); + } + + pool.Push(element); element.IsVisible = false; } } diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index e0768edfa4..7ec1808e63 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -52,10 +52,11 @@ namespace Avalonia.Controls nameof(VerticalSnapPointsChanged), RoutingStrategies.Bubble); - private static readonly AttachedProperty ItemIsOwnContainerProperty = - AvaloniaProperty.RegisterAttached("ItemIsOwnContainer"); + private static readonly AttachedProperty RecycleKeyProperty = + AvaloniaProperty.RegisterAttached("RecycleKey"); private static readonly Rect s_invalidViewport = new(double.PositiveInfinity, double.PositiveInfinity, 0, 0); + private static readonly object s_itemIsItsOwnContainer = new object(); private readonly Action _recycleElement; private readonly Action _recycleElementOnItemRemoved; private readonly Action _updateElementIndex; @@ -68,7 +69,7 @@ namespace Avalonia.Controls private RealizedStackElements? _realizedElements; private ScrollViewer? _scrollViewer; private Rect _viewport = s_invalidViewport; - private Stack? _recyclePool; + private Dictionary>? _recyclePool; private Control? _unrealizedFocusedElement; private int _unrealizedFocusedIndex = -1; @@ -331,7 +332,7 @@ namespace Avalonia.Controls return null; if (_realizedElements?.GetElement(index) is { } realized) return realized; - if (Items[index] is Control c && c.GetValue(ItemIsOwnContainerProperty)) + if (Items[index] is Control c && c.GetValue(RecycleKeyProperty) == s_itemIsItsOwnContainer) return c; return null; } @@ -492,8 +493,7 @@ namespace Avalonia.Controls c = c?.GetVisualParent(); } - - return viewport; + return viewport.Intersect(new Rect(0, 0, double.PositiveInfinity, double.PositiveInfinity)); } private void RealizeElements( @@ -561,10 +561,26 @@ namespace Avalonia.Controls private Control GetOrCreateElement(IReadOnlyList items, int index) { - var e = GetRealizedElement(index) ?? - GetItemIsOwnContainer(items, index) ?? - GetRecycledElement(items, index) ?? - CreateElement(items, index); + Debug.Assert(ItemContainerGenerator is not null); + + var e = GetRealizedElement(index); + + if (e is null) + { + var item = items[index]; + var generator = ItemContainerGenerator!; + + if (generator.NeedsContainer(item, index, out var recycleKey)) + { + e = GetRecycledElement(item, index, recycleKey) ?? + CreateElement(item, index, recycleKey); + } + else + { + e = GetItemAsOwnContainer(item, index); + } + } + return e; } @@ -575,38 +591,33 @@ namespace Avalonia.Controls return _realizedElements?.GetElement(index); } - private Control? GetItemIsOwnContainer(IReadOnlyList items, int index) + private Control GetItemAsOwnContainer(object? item, int index) { - var item = items[index]; + Debug.Assert(ItemContainerGenerator is not null); - if (item is Control controlItem) - { - var generator = ItemContainerGenerator!; + var controlItem = (Control)item!; + var generator = ItemContainerGenerator!; - if (controlItem.IsSet(ItemIsOwnContainerProperty)) - { - controlItem.IsVisible = true; - return controlItem; - } - else if (generator.IsItemItsOwnContainer(controlItem)) - { - generator.PrepareItemContainer(controlItem, controlItem, index); - AddInternalChild(controlItem); - controlItem.SetValue(ItemIsOwnContainerProperty, true); - generator.ItemContainerPrepared(controlItem, item, index); - return controlItem; - } + if (!controlItem.IsSet(RecycleKeyProperty)) + { + generator.PrepareItemContainer(controlItem, controlItem, index); + AddInternalChild(controlItem); + controlItem.SetValue(RecycleKeyProperty, s_itemIsItsOwnContainer); + generator.ItemContainerPrepared(controlItem, item, index); } - return null; + controlItem.IsVisible = true; + return controlItem; } - private Control? GetRecycledElement(IReadOnlyList items, int index) + private Control? GetRecycledElement(object? item, int index, object? recycleKey) { Debug.Assert(ItemContainerGenerator is not null); + if (recycleKey is null) + return null; + var generator = ItemContainerGenerator!; - var item = items[index]; if (_unrealizedFocusedIndex == index && _unrealizedFocusedElement is not null) { @@ -617,9 +628,9 @@ namespace Avalonia.Controls return element; } - if (_recyclePool?.Count > 0) + if (_recyclePool?.TryGetValue(recycleKey, out var recyclePool) == true && recyclePool.Count > 0) { - var recycled = _recyclePool.Pop(); + var recycled = recyclePool.Pop(); recycled.IsVisible = true; generator.PrepareItemContainer(recycled, item, index); generator.ItemContainerPrepared(recycled, item, index); @@ -629,14 +640,14 @@ namespace Avalonia.Controls return null; } - private Control CreateElement(IReadOnlyList items, int index) + private Control CreateElement(object? item, int index, object? recycleKey) { Debug.Assert(ItemContainerGenerator is not null); var generator = ItemContainerGenerator!; - var item = items[index]; - var container = generator.CreateContainer(); + var container = generator.CreateContainer(item, index, recycleKey); + container.SetValue(RecycleKeyProperty, recycleKey); generator.PrepareItemContainer(container, item, index); AddInternalChild(container); generator.ItemContainerPrepared(container, item, index); @@ -650,7 +661,10 @@ namespace Avalonia.Controls _scrollViewer?.UnregisterAnchorCandidate(element); - if (element.IsSet(ItemIsOwnContainerProperty)) + var recycleKey = element.GetValue(RecycleKeyProperty); + Debug.Assert(recycleKey is not null); + + if (recycleKey == s_itemIsItsOwnContainer) { element.IsVisible = false; } @@ -663,8 +677,7 @@ namespace Avalonia.Controls else { ItemContainerGenerator!.ClearItemContainer(element); - _recyclePool ??= new(); - _recyclePool.Push(element); + PushToRecyclePool(recycleKey, element); element.IsVisible = false; } } @@ -673,19 +686,34 @@ namespace Avalonia.Controls { Debug.Assert(ItemContainerGenerator is not null); - if (element.IsSet(ItemIsOwnContainerProperty)) + var recycleKey = element.GetValue(RecycleKeyProperty); + Debug.Assert(recycleKey is not null); + + if (recycleKey == s_itemIsItsOwnContainer) { RemoveInternalChild(element); } else { ItemContainerGenerator!.ClearItemContainer(element); - _recyclePool ??= new(); - _recyclePool.Push(element); + PushToRecyclePool(recycleKey, element); element.IsVisible = false; } } + private void PushToRecyclePool(object recycleKey, Control element) + { + _recyclePool ??= new(); + + if (!_recyclePool.TryGetValue(recycleKey, out var pool)) + { + pool = new(); + _recyclePool.Add(recycleKey, pool); + } + + pool.Push(element); + } + private void UpdateElementIndex(Control element, int oldIndex, int newIndex) { Debug.Assert(ItemContainerGenerator is not null); diff --git a/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs b/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs index 9bb471005b..38784871b1 100644 --- a/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs +++ b/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs @@ -11,6 +11,27 @@ public class BrowserPlatformOptions /// If null, default path resolved depending on the backend (browser or blazor) is used. /// public Func? FrameworkAssetPathResolver { get; set; } + + /// + /// Defines if the service worker used by Avalonia should be registered. + /// If registered, service worker can work as a save file picker fallback on the browsers that don't support native implementation. + /// For more details, see https://github.com/jimmywarting/native-file-system-adapter#a-note-when-downloading-with-the-polyfilled-version. + /// + public bool RegisterAvaloniaServiceWorker { get; set; } + + /// + /// If is enabled, it is possible to redefine scope for the worker. + /// By default, current domain root is used as a scope. + /// + public string? AvaloniaServiceWorkerScope { get; set; } + + /// + /// Avalonia uses "native-file-system-adapter" polyfill for the file dialogs. + /// If native implementation is available, by default it is used. + /// This property forces polyfill to be always used. + /// For more details, see https://github.com/jimmywarting/native-file-system-adapter#a-note-when-downloading-with-the-polyfilled-version. + /// + public bool PreferFileDialogPolyfill { get; set; } } public static class BrowserAppBuilder diff --git a/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs b/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs index 394f191dab..cb7aabbc39 100644 --- a/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs +++ b/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs @@ -25,6 +25,15 @@ internal static partial class AvaloniaModule public static Task ImportStorage() => s_importStorage.Value; + public static string ResolveServiceWorkerPath() + { + var options = AvaloniaLocator.Current.GetService() ?? new BrowserPlatformOptions(); + return options.FrameworkAssetPathResolver!("sw.js"); + } + [JSImport("Caniuse.isMobile", AvaloniaModule.MainModuleName)] public static partial bool IsMobile(); + + [JSImport("registerServiceWorker", AvaloniaModule.MainModuleName)] + public static partial void RegisterServiceWorker(string path, string? scope); } diff --git a/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs b/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs index c56023c0f7..d95d4405ba 100644 --- a/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs +++ b/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs @@ -9,15 +9,15 @@ internal static partial class StorageHelper public static partial bool HasNativeFilePicker(); [JSImport("StorageProvider.selectFolderDialog", AvaloniaModule.StorageModuleName)] - public static partial Task SelectFolderDialog(JSObject? startIn); + public static partial Task SelectFolderDialog(JSObject? startIn, bool preferPolyfill); [JSImport("StorageProvider.openFileDialog", AvaloniaModule.StorageModuleName)] public static partial Task OpenFileDialog(JSObject? startIn, bool multiple, - [JSMarshalAs>] object[]? types, bool excludeAcceptAllOption); + [JSMarshalAs>] object[]? types, bool excludeAcceptAllOption, bool preferPolyfill); [JSImport("StorageProvider.saveFileDialog", AvaloniaModule.StorageModuleName)] public static partial Task SaveFileDialog(JSObject? startIn, string? suggestedName, - [JSMarshalAs>] object[]? types, bool excludeAcceptAllOption); + [JSMarshalAs>] object[]? types, bool excludeAcceptAllOption, bool preferPolyfill); [JSImport("StorageItem.createWellKnownDirectory", AvaloniaModule.StorageModuleName)] public static partial JSObject CreateWellKnownDirectory(string wellKnownDirectory); diff --git a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs index e6849f2fcc..a28fd4cbde 100644 --- a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs +++ b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs @@ -6,20 +6,22 @@ using System.Runtime.InteropServices.JavaScript; using System.Threading.Tasks; using Avalonia.Browser.Interop; using Avalonia.Platform.Storage; +using Avalonia.Platform.Storage.FileIO; namespace Avalonia.Browser.Storage; -internal record FilePickerAcceptType(string Description, IReadOnlyDictionary> Accept); - internal class BrowserStorageProvider : IStorageProvider { internal const string PickerCancelMessage = "The user aborted a request"; internal const string NoPermissionsMessage = "Permissions denied"; public bool CanOpen => true; - public bool CanSave => StorageHelper.HasNativeFilePicker(); + public bool CanSave => true; public bool CanPickFolder => true; + private bool PreferPolyfill => + AvaloniaLocator.Current.GetService()?.PreferFileDialogPolyfill ?? false; + public async Task> OpenFilePickerAsync(FilePickerOpenOptions options) { await AvaloniaModule.ImportStorage(); @@ -29,7 +31,7 @@ internal class BrowserStorageProvider : IStorageProvider try { - using var items = await StorageHelper.OpenFileDialog(startIn, options.AllowMultiple, types, excludeAll); + using var items = await StorageHelper.OpenFileDialog(startIn, options.AllowMultiple, types, excludeAll, PreferPolyfill); if (items is null) { return Array.Empty(); @@ -63,7 +65,9 @@ internal class BrowserStorageProvider : IStorageProvider try { - var item = await StorageHelper.SaveFileDialog(startIn, options.SuggestedFileName, types, excludeAll); + var suggestedName = + StorageProviderHelpers.NameWithExtension(options.SuggestedFileName, options.DefaultExtension, null); + var item = await StorageHelper.SaveFileDialog(startIn, suggestedName, types, excludeAll, PreferPolyfill); return item is not null ? new JSStorageFile(item) : null; } catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) @@ -89,7 +93,7 @@ internal class BrowserStorageProvider : IStorageProvider try { - var item = await StorageHelper.SelectFolderDialog(startIn); + var item = await StorageHelper.SelectFolderDialog(startIn, PreferPolyfill); return item is not null ? new[] { new JSStorageFolder(item) } : Array.Empty(); } catch (JSException ex) when (ex.Message.Contains(PickerCancelMessage, StringComparison.Ordinal)) diff --git a/src/Browser/Avalonia.Browser/WindowingPlatform.cs b/src/Browser/Avalonia.Browser/WindowingPlatform.cs index be6e28f5cb..a33738079c 100644 --- a/src/Browser/Avalonia.Browser/WindowingPlatform.cs +++ b/src/Browser/Avalonia.Browser/WindowingPlatform.cs @@ -1,5 +1,6 @@ using System; using System.Threading; +using Avalonia.Browser.Interop; using Avalonia.Browser.Skia; using Avalonia.Input; using Avalonia.Input.Platform; @@ -46,6 +47,13 @@ namespace Avalonia.Browser .Bind().ToConstant(new BrowserSkiaGraphics()) .Bind().ToSingleton() .Bind().ToSingleton(); + + if (AvaloniaLocator.Current.GetService() is { } options + && options.RegisterAvaloniaServiceWorker) + { + var swPath = AvaloniaModule.ResolveServiceWorkerPath(); + AvaloniaModule.RegisterServiceWorker(swPath, options.AvaloniaServiceWorkerScope); + } } public IDisposable StartTimer(DispatcherPriority priority, TimeSpan interval, Action tick) diff --git a/src/Browser/Avalonia.Browser/webapp/build.js b/src/Browser/Avalonia.Browser/webapp/build.js index c1cbc84709..e8e49554cd 100644 --- a/src/Browser/Avalonia.Browser/webapp/build.js +++ b/src/Browser/Avalonia.Browser/webapp/build.js @@ -1,7 +1,8 @@ require("esbuild").build({ entryPoints: [ "./modules/avalonia.ts", - "./modules/storage.ts" + "./modules/storage.ts", + "./modules/sw.ts" ], outdir: "../wwwroot", bundle: true, diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts index 80faca7a50..2b69254cf2 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts @@ -7,6 +7,12 @@ import { NativeControlHost } from "./avalonia/nativeControlHost"; import { NavigationHelper } from "./avalonia/navigationHelper"; import { GeneralHelpers } from "./avalonia/generalHelpers"; +async function registerServiceWorker(path: string, scope: string | undefined) { + if ("serviceWorker" in navigator) { + await globalThis.navigator.serviceWorker.register(path, scope ? { scope } : undefined); + } +} + export { Caniuse, Canvas, @@ -17,5 +23,6 @@ export { StreamHelper, NativeControlHost, NavigationHelper, - GeneralHelpers + GeneralHelpers, + registerServiceWorker }; diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts index 7c7769ea36..2e160ec618 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts @@ -18,12 +18,7 @@ export class StreamHelper { const array = new Uint8Array(span.byteLength); span.copyTo(array); - const data = { - type: "write", - data: array - }; - - return await stream.write(data); + return await stream.write(array); } public static byteLength(stream: Blob) { diff --git a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts index 7a29992674..8c79f225ee 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts @@ -1,6 +1,6 @@ import { avaloniaDb, fileBookmarksStore } from "./indexedDb"; import { StorageItem, StorageItems } from "./storageItem"; -import { showOpenFilePicker, showDirectoryPicker, FileSystemFileHandle } from "native-file-system-adapter"; +import { showOpenFilePicker, showDirectoryPicker, showSaveFilePicker, FileSystemFileHandle } from "native-file-system-adapter"; declare global { type WellKnownDirectory = "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos"; @@ -12,10 +12,12 @@ declare global { export class StorageProvider { public static async selectFolderDialog( - startIn: StorageItem | null): Promise { + startIn: StorageItem | null, + preferPolyfill: boolean): Promise { // 'Picker' API doesn't accept "null" as a parameter, so it should be set to undefined. const options = { - startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined) + startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined), + _preferPolyfill: preferPolyfill }; const handle = await showDirectoryPicker(options as any); @@ -24,12 +26,14 @@ export class StorageProvider { public static async openFileDialog( startIn: StorageItem | null, multiple: boolean, - types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise { + types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean, + preferPolyfill: boolean): Promise { const options = { startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined), multiple, excludeAcceptAllOption, - types: (types ?? undefined) + types: (types ?? undefined), + _preferPolyfill: preferPolyfill }; const handles = await showOpenFilePicker(options); @@ -38,16 +42,17 @@ export class StorageProvider { public static async saveFileDialog( startIn: StorageItem | null, suggestedName: string | null, - types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise { + types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean, + preferPolyfill: boolean): Promise { const options = { startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined), suggestedName: (suggestedName ?? undefined), excludeAcceptAllOption, - types: (types ?? undefined) + types: (types ?? undefined), + _preferPolyfill: preferPolyfill }; - // Always prefer native save file picker, as polyfill solutions are not reliable. - const handle = await (globalThis as any).showSaveFilePicker(options); + const handle = await showSaveFilePicker(options); return StorageItem.createFromHandle(handle); } diff --git a/src/Browser/Avalonia.Browser/webapp/modules/sw.ts b/src/Browser/Avalonia.Browser/webapp/modules/sw.ts new file mode 100644 index 0000000000..2d08ae2c15 --- /dev/null +++ b/src/Browser/Avalonia.Browser/webapp/modules/sw.ts @@ -0,0 +1,78 @@ +const WRITE = 0; +const PULL = 0; +const ERROR = 1; +const ABORT = 1; +const CLOSE = 2; + +class MessagePortSource implements UnderlyingSource { + private controller?: ReadableStreamController; + + constructor (private readonly port: MessagePort) { + this.port.onmessage = evt => this.onMessage(evt.data); + } + + start (controller: ReadableStreamController) { + this.controller = controller; + } + + cancel (reason: Error) { + // Firefox can notify a cancel event, chrome can't + // https://bugs.chromium.org/p/chromium/issues/detail?id=638494 + this.port.postMessage({ type: ERROR, reason: reason.message }); + this.port.close(); + } + + onMessage (message: { type: number; chunk: Uint8Array; reason: any }) { + // enqueue() will call pull() if needed when there's no backpressure + if (!this.controller) { + return; + } + if (message.type === WRITE) { + this.controller.enqueue(message.chunk); + this.port.postMessage({ type: PULL }); + } + if (message.type === ABORT) { + this.controller.error(message.reason); + this.port.close(); + } + if (message.type === CLOSE) { + this.controller.close(); + this.port.close(); + } + } +} + +self.addEventListener("install", () => { + (self as any).skipWaiting(); +}); + +self.addEventListener("activate", event /* ExtendableEvent */ => { + (event as any).waitUntil((self as any).clients.claim()); +}); + +const map = new Map(); + +// This should be called once per download +// Each event has a dataChannel that the data will be piped through +globalThis.addEventListener("message", evt => { + const data = evt.data; + if (data.url && data.readablePort) { + data.rs = new ReadableStream( + new MessagePortSource(evt.data.readablePort), + new CountQueuingStrategy({ highWaterMark: 4 }) + ); + map.set(data.url, data); + } +}); + +globalThis.addEventListener("fetch", evt => { + const url = (evt as any).request.url; + const data = map.get(url); + if (!data) return null; + map.delete(url); + (evt as any).respondWith(new Response(data.rs, { + headers: data.headers + })); +}); + +export {}; diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/GroupTransformers/XamlIncludeGroupTransformer.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/GroupTransformers/XamlIncludeGroupTransformer.cs index afc7b569a8..b6a4b599fa 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/GroupTransformers/XamlIncludeGroupTransformer.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/GroupTransformers/XamlIncludeGroupTransformer.cs @@ -36,13 +36,29 @@ internal class AvaloniaXamlIncludeTransformer : IXamlAstGroupTransformer throw new InvalidOperationException($"\"{nodeTypeName}\".Loaded property is expected to be defined"); } - if (valueNode.Manipulation is not XamlObjectInitializationNode - { - Manipulation: XamlPropertyAssignmentNode { Property: { Name: "Source" } } sourceProperty - }) + if (valueNode.Manipulation is not XamlObjectInitializationNode initializationNode) { throw new XamlDocumentParseException(context.CurrentDocument, - $"Source property must be set on the \"{nodeTypeName}\" node.", valueNode); + $"Invalid \"{nodeTypeName}\" node initialization.", valueNode); + } + + var additionalProperties = new List(); + if (initializationNode.Manipulation is not XamlPropertyAssignmentNode { Property: { Name: "Source" } } sourceProperty) + { + if (initializationNode.Manipulation is XamlManipulationGroupNode manipulationGroup + && manipulationGroup.Children.OfType() + .FirstOrDefault(p => p.Property.Name == "Source") is { } sourceProperty2) + { + sourceProperty = sourceProperty2; + // We need to copy some additional properties from ResourceInclude to ResourceDictionary except the Source one. + // If there is any missing properties, then XAML compiler will throw an error in the emitter code. + additionalProperties = manipulationGroup.Children.Where(c => c != sourceProperty2).ToList(); + } + else + { + throw new XamlDocumentParseException(context.CurrentDocument, + $"Source property must be set on the \"{nodeTypeName}\" node.", valueNode); + } } var (assetPathUri, sourceUriNode) = ResolveSourceFromXamlInclude(context, nodeTypeName, sourceProperty, false); @@ -65,12 +81,12 @@ internal class AvaloniaXamlIncludeTransformer : IXamlAstGroupTransformer { if (targetDocument.BuildMethod is not null) { - return FromMethod(context, targetDocument.BuildMethod, sourceUriNode, expectedLoadedType, node, assetPathUri, assembly); + return FromMethod(context, targetDocument.BuildMethod, sourceUriNode, expectedLoadedType, node, assetPathUri, assembly, additionalProperties); } if (targetDocument.ClassType is not null) { - return FromType(context, targetDocument.ClassType, sourceUriNode, expectedLoadedType, node, assetPathUri, assembly); + return FromType(context, targetDocument.ClassType, sourceUriNode, expectedLoadedType, node, assetPathUri, assembly, additionalProperties); } return context.ParseError( @@ -95,11 +111,11 @@ internal class AvaloniaXamlIncludeTransformer : IXamlAstGroupTransformer var buildMethod = avaResType.FindMethod(m => m.Name == relativeName); if (buildMethod is not null) { - return FromMethod(context, buildMethod, sourceUriNode, expectedLoadedType, node, assetPathUri, assembly); + return FromMethod(context, buildMethod, sourceUriNode, expectedLoadedType, node, assetPathUri, assembly, additionalProperties); } else if (assetAssembly.FindType(fullTypeName) is { } type) { - return FromType(context, type, sourceUriNode, expectedLoadedType, node, assetPathUri, assembly); + return FromType(context, type, sourceUriNode, expectedLoadedType, node, assetPathUri, assembly, additionalProperties); } return context.ParseError( @@ -108,7 +124,8 @@ internal class AvaloniaXamlIncludeTransformer : IXamlAstGroupTransformer } private static IXamlAstNode FromType(AstTransformationContext context, IXamlType type, IXamlAstNode li, - IXamlType expectedLoadedType, IXamlAstNode fallbackNode, string assetPathUri, string assembly) + IXamlType expectedLoadedType, IXamlAstNode fallbackNode, string assetPathUri, string assembly, + IEnumerable manipulationNodes) { if (!expectedLoadedType.IsAssignableFrom(type)) { @@ -116,15 +133,17 @@ internal class AvaloniaXamlIncludeTransformer : IXamlAstGroupTransformer $"Resource \"{assetPathUri}\" is defined as \"{type}\" type in the \"{assembly}\" assembly, but expected \"{expectedLoadedType}\".", li, fallbackNode); } - + IXamlAstNode newObjNode = new XamlAstObjectNode(li, new XamlAstClrTypeReference(li, type, false)); + ((XamlAstObjectNode)newObjNode).Children.AddRange(manipulationNodes); newObjNode = new AvaloniaXamlIlConstructorServiceProviderTransformer().Transform(context, newObjNode); newObjNode = new ConstructableObjectTransformer().Transform(context, newObjNode); return new NewObjectTransformer().Transform(context, newObjNode); } private static IXamlAstNode FromMethod(AstTransformationContext context, IXamlMethod method, IXamlAstNode li, - IXamlType expectedLoadedType, IXamlAstNode fallbackNode, string assetPathUri, string assembly) + IXamlType expectedLoadedType, IXamlAstNode fallbackNode, string assetPathUri, string assembly, + IEnumerable manipulationNodes) { if (!expectedLoadedType.IsAssignableFrom(method.ReturnType)) { @@ -134,8 +153,11 @@ internal class AvaloniaXamlIncludeTransformer : IXamlAstGroupTransformer } var sp = context.Configuration.TypeMappings.ServiceProvider; - return new XamlStaticOrTargetedReturnMethodCallNode(li, method, - new[] { new NewServiceProviderNode(sp, li) }); + + return new XamlValueWithManipulationNode(li, + new XamlStaticOrTargetedReturnMethodCallNode(li, method, + new[] { new NewServiceProviderNode(sp, li) }), + new XamlManipulationGroupNode(li, manipulationNodes)); } internal static (string?, IXamlAstNode?) ResolveSourceFromXamlInclude( diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlThemeVariantProviderTransformer.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlThemeVariantProviderTransformer.cs index 05df8be1b6..256a7472f3 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlThemeVariantProviderTransformer.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlThemeVariantProviderTransformer.cs @@ -10,7 +10,8 @@ internal class AvaloniaXamlIlThemeVariantProviderTransformer : IXamlAstTransform { public IXamlAstNode Transform(AstTransformationContext context, IXamlAstNode node) { - var type = context.GetAvaloniaTypes().IThemeVariantProvider; + var avTypes = context.GetAvaloniaTypes(); + var type = avTypes.IThemeVariantProvider; if (!(node is XamlAstObjectNode on && type.IsAssignableFrom(on.Type.GetClrType()))) return node; @@ -21,6 +22,13 @@ internal class AvaloniaXamlIlThemeVariantProviderTransformer : IXamlAstTransform if (keyDirective is null) return node; + var themeDictionariesColl = avTypes.IDictionaryT.MakeGenericType(avTypes.ThemeVariant, avTypes.IThemeVariantProvider); + if (context.ParentNodes().FirstOrDefault() is not XamlAstXamlPropertyValueNode propertyValueNode + || !themeDictionariesColl.IsAssignableFrom(propertyValueNode.Property.GetClrProperty().Getter.ReturnType)) + { + return node; + } + var keyProp = type.Properties.First(p => p.Name == "Key"); on.Children.Add(new XamlAstXamlPropertyValueNode(keyDirective, new XamlAstClrProperty(keyDirective, keyProp, context.Configuration), diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs index b5c0c7734d..c5c3cdd123 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs @@ -65,6 +65,7 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers public IXamlType Int { get; } public IXamlType Long { get; } public IXamlType Uri { get; } + public IXamlType IDictionaryT { get; } public IXamlType FontFamily { get; } public IXamlConstructor FontFamilyConstructorUriName { get; } public IXamlType Thickness { get; } @@ -194,6 +195,7 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers Int = cfg.TypeSystem.GetType("System.Int32"); Long = cfg.TypeSystem.GetType("System.Int64"); Uri = cfg.TypeSystem.GetType("System.Uri"); + IDictionaryT = cfg.TypeSystem.GetType("System.Collections.Generic.IDictionary`2"); FontFamily = cfg.TypeSystem.GetType("Avalonia.Media.FontFamily"); FontFamilyConstructorUriName = FontFamily.GetConstructor(new List { Uri, XamlIlTypes.String }); ThemeVariant = cfg.TypeSystem.GetType("Avalonia.Styling.ThemeVariant"); diff --git a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs index e36ce21009..ac1547d09f 100644 --- a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs +++ b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs @@ -26,6 +26,138 @@ namespace Avalonia.Base.UnitTests.Input Assert.Same(target, FocusManager.Instance.Current); } } + + [Fact] + public void Invisible_Controls_Should_Not_Receive_Focus() + { + Button target; + + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var root = new TestRoot + { + Child = target = new Button() { IsVisible = false} + }; + + Assert.Null(FocusManager.Instance.Current); + + target.Focus(); + + Assert.False(target.IsFocused); + Assert.False(target.IsKeyboardFocusWithin); + + Assert.Null(FocusManager.Instance.Current); + } + } + + [Fact] + public void Effectively_Invisible_Controls_Should_Not_Receive_Focus() + { + var target = new Button(); + Panel container; + + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var root = new TestRoot + { + Child = container = new Panel + { + IsVisible = false, + Children = { target } + } + }; + + Assert.Null(FocusManager.Instance.Current); + + target.Focus(); + + Assert.False(target.IsFocused); + Assert.False(target.IsKeyboardFocusWithin); + + Assert.Null(FocusManager.Instance.Current); + } + } + + [Fact] + public void Trying_To_Focus_Invisible_Control_Should_Not_Change_Focus() + { + Button first; + Button second; + + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var root = new TestRoot + { + Child = new StackPanel + { + Children = + { + (first = new Button()), + (second = new Button() { IsVisible = false}), + } + } + }; + + first.Focus(); + + Assert.Same(first, FocusManager.Instance.Current); + + second.Focus(); + + Assert.Same(first, FocusManager.Instance.Current); + } + } + + [Fact] + public void Disabled_Controls_Should_Not_Receive_Focus() + { + Button target; + + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var root = new TestRoot + { + Child = target = new Button() { IsEnabled = false } + }; + + Assert.Null(FocusManager.Instance.Current); + + target.Focus(); + + Assert.False(target.IsFocused); + Assert.False(target.IsKeyboardFocusWithin); + + Assert.Null(FocusManager.Instance.Current); + } + } + + [Fact] + public void Effectively_Disabled_Controls_Should_Not_Receive_Focus() + { + var target = new Button(); + Panel container; + + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var root = new TestRoot + { + Child = container = new Panel + { + IsEnabled = false, + Children = { target } + } + }; + + Assert.Null(FocusManager.Instance.Current); + + target.Focus(); + + Assert.False(target.IsFocused); + Assert.False(target.IsKeyboardFocusWithin); + + Assert.Null(FocusManager.Instance.Current); + } + } [Fact] public void Focus_Should_Not_Get_Restored_To_Enabled_Control() @@ -54,6 +186,90 @@ namespace Avalonia.Base.UnitTests.Input } } + [Fact] + public void Focus_Should_Be_Cleared_When_Control_Is_Hidden() + { + Button target; + + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var root = new TestRoot + { + Child = target = new Button() + }; + + target.Focus(); + target.IsVisible = false; + + Assert.Null(FocusManager.Instance.Current); + } + } + + [Fact(Skip = "Need to implement IsEffectivelyVisible change notifications.")] + public void Focus_Should_Be_Cleared_When_Control_Is_Effectively_Hidden() + { + Border container; + Button target; + + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var root = new TestRoot + { + Child = container = new Border + { + Child = target = new Button(), + } + }; + + target.Focus(); + container.IsVisible = false; + + Assert.Null(FocusManager.Instance.Current); + } + } + + [Fact] + public void Focus_Should_Be_Cleared_When_Control_Is_Disabled() + { + Button target; + + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var root = new TestRoot + { + Child = target = new Button() + }; + + target.Focus(); + target.IsEnabled = false; + + Assert.Null(FocusManager.Instance.Current); + } + } + + [Fact] + public void Focus_Should_Be_Cleared_When_Control_Is_Effectively_Disabled() + { + Border container; + Button target; + + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var root = new TestRoot + { + Child = container = new Border + { + Child = target = new Button(), + } + }; + + target.Focus(); + container.IsEnabled = false; + + Assert.Null(FocusManager.Instance.Current); + } + } + [Fact] public void Focus_Should_Be_Cleared_When_Control_Is_Removed_From_VisualTree() { @@ -78,8 +294,8 @@ namespace Avalonia.Base.UnitTests.Input { using (UnitTestApplication.Start(TestServices.RealFocus)) { - var target1 = new Decorator(); - var target2 = new Decorator(); + var target1 = new Decorator { Focusable = true }; + var target2 = new Decorator { Focusable = true }; var root = new TestRoot { Child = new StackPanel @@ -115,8 +331,8 @@ namespace Avalonia.Base.UnitTests.Input { using (UnitTestApplication.Start(TestServices.RealFocus)) { - var target1 = new Decorator(); - var target2 = new Decorator(); + var target1 = new Decorator { Focusable = true }; + var target2 = new Decorator { Focusable = true }; var root = new TestRoot { Child = new StackPanel @@ -157,8 +373,8 @@ namespace Avalonia.Base.UnitTests.Input { using (UnitTestApplication.Start(TestServices.RealFocus)) { - var target1 = new Decorator(); - var target2 = new Decorator(); + var target1 = new Decorator { Focusable = true }; + var target2 = new Decorator { Focusable = true }; var root = new TestRoot { Child = new StackPanel @@ -190,8 +406,8 @@ namespace Avalonia.Base.UnitTests.Input { using (UnitTestApplication.Start(TestServices.RealFocus)) { - var target1 = new Decorator(); - var target2 = new Decorator(); + var target1 = new Decorator { Focusable = true }; + var target2 = new Decorator { Focusable = true }; var panel1 = new Panel { Children = { target1 } }; var panel2 = new Panel { Children = { target2 } }; var root = new TestRoot @@ -245,8 +461,8 @@ namespace Avalonia.Base.UnitTests.Input { using (UnitTestApplication.Start(TestServices.RealFocus)) { - var target1 = new Decorator(); - var target2 = new Decorator(); + var target1 = new Decorator { Focusable = true }; + var target2 = new Decorator { Focusable = true }; var root = new TestRoot { Child = new StackPanel @@ -290,8 +506,8 @@ namespace Avalonia.Base.UnitTests.Input { using (UnitTestApplication.Start(TestServices.RealFocus)) { - var target1 = new Decorator(); - var target2 = new Decorator(); + var target1 = new Decorator { Focusable = true }; + var target2 = new Decorator { Focusable = true }; var root1 = new TestRoot { diff --git a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs index 5e741cdc1d..dec295169f 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs @@ -1034,14 +1034,14 @@ namespace Avalonia.Controls.UnitTests { Type IStyleable.StyleKey => typeof(ItemsControl); - protected internal override Control CreateContainerForItemOverride() + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) { return new ContainerControl(); } - protected internal override bool IsItemItsOwnContainerOverride(Control item) + protected internal override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) { - return item is ContainerControl; + return NeedsContainer(item, out recycleKey); } } diff --git a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs index 9fd56dec4a..1d1065501f 100644 --- a/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/MaskedTextBoxTests.cs @@ -657,7 +657,6 @@ namespace Avalonia.Controls.UnitTests { Template = CreateTemplate(), Text = "1234", - IsVisible = false }; var root = new TestRoot { Child = target1 }; diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index daebc1e709..4f135d94ee 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -1064,6 +1064,14 @@ namespace Avalonia.Controls.UnitTests.Primitives // Scroll selected item back into view. scroll.Offset = new(0, 0); + + target.PropertyChanged += (s, e) => + { + if (e.Property == SelectingItemsControl.SelectedIndexProperty) + { + } + }; + Layout(target); // The selection should be preserved. @@ -1387,14 +1395,14 @@ namespace Avalonia.Controls.UnitTests.Primitives { Type IStyleable.StyleKey => typeof(TestSelector); - protected internal override bool IsItemItsOwnContainerOverride(Control item) + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) { - return item is TestContainer; + return new TestContainer(); } - protected internal override Control CreateContainerForItemOverride() + protected internal override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) { - return new TestContainer(); + return NeedsContainer(item, out recycleKey); } } diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index d71abe5a67..9c858a20e1 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -594,7 +594,6 @@ namespace Avalonia.Controls.UnitTests { Template = CreateTemplate(), Text = "1234", - IsVisible = false }; var root = new TestRoot { Child = target1 }; diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index baf8ad5c0e..12792305e2 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -1761,7 +1761,10 @@ namespace Avalonia.Controls.UnitTests private class DerivedTreeViewWithDerivedTreeViewItems : TreeView { - protected internal override Control CreateContainerForItemOverride() => new DerivedTreeViewItem(); + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return new DerivedTreeViewItem(); + } } private class DerivedTreeViewItem : TreeViewItem diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index 61c21f46f4..aa03a77d70 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -298,7 +298,9 @@ namespace Avalonia.Controls.UnitTests using var app = App(); var (target, scroll, itemsControl) = CreateTarget(); - target.GetRealizedElements().First()!.Focus(); + var focused = target.GetRealizedElements().First()!; + focused.Focusable = true; + focused.Focus(); Assert.True(target.GetRealizedElements().First()!.IsKeyboardFocusWithin); scroll.Offset = new Vector(0, 200); @@ -314,6 +316,7 @@ namespace Avalonia.Controls.UnitTests var (target, scroll, itemsControl) = CreateTarget(); var focused = target.GetRealizedElements().First()!; + focused.Focusable = true; focused.Focus(); Assert.True(focused.IsKeyboardFocusWithin); @@ -331,6 +334,7 @@ namespace Avalonia.Controls.UnitTests var (target, scroll, itemsControl) = CreateTarget(); var focused = target.GetRealizedElements().First()!; + focused.Focusable = true; focused.Focus(); Assert.True(focused.IsKeyboardFocusWithin); @@ -350,12 +354,14 @@ namespace Avalonia.Controls.UnitTests var (target, scroll, itemsControl) = CreateTarget(); var originalFocused = target.GetRealizedElements().First()!; + originalFocused.Focusable = true; originalFocused.Focus(); scroll.Offset = new Vector(0, 500); Layout(target); var newFocused = target.GetRealizedElements().First()!; + newFocused.Focusable = true; newFocused.Focus(); Assert.False(originalFocused.IsVisible); @@ -635,6 +641,22 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void Does_Not_Throw_When_Estimating_Viewport_With_Ancestor_Margin() + { + // Issue #11272 + using var app = App(); + var (_, _, itemsControl) = CreateUnrootedTarget(); + var container = new Decorator { Margin = new Thickness(100) }; + var root = new TestRoot(true, container); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + container.Child = itemsControl; + + root.LayoutManager.ExecuteLayoutPass(); + } + private static IReadOnlyList GetRealizedIndexes(VirtualizingStackPanel target, ItemsControl itemsControl) { return target.GetRealizedElements() @@ -681,6 +703,18 @@ namespace Avalonia.Controls.UnitTests IEnumerable? items = null, Optional itemTemplate = default, IEnumerable