diff --git a/src/Snap.Hutao/Snap.Hutao/App.xaml b/src/Snap.Hutao/Snap.Hutao/App.xaml index 8d40cc811..84b7b5a81 100644 --- a/src/Snap.Hutao/Snap.Hutao/App.xaml +++ b/src/Snap.Hutao/Snap.Hutao/App.xaml @@ -7,7 +7,6 @@ - @@ -30,6 +29,7 @@ + diff --git a/src/Snap.Hutao/Snap.Hutao/Control/AutoSuggestBox/AutoSuggestTokenBox.cs b/src/Snap.Hutao/Snap.Hutao/Control/AutoSuggestBox/AutoSuggestTokenBox.cs index 00a5a25c4..b39db50d1 100644 --- a/src/Snap.Hutao/Snap.Hutao/Control/AutoSuggestBox/AutoSuggestTokenBox.cs +++ b/src/Snap.Hutao/Snap.Hutao/Control/AutoSuggestBox/AutoSuggestTokenBox.cs @@ -1,12 +1,10 @@ // Copyright (c) DGP Studio. All rights reserved. // Licensed under the MIT license. -using CommunityToolkit.WinUI; -using CommunityToolkit.WinUI.Controls; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Controls.Primitives; using Snap.Hutao.Control.Extension; +using Snap.Hutao.Control.TokenizingTextBox; using System.Collections; namespace Snap.Hutao.Control.AutoSuggestBox; @@ -14,33 +12,16 @@ namespace Snap.Hutao.Control.AutoSuggestBox; [DependencyProperty("FilterCommand", typeof(ICommand))] [DependencyProperty("FilterCommandParameter", typeof(object))] [DependencyProperty("AvailableTokens", typeof(IReadOnlyDictionary))] -internal sealed partial class AutoSuggestTokenBox : TokenizingTextBox +internal sealed partial class AutoSuggestTokenBox : TokenizingTextBox.TokenizingTextBox { public AutoSuggestTokenBox() { - DefaultStyleKey = typeof(TokenizingTextBox); + DefaultStyleKey = typeof(TokenizingTextBox.TokenizingTextBox); TextChanged += OnFilterSuggestionRequested; QuerySubmitted += OnQuerySubmitted; TokenItemAdding += OnTokenItemAdding; TokenItemAdded += OnTokenItemCollectionChanged; TokenItemRemoved += OnTokenItemCollectionChanged; - Loaded += OnLoaded; - } - - private void OnLoaded(object sender, RoutedEventArgs e) - { - if (this.FindDescendant("SuggestionsPopup") is Popup { Child: Border { Child: ListView listView } border }) - { - IAppResourceProvider appResourceProvider = this.ServiceProvider().GetRequiredService(); - - listView.Background = null; - listView.Margin = appResourceProvider.GetResource("AutoSuggestListPadding"); - - border.Background = appResourceProvider.GetResource("AutoSuggestBoxSuggestionsListBackground"); - CornerRadius overlayCornerRadius = appResourceProvider.GetResource("OverlayCornerRadius"); - CornerRadiusFilterConverter cornerRadiusFilterConverter = new() { Filter = CornerRadiusFilterKind.Bottom }; - border.CornerRadius = (CornerRadius)cornerRadiusFilterConverter.Convert(overlayCornerRadius, typeof(CornerRadius), default, default); - } } private void OnFilterSuggestionRequested(Microsoft.UI.Xaml.Controls.AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) @@ -73,7 +54,7 @@ private void OnQuerySubmitted(Microsoft.UI.Xaml.Controls.AutoSuggestBox sender, CommandInvocation.TryExecute(FilterCommand, FilterCommandParameter); } - private void OnTokenItemAdding(TokenizingTextBox sender, TokenItemAddingEventArgs args) + private void OnTokenItemAdding(TokenizingTextBox.TokenizingTextBox sender, TokenItemAddingEventArgs args) { if (string.IsNullOrWhiteSpace(args.TokenText)) { @@ -90,7 +71,7 @@ private void OnTokenItemAdding(TokenizingTextBox sender, TokenItemAddingEventArg } } - private void OnTokenItemCollectionChanged(TokenizingTextBox sender, object args) + private void OnTokenItemCollectionChanged(TokenizingTextBox.TokenizingTextBox sender, object args) { if (args is SearchToken { Kind: SearchTokenKind.None } token) { diff --git a/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/ITokenStringContainer.cs b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/ITokenStringContainer.cs new file mode 100644 index 000000000..3d8e122e1 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/ITokenStringContainer.cs @@ -0,0 +1,11 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Control.TokenizingTextBox; + +internal interface ITokenStringContainer +{ + string Text { get; set; } + + bool IsLast { get; } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/InterspersedObservableCollection.cs b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/InterspersedObservableCollection.cs new file mode 100644 index 000000000..d656ef5d2 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/InterspersedObservableCollection.cs @@ -0,0 +1,350 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using CommunityToolkit.WinUI.Helpers; +using System.Collections; +using System.Collections.Specialized; + +namespace Snap.Hutao.Control.TokenizingTextBox; + +internal sealed class InterspersedObservableCollection : IList, IEnumerable, INotifyCollectionChanged +{ + private readonly Dictionary interspersedObjects = []; + private bool isInsertingOriginal; + + public InterspersedObservableCollection(object itemsSource) + { + if (itemsSource is not IList list) + { + throw new ArgumentException("The input items source must be assignable to the System.Collections.IList type."); + } + + ItemsSource = list; + + if (ItemsSource is INotifyCollectionChanged notifier) + { + WeakEventListener weakPropertyChangedListener = new(this) + { + OnEventAction = static (instance, source, eventArgs) => instance.ItemsSource_CollectionChanged(source, eventArgs), + OnDetachAction = (weakEventListener) => notifier.CollectionChanged -= weakEventListener.OnEvent, // Use Local Reference Only + }; + notifier.CollectionChanged += weakPropertyChangedListener.OnEvent; + } + } + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + public IList ItemsSource { get; private set; } + + public bool IsFixedSize => false; + + public bool IsReadOnly => false; + + public int Count => ItemsSource.Count + interspersedObjects.Count; + + public bool IsSynchronized => false; + + public object SyncRoot => new(); + + public object? this[int index] + { + get + { + if (interspersedObjects.TryGetValue(index, out object? value)) + { + return value; + } + + // Find out the number of elements in our dictionary with keys below ours. + return ItemsSource[ToInnerIndex(index)]; + } + set => throw new NotImplementedException(); + } + + public void Insert(int index, object? obj) + { + MoveKeysForward(index, 1); // Move existing keys at index over to make room for new item + + ArgumentNullException.ThrowIfNull(obj); + interspersedObjects[index] = obj; + + CollectionChanged?.Invoke(this, new(NotifyCollectionChangedAction.Add, obj, index)); + } + + public void InsertAt(int outerIndex, object obj) + { + // Find out our closest index based on interspersed keys + int index = outerIndex - interspersedObjects.Keys.Count(key => key!.Value < outerIndex); // Note: we exclude the = from ToInnerIndex here + + // If we're inserting where we would normally, then just do that, otherwise we need extra room to not move other keys + if (index != outerIndex) + { + MoveKeysForward(outerIndex, 1); // Skip over until the current spot unlike normal + + isInsertingOriginal = true; // Prevent Collection callback from moving keys forward on insert + } + + // Insert into original collection + ItemsSource.Insert(index, obj); + + // TODO: handle manipulation/notification if not observable + } + + public IEnumerator GetEnumerator() + { + int i = 0; // Index of our current 'virtual' position + int count = 0; + int realized = 0; + + foreach (object element in ItemsSource) + { + while (interspersedObjects.TryGetValue(i++, out object? obj)) + { + realized++; // Track interspersed items used + + yield return obj; + } + + count++; // Track original items used + + yield return element; + } + + // Add any remaining items in our interspersed collection past the index we reached in the original collection + if (realized < interspersedObjects.Count) + { + // Only select items past our current index, but make sure we've sorted them by their index as well. + foreach ((int? _, object value) in interspersedObjects.Where(kvp => kvp.Key >= i).OrderBy(kvp => kvp.Key)) + { + yield return value; + } + } + } + + public int Add(object? value) + { + ArgumentNullException.ThrowIfNull(value); + int index = ItemsSource.Add(value); //// TODO: If the collection isn't observable, we should do manipulations/notifications here...? + return ToOuterIndex(index); + } + + public void Clear() + { + ItemsSource.Clear(); + interspersedObjects.Clear(); + } + + public bool Contains(object? value) + { + ArgumentNullException.ThrowIfNull(value); + return interspersedObjects.ContainsValue(value) || ItemsSource.Contains(value); + } + + public int IndexOf(object? value) + { + ArgumentNullException.ThrowIfNull(value); + (int? key, object _) = ItemKeySearch(value); + + if (key is int k) + { + return k; + } + + int index = ItemsSource.IndexOf(value); + + // Find out the number of elements in our dictionary with keys below ours. + return index == -1 ? -1 : ToOuterIndex(index); + } + + public void Remove(object? value) + { + ArgumentNullException.ThrowIfNull(value); + (int? key, object obj) = ItemKeySearch(value); + + if (key is int k) + { + interspersedObjects.Remove(k); + + MoveKeysBackward(k, 1); // Move other interspersed items back + + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, obj, k)); + } + else + { + ItemsSource.Remove(value); // TODO: If not observable, update indices? + } + } + + public void RemoveAt(int index) + { + throw new NotImplementedException(); + } + + public void CopyTo(Array array, int index) + { + throw new NotImplementedException(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + private void ItemsSource_CollectionChanged(object? source, NotifyCollectionChangedEventArgs eventArgs) + { + switch (eventArgs.Action) + { + case NotifyCollectionChangedAction.Add: + // Shift any existing interspersed items after the inserted item + ArgumentNullException.ThrowIfNull(eventArgs.NewItems); + int count = eventArgs.NewItems.Count; + + if (count > 0) + { + if (!isInsertingOriginal) + { + MoveKeysForward(eventArgs.NewStartingIndex, count); + } + + isInsertingOriginal = false; + + CollectionChanged?.Invoke(this, new(NotifyCollectionChangedAction.Add, eventArgs.NewItems, ToOuterIndex(eventArgs.NewStartingIndex))); + } + + break; + case NotifyCollectionChangedAction.Remove: + ArgumentNullException.ThrowIfNull(eventArgs.OldItems); + count = eventArgs.OldItems.Count; + + if (count > 0) + { + int outerIndex = ToOuterIndexAfterRemoval(eventArgs.OldStartingIndex); + + MoveKeysBackward(outerIndex, count); + + CollectionChanged?.Invoke(this, new(NotifyCollectionChangedAction.Remove, eventArgs.OldItems, outerIndex)); + } + + break; + case NotifyCollectionChangedAction.Reset: + + ReadjustKeys(); + + // TODO: ListView doesn't like this notification and throws a visual tree duplication exception... + // Not sure what to do with that yet... + CollectionChanged?.Invoke(this, eventArgs); + break; + } + } + + private void MoveKeysForward(int pivot, int amount) + { + // Sort in reverse order to work from highest to lowest + foreach (int? key in interspersedObjects.Keys.OrderByDescending(v => v)) + { + if (key < pivot) //// If it's the last item in the collection, we still want to move our last key, otherwise we'd use <= + { + break; + } + + interspersedObjects[key + amount] = interspersedObjects[key]; + interspersedObjects.Remove(key); + } + } + + private void MoveKeysBackward(int pivot, int amount) + { + // Sort in regular order to work from the earliest indices onwards + foreach (int? key in interspersedObjects.Keys.OrderBy(v => v)) + { + // Skip elements before the pivot point + if (key <= pivot) //// Include pivot point as that's the point where we start modifying beyond + { + continue; + } + + interspersedObjects[key - amount] = interspersedObjects[key]; + interspersedObjects.Remove(key); + } + } + + private void ReadjustKeys() + { + int count = ItemsSource.Count; + int existing = 0; + + foreach (int? key in interspersedObjects.Keys.OrderBy(v => v)) + { + if (key <= count) + { + existing++; + continue; + } + + interspersedObjects[count + existing++] = interspersedObjects[key]; + interspersedObjects.Remove(key); + } + } + + private int ToInnerIndex(int outerIndex) + { + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(outerIndex, Count); + + if (interspersedObjects.ContainsKey(outerIndex)) + { + throw new ArgumentException("The outer index can't be inserted as a key to the original collection."); + } + + return outerIndex - interspersedObjects.Keys.Count(key => key!.Value <= outerIndex); + } + + private int ToOuterIndex(int innerIndex) + { + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(innerIndex, ItemsSource.Count); + + foreach ((int? key, object _) in interspersedObjects.OrderBy(v => v.Key)) + { + if (innerIndex >= key) + { + innerIndex++; + } + else + { + break; + } + } + + return innerIndex; + } + + private int ToOuterIndexAfterRemoval(int innerIndexToProject) + { + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(innerIndexToProject, ItemsSource.Count + 1); + + //// TODO: Deal with bounds (0 / Count)? Or is it the same? + + foreach ((int? key, object _) in interspersedObjects.OrderBy(v => v.Key)) + { + if (innerIndexToProject >= key) + { + innerIndexToProject++; + } + else + { + break; + } + } + + return innerIndexToProject; + } + + private KeyValuePair ItemKeySearch(object value) + { + if (value is null) + { + return interspersedObjects.FirstOrDefault(kvp => kvp.Value is null); + } + + return interspersedObjects.FirstOrDefault(kvp => kvp.Value.Equals(value)); + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/PretokenStringContainer.cs b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/PretokenStringContainer.cs new file mode 100644 index 000000000..ba21d0a40 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/PretokenStringContainer.cs @@ -0,0 +1,27 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.UI.Xaml; + +namespace Snap.Hutao.Control.TokenizingTextBox; + +[DependencyProperty("Text", typeof(string))] +internal sealed partial class PretokenStringContainer : DependencyObject, ITokenStringContainer +{ + public PretokenStringContainer(bool isLast = false) + { + IsLast = isLast; + } + + public PretokenStringContainer(string text) + { + Text = text; + } + + public bool IsLast { get; private set; } + + public override string ToString() + { + return Text; + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenItemAddingEventArgs.cs b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenItemAddingEventArgs.cs new file mode 100644 index 000000000..b56f39ed8 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenItemAddingEventArgs.cs @@ -0,0 +1,18 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using CommunityToolkit.Common.Deferred; + +namespace Snap.Hutao.Control.TokenizingTextBox; + +internal sealed class TokenItemAddingEventArgs : DeferredCancelEventArgs +{ + public TokenItemAddingEventArgs(string token) + { + TokenText = token; + } + + public string TokenText { get; private set; } + + public object? Item { get; set; } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenItemRemovingEventArgs.cs b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenItemRemovingEventArgs.cs new file mode 100644 index 000000000..8a068da4c --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenItemRemovingEventArgs.cs @@ -0,0 +1,19 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using CommunityToolkit.Common.Deferred; + +namespace Snap.Hutao.Control.TokenizingTextBox; + +internal sealed class TokenItemRemovingEventArgs : DeferredCancelEventArgs +{ + public TokenItemRemovingEventArgs(object item, TokenizingTextBoxItem token) + { + Item = item; + Token = token; + } + + public object Item { get; private set; } + + public TokenizingTextBoxItem Token { get; private set; } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBox.cs b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBox.cs new file mode 100644 index 000000000..b5be782ab --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBox.cs @@ -0,0 +1,951 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using CommunityToolkit.WinUI; +using CommunityToolkit.WinUI.Deferred; +using CommunityToolkit.WinUI.Helpers; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation.Peers; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using System.Collections.ObjectModel; +using Windows.ApplicationModel.DataTransfer; +using Windows.Foundation; +using Windows.Foundation.Metadata; +using Windows.System; +using Windows.UI.Core; +using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; +using DispatcherQueuePriority = Microsoft.UI.Dispatching.DispatcherQueuePriority; + +namespace Snap.Hutao.Control.TokenizingTextBox; + +[DependencyProperty("AutoSuggestBoxStyle", typeof(Style))] +[DependencyProperty("AutoSuggestBoxTextBoxStyle", typeof(Style))] +[DependencyProperty("MaximumTokens", typeof(int), -1, nameof(OnMaximumTokensChanged))] +[DependencyProperty("PlaceholderText", typeof(string))] +[DependencyProperty("QueryIcon", typeof(IconSource))] +[DependencyProperty("SuggestedItemsSource", typeof(object))] +[DependencyProperty("SuggestedItemTemplate", typeof(DataTemplate))] +[DependencyProperty("SuggestedItemTemplateSelector", typeof(DataTemplateSelector))] +[DependencyProperty("SuggestedItemContainerStyle", typeof(Style))] +[DependencyProperty("TabNavigateBackOnArrow", typeof(bool), false)] +[DependencyProperty("Text", typeof(string), default, nameof(TextPropertyChanged))] +[DependencyProperty("TextMemberPath", typeof(string))] +[DependencyProperty("TokenItemTemplate", typeof(DataTemplate))] +[DependencyProperty("TokenItemTemplateSelector", typeof(DataTemplateSelector))] +[DependencyProperty("TokenDelimiter", typeof(string), " ")] +[DependencyProperty("TokenSpacing", typeof(double))] +[TemplatePart(Name = NormalState, Type = typeof(VisualState))] +[TemplatePart(Name = PointerOverState, Type = typeof(VisualState))] +[TemplatePart(Name = FocusedState, Type = typeof(VisualState))] +[TemplatePart(Name = UnfocusedState, Type = typeof(VisualState))] +[TemplatePart(Name = MaxReachedState, Type = typeof(VisualState))] +[TemplatePart(Name = MaxUnreachedState, Type = typeof(VisualState))] +[SuppressMessage("", "SA1124")] +internal partial class TokenizingTextBox : ListViewBase +{ + public const string NormalState = "Normal"; + public const string PointerOverState = "PointerOver"; + public const string FocusedState = "Focused"; + public const string UnfocusedState = "Unfocused"; + public const string MaxReachedState = "MaxReachedState"; + public const string MaxUnreachedState = "MaxUnreachedState"; + + private DispatcherQueue dispatcherQueue; + private InterspersedObservableCollection innerItemsSource; + private ITokenStringContainer currentTextEdit; // Don't update this directly outside of initialization, use UpdateCurrentTextEdit Method + private ITokenStringContainer lastTextEdit; + + public TokenizingTextBox() + { + // Setup our base state of our collection + innerItemsSource = new InterspersedObservableCollection(new ObservableCollection()); // TODO: Test this still will let us bind to ItemsSource in XAML? + currentTextEdit = lastTextEdit = new PretokenStringContainer(true); + innerItemsSource.Insert(innerItemsSource.Count, currentTextEdit); + ItemsSource = innerItemsSource; + //// TODO: Consolidate with callback below for ItemsSourceProperty changed? + + DefaultStyleKey = typeof(TokenizingTextBox); + + // TODO: Do we want to support ItemsSource better? Need to investigate how that works with adding... + RegisterPropertyChangedCallback(ItemsSourceProperty, ItemsSource_PropertyChanged); + PreviewKeyDown += TokenizingTextBox_PreviewKeyDown; + PreviewKeyUp += TokenizingTextBox_PreviewKeyUp; + CharacterReceived += TokenizingTextBox_CharacterReceived; + ItemClick += TokenizingTextBox_ItemClick; + + dispatcherQueue = DispatcherQueue; + } + + public event TypedEventHandler? TextChanged; + + public event TypedEventHandler? SuggestionChosen; + + public event TypedEventHandler? QuerySubmitted; + + public event TypedEventHandler? TokenItemAdding; + + public event TypedEventHandler? TokenItemAdded; + + public event TypedEventHandler? TokenItemRemoving; + + public event TypedEventHandler? TokenItemRemoved; + + private enum MoveDirection + { + Next, + Previous, + } + + public static bool IsShiftPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); + + public static bool IsXamlRootAvailable { get; } = ApiInformation.IsPropertyPresent("Windows.UI.Xaml.UIElement", "XamlRoot"); + + public static bool IsControlPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + + public bool PauseTokenClearOnFocus { get; set; } + + public bool IsClearingForClick { get; set; } + + public string SelectedTokenText + { + get => PrepareSelectionForClipboard(); + } + + public void RaiseQuerySubmitted(Microsoft.UI.Xaml.Controls.AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) + { + QuerySubmitted?.Invoke(sender, args); + } + + public void RaiseSuggestionChosen(Microsoft.UI.Xaml.Controls.AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + SuggestionChosen?.Invoke(sender, args); + } + + public void RaiseTextChanged(Microsoft.UI.Xaml.Controls.AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + TextChanged?.Invoke(sender, args); + } + + public void AddTokenItem(object data, bool atEnd = false) + { + _ = AddTokenAsync(data, atEnd); + } + + public async ValueTask ClearAsync() + { + while (innerItemsSource.Count > 1) + { + if (ContainerFromItem(innerItemsSource[0]) is TokenizingTextBoxItem container) + { + if (!await RemoveTokenAsync(container, innerItemsSource[0]).ConfigureAwait(true)) + { + // if a removal operation fails then stop the clear process + break; + } + } + } + + // Clear the active pretoken string. + // Setting the text property directly avoids a delay when setting the text in the autosuggest box. + Text = string.Empty; + } + + public async Task AddTokenAsync(object data, bool? atEnd = default) + { + if (MaximumTokens >= 0 && MaximumTokens <= innerItemsSource.ItemsSource.Count) + { + // No tokens for you + return; + } + + if (data is string str && TokenItemAdding is not null) + { + TokenItemAddingEventArgs tiaea = new(str); + await TokenItemAdding.InvokeAsync(this, tiaea).ConfigureAwait(true); + + if (tiaea.Cancel) + { + return; + } + + if (tiaea.Item is not null) + { + data = tiaea.Item; // Transformed by event implementor + } + } + + // If we've been typing in the last box, just add this to the end of our collection + if (atEnd == true || currentTextEdit == lastTextEdit) + { + innerItemsSource.InsertAt(innerItemsSource.Count - 1, data); + } + else + { + // Otherwise, we'll insert before our current box + ITokenStringContainer edit = currentTextEdit; + int index = innerItemsSource.IndexOf(edit); + + // Insert our new data item at the location of our textbox + innerItemsSource.InsertAt(index, data); + + // Remove our textbox + innerItemsSource.Remove(edit); + } + + // Focus back to our end box as Outlook does. + TokenizingTextBoxItem last = (TokenizingTextBoxItem)ContainerFromItem(lastTextEdit); + last?.AutoSuggestTextBox.Focus(FocusState.Keyboard); + + TokenItemAdded?.Invoke(this, data); + + GuardAgainstPlaceholderTextLayoutIssue(); + } + + public async ValueTask RemoveAllSelectedTokens() + { + while (SelectedItems.Count > 0) + { + if (ContainerFromItem(SelectedItems[0]) is TokenizingTextBoxItem container) + { + if (IndexFromContainer(container) != Items.Count - 1) + { + // if its a text box, remove any selected text, and if its then empty remove the container, unless its focused + if (SelectedItems[0] is ITokenStringContainer) + { + TextBox asb = container.AutoSuggestTextBox; + + // grab any selected text + string tempStr = asb.SelectionStart == 0 + ? string.Empty + : asb.Text[..asb.SelectionStart]; + tempStr += + asb.SelectionStart + + asb.SelectionLength < asb.Text.Length + ? asb.Text[(asb.SelectionStart + asb.SelectionLength)..] + : string.Empty; + + if (tempStr.Length is 0) + { + // Need to be careful not to remove the last item in the list + await RemoveTokenAsync(container).ConfigureAwait(true); + } + else + { + asb.Text = tempStr; + } + } + else + { + // if the item is a token just remove it. + await RemoveTokenAsync(container).ConfigureAwait(true); + } + } + else + { + if (SelectedItems.Count == 1) + { + // at this point we have one selection and its the default textbox. + // stop the iteration here + break; + } + } + } + } + } + + public bool SelectPreviousItem(TokenizingTextBoxItem item) + { + return SelectNewItem(item, -1, i => i > 0); + } + + public bool SelectNextItem(TokenizingTextBoxItem item) + { + return SelectNewItem(item, 1, i => i < Items.Count - 1); + } + + public void SelectAllTokensAndText() + { + void SelectAllTokensAndTextCore() + { + this.SelectAllSafe(); + + // need to synchronize the select all and the focus behavior on the text box + // because there is no way to identify that the focus has been set from this point + // to avoid instantly clearing the selection of tokens + PauseTokenClearOnFocus = true; + + foreach (object? item in Items) + { + if (item is ITokenStringContainer) + { + // grab any selected text + if (ContainerFromItem(item) is TokenizingTextBoxItem pretoken) + { + pretoken.AutoSuggestTextBox.SelectionStart = 0; + pretoken.AutoSuggestTextBox.SelectionLength = pretoken.AutoSuggestTextBox.Text.Length; + } + } + } + + if (ContainerFromIndex(Items.Count - 1) is TokenizingTextBoxItem container) + { + container.Focus(FocusState.Programmatic); + } + } + + _ = dispatcherQueue.EnqueueAsync(SelectAllTokensAndTextCore, DispatcherQueuePriority.Normal); + } + + public void DeselectAllTokensAndText(TokenizingTextBoxItem? ignoreItem = default) + { + this.DeselectAll(); + ClearAllTextSelections(ignoreItem); + } + + protected void UpdateCurrentTextEdit(ITokenStringContainer edit) + { + currentTextEdit = edit; + + Text = edit.Text; // Update our text property. + } + + protected override AutomationPeer OnCreateAutomationPeer() + { + return new TokenizingTextBoxAutomationPeer(this); + } + + /// + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + MenuFlyoutItem selectAllMenuItem = new() + { + Text = "Select all", + }; + selectAllMenuItem.Click += (s, e) => SelectAllTokensAndText(); + MenuFlyout menuFlyout = new(); + menuFlyout.Items.Add(selectAllMenuItem); + + if (IsXamlRootAvailable && XamlRoot is not null) + { + menuFlyout.XamlRoot = XamlRoot; + } + + ContextFlyout = menuFlyout; + } + + /// + protected override DependencyObject GetContainerForItemOverride() + { + return new TokenizingTextBoxItem(); + } + + /// + protected override bool IsItemItsOwnContainerOverride(object item) + { + return item is TokenizingTextBoxItem; + } + + /// + protected override void PrepareContainerForItemOverride(DependencyObject element, object item) + { + base.PrepareContainerForItemOverride(element, item); + + if (element is TokenizingTextBoxItem tokenitem) + { + tokenitem.Owner = this; + + tokenitem.ContentTemplateSelector = TokenItemTemplateSelector; + tokenitem.ContentTemplate = TokenItemTemplate; + + tokenitem.ClearClicked -= TokenizingTextBoxItem_ClearClicked; + tokenitem.ClearClicked += TokenizingTextBoxItem_ClearClicked; + + tokenitem.ClearAllAction -= TokenizingTextBoxItem_ClearAllAction; + tokenitem.ClearAllAction += TokenizingTextBoxItem_ClearAllAction; + + tokenitem.GotFocus -= TokenizingTextBoxItem_GotFocus; + tokenitem.GotFocus += TokenizingTextBoxItem_GotFocus; + + tokenitem.LostFocus -= TokenizingTextBoxItem_LostFocus; + tokenitem.LostFocus += TokenizingTextBoxItem_LostFocus; + + MenuFlyout menuFlyout = new(); + + MenuFlyoutItem removeMenuItem = new() + { + Text = "Remove", + }; + removeMenuItem.Click += (s, e) => TokenizingTextBoxItem_ClearClicked(tokenitem, default); + + menuFlyout.Items.Add(removeMenuItem); + + if (IsXamlRootAvailable && XamlRoot is not null) + { + menuFlyout.XamlRoot = XamlRoot; + } + + MenuFlyoutItem selectAllMenuItem = new() + { + Text = "Select all", + }; + selectAllMenuItem.Click += (s, e) => SelectAllTokensAndText(); + + menuFlyout.Items.Add(selectAllMenuItem); + + tokenitem.ContextFlyout = menuFlyout; + } + } + + private static void TextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TokenizingTextBox { currentTextEdit: { } } ttb) + { + if (e.NewValue is string newValue) + { + ttb.currentTextEdit.Text = newValue; + + // Notify inner container of text change, see issue #4749 + TokenizingTextBoxItem item = (TokenizingTextBoxItem)ttb.ContainerFromItem(ttb.currentTextEdit); + item?.UpdateText(ttb.currentTextEdit.Text); + } + } + } + + private static void OnMaximumTokensChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TokenizingTextBox { MaximumTokens: >= 0 } ttb && e.NewValue is int newMaxTokens) + { + int tokenCount = ttb.innerItemsSource.ItemsSource.Count; + if (tokenCount > 0 && tokenCount > newMaxTokens) + { + int tokensToRemove = tokenCount - Math.Max(newMaxTokens, 0); + + // Start at the end, remove any extra tokens. + for (int i = tokenCount; i > tokenCount - tokensToRemove; --i) + { + object? token = ttb.innerItemsSource.ItemsSource[i - 1]; + + if (token is not null) + { + // Force remove the items. No warning and no option to cancel. + ttb.innerItemsSource.Remove(token); + ttb.TokenItemRemoved?.Invoke(ttb, token); + } + } + } + } + } + + private void ItemsSource_PropertyChanged(DependencyObject sender, DependencyProperty dp) + { + // If we're given a different ItemsSource, we need to wrap that collection in our helper class. + if (ItemsSource is { } and not InterspersedObservableCollection) + { + innerItemsSource = new(ItemsSource); + + if (MaximumTokens >= 0 && innerItemsSource.ItemsSource.Count >= MaximumTokens) + { + // Reduce down to below the max as necessary. + int endCount = MaximumTokens > 0 ? MaximumTokens : 0; + for (int i = innerItemsSource.ItemsSource.Count - 1; i >= endCount; --i) + { + innerItemsSource.Remove(innerItemsSource[i]); + } + } + + // Add our text box at the end of items and set its default value to our initial text, fix for #4749 + currentTextEdit = lastTextEdit = new PretokenStringContainer(true) { Text = Text }; + innerItemsSource.Insert(innerItemsSource.Count, currentTextEdit); + ItemsSource = innerItemsSource; + } + } + + private void TokenizingTextBox_ItemClick(object sender, ItemClickEventArgs e) + { + // If the user taps an item in the list, make sure to clear any text selection as required + // Note, token selection is cleared by the listview default behavior + if (!IsControlPressed) + { + // Set class state flag to prevent click item being immediately deselected + IsClearingForClick = true; + ClearAllTextSelections(default); + } + } + + private void TokenizingTextBox_PreviewKeyUp(object sender, KeyRoutedEventArgs e) + { + switch (e.Key) + { + case VirtualKey.Escape: + // Clear any selection and place the focus back into the text box + DeselectAllTokensAndText(); + FocusPrimaryAutoSuggestBox(); + break; + } + } + + private void FocusPrimaryAutoSuggestBox() + { + if (Items?.Count > 0) + { + if (ContainerFromIndex(Items.Count - 1) is TokenizingTextBoxItem container) + { + container.Focus(FocusState.Programmatic); + } + } + } + + private async void TokenizingTextBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + switch (e.Key) + { + case VirtualKey.C: + if (IsControlPressed) + { + CopySelectedToClipboard(); + e.Handled = true; + return; + } + + break; + + case VirtualKey.X: + if (IsControlPressed) + { + CopySelectedToClipboard(); + + // now clear all selected tokens and text, or all if none are selected + await RemoveAllSelectedTokens().ConfigureAwait(false); + } + + break; + + // For moving between tokens + case VirtualKey.Left: + e.Handled = MoveFocusAndSelection(MoveDirection.Previous); + return; + + case VirtualKey.Right: + e.Handled = MoveFocusAndSelection(MoveDirection.Next); + return; + + case VirtualKey.A: + // modify the select-all behavior to ensure the text in the edit box gets selected. + if (IsControlPressed) + { + SelectAllTokensAndText(); + e.Handled = true; + return; + } + + break; + } + } + + private async void TokenizingTextBox_CharacterReceived(UIElement sender, CharacterReceivedRoutedEventArgs args) + { + TokenizingTextBoxItem container = (TokenizingTextBoxItem)ContainerFromItem(currentTextEdit); + + if (container is not null && !(GetFocusedElement().Equals(container.AutoSuggestTextBox) || char.IsControl(args.Character))) + { + if (SelectedItems.Count > 0) + { + int index = innerItemsSource.IndexOf(SelectedItems.First()); + + await RemoveAllSelectedTokens().ConfigureAwait(false); + + void RemoveOldItems() + { + // If we're before the last textbox and it's empty, redirect focus to that one instead + if (index == innerItemsSource.Count - 1 && string.IsNullOrWhiteSpace(lastTextEdit.Text)) + { + if (ContainerFromItem(lastTextEdit) is TokenizingTextBoxItem lastContainer) + { + lastContainer.UseCharacterAsUser = true; // Make sure we trigger a refresh of suggested items. + + lastTextEdit.Text = string.Empty + args.Character; + + UpdateCurrentTextEdit(lastTextEdit); + + lastContainer.AutoSuggestTextBox.SelectionStart = 1; // Set position to after our new character inserted + + lastContainer.AutoSuggestTextBox.Focus(FocusState.Keyboard); + } + } + else + { + //// Otherwise, create a new textbox for this text. + + UpdateCurrentTextEdit(new PretokenStringContainer((string.Empty + args.Character).Trim())); // Trim so that 'space' isn't inserted and can be used to insert a new box. + + innerItemsSource.Insert(index, currentTextEdit); + + void Containerization() + { + if (ContainerFromIndex(index) is TokenizingTextBoxItem newContainer) // Should be our last text box + { + newContainer.UseCharacterAsUser = true; // Make sure we trigger a refresh of suggested items. + + void WaitForLoad(object s, RoutedEventArgs eargs) + { + if (newContainer.AutoSuggestTextBox is not null) + { + newContainer.AutoSuggestTextBox.SelectionStart = 1; // Set position to after our new character inserted + + newContainer.AutoSuggestTextBox.Focus(FocusState.Keyboard); + } + + newContainer.Loaded -= WaitForLoad; + } + + newContainer.AutoSuggestTextBoxLoaded += WaitForLoad; + } + } + + // Need to wait for containerization + _ = DispatcherQueue.EnqueueAsync(Containerization, DispatcherQueuePriority.Normal); + } + } + + // Wait for removal of old items + _ = DispatcherQueue.EnqueueAsync(RemoveOldItems, DispatcherQueuePriority.Normal); + } + else + { + // If no items are selected, send input to the last active string container. + // This code is only fires during an edgecase where an item is in the process of being deleted and the user inputs a character before the focus has been redirected to a string container. + if (innerItemsSource[^1] is ITokenStringContainer textToken) + { + if (ContainerFromIndex(Items.Count - 1) is TokenizingTextBoxItem last) // Should be our last text box + { + string text = last.AutoSuggestTextBox.Text; + int selectionStart = last.AutoSuggestTextBox.SelectionStart; + int position = selectionStart > text.Length ? text.Length : selectionStart; + textToken.Text = text[..position] + args.Character + text[position..]; + + last.AutoSuggestTextBox.SelectionStart = position + 1; // Set position to after our new character inserted + + last.AutoSuggestTextBox.Focus(FocusState.Keyboard); + } + } + } + } + } + + private object GetFocusedElement() + { + if (IsXamlRootAvailable && XamlRoot is not null) + { + return FocusManager.GetFocusedElement(XamlRoot); + } + else + { + return FocusManager.GetFocusedElement(); + } + } + + private void TokenizingTextBoxItem_GotFocus(object sender, RoutedEventArgs e) + { + // Keep track of our currently focused textbox + if (sender is TokenizingTextBoxItem { Content: ITokenStringContainer text }) + { + UpdateCurrentTextEdit(text); + } + } + + private void TokenizingTextBoxItem_LostFocus(object sender, RoutedEventArgs e) + { + // Keep track of our currently focused textbox + if (sender is TokenizingTextBoxItem { Content: ITokenStringContainer text } && + string.IsNullOrWhiteSpace(text.Text) && text != lastTextEdit) + { + // We're leaving an inner textbox that's blank, so we'll remove it + innerItemsSource.Remove(text); + + UpdateCurrentTextEdit(lastTextEdit); + + GuardAgainstPlaceholderTextLayoutIssue(); + } + } + + private async ValueTask RemoveTokenAsync(TokenizingTextBoxItem item, object? data = null) + { + data ??= ItemFromContainer(item); + + if (TokenItemRemoving is not null) + { + TokenItemRemovingEventArgs tirea = new(data, item); + await TokenItemRemoving.InvokeAsync(this, tirea).ConfigureAwait(true); + + if (tirea.Cancel) + { + return false; + } + } + + innerItemsSource.Remove(data); + + TokenItemRemoved?.Invoke(this, data); + + GuardAgainstPlaceholderTextLayoutIssue(); + + return true; + } + + private void GuardAgainstPlaceholderTextLayoutIssue() + { + // If the *PlaceholderText is visible* on the last AutoSuggestBox, it can incorrectly layout itself + // when the *ASB has focus*. We think this is an optimization in the platform, but haven't been able to + // isolate a straight-reproduction of this issue outside of this control (though we have eliminated + // most Toolkit influences like ASB/TextBox Style, the InterspersedObservableCollection, etc...). + // The only Toolkit component involved here should be WrapPanel (which is a straight-forward Panel). + // We also know the ASB itself is adjusting it's size correctly, it's the inner component. + // + // To combat this issue: + // We toggle the visibility of the Placeholder ContentControl in order to force it's layout to update properly + FrameworkElement? placeholder = ContainerFromItem(lastTextEdit)?.FindDescendant("PlaceholderTextContentPresenter"); + + if (placeholder?.Visibility == Visibility.Visible) + { + placeholder.Visibility = Visibility.Collapsed; + + // After we ensure we've hid the control, make it visible again (this is imperceptible to the user). + _ = CompositionTargetHelper.ExecuteAfterCompositionRenderingAsync(() => + { + placeholder.Visibility = Visibility.Visible; + }); + } + } + + private bool MoveFocusAndSelection(MoveDirection direction) + { + bool retVal = false; + + if (GetCurrentContainerItem() is { } currentContainerItem) + { + object? currentItem = ItemFromContainer(currentContainerItem); + int previousIndex = Items.IndexOf(currentItem); + int index = previousIndex; + + if (direction == MoveDirection.Previous) + { + if (previousIndex > 0) + { + index -= 1; + } + else + { + if (TabNavigateBackOnArrow) + { + FocusManager.TryMoveFocus(FocusNavigationDirection.Previous, new FindNextElementOptions + { + SearchRoot = XamlRoot.Content, + }); + } + + retVal = true; + } + } + else if (direction == MoveDirection.Next) + { + if (previousIndex < Items.Count - 1) + { + index += 1; + } + } + + // Only do stuff if the index is actually changing + if (index != previousIndex) + { + if (ContainerFromIndex(index) is TokenizingTextBoxItem newItem) + { + // Check for the new item being a text control. + // this must happen before focus is set to avoid seeing the caret + // jump in come cases + if (Items[index] is ITokenStringContainer && !IsShiftPressed) + { + newItem.AutoSuggestTextBox.SelectionLength = 0; + newItem.AutoSuggestTextBox.SelectionStart = direction == MoveDirection.Next + ? 0 + : newItem.AutoSuggestTextBox.Text.Length; + } + + newItem.Focus(FocusState.Keyboard); + + // if no control keys are selected then the selection also becomes just this item + if (IsShiftPressed) + { + // What we do here depends on where the selection started + // if the previous item is between the start and new position then we add the new item to the selected range + // if the new item is between the start and the previous position then we remove the previous position + int newDistance = Math.Abs(SelectedIndex - index); + int oldDistance = Math.Abs(SelectedIndex - previousIndex); + + if (newDistance > oldDistance) + { + SelectedItems.Add(Items[index]); + } + else + { + SelectedItems.Remove(Items[previousIndex]); + } + } + else if (!IsControlPressed) + { + SelectedIndex = index; + + // This looks like a bug in the underlying ListViewBase control. + // Might need to be reviewed if the base behavior is fixed + // When two consecutive items are selected and the navigation moves between them, + // the first time that happens the old focused item is not unselected + if (SelectedItems.Count > 1) + { + SelectedItems.Clear(); + SelectedIndex = index; + } + } + + retVal = true; + } + } + } + + return retVal; + } + + private TokenizingTextBoxItem? GetCurrentContainerItem() + { + if (IsXamlRootAvailable && XamlRoot is not null) + { + return (TokenizingTextBoxItem)FocusManager.GetFocusedElement(XamlRoot); + } + else + { + return (TokenizingTextBoxItem)FocusManager.GetFocusedElement(); + } + } + + private void ClearAllTextSelections(TokenizingTextBoxItem? ignoreItem) + { + // Clear any selection in the text box + foreach (object? item in Items) + { + if (item is ITokenStringContainer) + { + if (ContainerFromItem(item) is TokenizingTextBoxItem container) + { + if (container != ignoreItem) + { + container.AutoSuggestTextBox.SelectionLength = 0; + } + } + } + } + } + + private bool SelectNewItem(TokenizingTextBoxItem item, int increment, Func testFunc) + { + // find the item in the list + int currentIndex = IndexFromContainer(item); + + // Select previous token item (if there is one). + if (testFunc(currentIndex)) + { + if (ContainerFromItem(Items[currentIndex + increment]) is ListViewItem newItem) + { + newItem.Focus(FocusState.Keyboard); + SelectedItems.Add(Items[currentIndex + increment]); + return true; + } + } + + return false; + } + + private async void TokenizingTextBoxItem_ClearAllAction(TokenizingTextBoxItem sender, RoutedEventArgs args) + { + // find the first item selected + int newSelectedIndex = -1; + + if (SelectedRanges.Count > 0) + { + newSelectedIndex = SelectedRanges[0].FirstIndex - 1; + } + + await RemoveAllSelectedTokens().ConfigureAwait(true); + + SelectedIndex = newSelectedIndex; + + if (newSelectedIndex is -1) + { + newSelectedIndex = Items.Count - 1; + } + + // focus the item prior to the first selected item + if (ContainerFromIndex(newSelectedIndex) is TokenizingTextBoxItem container) + { + container.Focus(FocusState.Keyboard); + } + } + + private async void TokenizingTextBoxItem_ClearClicked(TokenizingTextBoxItem sender, RoutedEventArgs? args) + { + await RemoveTokenAsync(sender).ConfigureAwait(true); + } + + private void CopySelectedToClipboard() + { + DataPackage dataPackage = new() + { + RequestedOperation = DataPackageOperation.Copy, + }; + + string tokenString = PrepareSelectionForClipboard(); + + if (!string.IsNullOrEmpty(tokenString)) + { + dataPackage.SetText(tokenString); + Clipboard.SetContent(dataPackage); + } + } + + private string PrepareSelectionForClipboard() + { + string tokenString = string.Empty; + bool addSeparator = false; + + // Copy all items if none selected (and no text selected) + foreach (object? item in SelectedItems.Count > 0 ? SelectedItems : Items) + { + if (addSeparator) + { + tokenString += TokenDelimiter; + } + else + { + addSeparator = true; + } + + if (item is ITokenStringContainer) + { + // grab any selected text + if (ContainerFromItem(item) is TokenizingTextBoxItem { AutoSuggestTextBox: { } textBox }) + { + tokenString += textBox.Text.Substring( + textBox.SelectionStart, + textBox.SelectionLength); + } + } + else + { + tokenString += item.ToString(); + } + } + + return tokenString; + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBox.xaml b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBox.xaml new file mode 100644 index 000000000..fc7df908e --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBox.xaml @@ -0,0 +1,172 @@ + + + + + + + 3,2,3,2 + 0,0,6,0 + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBoxAutomationPeer.cs b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBoxAutomationPeer.cs new file mode 100644 index 000000000..f37dcc4a9 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBoxAutomationPeer.cs @@ -0,0 +1,84 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; +using Microsoft.UI.Xaml.Automation.Provider; +using Microsoft.UI.Xaml.Controls; + +namespace Snap.Hutao.Control.TokenizingTextBox; + +internal class TokenizingTextBoxAutomationPeer : ListViewBaseAutomationPeer, IValueProvider +{ + public TokenizingTextBoxAutomationPeer(TokenizingTextBox owner) + : base(owner) + { + } + + public bool IsReadOnly => !OwningTokenizingTextBox.IsEnabled; + + public string Value => OwningTokenizingTextBox.Text; + + private TokenizingTextBox OwningTokenizingTextBox + { + get => (TokenizingTextBox)Owner; + } + + public void SetValue(string value) + { + if (IsReadOnly) + { + throw new ElementNotEnabledException($"Could not set the value of the {nameof(TokenizingTextBox)} "); + } + + OwningTokenizingTextBox.Text = value; + } + + protected override string GetClassNameCore() + { + return Owner.GetType().Name; + } + + protected override string GetNameCore() + { + string name = OwningTokenizingTextBox.Name; + if (!string.IsNullOrWhiteSpace(name)) + { + return name; + } + + name = AutomationProperties.GetName(OwningTokenizingTextBox); + return !string.IsNullOrWhiteSpace(name) ? name : base.GetNameCore(); + } + + protected override object GetPatternCore(PatternInterface patternInterface) + { + return patternInterface switch + { + PatternInterface.Value => this, + _ => base.GetPatternCore(patternInterface), + }; + } + + protected override IList GetChildrenCore() + { + TokenizingTextBox owner = OwningTokenizingTextBox; + + ItemCollection items = owner.Items; + if (items.Count <= 0) + { + return default!; + } + + List peers = new(items.Count); + for (int i = 0; i < items.Count; i++) + { + if (owner.ContainerFromIndex(i) is TokenizingTextBoxItem element) + { + peers.Add(FromElement(element) ?? CreatePeerForElement(element)); + } + } + + return peers; + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBoxItem.cs b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBoxItem.cs new file mode 100644 index 000000000..233e16112 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBoxItem.cs @@ -0,0 +1,480 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using CommunityToolkit.WinUI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Input; +using Windows.Foundation; +using Windows.System; + +namespace Snap.Hutao.Control.TokenizingTextBox; + +[DependencyProperty("ClearButtonStyle", typeof(Style))] +[DependencyProperty("Owner", typeof(TokenizingTextBox))] +[TemplatePart(Name = PART_ClearButton, Type = typeof(ButtonBase))] //// Token case +[TemplatePart(Name = PART_AutoSuggestBox, Type = typeof(Microsoft.UI.Xaml.Controls.AutoSuggestBox))] //// String case +[TemplatePart(Name = PART_TokensCounter, Type = typeof(TextBlock))] +[SuppressMessage("", "SA1124")] +internal partial class TokenizingTextBoxItem : ListViewItem +{ + private const string PART_ClearButton = "PART_RemoveButton"; + private const string PART_AutoSuggestBox = "PART_AutoSuggestBox"; + private const string PART_TokensCounter = "PART_TokensCounter"; + private const string QueryButton = "QueryButton"; + + private Microsoft.UI.Xaml.Controls.AutoSuggestBox autoSuggestBox; + private TextBox autoSuggestTextBox; + private Button clearButton; + private bool isSelectedFocusOnFirstCharacter; + private bool isSelectedFocusOnLastCharacter; + + public TokenizingTextBoxItem() + { + DefaultStyleKey = typeof(TokenizingTextBoxItem); + + // TODO: only add these if token? + RightTapped += TokenizingTextBoxItem_RightTapped; + KeyDown += TokenizingTextBoxItem_KeyDown; + } + + public event TypedEventHandler? AutoSuggestTextBoxLoaded; + + public event TypedEventHandler? ClearClicked; + + public event TypedEventHandler? ClearAllAction; + + public TextBox AutoSuggestTextBox { get => autoSuggestTextBox; } + + public bool UseCharacterAsUser { get; set; } + + private bool IsCaretAtStart + { + get => autoSuggestTextBox?.SelectionStart is 0; + } + + private bool IsCaretAtEnd + { + get => autoSuggestTextBox?.SelectionStart == autoSuggestTextBox?.Text.Length || autoSuggestTextBox?.SelectionStart + autoSuggestTextBox?.SelectionLength == autoSuggestTextBox?.Text.Length; + } + + private bool IsAllSelected + { + get => autoSuggestTextBox?.SelectedText == autoSuggestTextBox?.Text && !string.IsNullOrEmpty(autoSuggestTextBox?.Text); + } + + // Called to update text by link:TokenizingTextBox.Properties.cs:TextPropertyChanged + public void UpdateText(string text) + { + if (autoSuggestBox is not null) + { + autoSuggestBox.Text = text; + } + else + { + void WaitForLoad(object s, RoutedEventArgs eargs) + { + if (autoSuggestTextBox is not null) + { + autoSuggestTextBox.Text = text; + } + + AutoSuggestTextBoxLoaded -= WaitForLoad; + } + + AutoSuggestTextBoxLoaded += WaitForLoad; + } + } + + /// + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + if (GetTemplateChild(PART_AutoSuggestBox) is Microsoft.UI.Xaml.Controls.AutoSuggestBox suggestbox) + { + OnApplyTemplateAutoSuggestBox(suggestbox); + } + + if (clearButton is not null) + { + clearButton.Click -= ClearButton_Click; + } + + clearButton = (Button)GetTemplateChild(PART_ClearButton); + + if (clearButton is not null) + { + clearButton.Click += ClearButton_Click; + } + } + + private void ClearButton_Click(object sender, RoutedEventArgs e) + { + ClearClicked?.Invoke(this, e); + } + + private void TokenizingTextBoxItem_RightTapped(object sender, RightTappedRoutedEventArgs e) + { + ContextFlyout.ShowAt(this); + } + + private void TokenizingTextBoxItem_KeyDown(object sender, KeyRoutedEventArgs e) + { + if (Content is not ITokenStringContainer) + { + // We only want to 'remove' our token if we're not a textbox. + switch (e.Key) + { + case VirtualKey.Back: + case VirtualKey.Delete: + { + ClearAllAction?.Invoke(this, e); + break; + } + } + } + } + + /// Called from + private void OnApplyTemplateAutoSuggestBox(Microsoft.UI.Xaml.Controls.AutoSuggestBox auto) + { + if (autoSuggestBox is not null) + { + autoSuggestBox.Loaded -= OnASBLoaded; + + autoSuggestBox.QuerySubmitted -= AutoSuggestBox_QuerySubmitted; + autoSuggestBox.SuggestionChosen -= AutoSuggestBox_SuggestionChosen; + autoSuggestBox.TextChanged -= AutoSuggestBox_TextChanged; + autoSuggestBox.PointerEntered -= AutoSuggestBox_PointerEntered; + autoSuggestBox.PointerExited -= AutoSuggestBox_PointerExited; + autoSuggestBox.PointerCanceled -= AutoSuggestBox_PointerExited; + autoSuggestBox.PointerCaptureLost -= AutoSuggestBox_PointerExited; + autoSuggestBox.GotFocus -= AutoSuggestBox_GotFocus; + autoSuggestBox.LostFocus -= AutoSuggestBox_LostFocus; + + // Remove any previous QueryIcon + autoSuggestBox.QueryIcon = default; + } + + autoSuggestBox = auto; + + if (autoSuggestBox is not null) + { + autoSuggestBox.Loaded += OnASBLoaded; + + autoSuggestBox.QuerySubmitted += AutoSuggestBox_QuerySubmitted; + autoSuggestBox.SuggestionChosen += AutoSuggestBox_SuggestionChosen; + autoSuggestBox.TextChanged += AutoSuggestBox_TextChanged; + autoSuggestBox.PointerEntered += AutoSuggestBox_PointerEntered; + autoSuggestBox.PointerExited += AutoSuggestBox_PointerExited; + autoSuggestBox.PointerCanceled += AutoSuggestBox_PointerExited; + autoSuggestBox.PointerCaptureLost += AutoSuggestBox_PointerExited; + autoSuggestBox.GotFocus += AutoSuggestBox_GotFocus; + autoSuggestBox.LostFocus += AutoSuggestBox_LostFocus; + + // Setup a binding to the QueryIcon of the Parent if we're the last box. + if (Content is ITokenStringContainer str) + { + // We need to set our initial text in all cases. + autoSuggestBox.Text = str.Text; + + // We only set/bind some properties on the last textbox to mimic the autosuggestbox look + if (str.IsLast) + { + // Workaround for https://github.com/microsoft/microsoft-ui-xaml/issues/2568 + if (Owner.QueryIcon is FontIconSource fis && + fis.ReadLocalValue(FontIconSource.FontSizeProperty) == DependencyProperty.UnsetValue) + { + // This can be expensive, could we optimize? + // Also, this is changing the FontSize on the IconSource (which could be shared?) + fis.FontSize = Owner.TryFindResource("TokenizingTextBoxIconFontSize") as double? ?? 16; + } + + Binding iconBinding = new() + { + Source = Owner, + Path = new PropertyPath(nameof(Owner.QueryIcon)), + RelativeSource = new() + { + Mode = RelativeSourceMode.TemplatedParent, + }, + }; + + IconSourceElement iconSourceElement = new(); + iconSourceElement.SetBinding(IconSourceElement.IconSourceProperty, iconBinding); + autoSuggestBox.QueryIcon = iconSourceElement; + } + } + } + } + + private async void AutoSuggestBox_QuerySubmitted(Microsoft.UI.Xaml.Controls.AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) + { + Owner.RaiseQuerySubmitted(sender, args); + + object? chosenItem = default; + if (args.ChosenSuggestion is not null) + { + chosenItem = args.ChosenSuggestion; + } + else if (!string.IsNullOrWhiteSpace(args.QueryText)) + { + chosenItem = args.QueryText; + } + + if (chosenItem is not null) + { + await Owner.AddTokenAsync(chosenItem).ConfigureAwait(true); // TODO: Need to pass index? + sender.Text = string.Empty; + Owner.Text = string.Empty; + sender.Focus(FocusState.Programmatic); + } + } + + private void AutoSuggestBox_SuggestionChosen(Microsoft.UI.Xaml.Controls.AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + Owner.RaiseSuggestionChosen(sender, args); + } + + private void AutoSuggestBox_TextChanged(Microsoft.UI.Xaml.Controls.AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + if (sender.Text is null) + { + return; + } + + if (!EqualityComparer.Default.Equals(sender.Text, Owner.Text)) + { + Owner.Text = sender.Text; // Update parent text property, if different + } + + // Override our programmatic manipulation as we're redirecting input for the user + if (UseCharacterAsUser) + { + UseCharacterAsUser = false; + + args.Reason = AutoSuggestionBoxTextChangeReason.UserInput; + } + + Owner.RaiseTextChanged(sender, args); + + string t = sender.Text?.Trim() ?? string.Empty; + + // Look for Token Delimiters to create new tokens when text changes. + if (!string.IsNullOrEmpty(Owner.TokenDelimiter) && t.Contains(Owner.TokenDelimiter, StringComparison.OrdinalIgnoreCase)) + { + bool lastDelimited = t[^1] == Owner.TokenDelimiter[0]; + + string[] tokens = t.Split(Owner.TokenDelimiter); + int numberToProcess = lastDelimited ? tokens.Length : tokens.Length - 1; + for (int position = 0; position < numberToProcess; position++) + { + string token = tokens[position]; + token = token.Trim(); + if (token.Length > 0) + { + _ = Owner.AddTokenAsync(token); //// TODO: Pass Index? + } + } + + if (lastDelimited) + { + sender.Text = string.Empty; + } + else + { + sender.Text = tokens[^1].Trim(); + } + } + } + + private void AutoSuggestBox_PointerEntered(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(Owner, TokenizingTextBox.PointerOverState, true); + } + + private void AutoSuggestBox_PointerExited(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(Owner, TokenizingTextBox.NormalState, true); + } + + private void AutoSuggestBox_LostFocus(object sender, RoutedEventArgs e) + { + VisualStateManager.GoToState(Owner, TokenizingTextBox.UnfocusedState, true); + } + + private void AutoSuggestBox_GotFocus(object sender, RoutedEventArgs e) + { + // Verify if the usual behavior of clearing token selection is required + if (Owner.PauseTokenClearOnFocus == false && !TokenizingTextBox.IsShiftPressed) + { + // Clear any selected tokens + Owner.DeselectAll(); + } + + Owner.PauseTokenClearOnFocus = false; + + VisualStateManager.GoToState(Owner, TokenizingTextBox.FocusedState, true); + } + + private void OnASBLoaded(object sender, RoutedEventArgs e) + { + if (autoSuggestTextBox is not null) + { + autoSuggestTextBox.PreviewKeyDown -= AutoSuggestTextBox_PreviewKeyDown; + autoSuggestTextBox.TextChanging -= AutoSuggestTextBox_TextChangingAsync; + autoSuggestTextBox.SelectionChanged -= AutoSuggestTextBox_SelectionChanged; + autoSuggestTextBox.SelectionChanging -= AutoSuggestTextBox_SelectionChanging; + } + + autoSuggestTextBox ??= autoSuggestBox.FindDescendant()!; + + UpdateQueryIconVisibility(); + UpdateTokensCounter(this); + + // Local function for Selection changed + void AutoSuggestTextBox_SelectionChanged(object box, RoutedEventArgs args) + { + if (!(IsAllSelected || TokenizingTextBox.IsShiftPressed || Owner.IsClearingForClick)) + { + Owner.DeselectAllTokensAndText(this); + } + + // Ensure flag is always reset + Owner.IsClearingForClick = false; + } + + // local function for clearing selection on interaction with text box + async void AutoSuggestTextBox_TextChangingAsync(TextBox o, TextBoxTextChangingEventArgs args) + { + // remove any selected tokens. + if (Owner.SelectedItems.Count > 1) + { + await Owner.RemoveAllSelectedTokens().ConfigureAwait(true); + } + } + + if (autoSuggestTextBox is not null) + { + autoSuggestTextBox.PreviewKeyDown += AutoSuggestTextBox_PreviewKeyDown; + autoSuggestTextBox.TextChanging += AutoSuggestTextBox_TextChangingAsync; + autoSuggestTextBox.SelectionChanged += AutoSuggestTextBox_SelectionChanged; + autoSuggestTextBox.SelectionChanging += AutoSuggestTextBox_SelectionChanging; + + AutoSuggestTextBoxLoaded?.Invoke(this, e); + } + } + + private void AutoSuggestTextBox_SelectionChanging(TextBox sender, TextBoxSelectionChangingEventArgs args) + { + isSelectedFocusOnFirstCharacter = args.SelectionLength > 0 && args.SelectionStart is 0 && autoSuggestTextBox.SelectionStart > 0; + isSelectedFocusOnLastCharacter = + //// see if we are NOW on the last character. + //// test if the new selection includes the last character, and the current selection doesn't + (args.SelectionStart + args.SelectionLength == autoSuggestTextBox.Text.Length) && + (autoSuggestTextBox.SelectionStart + autoSuggestTextBox.SelectionLength != autoSuggestTextBox.Text.Length); + } + + private void AutoSuggestTextBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (IsCaretAtStart && + (e.Key is VirtualKey.Back || + e.Key is VirtualKey.Left)) + { + // if the back key is pressed and there is any selection in the text box then the text box can handle it + if ((e.Key is VirtualKey.Left && isSelectedFocusOnFirstCharacter) || + autoSuggestTextBox.SelectionLength is 0) + { + if (Owner.SelectPreviousItem(this)) + { + if (!TokenizingTextBox.IsShiftPressed) + { + // Clear any text box selection + autoSuggestTextBox.SelectionLength = 0; + } + + e.Handled = true; + } + } + } + else if (IsCaretAtEnd && e.Key is VirtualKey.Right) + { + // if the back key is pressed and there is any selection in the text box then the text box can handle it + if (isSelectedFocusOnLastCharacter || autoSuggestTextBox.SelectionLength is 0) + { + if (Owner.SelectNextItem(this)) + { + if (!TokenizingTextBox.IsShiftPressed) + { + // Clear any text box selection + autoSuggestTextBox.SelectionLength = 0; + } + + e.Handled = true; + } + } + } + else if (e.Key is VirtualKey.A && TokenizingTextBox.IsControlPressed) + { + // Need to provide this shortcut from the textbox only, as ListViewBase will do it for us on token. + Owner.SelectAllTokensAndText(); + } + } + + private void UpdateTokensCounter(TokenizingTextBoxItem ttbi) + { + if (autoSuggestBox?.FindDescendant(PART_TokensCounter) is TextBlock maxTokensCounter) + { + void OnTokenCountChanged(TokenizingTextBox ttb, object? value = default) + { + if (ttb.ItemsSource is InterspersedObservableCollection itemsSource) + { + int currentTokens = itemsSource.ItemsSource.Count; + int maxTokens = ttb.MaximumTokens; + + maxTokensCounter.Text = $"{currentTokens}/{maxTokens}"; + maxTokensCounter.Visibility = Visibility.Visible; + + string targetState = (currentTokens >= maxTokens) + ? TokenizingTextBox.MaxReachedState + : TokenizingTextBox.MaxUnreachedState; + + VisualStateManager.GoToState(autoSuggestTextBox, targetState, true); + } + } + + ttbi.Owner.TokenItemAdded -= OnTokenCountChanged; + ttbi.Owner.TokenItemRemoved -= OnTokenCountChanged; + + if (Content is ITokenStringContainer { IsLast: true } str && ttbi is { Owner.MaximumTokens: >= 0 }) + { + ttbi.Owner.TokenItemAdded += OnTokenCountChanged; + ttbi.Owner.TokenItemRemoved += OnTokenCountChanged; + OnTokenCountChanged(ttbi.Owner); + } + else + { + maxTokensCounter.Visibility = Visibility.Collapsed; + maxTokensCounter.Text = string.Empty; + } + } + } + + private void UpdateQueryIconVisibility() + { + if (autoSuggestBox.FindDescendant(QueryButton) is Button queryButton) + { + if (Owner.QueryIcon is not null) + { + queryButton.Visibility = Visibility.Visible; + } + else + { + queryButton.Visibility = Visibility.Collapsed; + } + } + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBoxItem.xaml b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBoxItem.xaml new file mode 100644 index 000000000..dbc151fea --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBoxItem.xaml @@ -0,0 +1,837 @@ + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + 1 + + + + + 10,3,6,6 + + 10 + + 8,4,4,4 + 0,-2,0,1 + Center + Center + + 0,0,0,8 + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBoxStyleSelector.cs b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBoxStyleSelector.cs new file mode 100644 index 000000000..231bf0539 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/TokenizingTextBox/TokenizingTextBoxStyleSelector.cs @@ -0,0 +1,25 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Snap.Hutao.Control.TokenizingTextBox; + +internal class TokenizingTextBoxStyleSelector : StyleSelector +{ + public Style TokenStyle { get; set; } = default!; + + public Style TextStyle { get; set; } = default!; + + /// + protected override Style SelectStyleCore(object item, DependencyObject container) + { + if (item is ITokenStringContainer) + { + return TextStyle; + } + + return TokenStyle; + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj b/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj index 40283196b..ae3e05071 100644 --- a/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj +++ b/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj @@ -109,6 +109,8 @@ + + @@ -310,7 +312,6 @@ - @@ -362,6 +363,16 @@ + + + MSBuild:Compile + + + + + MSBuild:Compile + + MSBuild:Compile diff --git a/src/Snap.Hutao/Snap.Hutao/View/Page/WikiWeaponPage.xaml b/src/Snap.Hutao/Snap.Hutao/View/Page/WikiWeaponPage.xaml index 0e9d5d436..fb33aef3c 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/Page/WikiWeaponPage.xaml +++ b/src/Snap.Hutao/Snap.Hutao/View/Page/WikiWeaponPage.xaml @@ -184,6 +184,7 @@ MaximumTokens="5" PlaceholderText="{shcm:ResourceString Name=ViewPageWiKiWeaponAutoSuggestBoxPlaceHolder}" QueryIcon="{cw:FontIconSource Glyph=}" + Style="{StaticResource DefaultTokenizingTextBoxStyle}" SuggestedItemTemplate="{StaticResource TokenTemplate}" SuggestedItemsSource="{Binding AvailableTokens.Values}" Text="{Binding FilterToken, Mode=TwoWay}"