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 @@
+
+
+
+