XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEWarframe

【WinUI】基于Warframe游戏本体的公开服务器以及安装器

公开
关注 0 Fork 0 Star 0
UTF-8
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;
        }
    }

}