using System.Collections;
using System.Collections.Specialized;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
using Newtonsoft.Json.Linq;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
namespace XFEWarframe.Desktop.Editing;
/// <summary>Data access for a native AutoSuggestBox; remote searches do not replace the visible list.</summary>
public sealed class SuggestionProvider
{
private readonly Func<string, CancellationToken, Task<IReadOnlyList<object>>> search;
public SuggestionProvider(Func<string, Task<IReadOnlyList<object>>> search) : this((query, _) => search(query)) { }
public SuggestionProvider(Func<string, CancellationToken, Task<IReadOnlyList<object>>> search) => this.search = search;
public Task<IReadOnlyList<object>> SearchAsync(string query, CancellationToken cancellation = default) => search(query, cancellation);
public static SuggestionProvider OwnedRivens(Func<string, int, Task<Newtonsoft.Json.Linq.JObject>> read) => new(async (query, cancellation) =>
{
var suggestions = new List<object>();
for (var skip = 0; ; skip += 40)
{
cancellation.ThrowIfCancellationRequested();
var data = await read(string.IsNullOrWhiteSpace(query) ? "/Randomized/" : query, skip).WaitAsync(cancellation);
var items = (Newtonsoft.Json.Linq.JArray?)data["items"] ?? [];
foreach (var row in items.Where(x => x.Value<string>("type")?.Contains("/Randomized/", StringComparison.Ordinal) == true))
suggestions.Add(new EditorItem(row.Value<string>("key")!, row.Value<string>("name")!, row.Value<string>("detail") ?? ""));
if (suggestions.Count >= 60 || items.Count == 0 || skip + 40 >= data.Value<int>("total")) return suggestions.Take(60).ToArray();
}
});
}
public sealed record SearchSuggestion(string Label, string Key = "");
/// <summary>Native suggestions shared by both applications, with cancellable, debounced lookups.</summary>
public sealed class Suggestions : DependencyObject
{
public static IEnumerable? GetCandidates(DependencyObject target) => (IEnumerable?)target.GetValue(CandidatesProperty);
public static void SetCandidates(DependencyObject target, IEnumerable? value) => target.SetValue(CandidatesProperty, value);
public static readonly DependencyProperty CandidatesProperty = DependencyProperty.RegisterAttached("Candidates", typeof(IEnumerable), typeof(Suggestions), new PropertyMetadata(null, (sender, _) => GetSession(sender).SourceChanged()));
public static SuggestionProvider? GetProvider(DependencyObject target) => (SuggestionProvider?)target.GetValue(ProviderProperty);
public static void SetProvider(DependencyObject target, SuggestionProvider? value) => target.SetValue(ProviderProperty, value);
public static readonly DependencyProperty ProviderProperty = DependencyProperty.RegisterAttached("Provider", typeof(SuggestionProvider), typeof(Suggestions), new PropertyMetadata(null, (sender, _) => GetSession(sender).ClearSearch()));
public static object? GetSelectedItem(DependencyObject target) => target.GetValue(SelectedItemProperty);
public static void SetSelectedItem(DependencyObject target, object? value) => target.SetValue(SelectedItemProperty, value);
public static readonly DependencyProperty SelectedItemProperty = DependencyProperty.RegisterAttached("SelectedItem", typeof(object), typeof(Suggestions), new PropertyMetadata(null, (sender, _) => GetSession(sender).SelectionUpdated()));
public static bool GetIsSelectionRequired(DependencyObject target) => (bool)target.GetValue(IsSelectionRequiredProperty);
public static void SetIsSelectionRequired(DependencyObject target, bool value) => target.SetValue(IsSelectionRequiredProperty, value);
public static readonly DependencyProperty IsSelectionRequiredProperty = DependencyProperty.RegisterAttached("IsSelectionRequired", typeof(bool), typeof(Suggestions), new PropertyMetadata(false));
public static ICommand? GetSubmitCommand(DependencyObject target) => (ICommand?)target.GetValue(SubmitCommandProperty);
public static void SetSubmitCommand(DependencyObject target, ICommand? value) => target.SetValue(SubmitCommandProperty, value);
public static readonly DependencyProperty SubmitCommandProperty = DependencyProperty.RegisterAttached("SubmitCommand", typeof(ICommand), typeof(Suggestions), new PropertyMetadata(null));
public static bool GetSearchOnTextChanged(DependencyObject target) => (bool)target.GetValue(SearchOnTextChangedProperty);
public static void SetSearchOnTextChanged(DependencyObject target, bool value) => target.SetValue(SearchOnTextChangedProperty, value);
public static readonly DependencyProperty SearchOnTextChangedProperty = DependencyProperty.RegisterAttached("SearchOnTextChanged", typeof(bool), typeof(Suggestions), new PropertyMetadata(false));
public static bool GetCanSearch(DependencyObject target) => (bool)target.GetValue(CanSearchProperty);
public static void SetCanSearch(DependencyObject target, bool value) => target.SetValue(CanSearchProperty, value);
public static readonly DependencyProperty CanSearchProperty = DependencyProperty.RegisterAttached("CanSearch", typeof(bool), typeof(Suggestions), new PropertyMetadata(true));
public static object? GetContext(DependencyObject target) => target.GetValue(ContextProperty);
public static void SetContext(DependencyObject target, object? value) => target.SetValue(ContextProperty, value);
public static readonly DependencyProperty ContextProperty = DependencyProperty.RegisterAttached("Context", typeof(object), typeof(Suggestions), new PropertyMetadata(null, (sender, _) => GetSession(sender).ClearSearch()));
public static object? GetSecondaryContext(DependencyObject target) => target.GetValue(SecondaryContextProperty);
public static void SetSecondaryContext(DependencyObject target, object? value) => target.SetValue(SecondaryContextProperty, value);
public static readonly DependencyProperty SecondaryContextProperty = DependencyProperty.RegisterAttached("SecondaryContext", typeof(object), typeof(Suggestions), new PropertyMetadata(null, (sender, _) => GetSession(sender).ClearSearch()));
public static bool GetIsSearching(AutoSuggestBox target) => GetSession(target).IsSearching;
public static string GetSearchError(AutoSuggestBox target) => GetSession(target).SearchError;
private static readonly DependencyProperty SessionProperty = DependencyProperty.RegisterAttached("Session", typeof(Session), typeof(Suggestions), new PropertyMetadata(null));
private static Session GetSession(DependencyObject target)
{
if (target.GetValue(SessionProperty) is Session existing) return existing;
var session = new Session((AutoSuggestBox)target); target.SetValue(SessionProperty, session); return session;
}
private sealed class Session
{
private readonly AutoSuggestBox input;
private IEnumerable? Candidates => GetCandidates(input);
private SuggestionProvider? Provider => GetProvider(input);
private object? SelectedItem { get => GetSelectedItem(input); set => SetSelectedItem(input, value); }
private bool IsSelectionRequired => GetIsSelectionRequired(input);
private ICommand? SubmitCommand => GetSubmitCommand(input);
private const string SearchMemberPaths = "Key,Type,Id,Email,Value,Directory";
public bool IsSearching { get; private set; }
public string SearchError { get; private set; } = "";
private CancellationTokenSource? pending;
private CancellationTokenSource? pendingCommand;
private IReadOnlyList<object> results = [];
private bool editing;
private bool listening;
private INotifyCollectionChanged? observed;
public Session(AutoSuggestBox input)
{
this.input = input;
input.TextMemberPath = input.DisplayMemberPath;
input.QueryIcon = new SymbolIcon(Symbol.Find);
input.MaxSuggestionListHeight = 320;
input.HorizontalAlignment = HorizontalAlignment.Stretch;
if (string.IsNullOrEmpty(input.PlaceholderText)) input.PlaceholderText = UiText.Translate("输入名称搜索");
input.RegisterPropertyChangedCallback(AutoSuggestBox.DisplayMemberPathProperty, (_, _) => input.TextMemberPath = input.DisplayMemberPath);
input.RegisterPropertyChangedCallback(AutoSuggestBox.TextProperty, (_, _) =>
{
if (SelectedItem is null || input.Text == Label(SelectedItem)) return;
editing = true;
try { SelectedItem = null; }
finally { editing = false; }
});
input.GotFocus += async (_, _) => { Attach(); ObserveCollection(); await SearchAsync(SelectedItem is not null && input.Text == Label(SelectedItem) ? "" : input.Text, false); };
input.Loaded += (_, _) => { Attach(); ObserveCollection(); };
input.Unloaded += (_, _) =>
{
input.TextChanged -= TextChanged;
input.SuggestionChosen -= SuggestionChosen;
input.QuerySubmitted -= QuerySubmitted;
listening = false;
CancelSearch(); CancelCommand();
if (observed is not null) observed.CollectionChanged -= CollectionChanged;
observed = null;
};
}
private void Attach()
{
if (listening) return;
listening = true;
input.TextChanged += TextChanged;
input.SuggestionChosen += SuggestionChosen;
input.QuerySubmitted += QuerySubmitted;
}
private async void TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
if (GetSearchOnTextChanged(input) && HasFocusWithin()) _ = RefreshListAsync(true);
if (args.Reason == AutoSuggestionBoxTextChangeReason.SuggestionChosen) return;
if (SelectedItem is not null && input.Text == Label(SelectedItem)) return;
editing = true;
try { SelectedItem = null; }
finally { editing = false; }
await SearchAsync(sender.Text, true);
}
private void SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) => Choose(args.SelectedItem);
private void QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args)
{
var candidates = Provider is null ? Candidates?.Cast<object>() ?? [] : results;
var exact = candidates.Where(x => MatchesExactly(x, args.QueryText.Trim())).Take(2).ToArray();
var selected = args.ChosenSuggestion ?? (exact.Length == 1 ? exact[0] : null);
if (selected is not null) Choose(selected);
if (IsSelectionRequired && SelectedItem is null) return;
input.IsSuggestionListOpen = false;
_ = RefreshListAsync(false);
}
private string Label(object item) => Label(item, input.DisplayMemberPath);
private static string Label(object item, string path) => path.Length > 0 ? Member(item, path) : item.ToString() ?? "";
private static string Member(object item, string path) => item.GetType().GetProperty(path)?.GetValue(item)?.ToString() ?? "";
private static IEnumerable<string> SearchTerms(object item, string labelPath) => new[] { Label(item, labelPath) }.Concat(SearchMemberPaths.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(path => Member(item, path)));
private bool MatchesExactly(object item, string text) => text.Length > 0 && SearchTerms(item, input.DisplayMemberPath).Any(value => value.Equals(text, StringComparison.OrdinalIgnoreCase));
private void Choose(object? item)
{
if (item is null) return;
CancelSearch(); SelectedItem = item; input.Text = Label(item);
}
public void SelectionUpdated()
{
if (!editing) { CancelSearch(); input.Text = SelectedItem is null ? "" : Label(SelectedItem); }
}
public void SourceChanged()
{
CancelSearch(); ObserveCollection();
if (IsSelectionRequired && Provider is null && SelectedItem is not null && Candidates?.Cast<object>().Contains(SelectedItem) != true) SelectedItem = null;
}
private void ObserveCollection()
{
if (observed is not null) observed.CollectionChanged -= CollectionChanged;
observed = Candidates as INotifyCollectionChanged;
if (observed is not null) observed.CollectionChanged += CollectionChanged;
}
private void CollectionChanged(object? sender, NotifyCollectionChangedEventArgs args) => SourceChanged();
public void ClearSearch() { CancelSearch(); CancelCommand(); results = []; input.ItemsSource = results; input.IsSuggestionListOpen = false; }
private void CancelSearch() { pending?.Cancel(); pending?.Dispose(); pending = null; IsSearching = false; }
private void CancelCommand() { pendingCommand?.Cancel(); pendingCommand?.Dispose(); pendingCommand = null; }
private async Task RefreshListAsync(bool debounce)
{
CancelCommand();
if (SubmitCommand is null) return;
var cancellation = pendingCommand = new CancellationTokenSource();
var token = cancellation.Token;
try
{
if (debounce) await Task.Delay(300, token);
while (!GetCanSearch(input) || SubmitCommand is IAsyncRelayCommand { IsRunning: true }) await Task.Delay(30, token);
token.ThrowIfCancellationRequested();
if (SubmitCommand.CanExecute(null))
{
if (SubmitCommand is IAsyncRelayCommand command) await command.ExecuteAsync(null);
else SubmitCommand.Execute(null);
}
}
catch (OperationCanceledException) when (token.IsCancellationRequested) { }
catch (Exception error)
{
SearchError = UiText.Translate("无法加载候选项,请重试") + " · " + error.Message;
AutomationProperties.SetHelpText(input, SearchError);
ToolTipService.SetToolTip(input, SearchError);
}
finally { if (ReferenceEquals(pendingCommand, cancellation)) { pendingCommand = null; cancellation.Dispose(); } }
}
private async Task SearchAsync(string text, bool debounce)
{
CancelSearch();
var cancellation = pending = new CancellationTokenSource();
var token = cancellation.Token;
var query = text.Trim();
IsSearching = true; SearchError = "";
try
{
if (debounce && Provider is not null) await Task.Delay(180, token);
object[] values;
if (Provider is null)
{
// Snapshot observable collections on the dispatcher, then filter large option sets off-thread.
var candidates = Candidates?.Cast<object>().ToArray() ?? [];
var labelPath = input.DisplayMemberPath;
values = await Task.Run(() => candidates.Where(item =>
{
token.ThrowIfCancellationRequested();
return SearchTerms(item, labelPath).Any(value => value.Contains(query, StringComparison.OrdinalIgnoreCase));
}).Take(60).ToArray(), token);
}
else values = (await Provider.SearchAsync(query, token).WaitAsync(token)).Take(60).ToArray();
if (token.IsCancellationRequested) return;
results = values; input.ItemsSource = values;
ToolTipService.SetToolTip(input, null);
input.IsSuggestionListOpen = HasFocusWithin() && values.Length > 0;
AutomationProperties.SetHelpText(input, values.Length == 0 ? UiText.Translate("没有匹配的选项") : "");
}
catch (OperationCanceledException) when (token.IsCancellationRequested) { }
catch (Exception error)
{
if (token.IsCancellationRequested) return;
results = []; input.ItemsSource = results; input.IsSuggestionListOpen = false;
SearchError = UiText.Translate("无法加载候选项,请重试") + " · " + error.Message;
AutomationProperties.SetHelpText(input, SearchError);
ToolTipService.SetToolTip(input, SearchError);
}
finally { if (ReferenceEquals(pending, cancellation)) { IsSearching = false; pending = null; cancellation.Dispose(); } }
}
private bool HasFocusWithin()
{
if (input.XamlRoot is null) return false;
for (var current = FocusManager.GetFocusedElement(input.XamlRoot) as DependencyObject; current is not null; current = VisualTreeHelper.GetParent(current))
if (ReferenceEquals(current, input)) return true;
return false;
}
}
}
using System.Collections;
using System.Collections.Specialized;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
using Newtonsoft.Json.Linq;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
namespace XFEWarframe.Desktop.Editing;
/// <summary>Data access for a native AutoSuggestBox; remote searches do not replace the visible list.</summary>
public sealed class SuggestionProvider
{
private readonly Func<string, CancellationToken, Task<IReadOnlyList<object>>> search;
public SuggestionProvider(Func<string, Task<IReadOnlyList<object>>> search) : this((query, _) => search(query)) { }
public SuggestionProvider(Func<string, CancellationToken, Task<IReadOnlyList<object>>> search) => this.search = search;
public Task<IReadOnlyList<object>> SearchAsync(string query, CancellationToken cancellation = default) => search(query, cancellation);
public static SuggestionProvider OwnedRivens(Func<string, int, Task<Newtonsoft.Json.Linq.JObject>> read) => new(async (query, cancellation) =>
{
var suggestions = new List<object>();
for (var skip = 0; ; skip += 40)
{
cancellation.ThrowIfCancellationRequested();
var data = await read(string.IsNullOrWhiteSpace(query) ? "/Randomized/" : query, skip).WaitAsync(cancellation);
var items = (Newtonsoft.Json.Linq.JArray?)data["items"] ?? [];
foreach (var row in items.Where(x => x.Value<string>("type")?.Contains("/Randomized/", StringComparison.Ordinal) == true))
suggestions.Add(new EditorItem(row.Value<string>("key")!, row.Value<string>("name")!, row.Value<string>("detail") ?? ""));
if (suggestions.Count >= 60 || items.Count == 0 || skip + 40 >= data.Value<int>("total")) return suggestions.Take(60).ToArray();
}
});
}
public sealed record SearchSuggestion(string Label, string Key = "");
/// <summary>Native suggestions shared by both applications, with cancellable, debounced lookups.</summary>
public sealed class Suggestions : DependencyObject
{
public static IEnumerable? GetCandidates(DependencyObject target) => (IEnumerable?)target.GetValue(CandidatesProperty);
public static void SetCandidates(DependencyObject target, IEnumerable? value) => target.SetValue(CandidatesProperty, value);
public static readonly DependencyProperty CandidatesProperty = DependencyProperty.RegisterAttached("Candidates", typeof(IEnumerable), typeof(Suggestions), new PropertyMetadata(null, (sender, _) => GetSession(sender).SourceChanged()));
public static SuggestionProvider? GetProvider(DependencyObject target) => (SuggestionProvider?)target.GetValue(ProviderProperty);
public static void SetProvider(DependencyObject target, SuggestionProvider? value) => target.SetValue(ProviderProperty, value);
public static readonly DependencyProperty ProviderProperty = DependencyProperty.RegisterAttached("Provider", typeof(SuggestionProvider), typeof(Suggestions), new PropertyMetadata(null, (sender, _) => GetSession(sender).ClearSearch()));
public static object? GetSelectedItem(DependencyObject target) => target.GetValue(SelectedItemProperty);
public static void SetSelectedItem(DependencyObject target, object? value) => target.SetValue(SelectedItemProperty, value);
public static readonly DependencyProperty SelectedItemProperty = DependencyProperty.RegisterAttached("SelectedItem", typeof(object), typeof(Suggestions), new PropertyMetadata(null, (sender, _) => GetSession(sender).SelectionUpdated()));
public static bool GetIsSelectionRequired(DependencyObject target) => (bool)target.GetValue(IsSelectionRequiredProperty);
public static void SetIsSelectionRequired(DependencyObject target, bool value) => target.SetValue(IsSelectionRequiredProperty, value);
public static readonly DependencyProperty IsSelectionRequiredProperty = DependencyProperty.RegisterAttached("IsSelectionRequired", typeof(bool), typeof(Suggestions), new PropertyMetadata(false));
public static ICommand? GetSubmitCommand(DependencyObject target) => (ICommand?)target.GetValue(SubmitCommandProperty);
public static void SetSubmitCommand(DependencyObject target, ICommand? value) => target.SetValue(SubmitCommandProperty, value);
public static readonly DependencyProperty SubmitCommandProperty = DependencyProperty.RegisterAttached("SubmitCommand", typeof(ICommand), typeof(Suggestions), new PropertyMetadata(null));
public static bool GetSearchOnTextChanged(DependencyObject target) => (bool)target.GetValue(SearchOnTextChangedProperty);
public static void SetSearchOnTextChanged(DependencyObject target, bool value) => target.SetValue(SearchOnTextChangedProperty, value);
public static readonly DependencyProperty SearchOnTextChangedProperty = DependencyProperty.RegisterAttached("SearchOnTextChanged", typeof(bool), typeof(Suggestions), new PropertyMetadata(false));
public static bool GetCanSearch(DependencyObject target) => (bool)target.GetValue(CanSearchProperty);
public static void SetCanSearch(DependencyObject target, bool value) => target.SetValue(CanSearchProperty, value);
public static readonly DependencyProperty CanSearchProperty = DependencyProperty.RegisterAttached("CanSearch", typeof(bool), typeof(Suggestions), new PropertyMetadata(true));
public static object? GetContext(DependencyObject target) => target.GetValue(ContextProperty);
public static void SetContext(DependencyObject target, object? value) => target.SetValue(ContextProperty, value);
public static readonly DependencyProperty ContextProperty = DependencyProperty.RegisterAttached("Context", typeof(object), typeof(Suggestions), new PropertyMetadata(null, (sender, _) => GetSession(sender).ClearSearch()));
public static object? GetSecondaryContext(DependencyObject target) => target.GetValue(SecondaryContextProperty);
public static void SetSecondaryContext(DependencyObject target, object? value) => target.SetValue(SecondaryContextProperty, value);
public static readonly DependencyProperty SecondaryContextProperty = DependencyProperty.RegisterAttached("SecondaryContext", typeof(object), typeof(Suggestions), new PropertyMetadata(null, (sender, _) => GetSession(sender).ClearSearch()));
public static bool GetIsSearching(AutoSuggestBox target) => GetSession(target).IsSearching;
public static string GetSearchError(AutoSuggestBox target) => GetSession(target).SearchError;
private static readonly DependencyProperty SessionProperty = DependencyProperty.RegisterAttached("Session", typeof(Session), typeof(Suggestions), new PropertyMetadata(null));
private static Session GetSession(DependencyObject target)
{
if (target.GetValue(SessionProperty) is Session existing) return existing;
var session = new Session((AutoSuggestBox)target); target.SetValue(SessionProperty, session); return session;
}
private sealed class Session
{
private readonly AutoSuggestBox input;
private IEnumerable? Candidates => GetCandidates(input);
private SuggestionProvider? Provider => GetProvider(input);
private object? SelectedItem { get => GetSelectedItem(input); set => SetSelectedItem(input, value); }
private bool IsSelectionRequired => GetIsSelectionRequired(input);
private ICommand? SubmitCommand => GetSubmitCommand(input);
private const string SearchMemberPaths = "Key,Type,Id,Email,Value,Directory";
public bool IsSearching { get; private set; }
public string SearchError { get; private set; } = "";
private CancellationTokenSource? pending;
private CancellationTokenSource? pendingCommand;
private IReadOnlyList<object> results = [];
private bool editing;
private bool listening;
private INotifyCollectionChanged? observed;
public Session(AutoSuggestBox input)
{
this.input = input;
input.TextMemberPath = input.DisplayMemberPath;
input.QueryIcon = new SymbolIcon(Symbol.Find);
input.MaxSuggestionListHeight = 320;
input.HorizontalAlignment = HorizontalAlignment.Stretch;
if (string.IsNullOrEmpty(input.PlaceholderText)) input.PlaceholderText = UiText.Translate("输入名称搜索");
input.RegisterPropertyChangedCallback(AutoSuggestBox.DisplayMemberPathProperty, (_, _) => input.TextMemberPath = input.DisplayMemberPath);
input.RegisterPropertyChangedCallback(AutoSuggestBox.TextProperty, (_, _) =>
{
if (SelectedItem is null || input.Text == Label(SelectedItem)) return;
editing = true;
try { SelectedItem = null; }
finally { editing = false; }
});
input.GotFocus += async (_, _) => { Attach(); ObserveCollection(); await SearchAsync(SelectedItem is not null && input.Text == Label(SelectedItem) ? "" : input.Text, false); };
input.Loaded += (_, _) => { Attach(); ObserveCollection(); };
input.Unloaded += (_, _) =>
{
input.TextChanged -= TextChanged;
input.SuggestionChosen -= SuggestionChosen;
input.QuerySubmitted -= QuerySubmitted;
listening = false;
CancelSearch(); CancelCommand();
if (observed is not null) observed.CollectionChanged -= CollectionChanged;
observed = null;
};
}
private void Attach()
{
if (listening) return;
listening = true;
input.TextChanged += TextChanged;
input.SuggestionChosen += SuggestionChosen;
input.QuerySubmitted += QuerySubmitted;
}
private async void TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
if (GetSearchOnTextChanged(input) && HasFocusWithin()) _ = RefreshListAsync(true);
if (args.Reason == AutoSuggestionBoxTextChangeReason.SuggestionChosen) return;
if (SelectedItem is not null && input.Text == Label(SelectedItem)) return;
editing = true;
try { SelectedItem = null; }
finally { editing = false; }
await SearchAsync(sender.Text, true);
}
private void SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) => Choose(args.SelectedItem);
private void QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args)
{
var candidates = Provider is null ? Candidates?.Cast<object>() ?? [] : results;
var exact = candidates.Where(x => MatchesExactly(x, args.QueryText.Trim())).Take(2).ToArray();
var selected = args.ChosenSuggestion ?? (exact.Length == 1 ? exact[0] : null);
if (selected is not null) Choose(selected);
if (IsSelectionRequired && SelectedItem is null) return;
input.IsSuggestionListOpen = false;
_ = RefreshListAsync(false);
}
private string Label(object item) => Label(item, input.DisplayMemberPath);
private static string Label(object item, string path) => path.Length > 0 ? Member(item, path) : item.ToString() ?? "";
private static string Member(object item, string path) => item.GetType().GetProperty(path)?.GetValue(item)?.ToString() ?? "";
private static IEnumerable<string> SearchTerms(object item, string labelPath) => new[] { Label(item, labelPath) }.Concat(SearchMemberPaths.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(path => Member(item, path)));
private bool MatchesExactly(object item, string text) => text.Length > 0 && SearchTerms(item, input.DisplayMemberPath).Any(value => value.Equals(text, StringComparison.OrdinalIgnoreCase));
private void Choose(object? item)
{
if (item is null) return;
CancelSearch(); SelectedItem = item; input.Text = Label(item);
}
public void SelectionUpdated()
{
if (!editing) { CancelSearch(); input.Text = SelectedItem is null ? "" : Label(SelectedItem); }
}
public void SourceChanged()
{
CancelSearch(); ObserveCollection();
if (IsSelectionRequired && Provider is null && SelectedItem is not null && Candidates?.Cast<object>().Contains(SelectedItem) != true) SelectedItem = null;
}
private void ObserveCollection()
{
if (observed is not null) observed.CollectionChanged -= CollectionChanged;
observed = Candidates as INotifyCollectionChanged;
if (observed is not null) observed.CollectionChanged += CollectionChanged;
}
private void CollectionChanged(object? sender, NotifyCollectionChangedEventArgs args) => SourceChanged();
public void ClearSearch() { CancelSearch(); CancelCommand(); results = []; input.ItemsSource = results; input.IsSuggestionListOpen = false; }
private void CancelSearch() { pending?.Cancel(); pending?.Dispose(); pending = null; IsSearching = false; }
private void CancelCommand() { pendingCommand?.Cancel(); pendingCommand?.Dispose(); pendingCommand = null; }
private async Task RefreshListAsync(bool debounce)
{
CancelCommand();
if (SubmitCommand is null) return;
var cancellation = pendingCommand = new CancellationTokenSource();
var token = cancellation.Token;
try
{
if (debounce) await Task.Delay(300, token);
while (!GetCanSearch(input) || SubmitCommand is IAsyncRelayCommand { IsRunning: true }) await Task.Delay(30, token);
token.ThrowIfCancellationRequested();
if (SubmitCommand.CanExecute(null))
{
if (SubmitCommand is IAsyncRelayCommand command) await command.ExecuteAsync(null);
else SubmitCommand.Execute(null);
}
}
catch (OperationCanceledException) when (token.IsCancellationRequested) { }
catch (Exception error)
{
SearchError = UiText.Translate("无法加载候选项,请重试") + " · " + error.Message;
AutomationProperties.SetHelpText(input, SearchError);
ToolTipService.SetToolTip(input, SearchError);
}
finally { if (ReferenceEquals(pendingCommand, cancellation)) { pendingCommand = null; cancellation.Dispose(); } }
}
private async Task SearchAsync(string text, bool debounce)
{
CancelSearch();
var cancellation = pending = new CancellationTokenSource();
var token = cancellation.Token;
var query = text.Trim();
IsSearching = true; SearchError = "";
try
{
if (debounce && Provider is not null) await Task.Delay(180, token);
object[] values;
if (Provider is null)
{
// Snapshot observable collections on the dispatcher, then filter large option sets off-thread.
var candidates = Candidates?.Cast<object>().ToArray() ?? [];
var labelPath = input.DisplayMemberPath;
values = await Task.Run(() => candidates.Where(item =>
{
token.ThrowIfCancellationRequested();
return SearchTerms(item, labelPath).Any(value => value.Contains(query, StringComparison.OrdinalIgnoreCase));
}).Take(60).ToArray(), token);
}
else values = (await Provider.SearchAsync(query, token).WaitAsync(token)).Take(60).ToArray();
if (token.IsCancellationRequested) return;
results = values; input.ItemsSource = values;
ToolTipService.SetToolTip(input, null);
input.IsSuggestionListOpen = HasFocusWithin() && values.Length > 0;
AutomationProperties.SetHelpText(input, values.Length == 0 ? UiText.Translate("没有匹配的选项") : "");
}
catch (OperationCanceledException) when (token.IsCancellationRequested) { }
catch (Exception error)
{
if (token.IsCancellationRequested) return;
results = []; input.ItemsSource = results; input.IsSuggestionListOpen = false;
SearchError = UiText.Translate("无法加载候选项,请重试") + " · " + error.Message;
AutomationProperties.SetHelpText(input, SearchError);
ToolTipService.SetToolTip(input, SearchError);
}
finally { if (ReferenceEquals(pending, cancellation)) { IsSearching = false; pending = null; cancellation.Dispose(); } }
}
private bool HasFocusWithin()
{
if (input.XamlRoot is null) return false;
for (var current = FocusManager.GetFocusedElement(input.XamlRoot) as DependencyObject; current is not null; current = VisualTreeHelper.GetParent(current))
if (ReferenceEquals(current, input)) return true;
return false;
}
}
}