diff --git a/.gitignore b/.gitignore index 23b8efffa0..7b9a7c5a25 100644 --- a/.gitignore +++ b/.gitignore @@ -170,6 +170,7 @@ $RECYCLE.BIN/ ## VS Code ################# .vscode/ +*.lscache ################# ## Cake diff --git a/Directory.Packages.props b/Directory.Packages.props index e7d59ee139..7d2780e29e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,9 +15,10 @@ - - - + + + + diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarCustomizationPage.xaml b/samples/ControlCatalog/Pages/CommandBar/CommandBarCustomizationPage.xaml index 3ce911d9f8..16765b4921 100644 --- a/samples/ControlCatalog/Pages/CommandBar/CommandBarCustomizationPage.xaml +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarCustomizationPage.xaml @@ -29,10 +29,19 @@ HorizontalAlignment="Stretch" SelectionChanged="OnBgPresetChanged"> - - - - + + + + + + + + + + + + + @@ -41,8 +50,15 @@ HorizontalAlignment="Stretch" SelectionChanged="OnFgChanged"> - - + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarCustomizationPage.xaml.cs b/samples/ControlCatalog/Pages/CommandBar/CommandBarCustomizationPage.xaml.cs index 52c98757f0..2445bde045 100644 --- a/samples/ControlCatalog/Pages/CommandBar/CommandBarCustomizationPage.xaml.cs +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarCustomizationPage.xaml.cs @@ -14,16 +14,16 @@ namespace ControlCatalog.Pages { if (LiveBar == null) return; - - switch (BgPresetCombo.SelectedIndex) + + if (BgPresetCombo.SelectedItem is not ComboBoxItem { Tag: string preset }) { - case 1: - LiveBar.Background = new SolidColorBrush(Color.Parse("#0078D4")); - break; - case 2: - LiveBar.Background = new SolidColorBrush(Color.Parse("#1C1C1E")); - break; - case 3: + LiveBar.ClearValue(BackgroundProperty); + return; + } + + switch (preset) + { + case "Gradient": LiveBar.Background = new LinearGradientBrush { StartPoint = new Avalonia.RelativePoint(0, 0, Avalonia.RelativeUnit.Relative), @@ -35,11 +35,11 @@ namespace ControlCatalog.Pages } }; break; - case 4: + case "Transparent": LiveBar.Background = Brushes.Transparent; break; default: - LiveBar.ClearValue(BackgroundProperty); + LiveBar.Background = new SolidColorBrush(Color.Parse(preset)); break; } } @@ -48,19 +48,14 @@ namespace ControlCatalog.Pages { if (LiveBar == null) return; - - switch (FgCombo.SelectedIndex) + + if (FgCombo.SelectedItem is ComboBoxItem { Tag: string color }) { - case 1: - LiveBar.Foreground = Brushes.White; - break; - case 2: - LiveBar.Foreground = Brushes.Black; - break; - default: - LiveBar.ClearValue(ForegroundProperty); - break; + LiveBar.Foreground = new SolidColorBrush(Color.Parse(color)); + return; } + + LiveBar.ClearValue(ForegroundProperty); } private void OnRadiusChanged(object? sender, Avalonia.Controls.Primitives.RangeBaseValueChangedEventArgs e) diff --git a/samples/ControlCatalog/ViewModels/MainWindowViewModel_PageList.cs b/samples/ControlCatalog/ViewModels/MainWindowViewModel_PageList.cs index 4c85f64765..03047bedae 100644 --- a/samples/ControlCatalog/ViewModels/MainWindowViewModel_PageList.cs +++ b/samples/ControlCatalog/ViewModels/MainWindowViewModel_PageList.cs @@ -7,6 +7,8 @@ using Avalonia; using MiniMvvm; using Avalonia.Collections; using ControlCatalog.Pages; +using System.Globalization; +using System.Text; using System.Threading.Tasks; using System.Collections.Generic; using System.Linq; @@ -105,10 +107,29 @@ namespace ControlCatalog.ViewModels _ignoreListChange = true; Pages.Clear(); - if (!string.IsNullOrWhiteSpace(query)) - Pages.AddRange(_items.Where(x => x.Header.Contains(query))); - else + if (string.IsNullOrWhiteSpace(query)) + { Pages.AddRange(_items); + } + else + { + var querySearchKey = PageItem.CreateSearchKey(query); + + if (querySearchKey.Length == 0) + { + Pages.AddRange(_items); + } + else + { + foreach (var item in _items) + { + if (item.MatchesSearch(querySearchKey)) + { + Pages.Add(item); + } + } + } + } } finally { @@ -190,7 +211,38 @@ namespace ControlCatalog.ViewModels public string Header { get; } = header; public Func Factory { get; } = factory; public string? IconData { get; } = iconData; + private string SearchKey { get; } = CreateSearchKey(header); public bool IsVisible { get; set; } = true; + + public bool MatchesSearch(string searchKey) + { + return SearchKey.Contains(searchKey, StringComparison.Ordinal); + } + + public static string CreateSearchKey(string value) + { + var normalizedValue = value.Normalize(NormalizationForm.FormKD); + var builder = new StringBuilder(normalizedValue.Length); + + foreach (var c in normalizedValue) + { + var category = CharUnicodeInfo.GetUnicodeCategory(c); + + if (category is UnicodeCategory.NonSpacingMark or + UnicodeCategory.SpacingCombiningMark or + UnicodeCategory.EnclosingMark) + { + continue; + } + + if (char.IsLetterOrDigit(c)) + { + builder.Append(char.ToUpperInvariant(c)); + } + } + + return builder.ToString(); + } } } diff --git a/src/Android/Avalonia.Android/AvaloniaActivity.cs b/src/Android/Avalonia.Android/AvaloniaActivity.cs index 60345e0a50..1dfb18ef2c 100644 --- a/src/Android/Avalonia.Android/AvaloniaActivity.cs +++ b/src/Android/Avalonia.Android/AvaloniaActivity.cs @@ -95,7 +95,10 @@ public class AvaloniaActivity : AppCompatActivity, IAvaloniaActivity public override void OnBackPressed() { if (OperatingSystem.IsAndroidVersionAtLeast(33)) + { + OnBackPressedDispatcher.OnBackPressed(); return; + } var eventArgs = new AndroidBackRequestedEventArgs(); diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs index 59ec332b2d..2ee0236828 100644 --- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs +++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs @@ -157,29 +157,61 @@ internal class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder { } - public Task CreateFileAsync(string name) + public async Task CreateFileAsync(string name) { - var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream"; + // Try to return an existing file to avoid creating file (1). + var existingItem = await GetItemAsync(name, false); + if (existingItem != null) + { + if (existingItem is IStorageFile existingFile) + { + // The file should be truncated when it is created. + using (var _ = await existingFile.OpenWriteAsync()) { } + return existingFile; + } + else if (existingItem is IStorageFolder) + { + // There is an item with the same name but it's not a file. We can't create a file in this case. + throw new IOException($"Can not create '{name}' because a directory with the same name already exists."); + } + } + // Create new one and return it. var treeUri = GetTreeUri().treeUri; - var newFile = CreateDocument(Activity.ContentResolver!, treeUri!, mimeType, name); - if (newFile == null) + var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream"; + var newFile = DocumentsContract.CreateDocument(Activity.ContentResolver!, treeUri!, mimeType, name); + if(newFile == null) { - return Task.FromResult(null); + return null; } - return Task.FromResult(new AndroidStorageFile(Activity, newFile, this)); + return new AndroidStorageFile(Activity, newFile, this); } - public Task CreateFolderAsync(string name) + public async Task CreateFolderAsync(string name) { + // Try to return an existing folder to avoid creating folder (1). + var existingItem = await GetItemAsync(name, true); + if (existingItem != null) + { + if (existingItem is IStorageFolder existingFolder) + { + return existingFolder; + } + else if (existingItem is IStorageFile) + { + // There is an item with the same name but it's not a folder. We can't create a folder in this case. + throw new IOException($"Can not create '{name}' because a file with the same name already exists."); + } + } + // Create new one and return it. var treeUri = GetTreeUri().treeUri; var newFolder = CreateDocument(Activity.ContentResolver!, treeUri!, Document.MimeTypeDir, name); if (newFolder == null) { - return Task.FromResult(null); + return null; } - return Task.FromResult(new AndroidStorageFolder(Activity, newFolder, false, this, PermissionRoot)); + return new AndroidStorageFolder(Activity, newFolder, false, this, PermissionRoot); } public override async Task DeleteAsync() diff --git a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs index b6d8fd4daf..ac4dd8cd2c 100644 --- a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs +++ b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs @@ -37,16 +37,20 @@ public interface IStorageFolder : IStorageItem Task GetFileAsync(string name); /// - /// Creates a file with specified name as a child of the current storage folder + /// Creates, or truncates and overwrites, a file with specified name as a child of the current storage folder. /// /// The display name - /// A new pointing to the moved file. If not null, the current storage item becomes invalid + /// + /// A that provides read/write access to the file specified in name. + /// Task CreateFileAsync(string name); /// - /// Creates a folder with specified name as a child of the current storage folder + /// Creates a folder with specified name as a child of the current storage folder unless they already exist. /// /// The display name - /// A new pointing to the moved file. If not null, the current storage item becomes invalid + /// + /// A that represents the directory at the specified name. This object is returned regardless of whether a directory at the specified name already exists. + /// Task CreateFolderAsync(string name); } diff --git a/src/Avalonia.Base/Platform/Storage/IStorageItem.cs b/src/Avalonia.Base/Platform/Storage/IStorageItem.cs index b5873fdb27..59d039582b 100644 --- a/src/Avalonia.Base/Platform/Storage/IStorageItem.cs +++ b/src/Avalonia.Base/Platform/Storage/IStorageItem.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Avalonia.Metadata; diff --git a/src/Avalonia.Controls/CommandBar/CommandBarButton.cs b/src/Avalonia.Controls/CommandBar/CommandBarButton.cs index 776f4edc47..1eb7317cce 100644 --- a/src/Avalonia.Controls/CommandBar/CommandBarButton.cs +++ b/src/Avalonia.Controls/CommandBar/CommandBarButton.cs @@ -5,12 +5,6 @@ namespace Avalonia.Controls /// public class CommandBarButton : Button, ICommandBarElement { - static CommandBarButton() - { - ForegroundProperty.Changed.AddClassHandler((x, _) => x.UpdateIconForeground()); - IconProperty.Changed.AddClassHandler((x, _) => x.UpdateIconForeground()); - } - /// /// Defines the property. /// @@ -102,18 +96,5 @@ namespace Avalonia.Controls get => GetValue(IsInOverflowProperty); set => SetValue(IsInOverflowProperty, value); } - - private void UpdateIconForeground() - { - if (Icon is IconElement icon) - { - var fg = Foreground; - - if (fg != null) - icon.SetValue(ForegroundProperty, fg); - else - icon.ClearValue(ForegroundProperty); - } - } } } diff --git a/src/Avalonia.Controls/CommandBar/CommandBarToggleButton.cs b/src/Avalonia.Controls/CommandBar/CommandBarToggleButton.cs index 6d7ce4b61e..e1508feac4 100644 --- a/src/Avalonia.Controls/CommandBar/CommandBarToggleButton.cs +++ b/src/Avalonia.Controls/CommandBar/CommandBarToggleButton.cs @@ -7,12 +7,6 @@ namespace Avalonia.Controls /// public class CommandBarToggleButton : ToggleButton, ICommandBarElement { - static CommandBarToggleButton() - { - ForegroundProperty.Changed.AddClassHandler((x, _) => x.UpdateIconForeground()); - IconProperty.Changed.AddClassHandler((x, _) => x.UpdateIconForeground()); - } - /// /// Defines the property. /// @@ -104,18 +98,5 @@ namespace Avalonia.Controls get => GetValue(IsInOverflowProperty); set => SetValue(IsInOverflowProperty, value); } - - private void UpdateIconForeground() - { - if (Icon is IconElement icon) - { - var fg = Foreground; - - if (fg != null) - icon.SetValue(ForegroundProperty, fg); - else - icon.ClearValue(ForegroundProperty); - } - } } } diff --git a/src/Avalonia.Controls/TabControl.cs b/src/Avalonia.Controls/TabControl.cs index cb619a941d..c04432ef59 100644 --- a/src/Avalonia.Controls/TabControl.cs +++ b/src/Avalonia.Controls/TabControl.cs @@ -254,13 +254,7 @@ namespace Avalonia.Controls _currentTransition = null; _shouldAnimate = false; - if (_contentPresenter2 is { IsVisible: true }) - { - _contentPresenter2.IsVisible = false; - _contentPresenter2.Content = null; - _contentPresenter2.ContentTemplate = null; - _contentPresenter2.DataContext = null; - } + ResetTransitionPresenter(_contentPresenter2); int oldIndex = _previousSelectedIndex; _previousSelectedIndex = SelectedIndex; @@ -306,8 +300,8 @@ namespace Avalonia.Controls _contentPresenter2!.ContentTemplate = template; _contentPresenter2!.IsVisible = true; - if (content is Control && template is null) - _contentPresenter2.SetContentWithDataContext(content, container.DataContext); + if (content is Control control && template is null) + SetControlContent(_contentPresenter2, control, container.DataContext); else _contentPresenter2.Content = content; @@ -320,8 +314,8 @@ namespace Avalonia.Controls if (ContentPart != null) { var template = SelectContentTemplate(container.GetValue(ContentControl.ContentTemplateProperty)); - if (content is Control && template is null) - ContentPart.SetContentWithDataContext(content, container.DataContext); + if (content is Control control && template is null) + SetControlContent(ContentPart, control, container.DataContext); else ContentPart.Content = content; } @@ -408,6 +402,7 @@ namespace Avalonia.Controls _currentTransition?.Dispose(); _currentTransition = null; _shouldAnimate = false; + ResetTransitionPresenter(_contentPresenter2); } protected override Size ArrangeOverride(Size finalSize) @@ -496,6 +491,56 @@ namespace Avalonia.Controls (_contentPart, _contentPresenter2) = (_contentPresenter2, _contentPart); } + private void SetControlContent(ContentPresenter presenter, Control content, object? dataContext) + { + ClearOwningContentPresenter(presenter, content); + ClearPresenterContent(ContentPart, presenter, content); + ClearPresenterContent(_contentPresenter2, presenter, content); + presenter.SetContentWithDataContext(content, dataContext); + } + + private void ClearOwningContentPresenter(ContentPresenter targetPresenter, Control content) + { + if (content.VisualParent is ContentPresenter parent && + !ReferenceEquals(parent, targetPresenter) && + ReferenceEquals(parent.Host, this) && + ReferenceEquals(parent.Content, content)) + { + ClearPresenterContent(parent, content); + } + } + + private static void ClearPresenterContent(ContentPresenter? presenter, ContentPresenter targetPresenter, Control content) + { + if (!ReferenceEquals(presenter, targetPresenter)) + ClearPresenterContent(presenter, content); + } + + private static void ClearPresenterContent(ContentPresenter? presenter, Control content) + { + if (presenter != null && ReferenceEquals(presenter.Content, content)) + { + presenter.Content = null; + presenter.ContentTemplate = null; + presenter.DataContext = null; + presenter.RenderTransform = null; + presenter.Opacity = 1; + } + } + + private static void ResetTransitionPresenter(ContentPresenter? presenter) + { + if (presenter == null) + return; + + presenter.IsVisible = false; + presenter.Content = null; + presenter.ContentTemplate = null; + presenter.DataContext = null; + presenter.RenderTransform = null; + presenter.Opacity = 1; + } + private void UpdateTabStripPlacement() { var controls = ItemsPresenterPart?.Panel?.Children; diff --git a/src/Avalonia.Native/.gitignore b/src/Avalonia.Native/.gitignore deleted file mode 100644 index b270c05962..0000000000 --- a/src/Avalonia.Native/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -Generated/*.cs -*.Generated.cs diff --git a/src/Avalonia.Native/Avalonia.Native.csproj b/src/Avalonia.Native/Avalonia.Native.csproj index fb38606254..da64838fb9 100644 --- a/src/Avalonia.Native/Avalonia.Native.csproj +++ b/src/Avalonia.Native/Avalonia.Native.csproj @@ -7,7 +7,7 @@ True $(AvsCurrentTargetFramework);$(AvsLegacyTargetFrameworks) true - true + true @@ -22,7 +22,7 @@ - + diff --git a/src/Avalonia.Themes.Fluent/Controls/CarouselPage.xaml b/src/Avalonia.Themes.Fluent/Controls/CarouselPage.xaml index 25b5d67cb3..6415084dc9 100644 --- a/src/Avalonia.Themes.Fluent/Controls/CarouselPage.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/CarouselPage.xaml @@ -14,7 +14,7 @@ - + diff --git a/src/Avalonia.Themes.Fluent/Controls/PathIcon.xaml b/src/Avalonia.Themes.Fluent/Controls/PathIcon.xaml index a3395ad99f..2a817ccfcb 100644 --- a/src/Avalonia.Themes.Fluent/Controls/PathIcon.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/PathIcon.xaml @@ -10,7 +10,6 @@ - diff --git a/src/Avalonia.Themes.Simple/Controls/PathIcon.xaml b/src/Avalonia.Themes.Simple/Controls/PathIcon.xaml index 9e39ba4645..9e451cf966 100644 --- a/src/Avalonia.Themes.Simple/Controls/PathIcon.xaml +++ b/src/Avalonia.Themes.Simple/Controls/PathIcon.xaml @@ -3,7 +3,6 @@ x:ClassModifier="internal"> - diff --git a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts index f30e6f5916..2d30d1901b 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts @@ -65,7 +65,7 @@ export class StorageItem { await item.verityPermissions("readwrite"); - return await (item.handle as FileSystemFileHandle).createWritable({ keepExistingData: true }); + return await (item.handle as FileSystemFileHandle).createWritable({ keepExistingData: false }); } public static async getProperties(item: StorageItem): Promise<{ Size: number; LastModified: number; Type: string } | null> { @@ -103,8 +103,11 @@ export class StorageItem { } await item.verityPermissions("readwrite"); - - return await ((item.handle as any).getFileHandle(name, { create: true }) as Promise); + // The file should be truncated when it is created. + const fileHandle = await ((item.handle as any).getFileHandle(name, { create: true }) as Promise); + const writable = await fileHandle.createWritable({ keepExistingData: false }); + await writable.close(); + return fileHandle; } public static async getFile(item: StorageItem, name: string): Promise { diff --git a/src/Windows/Avalonia.Win32/.gitignore b/src/Windows/Avalonia.Win32/.gitignore deleted file mode 100644 index 7882fa6540..0000000000 --- a/src/Windows/Avalonia.Win32/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.Generated.cs diff --git a/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj b/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj index 29ccafd02e..ea834e26a6 100644 --- a/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj +++ b/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj @@ -3,7 +3,7 @@ $(AvsCurrentTargetFramework);$(AvsLegacyTargetFrameworks) true true - true + true @@ -18,10 +18,6 @@ - - - - @@ -29,6 +25,7 @@ + diff --git a/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs index 663b1fdd20..60360fa34d 100644 --- a/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs +++ b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs @@ -299,7 +299,7 @@ internal sealed class IOSStorageFolder : IOSStorageItem, IStorageBookmarkFolder var path = System.IO.Path.Combine(FilePath, name); NSFileAttributes? attributes = null; - if (NSFileManager.DefaultManager.CreateDirectory(path, false, attributes, out var error)) + if (NSFileManager.DefaultManager.CreateDirectory(path, true, attributes, out var error)) { return Task.FromResult(new IOSStorageFolder(new NSUrl(path, true), SecurityScopedAncestorUrl)); } diff --git a/tests/Avalonia.Controls.UnitTests/CommandBarTests.cs b/tests/Avalonia.Controls.UnitTests/CommandBarTests.cs index 371cbe4d6d..e368d93892 100644 --- a/tests/Avalonia.Controls.UnitTests/CommandBarTests.cs +++ b/tests/Avalonia.Controls.UnitTests/CommandBarTests.cs @@ -3,9 +3,14 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using Avalonia.Controls; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.LogicalTree; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Themes.Simple; using Avalonia.Threading; using Avalonia.UnitTests; using Avalonia.VisualTree; @@ -118,6 +123,28 @@ public class CommandBarButtonTests : ScopedTestBase var btn = new CommandBarButton { CommandParameter = "param" }; Assert.Equal("param", btn.CommandParameter); } + + [Fact] + public void Foreground_DoesNotSetOrOverwriteIconElementForeground() + { + var icon = new PathIcon(); + var btn = new CommandBarButton + { + Icon = icon, + Foreground = Brushes.Red + }; + + Assert.False(icon.IsSet(TemplatedControl.ForegroundProperty)); + + btn.Foreground = Brushes.Blue; + + Assert.False(icon.IsSet(TemplatedControl.ForegroundProperty)); + + icon.Foreground = Brushes.Green; + btn.Foreground = Brushes.Red; + + Assert.Same(Brushes.Green, icon.Foreground); + } } public class CommandBarToggleButtonTests : ScopedTestBase @@ -205,6 +232,28 @@ public class CommandBarToggleButtonTests : ScopedTestBase var btn = new CommandBarToggleButton { CommandParameter = 42 }; Assert.Equal(42, btn.CommandParameter); } + + [Fact] + public void Foreground_DoesNotSetOrOverwriteIconElementForeground() + { + var icon = new PathIcon(); + var btn = new CommandBarToggleButton + { + Icon = icon, + Foreground = Brushes.Red + }; + + Assert.False(icon.IsSet(TemplatedControl.ForegroundProperty)); + + btn.Foreground = Brushes.Blue; + + Assert.False(icon.IsSet(TemplatedControl.ForegroundProperty)); + + icon.Foreground = Brushes.Green; + btn.Foreground = Brushes.Red; + + Assert.Same(Brushes.Green, icon.Foreground); + } } public class CommandBarSeparatorTests : ScopedTestBase @@ -309,6 +358,54 @@ public class CommandBarDefaultsTests : ScopedTestBase public void Content_DefaultIsNull() => Assert.Null(new CommandBar().Content); + [Fact] + public void Foreground_IsInheritedByCommandBarButtonPathIconThroughThemeTemplate() + { + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var icon = new PathIcon(); + var btn = new CommandBarButton + { + Icon = icon + }; + var commandBar = new CommandBar + { + Foreground = Brushes.Red + }; + commandBar.PrimaryCommands.Add(btn); + + Assert.Same(icon, ApplySimpleThemeAndGetPresentedPathIcon(commandBar, btn)); + Assert.Same(Brushes.Red, icon.Foreground); + + commandBar.Foreground = Brushes.Blue; + + Assert.Same(Brushes.Blue, icon.Foreground); + } + + [Fact] + public void Foreground_IsInheritedByCommandBarToggleButtonPathIconThroughThemeTemplate() + { + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var icon = new PathIcon(); + var btn = new CommandBarToggleButton + { + Icon = icon + }; + var commandBar = new CommandBar + { + Foreground = Brushes.Red + }; + commandBar.PrimaryCommands.Add(btn); + + Assert.Same(icon, ApplySimpleThemeAndGetPresentedPathIcon(commandBar, btn)); + Assert.Same(Brushes.Red, icon.Foreground); + + commandBar.Foreground = Brushes.Blue; + + Assert.Same(Brushes.Blue, icon.Foreground); + } + [Fact] public void PrimaryCommands_NotNull() => Assert.NotNull(new CommandBar().PrimaryCommands); @@ -370,6 +467,46 @@ public class CommandBarDefaultsTests : ScopedTestBase [Fact] public void ItemWidthCollapsed_DefaultIs42() => Assert.Equal(42d, new CommandBar().ItemWidthCollapsed); + + private static PathIcon ApplySimpleThemeAndGetPresentedPathIcon(CommandBar commandBar, TemplatedControl command) + { + var simpleTheme = new SimpleTheme(); + Assert.True(simpleTheme.TryGetResource(typeof(CommandBar), ThemeVariant.Default, out var commandBarTheme)); + Assert.True(simpleTheme.TryGetResource(command.GetType(), ThemeVariant.Default, out var commandTheme)); + commandBar.Theme = Assert.IsType(commandBarTheme); + command.Theme = Assert.IsType(commandTheme); + + var root = new TestRoot + { + Width = 500, + Height = 200, + Child = commandBar, + Styles = + { + simpleTheme + } + }; + + root.ApplyStyling(); + commandBar.ApplyStyling(); + commandBar.ApplyTemplate(); + root.LayoutManager.ExecuteInitialLayoutPass(); + + command.ApplyStyling(); + command.ApplyTemplate(); + + var presenter = command.GetTemplateChildren() + .OfType() + .Single(x => x.Name == "PART_IconPresenter"); + + presenter.ApplyStyling(); + presenter.UpdateChild(); + + var pathIcon = Assert.IsType(presenter.Child); + pathIcon.ApplyStyling(); + + return pathIcon; + } } public class CommandBarPropertyRoundTripTests : ScopedTestBase diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index 5d4ee095fb..06a7054766 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Reflection; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -21,6 +22,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Platform; using Avalonia.Styling; using Avalonia.UnitTests; +using Avalonia.VisualTree; using Moq; using Xunit; @@ -913,6 +915,165 @@ namespace Avalonia.Controls.UnitTests Times.Once); } + [Fact] + public void Interrupted_PageTransition_Can_Select_Original_Control_Before_Previous_Transition_Completes() + { + using var app = Start(); + + var firstPage = new ContentPage { Content = "Alpha" }; + var secondPage = new ContentPage { Content = "Beta" }; + var starts = new List<(object? FromContent, object? ToContent, bool Forward)>(); + var transitionGate = new TaskCompletionSource(); + var transition = new Mock(); + transition + .Setup(t => t.Start( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((from, to, forward, _) => + { + starts.Add(( + (from as ContentPresenter)?.Content, + (to as ContentPresenter)?.Content, + forward)); + }) + .Returns(transitionGate.Task); + + var target = new TabControl + { + PageTransition = transition.Object, + Items = + { + new TabItem { Name = "first", Content = firstPage }, + new TabItem { Name = "second", Content = secondPage }, + }, + }; + + var root = CreateRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + + target.SelectedIndex = 1; + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Single(starts); + Assert.Same(firstPage, starts[0].FromContent); + Assert.Same(secondPage, starts[0].ToContent); + Assert.True(starts[0].Forward); + + var exception = Record.Exception(() => target.SelectedIndex = 0); + + Assert.Null(exception); + + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(2, starts.Count); + Assert.Null(starts[1].FromContent); + Assert.Same(firstPage, starts[1].ToContent); + Assert.False(starts[1].Forward); + Assert.Same(firstPage, target.SelectedContent); + } + + [Fact] + public void Pending_PageTransition_Can_Select_Original_Control_Before_Transition_Starts() + { + using var app = Start(); + + var firstPage = new ContentPage { Content = "Alpha" }; + var secondPage = new ContentPage { Content = "Beta" }; + var starts = new List<(object? FromContent, object? ToContent, bool Forward)>(); + var transition = new Mock(); + transition + .Setup(t => t.Start( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback((from, to, forward, _) => + { + starts.Add(( + (from as ContentPresenter)?.Content, + (to as ContentPresenter)?.Content, + forward)); + }) + .Returns(Task.CompletedTask); + + var target = new TabControl + { + PageTransition = transition.Object, + Items = + { + new TabItem { Name = "first", Content = firstPage }, + new TabItem { Name = "second", Content = secondPage }, + }, + }; + + var root = CreateRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + + target.SelectedIndex = 1; + var exception = Record.Exception(() => target.SelectedIndex = 0); + + Assert.Null(exception); + + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Single(starts); + Assert.Null(starts[0].FromContent); + Assert.Same(firstPage, starts[0].ToContent); + Assert.False(starts[0].Forward); + Assert.Same(firstPage, target.SelectedContent); + } + + [Fact] + public void Interrupted_PageTransition_Clears_Reused_Control_From_Owning_SelectedContentHost() + { + using var app = Start(); + + var firstPage = new ContentPage { Content = "Alpha" }; + var secondPage = new ContentPage { Content = "Beta" }; + var transition = new Mock(); + transition + .Setup(t => t.Start( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var target = new TabControl + { + Items = + { + new TabItem { Name = "first", Content = firstPage }, + new TabItem { Name = "second", Content = secondPage }, + }, + }; + + var root = CreateRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + + target.SelectedIndex = 1; + root.LayoutManager.ExecuteLayoutPass(); + + var primary = target.GetVisualDescendants() + .OfType() + .Single(x => x.Name == "PART_SelectedContentHost"); + var secondary = target.GetVisualDescendants() + .OfType() + .Single(x => x.Name == "PART_SelectedContentHost2"); + + // Simulate the stale presenter ownership that can happen when tab changes + // interrupt a transition: the page is still parented by the named content + // host, but the active field no longer points at that host. + primary.SetContentWithDataContext(firstPage, null); + secondary.IsVisible = false; + SetPrivateField(target, "_contentPart", secondary); + SetPrivateField(target, "_contentPresenter2", secondary); + + target.PageTransition = transition.Object; + var exception = Record.Exception(() => target.SelectedIndex = 0); + + Assert.Null(exception); + Assert.Same(firstPage, target.SelectedContent); + Assert.Null(primary.Content); + Assert.Same(firstPage, secondary.Content); + } + private static IControlTemplate TabControlTemplate() { return new FuncControlTemplate((parent, scope) => @@ -943,72 +1104,11 @@ namespace Avalonia.Controls.UnitTests }); } - /// - /// This test ensures that the selected tab is the first visible and enabled tab. - /// - [Fact] - public void Only_First_Visible_And_Enabled_Tab_Should_Be_Selected_By_Default() - { - using (UnitTestApplication.Start(TestServices.StyledWindow)) - { - TabItem expectedSelectionItem; - - var target = new TabControl - { - Template = TabControlTemplate(), - Items = - { - new TabItem { Name = "first", Content = "foo", IsVisible = false}, - new TabItem { Name = "second", Content = "bar", IsEnabled = false}, - (expectedSelectionItem = new TabItem { Name = "third", Content = "baz" }), - } - }; - - target.ApplyTemplate(); - - var root = new TestRoot(target); - root.LayoutManager.ExecuteInitialLayoutPass(); - - // the 3rd item should be selected - Assert.Equal(2, target.SelectedIndex); - Assert.Equal(expectedSelectionItem, target.SelectedItem); - } - } - - /// - /// This test ensures that also an invisible TabControl can receive a selection - /// - [Fact] - public void SelectedIndex_Should_Restore_After_Control_Gets_Visible() + private static void SetPrivateField(TabControl target, string name, T value) { - using (UnitTestApplication.Start(TestServices.StyledWindow)) - { - TabItem expectedSelectionItem; - - var target = new TabControl - { - IsVisible = false, - Template = TabControlTemplate(), - Items = - { - new TabItem { Name = "first", Content = "foo"}, - (expectedSelectionItem = new TabItem { Name = "second", Content = "bar"}), - new TabItem { Name = "third", Content = "baz" }, - }, - SelectedIndex = 1 - }; - - target.ApplyTemplate(); - - var root = new TestRoot(target); - root.LayoutManager.ExecuteInitialLayoutPass(); - - target.IsVisible = true; - - // the 2nd item should be selected - Assert.Equal(1, target.SelectedIndex); - Assert.Equal(expectedSelectionItem, target.SelectedItem); - } + var field = typeof(TabControl).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + field.SetValue(target, value); } private static IControlTemplate TabItemTemplate()