diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml index cba7837314..4987e8979e 100644 --- a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml +++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml @@ -118,6 +118,11 @@ Header="Customization" DrawerLength="260" DrawerHeaderBackground="{DynamicResource SystemControlHighlightAccentBrush}"> + + + + + diff --git a/samples/ControlCatalog/Pages/DrawerPage/EcoTrackerAppPage.xaml b/samples/ControlCatalog/Pages/DrawerPage/EcoTrackerAppPage.xaml index 22320fbc8d..1e9106ccfe 100644 --- a/samples/ControlCatalog/Pages/DrawerPage/EcoTrackerAppPage.xaml +++ b/samples/ControlCatalog/Pages/DrawerPage/EcoTrackerAppPage.xaml @@ -52,9 +52,13 @@ - + M12 3C9 6 6 9 6 13C6 17.4 8.7 21 12 22C15.3 21 18 17.4 18 13C18 9 15 6 12 3Z + + + + + diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs index bb7302c1cd..b1a692b85a 100644 --- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs +++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomTabBarPage.xaml.cs @@ -5,7 +5,6 @@ namespace ControlCatalog.Pages { public partial class TabbedPageCustomTabBarPage : UserControl { - // Fluent UI icon geometries (24x24 viewbox) private static readonly StreamGeometry HomeGeometry = StreamGeometry.Parse("M12.9942 2.79444C12.4118 2.30208 11.5882 2.30208 11.0058 2.79444L3.50582 9.39444C3.18607 9.66478 3 10.0634 3 10.4828V20.25C3 20.9404 3.55964 21.5 4.25 21.5H8.25C8.94036 21.5 9.5 20.9404 9.5 20.25V14.75C9.5 14.6119 9.61193 14.5 9.75 14.5H14.25C14.3881 14.5 14.5 14.6119 14.5 14.75V20.25C14.5 20.9404 15.0596 21.5 15.75 21.5H19.75C20.4404 21.5 21 20.9404 21 20.25V10.4828C21 10.0634 20.8139 9.66478 20.4942 9.39444L12.9942 2.79444Z"); private static readonly StreamGeometry WalletGeometry = @@ -25,16 +24,11 @@ namespace ControlCatalog.Pages private void SetupIcons() { - SetIcon(HomePage, HomeGeometry); - SetIcon(WalletPage, WalletGeometry); - SetIcon(SendPage, SendGeometry); - SetIcon(ActivityPage, ActivityGeometry); - SetIcon(ProfilePage, ProfileGeometry); - } - - private static void SetIcon(ContentPage page, StreamGeometry geometry) - { - page.Icon = geometry; + HomePage.Icon = new PathIcon { Data = HomeGeometry }; + WalletPage.Icon = new PathIcon { Data = WalletGeometry }; + SendPage.Icon = new PathIcon { Data = SendGeometry }; + ActivityPage.Icon = new PathIcon { Data = ActivityGeometry }; + ProfilePage.Icon = new PathIcon { Data = ProfileGeometry }; } } } diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs index dc72759c5e..b4eb6d9b49 100644 --- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs +++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageCustomizationPage.xaml.cs @@ -109,14 +109,9 @@ namespace ControlCatalog.Pages private void OnShowIconsChanged(object? sender, RoutedEventArgs e) { bool show = ShowIconsCheck.IsChecked == true; - SetIcon(HomePage, show ? HomeGeometry : null); - SetIcon(SearchPage, show ? SearchGeometry : null); - SetIcon(SettingsPage, show ? SettingsGeometry : null); - } - - private static void SetIcon(ContentPage page, StreamGeometry? geometry) - { - page.Icon = geometry; + HomePage.Icon = show ? new PathIcon { Data = HomeGeometry } : null; + SearchPage.Icon = show ? new PathIcon { Data = SearchGeometry } : null; + SettingsPage.Icon = show ? new PathIcon { Data = SettingsGeometry } : null; } private void OnTabEnabledChanged(object? sender, RoutedEventArgs e) diff --git a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs index 5c10a50df7..b52bfd4d8a 100644 --- a/samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs +++ b/samples/ControlCatalog/Pages/TabbedPage/TabbedPageFabPage.xaml.cs @@ -28,10 +28,10 @@ namespace ControlCatalog.Pages private void SetupIcons() { - FeedPage.Icon = FeedGeometry; - DiscoverPage.Icon = DiscoverGeometry; - AlertsPage.Icon = AlertsGeometry; - ProfilePage.Icon = ProfileGeometry; + FeedPage.Icon = new PathIcon { Data = FeedGeometry }; + DiscoverPage.Icon = new PathIcon { Data = DiscoverGeometry }; + AlertsPage.Icon = new PathIcon { Data = AlertsGeometry }; + ProfilePage.Icon = new PathIcon { Data = ProfileGeometry }; } private void OnFabClicked(object? sender, RoutedEventArgs e) diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs index 47d95d8da1..59ec332b2d 100644 --- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs +++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs @@ -12,6 +12,7 @@ using Avalonia.Logging; using Avalonia.Platform.Storage; using Avalonia.Platform.Storage.FileIO; using Java.Lang; +using static Android.Provider.DocumentsContract; using AndroidUri = Android.Net.Uri; using Exception = System.Exception; using JavaFile = Java.IO.File; @@ -35,10 +36,10 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem } internal AndroidUri Uri { get; set; } - + protected Activity Activity => _activity ?? throw new ObjectDisposedException(nameof(AndroidStorageItem)); - public virtual string Name => GetColumnValue(Activity, Uri, DocumentsContract.Document.ColumnDisplayName) + public virtual string Name => GetColumnValue(Activity, Uri, Document.ColumnDisplayName) ?? GetColumnValue(Activity, Uri, MediaStore.IMediaColumns.DisplayName) ?? Uri.PathSegments?.LastOrDefault()?.Split("/", StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? string.Empty; @@ -67,7 +68,7 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem Activity.ContentResolver?.ReleasePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission); } - + public abstract Task GetBasicPropertiesAsync(); protected static string? GetColumnValue(Context context, AndroidUri contentUri, string column, string? selection = null, string[]? selectionArgs = null) @@ -98,7 +99,7 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem return null; } - if(_parent != null) + if (_parent != null) { return _parent; } @@ -106,8 +107,8 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem using var javaFile = new JavaFile(Uri.Path!); // Java file represents files AND directories. Don't be confused. - if (javaFile.ParentFile is {} parentFile - && AndroidUri.FromFile(parentFile) is {} androidUri) + if (javaFile.ParentFile is { } parentFile + && AndroidUri.FromFile(parentFile) is { } androidUri) { return new AndroidStorageFolder(Activity, androidUri, false); } @@ -124,12 +125,12 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem return await _activity!.CheckPermission(Manifest.Permission.ReadExternalStorage); } - + public void Dispose() { _activity = null; } - + internal AndroidUri? PermissionRoot => _permissionRoot; public abstract Task DeleteAsync(); @@ -138,8 +139,8 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem public static IStorageItem CreateItem(Activity activity, AndroidUri uri) { - var mimeType = GetColumnValue(activity, uri, DocumentsContract.Document.ColumnMimeType); - if (mimeType == DocumentsContract.Document.MimeTypeDir) + var mimeType = GetColumnValue(activity, uri, Document.ColumnMimeType); + if (mimeType == Document.MimeTypeDir) { return new AndroidStorageFolder(activity, uri, false); } @@ -160,8 +161,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder { var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream"; var treeUri = GetTreeUri().treeUri; - var newFile = DocumentsContract.CreateDocument(Activity.ContentResolver!, treeUri!, mimeType, name); - if(newFile == null) + var newFile = CreateDocument(Activity.ContentResolver!, treeUri!, mimeType, name); + if (newFile == null) { return Task.FromResult(null); } @@ -172,7 +173,7 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder public Task CreateFolderAsync(string name) { var treeUri = GetTreeUri().treeUri; - var newFolder = DocumentsContract.CreateDocument(Activity.ContentResolver!, treeUri!, DocumentsContract.Document.MimeTypeDir, name); + var newFolder = CreateDocument(Activity.ContentResolver!, treeUri!, Document.MimeTypeDir, name); if (newFolder == null) { return Task.FromResult(null); @@ -197,24 +198,76 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder { await foreach (var file in storageFolder.GetItemsAsync()) { - if(file is AndroidStorageFolder folder) + if (file is AndroidStorageFolder folder) { await DeleteContents(folder); } - else if(file is AndroidStorageFile storageFile) + else if (file is AndroidStorageFile storageFile) { await storageFile.DeleteAsync(); } } var treeUri = GetTreeUri().treeUri; - DocumentsContract.DeleteDocument(Activity.ContentResolver!, treeUri!); + DeleteDocument(Activity.ContentResolver!, treeUri!); } } public override Task GetBasicPropertiesAsync() { - return Task.FromResult(new StorageItemProperties()); + DateTimeOffset? dateModified = null; + + AndroidUri? queryUri = null; + + try + { + try + { + // When Uri is a tree URI, use its document id to build a document URI. + var folderId = GetTreeDocumentId(Uri); + queryUri = BuildDocumentUriUsingTree(Uri, folderId); + } + catch (UnsupportedOperationException) + { + // For non-root items, Uri may already be a document URI; use it directly. + queryUri = Uri; + } + + if (queryUri != null) + { + var projection = new[] + { + Document.ColumnLastModified + }; + using var cursor = Activity.ContentResolver!.Query(queryUri, projection, null, null, null); + + if (cursor?.MoveToFirst() == true) + { + try + { + var columnIndex = cursor.GetColumnIndex(Document.ColumnLastModified); + if (columnIndex != -1) + { + var longValue = cursor.GetLong(columnIndex); + dateModified = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null; + } + } + catch (Exception ex) + { + Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)? + .Log(this, "Directory LastModified metadata reader failed: '{Exception}'", ex); + } + } + } + } + catch (Exception ex) + { + // Data may not be available for this item or the URI may not be in the expected shape. + Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)? + .Log(this, "Directory basic properties metadata unavailable: '{Exception}'", ex); + } + + return Task.FromResult(new StorageItemProperties(null, null, dateModified)); } public async IAsyncEnumerable GetItemsAsync() @@ -234,8 +287,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder var projection = new[] { - DocumentsContract.Document.ColumnDocumentId, - DocumentsContract.Document.ColumnMimeType + Document.ColumnDocumentId, + Document.ColumnMimeType }; if (childrenUri != null) { @@ -247,8 +300,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder var mime = cursor.GetString(1); var id = cursor.GetString(0); - bool isDirectory = mime == DocumentsContract.Document.MimeTypeDir; - var uri = DocumentsContract.BuildDocumentUriUsingTree(root, id); + bool isDirectory = mime == Document.MimeTypeDir; + var uri = BuildDocumentUriUsingTree(root, id); if (uri == null) { @@ -313,9 +366,9 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder var projection = new[] { - DocumentsContract.Document.ColumnDocumentId, - DocumentsContract.Document.ColumnMimeType, - DocumentsContract.Document.ColumnDisplayName + Document.ColumnDocumentId, + Document.ColumnMimeType, + Document.ColumnDisplayName }; if (childrenUri != null) @@ -332,15 +385,15 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder if (fileName != name) { continue; - } + } - bool mineDirectory = mime == DocumentsContract.Document.MimeTypeDir; + bool mineDirectory = mime == Document.MimeTypeDir; if (isDirectory != mineDirectory) { return null; } - var uri = DocumentsContract.BuildDocumentUriUsingTree(root, id); + var uri = BuildDocumentUriUsingTree(root, id); if (uri == null) { return null; @@ -370,8 +423,8 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder private (AndroidUri root, AndroidUri? treeUri) GetTreeUri() { var root = PermissionRoot ?? Uri; - var folderId = root != Uri ? DocumentsContract.GetDocumentId(Uri) : DocumentsContract.GetTreeDocumentId(Uri); - return (root, DocumentsContract.BuildChildDocumentsUriUsingTree(root, folderId)); + var folderId = root != Uri ? GetDocumentId(Uri) : GetTreeDocumentId(Uri); + return (root, BuildChildDocumentsUriUsingTree(root, folderId)); } } @@ -419,10 +472,10 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF if (!OperatingSystem.IsAndroidVersionAtLeast(24)) return false; - if (!DocumentsContract.IsDocumentUri(context, uri)) + if (!IsDocumentUri(context, uri)) return false; - var value = GetColumnValue(context, uri, DocumentsContract.Document.ColumnFlags); + var value = GetColumnValue(context, uri, Document.ColumnFlags); if (!string.IsNullOrEmpty(value) && int.TryParse(value, out var flagsInt)) { var flags = (DocumentContractFlags)flagsInt; @@ -530,7 +583,7 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF if (Activity != null) { - DocumentsContract.DeleteDocument(Activity.ContentResolver!, Uri); + DeleteDocument(Activity.ContentResolver!, Uri); } } @@ -553,7 +606,7 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF storageFolder.Uri is { } targetParentUri && await GetParentAsync() is AndroidStorageFolder parentFolder) { - movedUri = DocumentsContract.MoveDocument(contentResolver, Uri, parentFolder.Uri, targetParentUri); + movedUri = MoveDocument(contentResolver, Uri, parentFolder.Uri, targetParentUri); } } catch (Exception) diff --git a/src/Avalonia.Base/Input/DragEventArgs.cs b/src/Avalonia.Base/Input/DragEventArgs.cs index e68a6138e0..d4e0cb1bce 100644 --- a/src/Avalonia.Base/Input/DragEventArgs.cs +++ b/src/Avalonia.Base/Input/DragEventArgs.cs @@ -1,6 +1,5 @@ using System; using Avalonia.Interactivity; -using Avalonia.Metadata; namespace Avalonia.Input { @@ -25,9 +24,8 @@ namespace Avalonia.Input return _target.TranslatePoint(_targetLocation, relativeTo) ?? new Point(0, 0); } - [Unstable("This constructor might be removed in 12.0. For unit testing, consider using DragDrop.DoDragDrop or IHeadlessWindow.DragDrop.")] public DragEventArgs( - RoutedEvent routedEvent, + RoutedEvent? routedEvent, IDataTransfer dataTransfer, Interactive target, Point targetLocation, diff --git a/src/Avalonia.Base/Input/FocusChangedEventArgs.cs b/src/Avalonia.Base/Input/FocusChangedEventArgs.cs index ade599ee08..ecca7750ed 100644 --- a/src/Avalonia.Base/Input/FocusChangedEventArgs.cs +++ b/src/Avalonia.Base/Input/FocusChangedEventArgs.cs @@ -11,7 +11,7 @@ namespace Avalonia.Input /// Initializes a new instance of . /// /// The routed event associated with these event args. - public FocusChangedEventArgs(RoutedEvent routedEvent) + public FocusChangedEventArgs(RoutedEvent? routedEvent) : base(routedEvent) { } diff --git a/src/Avalonia.Base/Input/FocusChangingEventArgs.cs b/src/Avalonia.Base/Input/FocusChangingEventArgs.cs index 372ddf38b6..ed237265a6 100644 --- a/src/Avalonia.Base/Input/FocusChangingEventArgs.cs +++ b/src/Avalonia.Base/Input/FocusChangingEventArgs.cs @@ -12,7 +12,7 @@ namespace Avalonia.Input /// /// Provides data for focus changing. /// - internal FocusChangingEventArgs(RoutedEvent routedEvent) : base(routedEvent) + public FocusChangingEventArgs(RoutedEvent? routedEvent) : base(routedEvent) { } diff --git a/src/Avalonia.Base/Input/PointerDeltaEventArgs.cs b/src/Avalonia.Base/Input/PointerDeltaEventArgs.cs index 3c4562edf4..6a6c61d315 100644 --- a/src/Avalonia.Base/Input/PointerDeltaEventArgs.cs +++ b/src/Avalonia.Base/Input/PointerDeltaEventArgs.cs @@ -1,7 +1,4 @@ -using System; using Avalonia.Interactivity; -using Avalonia.Metadata; -using Avalonia.VisualTree; namespace Avalonia.Input { @@ -9,8 +6,7 @@ namespace Avalonia.Input { public Vector Delta { get; } - [Unstable("This constructor might be removed in 12.0.")] - public PointerDeltaEventArgs(RoutedEvent routedEvent, object? source, + public PointerDeltaEventArgs(RoutedEvent? routedEvent, object? source, IPointer pointer, Visual rootVisual, Point rootVisualPosition, ulong timestamp, PointerPointProperties properties, KeyModifiers modifiers, Vector delta) : base(routedEvent, source, pointer, rootVisual, rootVisualPosition, diff --git a/src/Avalonia.Base/Input/PointerEventArgs.cs b/src/Avalonia.Base/Input/PointerEventArgs.cs index f4bf785c56..9285e65fa5 100644 --- a/src/Avalonia.Base/Input/PointerEventArgs.cs +++ b/src/Avalonia.Base/Input/PointerEventArgs.cs @@ -14,8 +14,7 @@ namespace Avalonia.Input private readonly PointerPointProperties _properties; private readonly Lazy?>? _previousPoints; - [Unstable("This constructor might be removed in 12.0. For unit testing, consider using IHeadlessWindow mouse methods.")] - public PointerEventArgs(RoutedEvent routedEvent, + public PointerEventArgs(RoutedEvent? routedEvent, object? source, IPointer pointer, Visual? rootVisual, Point rootVisualPosition, @@ -32,8 +31,9 @@ namespace Avalonia.Input Timestamp = timestamp; KeyModifiers = modifiers; } - - internal PointerEventArgs(RoutedEvent routedEvent, + + [PrivateApi] + public PointerEventArgs(RoutedEvent? routedEvent, object? source, IPointer pointer, Visual? rootVisual, Point rootVisualPosition, @@ -41,9 +41,7 @@ namespace Avalonia.Input PointerPointProperties properties, KeyModifiers modifiers, Lazy?>? previousPoints) -#pragma warning disable CS0618 : this(routedEvent, source, pointer, rootVisual, rootVisualPosition, timestamp, properties, modifiers) -#pragma warning restore CS0618 { _previousPoints = previousPoints; } @@ -160,9 +158,8 @@ namespace Avalonia.Input public class PointerPressedEventArgs : PointerEventArgs { - [Unstable("This constructor might be removed in 12.0. For unit testing, consider using IHeadlessWindow mouse methods.")] public PointerPressedEventArgs( - object source, + object? source, IPointer pointer, Visual rootVisual, Point rootVisualPosition, ulong timestamp, @@ -180,9 +177,8 @@ namespace Avalonia.Input public class PointerReleasedEventArgs : PointerEventArgs { - [Unstable("This constructor might be removed in 12.0. For unit testing, consider using IHeadlessWindow mouse methods.")] public PointerReleasedEventArgs( - object source, IPointer pointer, + object? source, IPointer pointer, Visual rootVisual, Point rootVisualPosition, ulong timestamp, PointerPointProperties properties, KeyModifiers modifiers, MouseButton initialPressMouseButton) @@ -202,8 +198,7 @@ namespace Avalonia.Input { public IPointer Pointer { get; } - [Unstable("This constructor might be removed in 12.0. If you need to remove capture, use stable methods on the IPointer instance.")] - public PointerCaptureLostEventArgs(object source, IPointer pointer) : base(InputElement.PointerCaptureLostEvent) + public PointerCaptureLostEventArgs(object? source, IPointer pointer) : base(InputElement.PointerCaptureLostEvent) { Pointer = pointer; Source = source; @@ -216,7 +211,7 @@ namespace Avalonia.Input public CaptureSource CaptureSource { get; } public IInputElement? NewValue { get; } - internal PointerCaptureChangingEventArgs(object source, IPointer pointer, IInputElement? newValue, CaptureSource captureSource) : base(InputElement.PointerCaptureChangingEvent) + internal PointerCaptureChangingEventArgs(object? source, IPointer pointer, IInputElement? newValue, CaptureSource captureSource) : base(InputElement.PointerCaptureChangingEvent) { Pointer = pointer; Source = source; diff --git a/src/Avalonia.Base/Input/PointerWheelEventArgs.cs b/src/Avalonia.Base/Input/PointerWheelEventArgs.cs index 22624a61dd..9cfeaeea74 100644 --- a/src/Avalonia.Base/Input/PointerWheelEventArgs.cs +++ b/src/Avalonia.Base/Input/PointerWheelEventArgs.cs @@ -1,16 +1,10 @@ -using System; -using Avalonia.Interactivity; -using Avalonia.Metadata; -using Avalonia.VisualTree; - namespace Avalonia.Input { public class PointerWheelEventArgs : PointerEventArgs { public Vector Delta { get; } - [Unstable("This constructor might be removed in 12.0. For unit testing, consider using IHeadlessWindow.MouseWheel.")] - public PointerWheelEventArgs(object source, IPointer pointer, Visual rootVisual, + public PointerWheelEventArgs(object? source, IPointer pointer, Visual rootVisual, Point rootVisualPosition, ulong timestamp, PointerPointProperties properties, KeyModifiers modifiers, Vector delta) : base(InputElement.PointerWheelChangedEvent, source, pointer, rootVisual, rootVisualPosition, diff --git a/src/Avalonia.Base/Input/TappedEventArgs.cs b/src/Avalonia.Base/Input/TappedEventArgs.cs index a235d495d6..924f4be11f 100644 --- a/src/Avalonia.Base/Input/TappedEventArgs.cs +++ b/src/Avalonia.Base/Input/TappedEventArgs.cs @@ -6,7 +6,7 @@ namespace Avalonia.Input { private readonly PointerEventArgs lastPointerEventArgs; - public TappedEventArgs(RoutedEvent routedEvent, PointerEventArgs lastPointerEventArgs) + public TappedEventArgs(RoutedEvent? routedEvent, PointerEventArgs lastPointerEventArgs) : base(routedEvent) { this.lastPointerEventArgs = lastPointerEventArgs; diff --git a/src/Avalonia.Base/Rect.cs b/src/Avalonia.Base/Rect.cs index 58a8c56c8b..9c901254a6 100644 --- a/src/Avalonia.Base/Rect.cs +++ b/src/Avalonia.Base/Rect.cs @@ -1,7 +1,6 @@ using System; using System.Globalization; using System.Numerics; -using Avalonia.Animation.Animators; using Avalonia.Utilities; namespace Avalonia diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs index 81a3c09b35..e8ae84eb03 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs @@ -149,12 +149,16 @@ namespace Avalonia.Rendering.Composition.Server try { - if (_renderTarget == null && !_compositor.IsReadyToCreateRenderTarget(_surfaces())) + if (_renderTarget == null) { - IsWaitingForReadyRenderTarget = IsEnabled; - return; + if (!_compositor.IsReadyToCreateRenderTarget(_surfaces())) + { + IsWaitingForReadyRenderTarget = IsEnabled; + return; + } + + _renderTarget = _compositor.CreateRenderTarget(_surfaces()); } - _renderTarget ??= _compositor.CreateRenderTarget(_surfaces()); } catch (RenderTargetNotReadyException) { diff --git a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs index fbd96ed7d8..90167fa8a1 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs @@ -737,9 +737,6 @@ public partial class Dispatcher /// public static DispatcherPriorityAwaitable Yield(DispatcherPriority priority) { - // TODO12: Update to use Dispatcher.CurrentDispatcher once multi-dispatcher support is merged - var current = UIThread; - current.VerifyAccess(); - return UIThread.Resume(priority); + return CurrentDispatcher.Resume(priority); } } diff --git a/src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs b/src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs new file mode 100644 index 0000000000..913b2f1534 --- /dev/null +++ b/src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs @@ -0,0 +1,126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Controls.Shapes; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace Avalonia.Controls.Converters +{ + // Ported from https://github.com/dotnet/wpf/blob/main/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/BorderGapMaskConverter.cs + + /// + /// Converter that generates the visual brush for + /// + public class BorderGapMaskConverter : IMultiValueConverter + { + /// + /// Convert a value. + /// + /// values as produced by source binding + /// target type + /// converter parameter + /// culture information + /// + /// Converted value. + /// Visual Brush that is used as the opacity mask for the Border + /// in the style for GroupBox. + /// + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + // + // Parameter Validation + // + if (parameter == null || + values == null || + values.Count != 3 || + values[0] is not double || + values[1] is not double || + values[2] is not double) + { + return AvaloniaProperty.UnsetValue; + } + + if (parameter is not double && parameter is not string) + { + return AvaloniaProperty.UnsetValue; + } + + // + // Conversion + // + double? headerWidth = (double?)values[0]; + double? borderWidth = (double?)values[1]; + double? borderHeight = (double?)values[2]; + + // Doesn't make sense to have a Grid + // with 0 as width or height + if (borderWidth == 0 + || borderHeight == 0) + { + return null; + } + + // Width of the line to the left of the header + // to be used to set the width of the first column of the Grid + double lineWidth; + if (parameter is string) + { + lineWidth = Double.Parse(((string)parameter), NumberFormatInfo.InvariantInfo); + } + else + { + lineWidth = (double)parameter; + } + + Grid grid = new Grid + { + Width = borderWidth ?? 0, + Height = borderHeight ?? 0 + }; + + ColumnDefinition colDef1 = new ColumnDefinition(); + ColumnDefinition colDef2 = new ColumnDefinition(); + ColumnDefinition colDef3 = new ColumnDefinition(); + colDef1.Width = new GridLength(lineWidth); + colDef2.Width = new GridLength(headerWidth ?? 0); + colDef3.Width = new GridLength(1, GridUnitType.Star); + grid.ColumnDefinitions.Add(colDef1); + grid.ColumnDefinitions.Add(colDef2); + grid.ColumnDefinitions.Add(colDef3); + RowDefinition rowDef1 = new RowDefinition(); + RowDefinition rowDef2 = new RowDefinition(); + rowDef1.Height = new GridLength((borderHeight ?? 0) / 2); + rowDef2.Height = new GridLength(1, GridUnitType.Star); + grid.RowDefinitions.Add(rowDef1); + grid.RowDefinitions.Add(rowDef2); + + Rectangle rectColumn1 = new Rectangle(); + Rectangle rectColumn2 = new Rectangle(); + Rectangle rectColumn3 = new Rectangle(); + rectColumn1.Fill = Brushes.Black; + rectColumn2.Fill = Brushes.Black; + rectColumn3.Fill = Brushes.Black; + + Grid.SetRowSpan(rectColumn1, 2); + Grid.SetRow(rectColumn1, 0); + Grid.SetColumn(rectColumn1, 0); + + Grid.SetRow(rectColumn2, 1); + Grid.SetColumn(rectColumn2, 1); + + Grid.SetRowSpan(rectColumn3, 2); + Grid.SetRow(rectColumn3, 0); + Grid.SetColumn(rectColumn3, 2); + + grid.Children.Add(rectColumn1); + grid.Children.Add(rectColumn2); + grid.Children.Add(rectColumn3); + + return (new VisualBrush(grid)); + } + } +} diff --git a/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs b/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs index af835541ae..a5cc9de8b1 100644 --- a/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs +++ b/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs @@ -4,7 +4,7 @@ namespace Avalonia.Controls { public class NumericUpDownValueChangedEventArgs : RoutedEventArgs { - public NumericUpDownValueChangedEventArgs(RoutedEvent routedEvent, decimal? oldValue, decimal? newValue) : base(routedEvent) + public NumericUpDownValueChangedEventArgs(RoutedEvent? routedEvent, decimal? oldValue, decimal? newValue) : base(routedEvent) { OldValue = oldValue; NewValue = newValue; diff --git a/src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs b/src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs index 083d64e6df..7c9d3de86d 100644 --- a/src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs +++ b/src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs @@ -11,7 +11,7 @@ namespace Avalonia.Controls /// Initializes a new instance of the class. /// /// The routed event associated with these event data. - public DrawerClosingEventArgs(RoutedEvent routedEvent) : base(routedEvent) { } + public DrawerClosingEventArgs(RoutedEvent? routedEvent) : base(routedEvent) { } /// /// Gets or sets a value indicating whether the closing should be cancelled. diff --git a/src/Avalonia.Controls/Page/DrawerPage.cs b/src/Avalonia.Controls/Page/DrawerPage.cs index 69b1a41842..954ec9c918 100644 --- a/src/Avalonia.Controls/Page/DrawerPage.cs +++ b/src/Avalonia.Controls/Page/DrawerPage.cs @@ -29,9 +29,6 @@ namespace Avalonia.Controls [TemplatePart("PART_PaneButton", typeof(ToggleButton))] [TemplatePart("PART_CompactPaneToggle", typeof(ToggleButton))] [TemplatePart("PART_Backdrop", typeof(Border))] - [TemplatePart("PART_CompactPaneIconPresenter", typeof(ContentPresenter))] - [TemplatePart("PART_PaneIconPresenter", typeof(ContentPresenter))] - [TemplatePart("PART_BottomPaneIconPresenter", typeof(ContentPresenter))] [PseudoClasses(":placement-right", ":placement-top", ":placement-bottom", ":detail-is-navpage")] public class DrawerPage : Page { @@ -133,6 +130,12 @@ namespace Avalonia.Controls public static readonly StyledProperty DrawerIconProperty = AvaloniaProperty.Register(nameof(DrawerIcon)); + /// + /// Defines the property. + /// + public static readonly StyledProperty DrawerIconTemplateProperty = + AvaloniaProperty.Register(nameof(DrawerIconTemplate)); + private static readonly DefaultPageDataTemplate s_defaultPageDataTemplate = new DefaultPageDataTemplate(); /// @@ -206,9 +209,6 @@ namespace Avalonia.Controls private ContentPresenter? _drawerPresenter; private ContentPresenter? _drawerHeaderPresenter; private ContentPresenter? _drawerFooterPresenter; - private ContentPresenter? _compactPaneIconPresenter; - private ContentPresenter? _paneIconPresenter; - private ContentPresenter? _bottomPaneIconPresenter; private SplitView? _splitView; private Border? _topBar; private ToggleButton? _paneButton; @@ -427,6 +427,15 @@ namespace Avalonia.Controls set => SetValue(DrawerIconProperty, value); } + /// + /// Gets or sets the data template used to display the drawer icon. + /// + public IDataTemplate? DrawerIconTemplate + { + get => GetValue(DrawerIconTemplateProperty); + set => SetValue(DrawerIconTemplateProperty, value); + } + /// /// Gets or sets the data template used to display content. /// @@ -536,16 +545,11 @@ namespace Avalonia.Controls _drawerPresenter = e.NameScope.Find("PART_DrawerPresenter"); _drawerHeaderPresenter = e.NameScope.Find("PART_DrawerHeader"); _drawerFooterPresenter = e.NameScope.Find("PART_DrawerFooter"); - _compactPaneIconPresenter = e.NameScope.Find("PART_CompactPaneIconPresenter"); - _paneIconPresenter = e.NameScope.Find("PART_PaneIconPresenter"); - _bottomPaneIconPresenter = e.NameScope.Find("PART_BottomPaneIconPresenter"); _splitView = e.NameScope.Find("PART_SplitView"); _topBar = e.NameScope.Find("PART_TopBar"); _paneButton = e.NameScope.Find("PART_PaneButton"); _backdrop = e.NameScope.Find("PART_Backdrop"); - UpdateIconPresenters(); - if (_backdrop != null) { if (IsAttachedToVisualTree) @@ -568,11 +572,7 @@ namespace Avalonia.Controls { base.OnPropertyChanged(change); - if (change.Property == DrawerIconProperty) - { - UpdateIconPresenters(); - } - else if (change.Property == DrawerProperty || change.Property == ContentProperty) + if (change.Property == DrawerProperty || change.Property == ContentProperty) { if (change.OldValue is ILogical oldLogical) LogicalChildren.Remove(oldLogical); @@ -1006,26 +1006,6 @@ namespace Avalonia.Controls e.Handled = true; } - private void UpdateIconPresenters() - { - if (_compactPaneIconPresenter != null) - _compactPaneIconPresenter.Content = CreateIconContent(DrawerIcon); - if (_paneIconPresenter != null) - _paneIconPresenter.Content = CreateIconContent(DrawerIcon); - if (_bottomPaneIconPresenter != null) - _bottomPaneIconPresenter.Content = CreateIconContent(DrawerIcon); - } - - internal static object? CreateIconContent(object? icon) => icon switch - { - ITemplate template => template.Build(), - Geometry g => new PathIcon { Data = g }, - PathIcon pi => new PathIcon { Data = pi.Data }, - DrawingImage { Drawing: GeometryDrawing { Geometry: { } gd } } => new PathIcon { Data = gd }, - IImage image => new Image { Source = image }, - _ => null - }; - private void ApplyDrawerBackground() { if (_splitView == null) diff --git a/src/Avalonia.Controls/Page/Page.cs b/src/Avalonia.Controls/Page/Page.cs index a894a9b86f..a12ce76ddf 100644 --- a/src/Avalonia.Controls/Page/Page.cs +++ b/src/Avalonia.Controls/Page/Page.cs @@ -2,6 +2,7 @@ using System; using System.Threading.Tasks; using Avalonia.Automation; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; using Avalonia.Interactivity; namespace Avalonia.Controls @@ -31,6 +32,12 @@ namespace Avalonia.Controls public static readonly StyledProperty IconProperty = AvaloniaProperty.Register(nameof(Icon)); + /// + /// Defines the property. + /// + public static readonly StyledProperty IconTemplateProperty = + AvaloniaProperty.Register(nameof(IconTemplate)); + /// /// Defines the property. /// @@ -94,6 +101,15 @@ namespace Avalonia.Controls set => SetValue(IconProperty, value); } + /// + /// Gets or sets the data template used to display the icon. + /// + public IDataTemplate? IconTemplate + { + get => GetValue(IconTemplateProperty); + set => SetValue(IconTemplateProperty, value); + } + /// /// Gets or sets the safe-area padding applied to this page's content. /// diff --git a/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs b/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs index 7163c2075a..e39596cccc 100644 --- a/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs +++ b/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs @@ -13,7 +13,7 @@ namespace Avalonia.Controls /// The routed event associated with this event args instance. /// The page that was selected before the change, or if no page was selected. /// The page that is now selected, or if selection was cleared. - public PageSelectionChangedEventArgs(RoutedEvent routedEvent, Page? previousPage, Page? currentPage) + public PageSelectionChangedEventArgs(RoutedEvent? routedEvent, Page? previousPage, Page? currentPage) : base(routedEvent) { PreviousPage = previousPage; diff --git a/src/Avalonia.Controls/Page/TabbedPage.cs b/src/Avalonia.Controls/Page/TabbedPage.cs index 76bdeaa560..69815eb56a 100644 --- a/src/Avalonia.Controls/Page/TabbedPage.cs +++ b/src/Avalonia.Controls/Page/TabbedPage.cs @@ -6,13 +6,10 @@ using Avalonia.Automation.Peers; using Avalonia.Collections; using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; -using Avalonia.Controls.Shapes; using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Input.GestureRecognizers; -using Avalonia.Layout; using Avalonia.LogicalTree; -using Avalonia.Media; using Avalonia.Threading; namespace Avalonia.Controls @@ -323,7 +320,8 @@ namespace Avalonia.Controls tabItem.IsEnabled = GetIsTabEnabled(page); tabItem.Header = page.Header; - tabItem.Icon = CreateIconContent(page.Icon); + tabItem.Icon = page.Icon; + tabItem.IconTemplate = page.IconTemplate; if (e.Index == (_tabControl?.SelectedIndex ?? -1)) UpdateActivePage(); @@ -351,7 +349,8 @@ namespace Avalonia.Controls tabItem.IsEnabled = GetIsTabEnabled(page); tabItem.Header = page.Header; - tabItem.Icon = CreateIconContent(page.Icon); + tabItem.Icon = page.Icon; + tabItem.IconTemplate = page.IconTemplate; } UpdateActivePage(); @@ -365,7 +364,12 @@ namespace Avalonia.Controls if (e.Property == Page.IconProperty) { if (_pageContainerMap.TryGetValue(page, out var tabItem)) - tabItem.Icon = CreateIconContent(page.Icon); + tabItem.Icon = page.Icon; + } + else if (e.Property == Page.IconTemplateProperty) + { + if (_pageContainerMap.TryGetValue(page, out var tabItem)) + tabItem.IconTemplate = page.IconTemplate; } else if (e.Property == Page.HeaderProperty) { @@ -378,44 +382,6 @@ namespace Avalonia.Controls } } - /// - /// Creates a visual control from a page icon value. - /// - internal static Control? CreateIconContent(object? icon) - { - if (icon is ITemplate template) - return template.Build(); - - Geometry? geometry = icon switch - { - Geometry g => g, - PathIcon pi => pi.Data, - DrawingImage { Drawing: GeometryDrawing { Geometry: { } gd } } => gd, - _ => null - }; - - if (geometry != null) - { - var path = new Path - { - Data = geometry, - Stretch = Stretch.Uniform, - HorizontalAlignment = HorizontalAlignment.Center, - }; - - path.Bind( - Path.FillProperty, - path.GetObservable(Documents.TextElement.ForegroundProperty)); - - return path; - } - - if (icon is IImage image) - return new Image { Source = image }; - - return null; - } - private int FindNearestEnabledTab(int disabledIndex) { int count = GetTabCount(); @@ -687,7 +653,7 @@ namespace Avalonia.Controls var placement = ResolveTabPlacement(); bool isHorizontal = placement == TabPlacement.Top || placement == TabPlacement.Bottom; - bool isRtl = FlowDirection == FlowDirection.RightToLeft; + bool isRtl = FlowDirection == Media.FlowDirection.RightToLeft; int delta = (e.SwipeDirection, isHorizontal, isRtl) switch { @@ -721,7 +687,7 @@ namespace Avalonia.Controls var resolved = ResolveTabPlacement(); bool isHorizontal = resolved == TabPlacement.Top || resolved == TabPlacement.Bottom; - bool isRtl = FlowDirection == FlowDirection.RightToLeft; + bool isRtl = FlowDirection == Media.FlowDirection.RightToLeft; bool next = isHorizontal ? (isRtl ? e.Key == Key.Left : e.Key == Key.Right) : e.Key == Key.Down; bool prev = isHorizontal ? (isRtl ? e.Key == Key.Right : e.Key == Key.Left) : e.Key == Key.Up; diff --git a/src/Avalonia.Controls/ScrollChangedEventArgs.cs b/src/Avalonia.Controls/ScrollChangedEventArgs.cs index fed23964f5..617d5c3f4d 100644 --- a/src/Avalonia.Controls/ScrollChangedEventArgs.cs +++ b/src/Avalonia.Controls/ScrollChangedEventArgs.cs @@ -16,7 +16,7 @@ namespace Avalonia.Controls } public ScrollChangedEventArgs( - RoutedEvent routedEvent, + RoutedEvent? routedEvent, Vector extentDelta, Vector offsetDelta, Vector viewportDelta) diff --git a/src/Avalonia.Controls/SelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionChangedEventArgs.cs index c19d868d57..efbec0c490 100644 --- a/src/Avalonia.Controls/SelectionChangedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionChangedEventArgs.cs @@ -15,7 +15,7 @@ namespace Avalonia.Controls /// The event being raised. /// The items removed from the selection. /// The items added to the selection. - public SelectionChangedEventArgs(RoutedEvent routedEvent, IList removedItems, IList addedItems) + public SelectionChangedEventArgs(RoutedEvent? routedEvent, IList removedItems, IList addedItems) : base(routedEvent) { RemovedItems = removedItems; diff --git a/src/Avalonia.Controls/Spinner.cs b/src/Avalonia.Controls/Spinner.cs index 64c0db62b1..840113acd4 100644 --- a/src/Avalonia.Controls/Spinner.cs +++ b/src/Avalonia.Controls/Spinner.cs @@ -66,7 +66,7 @@ namespace Avalonia.Controls Direction = direction; } - public SpinEventArgs(RoutedEvent routedEvent, SpinDirection direction) + public SpinEventArgs(RoutedEvent? routedEvent, SpinDirection direction) : base(routedEvent) { Direction = direction; @@ -78,7 +78,7 @@ namespace Avalonia.Controls UsingMouseWheel = usingMouseWheel; } - public SpinEventArgs(RoutedEvent routedEvent, SpinDirection direction, bool usingMouseWheel) + public SpinEventArgs(RoutedEvent? routedEvent, SpinDirection direction, bool usingMouseWheel) : base(routedEvent) { Direction = direction; diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index bc529b526c..669e75d81a 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -84,7 +84,8 @@ namespace Avalonia.Controls private bool _hasReachedStart = false; private bool _hasReachedEnd = false; - private Rect _extendedViewport; + private Rect _lastMeasuredExtendedViewport; + private Rect _lastKnownExtendedViewport; static VirtualizingStackPanel() { @@ -182,7 +183,7 @@ namespace Avalonia.Controls /// /// Returns the extended viewport that contains any visible elements and the additional elements for fast scrolling (viewport * CacheLength * 2) /// - internal Rect ExtendedViewPort => _extendedViewport; + internal Rect LastMeasuredExtendedViewPort => _lastMeasuredExtendedViewport; protected override Size MeasureOverride(Size availableSize) { @@ -692,7 +693,7 @@ namespace Avalonia.Controls Debug.Assert(_realizedElements is not null); // Use the extended viewport for calculations - var viewport = _extendedViewport; + var viewport = _lastMeasuredExtendedViewport; // Get the viewport in the orientation direction. var viewportStart = orientation == Orientation.Horizontal ? viewport.X : viewport.Y; @@ -1165,40 +1166,25 @@ namespace Avalonia.Controls ItemContainerGenerator.ItemContainerIndexChanged(element, oldIndex, newIndex); } - private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e) + private Rect CalculateExtendedViewport(bool vertical, double viewportSize, double bufferSize) { - var vertical = Orientation == Orientation.Vertical; - var oldViewportStart = vertical ? _viewport.Top : _viewport.Left; - var oldViewportEnd = vertical ? _viewport.Bottom : _viewport.Right; - var oldExtendedViewportStart = vertical ? _extendedViewport.Top : _extendedViewport.Left; - var oldExtendedViewportEnd = vertical ? _extendedViewport.Bottom : _extendedViewport.Right; - - // Update current viewport - _viewport = e.EffectiveViewport.Intersect(new(Bounds.Size)); - _isWaitingForViewportUpdate = false; - // Calculate buffer sizes based on viewport dimensions - var viewportSize = vertical ? _viewport.Height : _viewport.Width; - var bufferSize = viewportSize * _bufferFactor; - - // Calculate extended viewport with relative buffers - var extendedViewportStart = vertical ? - Math.Max(0, _viewport.Top - bufferSize) : + var extendedViewportStart = vertical ? + Math.Max(0, _viewport.Top - bufferSize) : Math.Max(0, _viewport.Left - bufferSize); - - var extendedViewportEnd = vertical ? - Math.Min(Bounds.Height, _viewport.Bottom + bufferSize) : + + var extendedViewportEnd = vertical ? + Math.Min(Bounds.Height, _viewport.Bottom + bufferSize) : Math.Min(Bounds.Width, _viewport.Right + bufferSize); - // special case: // If we are at the start of the list, append 2 * CacheLength additional items // If we are at the end of the list, prepend 2 * CacheLength additional items - // - this way we always maintain "2 * CacheLength * element" items. + // - this way we always maintain "2 * CacheLength * element" items. if (vertical) { var spaceAbove = _viewport.Top - bufferSize; var spaceBelow = Bounds.Height - (_viewport.Bottom + bufferSize); - + if (spaceAbove < 0 && spaceBelow >= 0) extendedViewportEnd = Math.Min(Bounds.Height, extendedViewportEnd + Math.Abs(spaceAbove)); if (spaceAbove >= 0 && spaceBelow < 0) @@ -1208,30 +1194,48 @@ namespace Avalonia.Controls { var spaceLeft = _viewport.Left - bufferSize; var spaceRight = Bounds.Width - (_viewport.Right + bufferSize); - + if (spaceLeft < 0 && spaceRight >= 0) extendedViewportEnd = Math.Min(Bounds.Width, extendedViewportEnd + Math.Abs(spaceLeft)); - if(spaceLeft >= 0 && spaceRight < 0) + if (spaceLeft >= 0 && spaceRight < 0) extendedViewportStart = Math.Max(0, extendedViewportStart - Math.Abs(spaceRight)); } - Rect extendedViewPort; if (vertical) { - extendedViewPort = new Rect( - _viewport.X, + return new Rect( + _viewport.X, extendedViewportStart, _viewport.Width, extendedViewportEnd - extendedViewportStart); } else { - extendedViewPort = new Rect( + return new Rect( extendedViewportStart, _viewport.Y, extendedViewportEnd - extendedViewportStart, _viewport.Height); } + } + + private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e) + { + var vertical = Orientation == Orientation.Vertical; + var oldViewportStart = vertical ? _viewport.Top : _viewport.Left; + var oldViewportEnd = vertical ? _viewport.Bottom : _viewport.Right; + var oldExtendedViewportStart = vertical ? _lastMeasuredExtendedViewport.Top : _lastMeasuredExtendedViewport.Left; + var oldExtendedViewportEnd = vertical ? _lastMeasuredExtendedViewport.Bottom : _lastMeasuredExtendedViewport.Right; + + // Update current viewport + _viewport = e.EffectiveViewport.Intersect(new(Bounds.Size)); + _isWaitingForViewportUpdate = false; + + // Calculate buffer sizes based on viewport dimensions + var viewportSize = vertical ? _viewport.Height : _viewport.Width; + var bufferSize = viewportSize * _bufferFactor; + + var extendedViewPort = CalculateExtendedViewport(vertical, viewportSize, bufferSize); // Determine if we need a new measure var newViewportStart = vertical ? _viewport.Top : _viewport.Left; @@ -1240,14 +1244,13 @@ namespace Avalonia.Controls var newExtendedViewportEnd = vertical ? extendedViewPort.Bottom : extendedViewPort.Right; var needsMeasure = false; - - + // Case 1: Viewport has changed significantly if (!MathUtilities.AreClose(oldViewportStart, newViewportStart) || !MathUtilities.AreClose(oldViewportEnd, newViewportEnd)) { // Case 1a: The new viewport exceeds the old extended viewport - if (newViewportStart < oldExtendedViewportStart || + if (newViewportStart < oldExtendedViewportStart || newViewportEnd > oldExtendedViewportEnd) { needsMeasure = true; @@ -1259,19 +1262,19 @@ namespace Avalonia.Controls // Check if we're about to scroll into an area where we don't have realized elements // This would be the case if we're near the edge of our current extended viewport var nearingEdge = false; - + if (_realizedElements != null) { var firstRealizedElementU = _realizedElements.StartU; var lastRealizedElementU = _realizedElements.StartU; - + for (var i = 0; i < _realizedElements.Count; i++) { lastRealizedElementU += _realizedElements.SizeU[i]; } - + // If scrolling up/left and nearing the top/left edge of realized elements - if (newViewportStart < oldViewportStart && + if (newViewportStart < oldViewportStart && newViewportStart - newExtendedViewportStart < bufferSize) { // Edge case: We're at item 0 with excess measurement space. @@ -1279,9 +1282,9 @@ namespace Avalonia.Controls // This prevents redundant Measure-Arrange cycles when at list beginning. nearingEdge = !_hasReachedStart; } - + // If scrolling down/right and nearing the bottom/right edge of realized elements - if (newViewportEnd > oldViewportEnd && + if (newViewportEnd > oldViewportEnd && newExtendedViewportEnd - newViewportEnd < bufferSize) { // Edge case: We're at the last item with excess measurement space. @@ -1294,16 +1297,34 @@ namespace Avalonia.Controls { nearingEdge = true; } - + needsMeasure = nearingEdge; } } + // Supplementary check: detect viewport growth after a previous shrink. + // The main comparison (Cases 1a/1b) uses _extendedViewport which only updates + // on measure. When the viewport shrinks (e.g. ComboBox popup during filtering), + // _extendedViewport stays stale-large, masking subsequent growth. Compare against + // _lastKnownExtendedViewport (always updated) to catch this case. + if (!needsMeasure) + { + var lastKnownStart = vertical ? _lastKnownExtendedViewport.Top : _lastKnownExtendedViewport.Left; + var lastKnownEnd = vertical ? _lastKnownExtendedViewport.Bottom : _lastKnownExtendedViewport.Right; + if (newViewportStart < lastKnownStart || newViewportEnd > lastKnownEnd) + { + needsMeasure = true; + } + } + + _lastKnownExtendedViewport = extendedViewPort; + if (needsMeasure) { - // only store the new "old" extended viewport if we _did_ actually measure - _extendedViewport = extendedViewPort; - + // Only update the measure viewport when triggering a measure. This keeps the + // wider realization range available for externally-triggered measures (e.g. from + // OnItemsChanged), ensuring enough items are realized. + _lastMeasuredExtendedViewport = extendedViewPort; InvalidateMeasure(); } } diff --git a/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml b/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml index 4f892153b1..985814c967 100644 --- a/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml @@ -47,6 +47,8 @@ VerticalAlignment="Center"/> @@ -101,6 +103,8 @@ VerticalAlignment="Center"/> @@ -146,6 +150,8 @@ VerticalAlignment="Center"/> diff --git a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml index 4278b91e36..5b31fdb55c 100644 --- a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml @@ -1,11 +1,13 @@ + @@ -18,62 +20,60 @@ - + - - - - - - - - - - - + + + + + + + + + - - - - - - + + + VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" + UseLayoutRounding="{TemplateBinding UseLayoutRounding}" /> + + + @@ -84,7 +84,7 @@ + diff --git a/src/Avalonia.Themes.Simple/Controls/DrawerPage.xaml b/src/Avalonia.Themes.Simple/Controls/DrawerPage.xaml index 9a3e6d37cf..9acf4c4d43 100644 --- a/src/Avalonia.Themes.Simple/Controls/DrawerPage.xaml +++ b/src/Avalonia.Themes.Simple/Controls/DrawerPage.xaml @@ -46,6 +46,8 @@ VerticalAlignment="Center"/> @@ -92,6 +94,8 @@ VerticalAlignment="Center"/> @@ -129,6 +133,8 @@ VerticalAlignment="Center"/> diff --git a/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml b/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml index 526ba7cc77..826c3ec2fe 100644 --- a/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml +++ b/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml @@ -1,11 +1,13 @@ + @@ -16,60 +18,58 @@ - + - - - - - - - - - - - + + + + + + + + + - - - - - - + + + VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" + UseLayoutRounding="{TemplateBinding UseLayoutRounding}" /> + + + @@ -80,6 +80,6 @@ - + + diff --git a/src/Avalonia.Vulkan/VulkanContext.cs b/src/Avalonia.Vulkan/VulkanContext.cs index 6667055e8c..8691e55a05 100644 --- a/src/Avalonia.Vulkan/VulkanContext.cs +++ b/src/Avalonia.Vulkan/VulkanContext.cs @@ -41,6 +41,10 @@ internal class VulkanContext : IVulkanPlatformGraphicsContext { if (featureType == typeof(IVulkanContextExternalObjectsFeature)) return _externalObjectsFeature; + + if (featureType == typeof(IVulkanKhrSurfacePlatformSurfaceFactory)) + return _surfaceFactory; + return null; } @@ -71,4 +75,4 @@ internal class VulkanContext : IVulkanPlatformGraphicsContext throw new VulkanException("Unable to find a suitable platform surface"); } -} \ No newline at end of file +} diff --git a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs index 5596761b7c..ffb94cc5d6 100644 --- a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs +++ b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs @@ -512,76 +512,59 @@ public partial class DispatcherTests [Fact] - public async Task DispatcherResumeContinuesOnUIThread() + public async Task DispatcherResumeContinuesOnCurrentThread() { using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); var tokenSource = new CancellationTokenSource(); - var workload = Dispatcher.UIThread.InvokeAsync( + var dispatcher = Dispatcher.CurrentDispatcher; + + var workload = dispatcher.InvokeAsync( async () => { - Assert.True(Dispatcher.UIThread.CheckAccess()); + Assert.True(dispatcher.CheckAccess()); await Task.Delay(1).ConfigureAwait(false); - Assert.False(Dispatcher.UIThread.CheckAccess()); + Assert.False(dispatcher.CheckAccess()); - await Dispatcher.UIThread.Resume(); - Assert.True(Dispatcher.UIThread.CheckAccess()); + await dispatcher.Resume(); + Assert.True(dispatcher.CheckAccess()); tokenSource.Cancel(); }); - Dispatcher.UIThread.MainLoop(tokenSource.Token); + dispatcher.MainLoop(tokenSource.Token); } [Fact] - public async Task DispatcherYieldContinuesOnUIThread() + public async Task DispatcherYieldContinuesOnCurrentThread() { using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); var tokenSource = new CancellationTokenSource(); - var workload = Dispatcher.UIThread.InvokeAsync( - async () => - { - Assert.True(Dispatcher.UIThread.CheckAccess()); - - await Dispatcher.Yield(); - Assert.True(Dispatcher.UIThread.CheckAccess()); - - tokenSource.Cancel(); - }); - - Dispatcher.UIThread.MainLoop(tokenSource.Token); - } - - [Fact] - public async Task DispatcherYieldThrowsOnNonUIThread() - { - using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + var dispatcher = Dispatcher.CurrentDispatcher; - var tokenSource = new CancellationTokenSource(); - var workload = Dispatcher.UIThread.InvokeAsync( + var workload = dispatcher.InvokeAsync( async () => { - Assert.True(Dispatcher.UIThread.CheckAccess()); + Assert.True(dispatcher.CheckAccess()); - await Task.Delay(1).ConfigureAwait(false); - Assert.False(Dispatcher.UIThread.CheckAccess()); - await Assert.ThrowsAsync(async () => await Dispatcher.Yield()); + await Dispatcher.Yield(); + Assert.True(dispatcher.CheckAccess()); tokenSource.Cancel(); }); - Dispatcher.UIThread.MainLoop(tokenSource.Token); + dispatcher.MainLoop(tokenSource.Token); } [Fact] - public async Task AwaitWithPriorityRunsOnUIThread() + public async Task AwaitWithPriorityRunsOnCurrentThread() { - static async Task Workload() + static async Task Workload(Dispatcher dispatcher) { await Task.Delay(1).ConfigureAwait(false); - Assert.False(Dispatcher.UIThread.CheckAccess()); + Assert.False(dispatcher.CheckAccess()); return Thread.CurrentThread.ManagedThreadId; } @@ -589,25 +572,27 @@ public partial class DispatcherTests using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); var tokenSource = new CancellationTokenSource(); - var workload = Dispatcher.UIThread.InvokeAsync( + var dispatcher = Dispatcher.CurrentDispatcher; + + var workload = dispatcher.InvokeAsync( async () => { - Assert.True(Dispatcher.UIThread.CheckAccess()); - Task taskWithoutResult = Workload(); + Assert.True(dispatcher.CheckAccess()); + Task taskWithoutResult = Workload(dispatcher); - await Dispatcher.UIThread.AwaitWithPriority(taskWithoutResult, DispatcherPriority.Default); + await dispatcher.AwaitWithPriority(taskWithoutResult, DispatcherPriority.Default); - Assert.True(Dispatcher.UIThread.CheckAccess()); - Task taskWithResult = Workload(); + Assert.True(dispatcher.CheckAccess()); + Task taskWithResult = Workload(dispatcher); - await Dispatcher.UIThread.AwaitWithPriority(taskWithResult, DispatcherPriority.Default); + await dispatcher.AwaitWithPriority(taskWithResult, DispatcherPriority.Default); - Assert.True(Dispatcher.UIThread.CheckAccess()); + Assert.True(dispatcher.CheckAccess()); tokenSource.Cancel(); }); - Dispatcher.UIThread.MainLoop(tokenSource.Token); + dispatcher.MainLoop(tokenSource.Token); } private class AsyncLocalTestClass diff --git a/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs b/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs index f4afe81e19..87fb421a72 100644 --- a/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs @@ -1160,113 +1160,26 @@ public class DrawerPageTests public class IconTests : ScopedTestBase { [Fact] - public void Geometry_ReturnsPathIcon() + public void DrawerIconTemplate_RoundTrips() { - var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) }; - var result = DrawerPage.CreateIconContent(geometry); - Assert.IsType(result); - Assert.Same(geometry, ((PathIcon)result!).Data); - } - - [Fact] - public void PathIcon_ReturnsPathIcon() - { - var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) }; - var pathIcon = new PathIcon { Data = geometry }; - var result = DrawerPage.CreateIconContent(pathIcon); - Assert.IsType(result); - Assert.Same(geometry, ((PathIcon)result!).Data); - } - - [Fact] - public void DrawingImage_WithGeometryDrawing_ReturnsPathIcon() - { - var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) }; - var drawing = new GeometryDrawing { Geometry = geometry }; - var drawingImage = new DrawingImage(drawing); - var result = DrawerPage.CreateIconContent(drawingImage); - Assert.IsType(result); - Assert.Same(geometry, ((PathIcon)result!).Data); - } - - [Fact] - public void Image_ReturnsImage() - { - var image = new TestImage(); - var result = DrawerPage.CreateIconContent(image); - Assert.IsType(result); - Assert.Same(image, ((Image)result!).Source); - } - - private sealed class TestImage : IImage - { - public Size Size => new Size(1, 1); - public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) { } - } - - [Fact] - public void EmptyString_ReturnsNull() - { - var result = DrawerPage.CreateIconContent(""); - Assert.Null(result); - } - - [Fact] - public void NullString_ReturnsNull() - { - var result = DrawerPage.CreateIconContent((string?)null); - Assert.Null(result); - } - - [Fact] - public void Null_ReturnsNull() - { - var result = DrawerPage.CreateIconContent(null); - Assert.Null(result); - } - - [Fact] - public void Template_BuildsControl() - { - var template = new FuncTemplate(() => new Border()); - var result = DrawerPage.CreateIconContent(template); - Assert.IsType(result); - } - - [Fact] - public void Template_BuildsSeparateInstances() - { - var template = new FuncTemplate(() => new Border()); - var first = DrawerPage.CreateIconContent(template); - var second = DrawerPage.CreateIconContent(template); - Assert.NotSame(first, second); + var template = new FuncDataTemplate((_, _) => new PathIcon()); + var dp = new DrawerPage { DrawerIconTemplate = template }; + Assert.Same(template, dp.DrawerIconTemplate); } [Fact] - public void NonEmptyString_ReturnsNull() - { - var result = DrawerPage.CreateIconContent("M10 20v-6h4v6"); - Assert.Null(result); - } - - [Fact] - public void UnsupportedType_ReturnsNull() - { - var result = DrawerPage.CreateIconContent(42); - Assert.Null(result); - } - - [Fact] - public void ChangingDrawerIcon_AfterTemplateApplied_UpdatesPresenters() + public void DrawerIcon_With_Geometry_Does_Not_Throw() { var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) }; - var dp = new DrawerPage { DrawerIcon = new PathIcon { Data = geometry } }; + var dp = new DrawerPage + { + DrawerIcon = geometry, + DrawerIconTemplate = new FuncDataTemplate((_, _) => new PathIcon()), + }; var root = new TestRoot { Child = dp }; - var geometry2 = new EllipseGeometry { Rect = new Rect(0, 0, 20, 20) }; - dp.DrawerIcon = new PathIcon { Data = geometry2 }; - - Assert.Same(geometry2, dp.DrawerIcon is PathIcon pi ? pi.Data : null); + dp.DrawerIcon = new EllipseGeometry { Rect = new Rect(0, 0, 20, 20) }; + Assert.NotNull(dp.DrawerIcon); } } diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index 8bad019c2b..42643c5560 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -955,6 +955,30 @@ namespace Avalonia.Controls.UnitTests }.RegisterInNameScope(scope)); } + private static IControlTemplate TabItemWithIconTemplate() + { + return new FuncControlTemplate((parent, scope) => + new StackPanel + { + Children = + { + new ContentPresenter + { + Name = "PART_IconPresenter", + [~ContentPresenter.ContentProperty] = new TemplateBinding(TabItem.IconProperty), + [~ContentPresenter.ContentTemplateProperty] = new TemplateBinding(TabItem.IconTemplateProperty), + }.RegisterInNameScope(scope), + new ContentPresenter + { + Name = "PART_ContentPresenter", + [~ContentPresenter.ContentProperty] = new TemplateBinding(TabItem.HeaderProperty), + [~ContentPresenter.ContentTemplateProperty] = new TemplateBinding(TabItem.HeaderTemplateProperty), + RecognizesAccessKey = true, + }.RegisterInNameScope(scope), + } + }); + } + private static ControlTheme CreateTabControlControlTheme() { return new ControlTheme(typeof(TabControl)) @@ -1495,35 +1519,72 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void TabItem_Icon_DefaultIsNull() + public void TabItem_IconTemplate_Creates_Content_From_NonControl_Icon() { - var tabItem = new TabItem(); - Assert.Null(tabItem.Icon); + var tabItem = new TabItem + { + Icon = "home", + IconTemplate = new FuncDataTemplate((val, _) => + new TextBlock { Text = (string)val }), + Template = TabItemWithIconTemplate(), + }; + + var root = new TestRoot { Child = tabItem }; + tabItem.ApplyTemplate(); + tabItem.Presenter!.UpdateChild(); + + var iconPresenter = tabItem.GetTemplateChildren().OfType().First(x => x.Name == "PART_IconPresenter"); + Assert.NotNull(iconPresenter); + Assert.Equal("home", iconPresenter!.Content); + Assert.NotNull(iconPresenter.ContentTemplate); + + iconPresenter.UpdateChild(); + var textBlock = iconPresenter.Child as TextBlock; + Assert.NotNull(textBlock); + Assert.Equal("home", textBlock!.Text); } [Fact] - public void TabItem_Icon_RoundTrips() + public void TabItem_Icon_Without_Template_Renders_Control_Directly() { - var tabItem = new TabItem(); var icon = new Avalonia.Controls.Shapes.Path { Data = new Avalonia.Media.EllipseGeometry { Rect = new Rect(0, 0, 10, 10) } }; - tabItem.Icon = icon; - Assert.Same(icon, tabItem.Icon); + var tabItem = new TabItem + { + Icon = icon, + Template = TabItemWithIconTemplate(), + }; + + var root = new TestRoot { Child = tabItem }; + tabItem.ApplyTemplate(); + tabItem.Presenter!.UpdateChild(); + + var iconPresenter = tabItem.GetTemplateChildren().OfType().First(x => x.Name == "PART_IconPresenter"); + Assert.NotNull(iconPresenter); + Assert.Same(icon, iconPresenter!.Content); + Assert.Null(iconPresenter.ContentTemplate); } [Fact] - public void TabItem_Icon_CanBeSetToNull() + public void TabItem_Icon_Change_Updates_Presenter_Content() { - var tabItem = new TabItem(); - var icon = new Avalonia.Controls.Shapes.Path + var tabItem = new TabItem { - Data = new Avalonia.Media.EllipseGeometry { Rect = new Rect(0, 0, 10, 10) } + Icon = "first", + Template = TabItemWithIconTemplate(), }; - tabItem.Icon = icon; - tabItem.Icon = null; - Assert.Null(tabItem.Icon); + + var root = new TestRoot { Child = tabItem }; + tabItem.ApplyTemplate(); + tabItem.Presenter!.UpdateChild(); + + var iconPresenter = tabItem.GetTemplateChildren().OfType().First(x => x.Name == "PART_IconPresenter"); + Assert.Equal("first", iconPresenter!.Content); + + tabItem.Icon = "second"; + Assert.Equal("second", iconPresenter.Content); } [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs b/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs index 3fa34e0f2b..a27f398a17 100644 --- a/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs @@ -913,111 +913,53 @@ public class TabbedPageTests } } - public class IconTests : ScopedTestBase + public class PageIconTemplateTests : ScopedTestBase { [Fact] - public void Geometry_ReturnsPath() + public void Page_Icon_AcceptsControlValue() { - var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) }; - var result = TabbedPage.CreateIconContent(geometry); - Assert.IsType(result); - Assert.Same(geometry, ((Path)result!).Data); + var icon = new PathIcon { Data = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) } }; + var page = new ContentPage { Icon = icon }; + Assert.Same(icon, page.Icon); } [Fact] - public void PathIcon_ReturnsPath() + public void Page_Icon_AcceptsNonControlValue() { var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) }; - var pathIcon = new PathIcon { Data = geometry }; - var result = TabbedPage.CreateIconContent(pathIcon); - Assert.IsType(result); - Assert.Same(geometry, ((Path)result!).Data); - } - - [Fact] - public void EmptyString_ReturnsNull() - { - var result = TabbedPage.CreateIconContent(""); - Assert.Null(result); - } - - [Fact] - public void NullString_ReturnsNull() - { - var result = TabbedPage.CreateIconContent((string?)null); - Assert.Null(result); + var page = new ContentPage { Icon = geometry }; + Assert.Same(geometry, page.Icon); } [Fact] - public void Null_ReturnsNull() + public void Page_IconTemplate_RoundTrips() { - var result = TabbedPage.CreateIconContent(null); - Assert.Null(result); + var template = new FuncDataTemplate((_, _) => new Border()); + var page = new ContentPage { IconTemplate = template }; + Assert.Same(template, page.IconTemplate); } [Fact] - public void DrawingImage_WithGeometryDrawing_ReturnsPath() + public void DrawerPage_DrawerIconTemplate_RoundTrips() { - var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) }; - var drawing = new GeometryDrawing { Geometry = geometry }; - var drawingImage = new DrawingImage(drawing); - var result = TabbedPage.CreateIconContent(drawingImage); - Assert.IsType(result); - Assert.Same(geometry, ((Path)result!).Data); + var template = new FuncDataTemplate((_, _) => new Border()); + var dp = new DrawerPage { DrawerIconTemplate = template }; + Assert.Same(template, dp.DrawerIconTemplate); } [Fact] - public void Path_HasStretchUniform() + public void DrawerPage_DrawerIcon_With_Geometry_Does_Not_Throw() { var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) }; - var result = TabbedPage.CreateIconContent(geometry); - Assert.Equal(Stretch.Uniform, ((Path)result!).Stretch); - } - - [Fact] - public void Image_ReturnsImage() - { - var image = new TestImage(); - var result = TabbedPage.CreateIconContent(image); - Assert.IsType(result); - Assert.Same(image, ((Image)result!).Source); - } - - private sealed class TestImage : IImage - { - public Size Size => new Size(1, 1); - public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) { } - } - - [Fact] - public void Template_BuildsControl() - { - var template = new FuncTemplate(() => new Border()); - var result = TabbedPage.CreateIconContent(template); - Assert.IsType(result); - } - - [Fact] - public void Template_BuildsSeparateInstances() - { - var template = new FuncTemplate(() => new Border()); - var first = TabbedPage.CreateIconContent(template); - var second = TabbedPage.CreateIconContent(template); - Assert.NotSame(first, second); - } - - [Fact] - public void NonEmptyString_ReturnsNull() - { - var result = TabbedPage.CreateIconContent("M10 20v-6h4v6"); - Assert.Null(result); - } + var dp = new DrawerPage + { + DrawerIcon = geometry, + DrawerIconTemplate = new FuncDataTemplate((_, _) => new PathIcon()), + }; + var root = new TestRoot { Child = dp }; - [Fact] - public void UnsupportedType_ReturnsNull() - { - var result = TabbedPage.CreateIconContent(42); - Assert.Null(result); + dp.DrawerIcon = new EllipseGeometry { Rect = new Rect(0, 0, 20, 20) }; + Assert.NotNull(dp.DrawerIcon); } } diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index 36c15f76bb..873da11c67 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -632,6 +632,69 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(new Vector(0, 0), scroll.Offset); } + [Fact] + public void Shrinking_Viewport_Then_Growing_Back_Triggers_Remeasure() + { + // Regression test for stale _extendedViewport comparison in OnEffectiveViewportChanged. + // + // When the viewport shrinks (e.g., ComboBox popup shrinks during filtering), + // OnEffectiveViewportChanged doesn't trigger a measure (needsMeasure=false because + // the smaller viewport is within the old extended viewport). The _extendedViewport + // comparison baseline is NOT updated. When the viewport later grows back, + // OnEffectiveViewportChanged compares against the stale large _extendedViewport, + // concludes "no significant change", and skips the measure. This prevents item + // realization when the only measure trigger is OnEffectiveViewportChanged. + // + // The fix uses a separate _lastKnownExtendedViewport that is always updated, + // so the comparison correctly detects viewport growth after a shrink. + // + // Key: ScrollContentPresenter passes infinite height for vertical scroll, so + // the panel's MeasureOverride is NOT called from the layout cascade when only + // the root size changes. OnEffectiveViewportChanged is the sole measure trigger. + using var app = App(); + + var items = Enumerable.Range(0, 20).Select(x => $"Item {x}"); + var (target, scroll, itemsControl) = + CreateUnrootedTarget( + items: items, bufferFactor: 0); + var root = CreateRoot(itemsControl, new Size(100, 100)); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + // Initial state: viewport 0-100, 10 items visible, _extendedViewport = (0,0,100,100) + AssertRealizedItems(target, itemsControl, 0, 10); + + // Shrink viewport (simulates popup shrinking when items are filtered). + // Panel MeasureOverride is NOT called (ScrollContentPresenter passes infinite height). + // OnEffectiveViewportChanged fires with small viewport but needsMeasure=false + // because the small viewport is within the old _extendedViewport. + root.ClientSize = new Size(100, 10); + root.InvalidateMeasure(); + Layout(target); + + // Reset counters after shrink + target.ResetMeasureArrangeCounters(); + + // Grow viewport back (simulates popup growing when filter is removed). + // Panel MeasureOverride is NOT called from layout cascade (same infinite constraint). + // OnEffectiveViewportChanged is the ONLY path to trigger a remeasure. + root.ClientSize = new Size(100, 100); + root.InvalidateMeasure(); + Layout(target); + + // Without fix: OnEffectiveViewportChanged compares new viewport (0-100) against + // stale _extendedViewport (0-100, never updated during shrink). Sees no change. + // needsMeasure=false. No remeasure triggered. Measure count = 0. + // + // With fix: compares against _lastKnownExtendedViewport (0-10, updated during + // shrink). Detects that viewport grew past it (100 > 10). needsMeasure=true. + // InvalidateMeasure called. Measure count >= 1. + Assert.True(target.Measured >= 1, + "Panel should be re-measured when viewport grows back after a previous shrink. " + + "OnEffectiveViewportChanged must detect viewport growth by comparing against " + + "the last known extended viewport, not the stale _extendedViewport."); + } + [Theory] [InlineData(0d, 10, "4,9")] [InlineData(0.5d, 20, "4,9,14,19")] @@ -1655,8 +1718,8 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(0, target.ViewPort.Top); Assert.Equal(100, target.ViewPort.Bottom); - Assert.Equal(0, target.ExtendedViewPort.Top); - Assert.Equal(200, target.ExtendedViewPort.Bottom); + Assert.Equal(0, target.LastMeasuredExtendedViewPort.Top); + Assert.Equal(200, target.LastMeasuredExtendedViewPort.Bottom); } [Fact] @@ -1680,8 +1743,8 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(900, target.ViewPort.Top); Assert.Equal(1000, target.ViewPort.Bottom); - Assert.Equal(800, target.ExtendedViewPort.Top); - Assert.Equal(1000, target.ExtendedViewPort.Bottom); + Assert.Equal(800, target.LastMeasuredExtendedViewPort.Top); + Assert.Equal(1000, target.LastMeasuredExtendedViewPort.Bottom); } [Fact] @@ -1705,8 +1768,8 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(500, target.ViewPort.Top); Assert.Equal(600, target.ViewPort.Bottom); - Assert.Equal(450, target.ExtendedViewPort.Top); - Assert.Equal(650, target.ExtendedViewPort.Bottom); + Assert.Equal(450, target.LastMeasuredExtendedViewPort.Top); + Assert.Equal(650, target.LastMeasuredExtendedViewPort.Bottom); } [Fact] @@ -1729,8 +1792,8 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(0, target.ViewPort.Left); Assert.Equal(100, target.ViewPort.Right); - Assert.Equal(0, target.ExtendedViewPort.Left); - Assert.Equal(200, target.ExtendedViewPort.Right); + Assert.Equal(0, target.LastMeasuredExtendedViewPort.Left); + Assert.Equal(200, target.LastMeasuredExtendedViewPort.Right); } [Fact] @@ -1754,8 +1817,8 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(900, target.ViewPort.Left); Assert.Equal(1000, target.ViewPort.Right); - Assert.Equal(800, target.ExtendedViewPort.Left); - Assert.Equal(1000, target.ExtendedViewPort.Right); + Assert.Equal(800, target.LastMeasuredExtendedViewPort.Left); + Assert.Equal(1000, target.LastMeasuredExtendedViewPort.Right); } [Fact] @@ -1780,8 +1843,8 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(500, target.ViewPort.Left); Assert.Equal(600, target.ViewPort.Right); - Assert.Equal(450, target.ExtendedViewPort.Left); - Assert.Equal(650, target.ExtendedViewPort.Right); + Assert.Equal(450, target.LastMeasuredExtendedViewPort.Left); + Assert.Equal(650, target.LastMeasuredExtendedViewPort.Right); } [Fact] @@ -1806,11 +1869,15 @@ namespace Avalonia.Controls.UnitTests // visible are 10 => need to scroll down 100px until the next 5 (visible*BufferFactor) additional items are added. // until then no measure-arrange call should happen + var count = 0; // Scroll down until the extended viewport bounds are reached while (target.LastRealizedIndex < 20) { scroll.Offset = new Vector(0, scroll.Offset.Y + 5); Layout(target); + count++; + if (count > 1000) + throw new InvalidOperationException("infinite scroll detected"); } // Assert @@ -1862,11 +1929,15 @@ namespace Avalonia.Controls.UnitTests var initialFirstRealizedIndex = target.FirstRealizedIndex; + var count = 0; // Scroll down until the extended viewport bounds are reached while (target.FirstRealizedIndex >= 15) { scroll.Offset = new Vector(0, scroll.Offset.Y - 5); Layout(target); + count++; + if (count > 1000) + throw new InvalidOperationException("infinite scroll detected"); } // Assert @@ -1918,11 +1989,15 @@ namespace Avalonia.Controls.UnitTests var initialLastRealizedIndex = target.LastRealizedIndex; + var count = 0; // Scroll down until we reached the very last item while (target.LastRealizedIndex < 99) { scroll.Offset = new Vector(0, scroll.Offset.Y + 5); Layout(target); + count++; + if (count > 1000) + throw new InvalidOperationException("infinite scroll detected"); } // Assert @@ -1972,11 +2047,15 @@ namespace Avalonia.Controls.UnitTests // visible are 10 => need to scroll down 100px until the next 5 (visible*BufferFactor) additional items are added. // until then no measure-arrange call should happen + var count = 0; // Scroll down until the extended viewport bounds are reached while (target.FirstRealizedIndex > 0) { scroll.Offset = new Vector(0, scroll.Offset.Y - 5); Layout(target); + count++; + if (count > 1000) + throw new InvalidOperationException("infinite scroll detected"); } // Assert @@ -2022,12 +2101,15 @@ namespace Avalonia.Controls.UnitTests // shows 20 items, each is 10 high. // visible are 10 => need to scroll down 100px until the next 5 (visible*BufferFactor) additional items are added. // until then no measure-arrange call should happen - + var count = 0; // Scroll down until the extended viewport bounds are reached while (target.LastRealizedIndex < 20) { scroll.Offset = new Vector(scroll.Offset.X + 5, 0); Layout(target); + count++; + if (count > 1000) + throw new InvalidOperationException("infinite scroll detected"); } // Assert @@ -2079,12 +2161,15 @@ namespace Avalonia.Controls.UnitTests // until then no measure-arrange call should happen var initialFirstRealizedIndex = target.FirstRealizedIndex; - + var count = 0; // Scroll down until the extended viewport bounds are reached while (target.FirstRealizedIndex >= 15) { scroll.Offset = new Vector(scroll.Offset.X - 5, 0); Layout(target); + count++; + if (count > 1000) + throw new InvalidOperationException("infinite scroll detected"); } // Assert @@ -2137,11 +2222,15 @@ namespace Avalonia.Controls.UnitTests var initialLastRealizedIndex = target.LastRealizedIndex; + var count = 0; // Scroll down until we reached the very last item while (target.LastRealizedIndex < 99) { scroll.Offset = new Vector(scroll.Offset.X + 5, 0); Layout(target); + count++; + if (count > 1000) + throw new InvalidOperationException("infinite scroll detected"); } // Assert @@ -2192,11 +2281,15 @@ namespace Avalonia.Controls.UnitTests // visible are 10 => need to scroll down 100px until the next 5 (visible*BufferFactor) additional items are added. // until then no measure-arrange call should happen + var count = 0; // Scroll down until the extended viewport bounds are reached while (target.FirstRealizedIndex > 0) { scroll.Offset = new Vector(scroll.Offset.X - 5, 0); Layout(target); + count++; + if (count > 1000) + throw new InvalidOperationException("infinite scroll detected"); } // Assert