Files
osu-framework/osu.Framework/Graphics/Containers/SearchContainer.cs
2025-01-19 23:13:10 +01:00

201 lines
7.9 KiB
C#

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Localisation;
namespace osu.Framework.Graphics.Containers
{
public partial class SearchContainer : SearchContainer<Drawable>
{
}
/// <summary>
/// A container which automatically filters <see cref="IFilterable"/> children based on a search term.
/// Re-filtering will only be performed when the <see cref="SearchTerm"/> changes, or
/// the layout of the container is invalidated.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>Children which are searchable should be marked with the <see cref="IFilterable"/> interface. They do not need to be direct children to work (filtering will traverse the full drawable subtree).</item>
/// <item>Marking a container (ie. a "group" or "section" that contains nested <see cref="IFilterable"/>s) as <see cref="IFilterable"/> will automatically keep it non-filtered as long as at least one nested item is not filtered away.</item>
/// <item>Any <see cref="IFilterable"/>s which are contained in a <see cref="IConditionalFilterable"/> which has <see cref="IConditionalFilterable.CanBeShown"/> set to <see langword="false"/> will be excluded from filtering. This can be used to exclude certain items from consideration (ie. items which are hidden from display), allowing group/sections to be correctly filtered away.</item>
/// </list>
/// </remarks>
/// <typeparam name="T"></typeparam>
public partial class SearchContainer<T> : FillFlowContainer<T> where T : Drawable
{
/// <summary>
/// Fired whenever a filter operation completes.
/// </summary>
public event Action FilterCompleted;
private bool allowNonContiguousMatching;
/// <summary>
/// Whether the matching algorithm should consider cases where other characters exist between consecutive characters in the search term.
/// If <c>true</c>, searching for "BSI" will match "BeatmapSetInfo".
/// </summary>
public bool AllowNonContiguousMatching
{
get => allowNonContiguousMatching;
set
{
if (value == allowNonContiguousMatching)
return;
allowNonContiguousMatching = value;
filterValid.Invalidate();
}
}
private string searchTerm;
/// <summary>
/// A string that should match the <see cref="IFilterable"/> children
/// </summary>
public string SearchTerm
{
get => searchTerm;
set
{
if (value == searchTerm)
return;
searchTerm = value;
filterValid.Invalidate();
}
}
[Resolved]
private LocalisationManager localisation { get; set; }
private readonly Cached filterValid = new Cached();
private readonly ICollection<IBindable<bool>> canBeShownBindables = new List<IBindable<bool>>();
protected override void AddInternal(Drawable drawable)
{
base.AddInternal(drawable);
filterValid.Invalidate();
}
protected override void Update()
{
base.Update();
if (!filterValid.IsValid)
Filter();
}
/// <summary>
/// Immediately filter <see cref="IFilterable"/> children based on the current <see cref="SearchTerm"/>.
/// </summary>
/// <remarks>
/// Filtering is done automatically after a change to <see cref="SearchTerm"/>, on new drawables being added, and on certain changes to
/// searchable children (like <see cref="IConditionalFilterable.CanBeShown"/> changing).
///
/// However, if <see cref="SearchContainer{T}"/> or any of its parents are hidden this will not be run.
/// If an implementation relies on filtering to become present / visible, this method can be used to force a filter.
///
/// Note that this will only run if the current filter is not in an already valid state.
/// </remarks>
protected void Filter()
{
if (filterValid.IsValid)
return;
canBeShownBindables.Clear();
performFilter();
filterValid.Validate();
FilterCompleted?.Invoke();
}
private void performFilter()
{
string[] terms = (searchTerm ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries);
matchSubTree(this, terms, terms.Length > 0, allowNonContiguousMatching);
}
private bool matchSubTree(Drawable drawable, IReadOnlyList<string> searchTerms, bool searchActive, bool nonContiguousMatching)
{
bool matching = match(drawable, searchTerms, nonContiguousMatching, out var nonMatchingTerms);
if (drawable is IConditionalFilterable conditionalFilterable)
{
var canBeShownBindable = conditionalFilterable.CanBeShown.GetBoundCopy();
canBeShownBindables.Add(canBeShownBindable);
canBeShownBindable.BindValueChanged(_ => filterValid.Invalidate());
if (!conditionalFilterable.CanBeShown.Value)
{
conditionalFilterable.FilteringActive = true;
return conditionalFilterable.MatchingFilter = false;
}
}
if (drawable is IContainerEnumerable<Drawable> container)
{
foreach (var child in container.Children)
matching |= matchSubTree(child, nonMatchingTerms, searchActive, nonContiguousMatching);
}
if (drawable is IFilterable filterable)
{
filterable.FilteringActive = searchActive;
filterable.MatchingFilter = matching;
}
return matching;
}
private bool match(Drawable drawable, IReadOnlyList<string> searchTerms, bool nonContiguousMatching, out IReadOnlyList<string> nonMatchingTerms)
{
nonMatchingTerms = searchTerms;
if (drawable is IFilterable filterable)
{
IEnumerable<string> filterTerms = filterable.FilterTerms.SelectMany(localisedStr =>
new[] { localisedStr.ToString(), localisation.GetLocalisedString(localisedStr) });
//Words matched by parent is not needed to match children
nonMatchingTerms = searchTerms.Where(term =>
!filterTerms.Any(filterTerm =>
checkTerm(filterTerm, term, nonContiguousMatching))).ToArray();
}
return nonMatchingTerms.Count == 0;
}
/// <summary>
/// Check whether a search term exists in a forward direction, allowing for potentially non-matching characters to exist between matches.
/// </summary>
private static bool checkTerm(string haystack, string needle, bool nonContiguous)
{
if (!nonContiguous)
return haystack.Contains(needle, StringComparison.OrdinalIgnoreCase);
int index = 0;
for (int i = 0; i < needle.Length; i++)
{
// string.IndexOf doesn't have an overload which takes both a `startIndex` and `StringComparison` mode.
int found = CultureInfo.InvariantCulture.CompareInfo.IndexOf(haystack, needle[i], index, CompareOptions.OrdinalIgnoreCase);
if (found < 0)
return false;
index = found + 1;
}
return true;
}
}
}