diff --git a/sources/editor/Stride.Core.Assets.Editor.Avalonia/Converters/AssetFilterViewModelToFullDisplayName.cs b/sources/editor/Stride.Core.Assets.Editor.Avalonia/Converters/AssetFilterViewModelToFullDisplayName.cs new file mode 100644 index 0000000000..f9b0bb18c3 --- /dev/null +++ b/sources/editor/Stride.Core.Assets.Editor.Avalonia/Converters/AssetFilterViewModelToFullDisplayName.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Globalization; +using Stride.Core.Assets.Editor.ViewModels; +using Stride.Core.Presentation.Avalonia.Converters; + +namespace Stride.Core.Assets.Editor.Avalonia.Converters; + +/// +/// Converts an asset filter to a display name by prefixing the category to the name. +/// +public sealed class AssetFilterViewModelToFullDisplayName : OneWayValueConverter +{ + /// + /// Converts an asset filter to a display name by prefixing the category to the name. + /// + public override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not AssetFilterViewModel filter) + return null; + + var category = filter.Category switch + { + FilterCategory.AssetName => "name", + FilterCategory.AssetTag => "tag", + FilterCategory.AssetType => "type", + _ => filter.Category.ToString().ToLowerInvariant(), + }; + return $"{category}: {filter.DisplayName}"; + } +} diff --git a/sources/editor/Stride.Core.Assets.Editor.Avalonia/Views/ImageResources.axaml b/sources/editor/Stride.Core.Assets.Editor.Avalonia/Views/ImageResources.axaml index c8249c12ab..c7ec49ddaf 100644 --- a/sources/editor/Stride.Core.Assets.Editor.Avalonia/Views/ImageResources.axaml +++ b/sources/editor/Stride.Core.Assets.Editor.Avalonia/Views/ImageResources.axaml @@ -151,4 +151,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sources/editor/Stride.Core.Assets.Editor/ViewModels/AssetCollectionViewModel.cs b/sources/editor/Stride.Core.Assets.Editor/ViewModels/AssetCollectionViewModel.cs index 138d7b080c..ef7c56adec 100644 --- a/sources/editor/Stride.Core.Assets.Editor/ViewModels/AssetCollectionViewModel.cs +++ b/sources/editor/Stride.Core.Assets.Editor/ViewModels/AssetCollectionViewModel.cs @@ -4,23 +4,91 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; using Stride.Core.Assets.Editor.Components.Properties; +using Stride.Core.Reflection; using Stride.Core.Assets.Presentation.ViewModels; using Stride.Core.Extensions; using Stride.Core.Presentation.Collections; using Stride.Core.Presentation.Commands; +using Stride.Core.Presentation.Core; using Stride.Core.Presentation.ViewModels; namespace Stride.Core.Assets.Editor.ViewModels; +/// +/// Assets are filtered via categories defined in this enum. +/// +public enum FilterCategory +{ + /// + /// Filters the asset collection by the name of the asset. + /// + AssetName, + /// + /// Filters the asset collection by the asset's tag + /// + AssetTag, + /// + /// Filters the asset collection by the asset's type. + /// + AssetType +} + +/// +/// Assets are sorted based on rules defined in this enum. +/// +public enum SortRule +{ + /// + /// Sorts based on name. + /// + Name, + /// + /// Sorts based on type, asset then name + /// + TypeOrderThenName, + /// + /// Sorts based on if the asset has unsaved changes, then name. + /// + DirtyThenName, + /// + /// Sorts based on date modified, then name. + /// + ModificationDateThenName, +} + +/// +/// Filters which assets are meant to be shown based on folder hierarchy. +/// +public enum DisplayAssetMode +{ + /// + /// Filters assets that are only in the selected folder. + /// + AssetInSelectedFolderOnly, + /// + /// Filters assets and folders that are in the selected folder. + /// + AssetAndFolderInSelectedFolder, + /// + /// Filters assets that are in selected folders and sub-folders. + /// + AssetInSelectedFolderAndSubFolder, +} + public sealed class AssetCollectionViewModel : DispatcherViewModel { private readonly ObservableSet assets = []; private readonly HashSet monitoredDirectories = []; private readonly ObservableSet selectedAssets = []; private readonly ObservableSet selectedContent = []; + private readonly List typeFilters = []; + private readonly ObservableList availableAssetFilters = []; + private readonly ObservableSet currentAssetFilters = []; private object? singleSelectedContent; private bool discardSelectionChanges; + private DisplayAssetMode displayAssetMode; + private SortRule sortRule; public AssetCollectionViewModel(SessionViewModel session) : base(session.SafeArgument().ServiceProvider) @@ -30,14 +98,202 @@ public AssetCollectionViewModel(SessionViewModel session) // Initialize the view model that will manage the properties of the assets selected on the main asset view AssetViewProperties = new SessionObjectPropertiesViewModel(session); + typeFilters.AddRange(AssetRegistry.GetPublicTypes() + .Where(type => type != typeof(Package)) + .Select(type => new AssetFilterViewModel(this, FilterCategory.AssetType, type.FullName!, TypeDescriptorFactory.Default.AttributeRegistry.GetAttribute(type)?.Name ?? type.Name))); + typeFilters.Sort((a, b) => string.Compare(a.DisplayName, b.DisplayName, StringComparison.InvariantCultureIgnoreCase)); + SelectAssetCommand = new AnonymousCommand(ServiceProvider, x => SelectAssets(x.Yield()!)); + AddAssetFilterCommand = new AnonymousCommand(ServiceProvider, AddAssetFilter); + ClearAssetFiltersCommand = new AnonymousCommand(ServiceProvider, ClearAssetFilters); + RefreshAssetFilterCommand = new AnonymousCommand(ServiceProvider, RefreshAssetFilter); + ChangeDisplayAssetModeCommand = new AnonymousCommand(ServiceProvider, mode => DisplayAssetMode = mode); + SortAssetsCommand = new AnonymousCommand(ServiceProvider, rule => SortRule = rule); + currentAssetFilters.CollectionChanged += (_, e) => + { + if (e.OldItems != null) + foreach (AssetFilterViewModel f in e.OldItems) + f.PropertyChanged -= OnFilterPropertyChanged; + if (e.NewItems != null) + foreach (AssetFilterViewModel f in e.NewItems) + f.PropertyChanged += OnFilterPropertyChanged; + RefreshFilters(); + }; selectedContent.CollectionChanged += SelectedContentCollectionChanged; SelectedLocations.CollectionChanged += SelectedLocationCollectionChanged; } public IReadOnlyObservableCollection Assets => assets; + /// + /// A list of assets after the filter rules are applied to the + /// + public IReadOnlyObservableCollection FilteredAssets => filteredAssets; + private readonly ObservableList filteredAssets = []; + + /// + /// A list of asset filters that can currently be applied. + /// + public IReadOnlyObservableCollection AvailableAssetFilters => availableAssetFilters; + + /// + /// A list of applied asset filters. + /// + public IReadOnlyObservableCollection CurrentAssetFilters => currentAssetFilters; + + /// + /// The filter text actively being searched. + /// + public string? AssetFilterPattern + { + get; + set => SetValue(ref field, value, () => UpdateAvailableAssetFilters(value)); + } + + /// + /// Adds the asset filters. See . + /// + public ICommandBase AddAssetFilterCommand { get; } + + /// + /// Clears the asset filters. See . + /// + public ICommandBase ClearAssetFiltersCommand { get; } + + /// + /// Adds an asset filter, but replaces it if it's of the same type. See . + /// + public ICommandBase RefreshAssetFilterCommand { get; } + + /// + /// Updates the . + /// + public ICommandBase ChangeDisplayAssetModeCommand { get; } + + /// + /// Updates the . + /// + public ICommandBase SortAssetsCommand { get; } + + /// + /// Current value of . + /// + public DisplayAssetMode DisplayAssetMode + { + get => displayAssetMode; + set => SetValue(ref displayAssetMode, value, UpdateLocations); + } + /// + /// Current value of . + /// + public SortRule SortRule + { + get => sortRule; + set => SetValue(ref sortRule, value, RefreshFilters); + } + + /// + /// Removes an asset filter. + /// + /// + public void RemoveAssetFilter(AssetFilterViewModel filter) + { + filter.IsActive = false; + currentAssetFilters.Remove(filter); + } + + private void AddAssetFilter(AssetFilterViewModel filter) + { + filter.IsActive = true; + currentAssetFilters.Add(filter); + } + + private void ClearAssetFilters() + { + foreach (var filter in currentAssetFilters.ToList()) + RemoveAssetFilter(filter); + } + private void RefreshAssetFilter(AssetFilterViewModel filter) + { + foreach (var f in currentAssetFilters.Where(f => f.Category == filter.Category).ToList()) + RemoveAssetFilter(f); + AddAssetFilter(filter); + } + + private void UpdateAvailableAssetFilters(string? filterText) + { + availableAssetFilters.Clear(); + if (string.IsNullOrEmpty(filterText)) + return; + + availableAssetFilters.Add(new AssetFilterViewModel(this, FilterCategory.AssetName, filterText, filterText)); + availableAssetFilters.Add(new AssetFilterViewModel(this, FilterCategory.AssetTag, filterText, filterText)); + + foreach (var filter in typeFilters) + { + if (filter.DisplayName.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) >= 0) + availableAssetFilters.Add(filter); + } + } + + private void RefreshFilters() + { + filteredAssets.Clear(); + // Add assets either that matches the filter or are currently selected. + var filteredList = Assets.Where(x => selectedAssets.Contains(x) || Match(x)).ToList(); + var nameComparer = new NaturalStringComparer(); + var assetNameComparer = new AnonymousComparer((x, y) => nameComparer.Compare(x?.Name, y?.Name)); + int GetTypeOrder(Type t) => + TypeDescriptorFactory.Default.AttributeRegistry.GetAttribute(t)?.Order ?? 0; + + IComparer comparer = sortRule switch + { + SortRule.TypeOrderThenName => new AnonymousComparer((x, y) => + { + ArgumentNullException.ThrowIfNull(x); + ArgumentNullException.ThrowIfNull(y); + var r = -GetTypeOrder(x.AssetType).CompareTo(GetTypeOrder(y.AssetType)); + return r == 0 ? assetNameComparer.Compare(x, y) : r; + }), + SortRule.DirtyThenName => new AnonymousComparer((x, y) => + { + ArgumentNullException.ThrowIfNull(x); + ArgumentNullException.ThrowIfNull(y); + var r = -x.IsDirty.CompareTo(y.IsDirty); + return r == 0 ? assetNameComparer.Compare(x, y) : r; + }), + SortRule.ModificationDateThenName => new AnonymousComparer((x, y) => + { + ArgumentNullException.ThrowIfNull(x); + ArgumentNullException.ThrowIfNull(y); + var r = -x.AssetItem.ModifiedTime.CompareTo(y.AssetItem.ModifiedTime); + return r == 0 ? assetNameComparer.Compare(x, y) : r; + }), + _ => assetNameComparer + }; + + filteredList.Sort(comparer); + filteredAssets.AddRange(filteredList); + } + + private void OnFilterPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(AssetFilterViewModel.IsActive)) + RefreshFilters(); + } + + private bool Match(AssetViewModel asset) + { + // Type filters are OR-ed + var activeTypeFilters = currentAssetFilters.Where(f => f is { Category: FilterCategory.AssetType, IsActive: true }).ToList(); + if (activeTypeFilters.Count > 0 && !activeTypeFilters.Any(f => f.Match(asset))) + return false; + + // Name and tag filters are AND-ed + return currentAssetFilters.Where(f => f.Category != FilterCategory.AssetType && f.IsActive).All(f => f.Match(asset)); + } + /// /// Gets the associated to the current selection in the collection. /// @@ -98,11 +354,10 @@ internal IReadOnlyCollection GetSelectedDirectories(bool var selectedDirectories = new List(); foreach (var location in SelectedLocations) { - //var packageCategory = location as PackageCategoryViewModel; - //if (packageCategory != null && includeSubDirectoriesOfSelected) - //{ - // selectedDirectories.AddRange(packageCategory.Content.Select(x => x.AssetMountPoint).NotNull()); - //} + if (location is PackageCategoryViewModel packageCategory && includeSubDirectoriesOfSelected) + { + selectedDirectories.AddRange(packageCategory.Content.Select(x => x.AssetMountPoint).NotNull()); + } switch (location) { case DirectoryBaseViewModel directory: @@ -140,7 +395,8 @@ private void AssetsCollectionInDirectoryChanged(object? sender, NotifyCollection // If the changes are too important, rebuild completely the collection. if (e.Action == NotifyCollectionChangedAction.Reset || e.NewItems is { Count: > 3 } || e.OldItems is { Count: > 3 }) { - var selectedDirectoryHierarchy = GetSelectedDirectories(false); + var includeSubFolders = displayAssetMode == DisplayAssetMode.AssetInSelectedFolderAndSubFolder; + var selectedDirectoryHierarchy = GetSelectedDirectories(includeSubFolders); UpdateAssetsCollection(selectedDirectoryHierarchy.SelectMany(x => x.Assets).ToList(), false); } // Otherwise, simply perform Adds and Removes on the current collection @@ -162,11 +418,11 @@ private void AssetsCollectionInDirectoryChanged(object? sender, NotifyCollection refreshFilter = true; } - //if (refreshFilter) - //{ - // // Some assets have been added, we need to refresh sorting - // RefreshFilters(); - //} + if (refreshFilter) + { + // Some assets have been added, we need to refresh sorting + RefreshFilters(); + } } } } @@ -241,7 +497,7 @@ private void UpdateAssetsCollection(ICollection newAssets, bool selectedContent.Clear(); selectedContent.AddRange(previousSelection); - //RefreshFilters(); + RefreshFilters(); discardSelectionChanges = false; } @@ -256,7 +512,8 @@ private void UpdateLocations() } monitoredDirectories.Clear(); - var selectedDirectoryHierarchy = GetSelectedDirectories(false); + var includeSubFolders = displayAssetMode == DisplayAssetMode.AssetInSelectedFolderAndSubFolder; + var selectedDirectoryHierarchy = GetSelectedDirectories(includeSubFolders); var newAssets = new List(); foreach (var directory in selectedDirectoryHierarchy) { diff --git a/sources/editor/Stride.Core.Assets.Editor/ViewModels/AssetFilterViewModel.cs b/sources/editor/Stride.Core.Assets.Editor/ViewModels/AssetFilterViewModel.cs new file mode 100644 index 0000000000..e6622a3203 --- /dev/null +++ b/sources/editor/Stride.Core.Assets.Editor/ViewModels/AssetFilterViewModel.cs @@ -0,0 +1,111 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Stride.Core.Assets.Presentation.ViewModels; +using Stride.Core.Presentation.Commands; +using Stride.Core.Presentation.ViewModels; + +namespace Stride.Core.Assets.Editor.ViewModels; + +/// +/// View model information for an asset filter. +/// +public sealed class AssetFilterViewModel : DispatcherViewModel, IEquatable +{ + /// + /// View model information for an asset filter. + /// + /// The parent view model that contains the asset filter. + /// The filter category. + /// The filter string. + /// The filter display name. + public AssetFilterViewModel(AssetCollectionViewModel collection, FilterCategory category, string filter, string displayName) + : base(collection.ServiceProvider) + { + Category = category; + DisplayName = displayName; + Filter = filter; + isActive = true; + + RemoveFilterCommand = new AnonymousCommand(ServiceProvider, collection.RemoveAssetFilter); + ToggleIsActiveCommand = new AnonymousCommand(ServiceProvider, () => IsActive = !IsActive); + } + + /// + /// The filter category. See + /// + public FilterCategory Category { get; } + + /// + /// The filter's display name. + /// + public string DisplayName { get; } + + /// + /// The filter's value. + /// + public string Filter { get; } + + private bool isActive; + + /// + /// Whether the filter is enabled. + /// + public bool IsActive { get => isActive; set => SetValue(ref isActive, value); } + + /// + /// Removes itself from the parent. See + /// + public ICommandBase RemoveFilterCommand { get; } + + /// + /// Toggles . + /// + public ICommandBase ToggleIsActiveCommand { get; } + + /// + /// Checks if the filter matches the asset. + /// + /// The asset to check. + /// Whether the filter is active. + public bool Match(AssetViewModel asset) + { + return Category switch + { + FilterCategory.AssetName => ComputeTokens(Filter).All(t => asset.Name.IndexOf(t, StringComparison.OrdinalIgnoreCase) >= 0), + FilterCategory.AssetTag => asset.Tags.Any(y => y.IndexOf(Filter, StringComparison.OrdinalIgnoreCase) >= 0), + FilterCategory.AssetType => string.Equals(asset.AssetType.FullName, Filter), + _ => false, + }; + } + + private static string[] ComputeTokens(string pattern) => pattern.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + /// + /// Checks if an asset filter has the same string pattern and category. + /// + /// The asset filter to check. + /// Whether the asset filter is functionally identical. + public bool Equals(AssetFilterViewModel? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + return Category == other.Category && string.Equals(Filter, other.Filter, StringComparison.OrdinalIgnoreCase); + } + + /// + public override bool Equals(object? obj) => obj is AssetFilterViewModel other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(Category, Filter); + + /// + /// See . + /// + public static bool operator ==(AssetFilterViewModel? left, AssetFilterViewModel? right) => Equals(left, right); + + /// + /// See . + /// + public static bool operator !=(AssetFilterViewModel? left, AssetFilterViewModel? right) => !Equals(left, right); +} diff --git a/sources/editor/Stride.Core.Assets.Presentation/ViewModels/AssetViewModel.cs b/sources/editor/Stride.Core.Assets.Presentation/ViewModels/AssetViewModel.cs index bfee60aec3..b54ee3ba5b 100644 --- a/sources/editor/Stride.Core.Assets.Presentation/ViewModels/AssetViewModel.cs +++ b/sources/editor/Stride.Core.Assets.Presentation/ViewModels/AssetViewModel.cs @@ -5,6 +5,7 @@ using Stride.Core.Assets.Presentation.Components.Properties; using Stride.Core.Assets.Presentation.Quantum; using Stride.Core.Assets.Quantum; +using Stride.Core.Presentation.Collections; using Stride.Core.Presentation.Dirtiables; using Stride.Core.Presentation.Quantum; using Stride.Core.Quantum; @@ -96,6 +97,11 @@ public override string Name get => name; set => SetValue(ref name, value); // TODO rename } + + /// + /// The collection of tags associated to this asset. + /// + public ObservableList Tags { get; } = []; public AssetPropertyGraph? PropertyGraph { get; } diff --git a/sources/editor/Stride.GameStudio.Avalonia/App.axaml b/sources/editor/Stride.GameStudio.Avalonia/App.axaml index 08773ca267..5b38e7f027 100644 --- a/sources/editor/Stride.GameStudio.Avalonia/App.axaml +++ b/sources/editor/Stride.GameStudio.Avalonia/App.axaml @@ -49,6 +49,7 @@ + diff --git a/sources/editor/Stride.GameStudio.Avalonia/Views/AssetExplorerView.axaml b/sources/editor/Stride.GameStudio.Avalonia/Views/AssetExplorerView.axaml index e99899fd9b..e921997827 100644 --- a/sources/editor/Stride.GameStudio.Avalonia/Views/AssetExplorerView.axaml +++ b/sources/editor/Stride.GameStudio.Avalonia/Views/AssetExplorerView.axaml @@ -2,17 +2,194 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:sd="http://schemas.stride3d.net/xaml/presentation" xmlns:caevm="using:Stride.Core.Assets.Editor.ViewModels" + xmlns:caec="using:Stride.Core.Assets.Editor.Avalonia.Converters" xmlns:capvm="using:Stride.Core.Assets.Presentation.ViewModels" xmlns:gscv="using:Stride.GameStudio.Avalonia.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Stride.GameStudio.Avalonia.Views.AssetExplorerView" x:DataType="caevm:AssetCollectionViewModel"> + + + + + + #84CE80 + #A0A0A0 + #D7AA67 + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Controls/SearchComboBox.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Controls/SearchComboBox.cs new file mode 100644 index 0000000000..9298a6cdcb --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Controls/SearchComboBox.cs @@ -0,0 +1,412 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace Stride.Core.Presentation.Avalonia.Controls; + +[TemplatePart(EditableTextBoxPartName, typeof(TextBox), IsRequired = true)] +[TemplatePart(ListBoxPartName, typeof(ListBox), IsRequired = true)] +public class SearchComboBox : SelectingItemsControl +{ + /// + /// The name of the part for the . + /// + private const string EditableTextBoxPartName = "PART_EditableTextBox"; + + /// + /// The name of the part for the . + /// + private const string ListBoxPartName = "PART_ListBox"; + + /// + /// The input text box. + /// + private TextBox? editableTextBox; + /// + /// The suggestion list box. + /// + private ListBox? listBox; + + /// + /// Indicates that the selection is being internally cleared and that the drop-down should not be opened nor refreshed. + /// + private bool clearing; + /// + /// Indicates that the user clicked on the listbox so the dropdown should not be force-closed. + /// + private bool listBoxClicking; + + /// + /// Identifies the styled property. + /// + public static readonly StyledProperty CommandProperty = + AvaloniaProperty.Register(nameof(Command)); + + /// + /// Identifies the dependency property. + /// + public static readonly StyledProperty AlternativeCommandProperty = + AvaloniaProperty.Register(nameof(AlternativeCommand)); + + /// + /// Identifies the styled property. + /// + public static readonly StyledProperty AlternativeModifiersProperty = + AvaloniaProperty.Register(nameof(AlternativeModifiers), KeyModifiers.Shift); + + /// + /// Identifies the styled property. + /// + public static readonly StyledProperty ClearTextAfterSelectionProperty = + AvaloniaProperty.Register(nameof(ClearTextAfterSelection)); + + /// + /// Identifies the styled property. + /// + public static readonly StyledProperty IsAlternativeProperty = + AvaloniaProperty.Register(nameof(IsAlternative)); + + /// + /// Identifies the styled property. + /// + public static readonly StyledProperty IsDropDownOpenProperty = + AvaloniaProperty.Register(nameof(IsDropDownOpen)); + + /// + /// Identifies the styled property. + /// + public static readonly StyledProperty OpenDropDownOnFocusProperty = + AvaloniaProperty.Register(nameof(OpenDropDownOnFocus)); + + /// + /// Identifies the styled property. + /// + public static readonly StyledProperty SearchTextProperty = + AvaloniaProperty.Register(nameof(SearchText), defaultBindingMode: BindingMode.TwoWay); + + /// + /// Identifies the styled property. + /// + public static readonly StyledProperty WatermarkProperty = + TextBox.WatermarkProperty.AddOwner(); + + /// + /// The command invoked on selection when are active. + /// The command parameter is the current . + /// + public ICommand? AlternativeCommand + { + get => GetValue(AlternativeCommandProperty); + set => SetValue(AlternativeCommandProperty, value); + } + + /// + /// The modifier keys that activate the alternative command. + /// + public KeyModifiers AlternativeModifiers + { + get => GetValue(AlternativeModifiersProperty); + set => SetValue(AlternativeModifiersProperty, value); + } + + /// + /// Whether to clear the search text after a selection is committed. + /// + public bool ClearTextAfterSelection + { + get => GetValue(ClearTextAfterSelectionProperty); + set => SetValue(ClearTextAfterSelectionProperty, value); + } + + /// + /// The command invoked once a selection has been committed. + /// The command parameter is the current . + /// + public ICommand? Command + { + get => GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + /// + /// Whether the alternative modifier is currently active. + /// + public bool IsAlternative + { + get => GetValue(IsAlternativeProperty); + set => SetValue(IsAlternativeProperty, value); + } + + /// + /// Whether the suggestion dropdown is open. + /// + public bool IsDropDownOpen + { + get => GetValue(IsDropDownOpenProperty); + set => SetValue(IsDropDownOpenProperty, value); + } + + /// + /// Whether to open the dropdown when the control receives focus. + /// + public bool OpenDropDownOnFocus + { + get => GetValue(OpenDropDownOnFocusProperty); + set => SetValue(OpenDropDownOnFocusProperty, value); + } + + /// + /// The current search text typed by the user. + /// + public string? SearchText + { + get => GetValue(SearchTextProperty); + set => SetValue(SearchTextProperty, value); + } + + /// + /// The placeholder text displayed when the search box is empty. + /// + public string? Watermark + { + get => GetValue(WatermarkProperty); + set => SetValue(WatermarkProperty, value); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + if (editableTextBox is not null) + { + editableTextBox.LostFocus -= EditableTextBoxLostFocus; + editableTextBox.KeyDown -= EditableTextBoxKeyDown; + editableTextBox.KeyUp -= EditableTextBoxKeyUp; + editableTextBox.TextChanged -= EditableTextBoxTextChanged; + } + + if (listBox is not null) + { + listBox.RemoveHandler(PointerPressedEvent, ListBoxPointerPressed); + listBox.RemoveHandler(PointerReleasedEvent, ListBoxPointerReleased); + } + + editableTextBox = e.NameScope.Find(EditableTextBoxPartName) + ?? throw new InvalidOperationException($"A part named '{EditableTextBoxPartName}' must be present in the ControlTemplate."); + + listBox = e.NameScope.Find(ListBoxPartName) + ?? throw new InvalidOperationException($"A part named '{ListBoxPartName}' must be present in the ControlTemplate."); + + editableTextBox.LostFocus += EditableTextBoxLostFocus; + editableTextBox.KeyDown += EditableTextBoxKeyDown; + editableTextBox.KeyUp += EditableTextBoxKeyUp; + editableTextBox.TextChanged += EditableTextBoxTextChanged; + listBox.AddHandler(PointerPressedEvent, ListBoxPointerPressed, RoutingStrategies.Tunnel); + listBox.AddHandler(PointerReleasedEvent, ListBoxPointerReleased, RoutingStrategies.Tunnel); + } + + protected override void OnGotFocus(GotFocusEventArgs e) + { + base.OnGotFocus(e); + if (OpenDropDownOnFocus && !listBoxClicking) + { + IsDropDownOpen = true; + } + listBoxClicking = false; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (IsAlternativeModifier(e.Key)) + { + IsAlternative = true; + } + } + + protected override void OnKeyUp(KeyEventArgs e) + { + base.OnKeyUp(e); + if (IsAlternativeModifier(e.Key)) + { + IsAlternative = false; + } + } + + protected override void OnLostFocus(RoutedEventArgs e) + { + base.OnLostFocus(e); + + // The user probably clicked (MouseDown) somewhere on our dropdown listbox, so we won't clear to be able to + // get the pointer released event. + if (listBox?.IsKeyboardFocusWithin == true) + return; + + Clear(); + } + + private void EditableTextBoxLostFocus(object? sender, RoutedEventArgs e) + { + // This may happen somehow when the template is refreshed or if the list box is actively being clicked. + if (!ReferenceEquals(sender, editableTextBox) || listBoxClicking) + return; + + Clear(); + } + + private void EditableTextBoxKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + Clear(); + e.Handled = true; + return; + } + + if (listBox is null || listBox.ItemCount <= 0) + return; + + switch (e.Key) + { + case Key.Up: + listBox.SelectedIndex = Math.Max(listBox.SelectedIndex - 1, 0); + BringSelectedItemIntoView(); + e.Handled = true; + break; + + case Key.Down: + listBox.SelectedIndex = Math.Min(listBox.SelectedIndex + 1, listBox.ItemCount - 1); + BringSelectedItemIntoView(); + e.Handled = true; + break; + + case Key.PageUp: + listBox.SelectedIndex = Math.Max(listBox.SelectedIndex - 10, 0); + BringSelectedItemIntoView(); + e.Handled = true; + break; + + case Key.PageDown: + listBox.SelectedIndex = Math.Min(listBox.SelectedIndex + 10, listBox.ItemCount - 1); + BringSelectedItemIntoView(); + e.Handled = true; + break; + + case Key.Home: + listBox.SelectedIndex = 0; + BringSelectedItemIntoView(); + e.Handled = true; + break; + + case Key.End: + listBox.SelectedIndex = listBox.ItemCount - 1; + BringSelectedItemIntoView(); + e.Handled = true; + break; + } + } + + private void EditableTextBoxKeyUp(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + if (listBox is not null && listBox.SelectedItem is null && listBox.ItemCount > 0) + { + listBox.SelectedIndex = 0; + } + ValidateSelection(); + if (ClearTextAfterSelection) + { + Clear(); + } + } + } + + private void EditableTextBoxTextChanged(object? sender, TextChangedEventArgs e) + { + SetCurrentValue(SearchTextProperty, editableTextBox?.Text); + + if (clearing) + { + return; + } + + IsDropDownOpen = editableTextBox?.IsFocused is true && ItemCount > 0; + } + + private void ListBoxPointerPressed(object? sender, PointerPressedEventArgs e) + { + listBoxClicking = true; + } + + private void ListBoxPointerReleased(object? sender, PointerReleasedEventArgs e) + { + ValidateSelection(); + if (ClearTextAfterSelection) + { + Clear(); + } + listBoxClicking = false; + } + + private void BringSelectedItemIntoView() + { + if (listBox?.SelectedItem is not null) + { + listBox.ScrollIntoView(listBox.SelectedIndex); + } + } + + private void Clear() + { + clearing = true; + if (editableTextBox is not null) + editableTextBox.Text = string.Empty; + if (listBox is not null) + listBox.SelectedItem = null; + IsDropDownOpen = false; + clearing = false; + } + + private bool IsAlternativeModifier(Key key) + { + return AlternativeModifiers switch + { + KeyModifiers.None => false, + KeyModifiers.Alt => key == Key.LeftAlt || key == Key.RightAlt, + KeyModifiers.Control => key == Key.LeftCtrl || key == Key.RightCtrl, + KeyModifiers.Shift => key == Key.LeftShift || key == Key.RightShift, + KeyModifiers.Meta => key == Key.LWin || key == Key.RWin, + _ => false, + }; + } + + private void ValidateSelection() + { + if (listBox is null) + return; + + // Commit the internal listbox selection to the outer control + SetCurrentValue(SelectedItemProperty, listBox.SelectedItem); + BindingOperations.GetBindingExpressionBase(this, SelectedItemProperty)?.UpdateSource(); + BindingOperations.GetBindingExpressionBase(this, SelectedIndexProperty)?.UpdateSource(); + + var commandParameter = listBox.SelectedItem; + if (IsAlternative && AlternativeCommand?.CanExecute(commandParameter) == true) + { + AlternativeCommand.Execute(commandParameter); + } + else if (Command?.CanExecute(commandParameter) == true) + { + Command.Execute(commandParameter); + } + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/BoolToOpacity.cs b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/BoolToOpacity.cs new file mode 100644 index 0000000000..fd5c68434f --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Converters/BoolToOpacity.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Globalization; + +namespace Stride.Core.Presentation.Avalonia.Converters; + +/// +/// Returns full opacity (1.0) when the value is true, otherwise half opacity (0.5). +/// +public sealed class BoolToOpacity : OneWayValueConverter +{ + /// + /// Returns full opacity (1.0) when the value is true, otherwise half opacity (0.5). + /// + public override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is true ? 1.0 : 0.5; + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Avalonia/Themes/SearchComboBoxStyle.axaml b/sources/presentation/Stride.Core.Presentation.Avalonia/Themes/SearchComboBoxStyle.axaml new file mode 100644 index 0000000000..7195fd0d44 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Avalonia/Themes/SearchComboBoxStyle.axaml @@ -0,0 +1,27 @@ + + + +