From 5e7fd1cfc8c0d0a3aa0fa786118b577b0d33c74b Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Thu, 1 Jun 2023 12:12:58 +0200 Subject: [PATCH 01/25] Init --- components/TokenizingTextBox/OpenSolution.bat | 3 + .../samples/Dependencies.props | 31 + .../samples/TokenizingTextBox.Samples.csproj | 8 + .../samples/TokenizingTextBox.md | 32 + .../TokenizingTextBoxCustomSample.xaml | 25 + .../TokenizingTextBoxCustomSample.xaml.cs | 30 + .../src/AdditionalAssemblyInfo.cs | 13 + ...it.WinUI.Controls.TokenizingTextBox.csproj | 13 + .../TokenizingTextBox/src/Dependencies.props | 31 + .../src/ITokenStringContainer.cs | 22 + .../src/InterspersedObservableCollection.cs | 428 +++++++++++++ .../TokenizingTextBox/src/MultiTarget.props | 9 + .../src/PretokenStringContainer.cs | 45 ++ .../TokenizingTextBox/src/Themes/Generic.xaml | 12 + .../src/TokenItemAddingEventArgs.cs | 33 + .../src/TokenItemRemovingEventArgs.cs | 35 ++ .../src/TokenizingTextBox.Events.cs | 51 ++ .../src/TokenizingTextBox.Properties.cs | 351 +++++++++++ .../src/TokenizingTextBox.Selection.cs | 365 +++++++++++ .../src/TokenizingTextBox.cs | 576 ++++++++++++++++++ .../src/TokenizingTextBox.xaml | 169 +++++ .../src/TokenizingTextBoxAutomationPeer.cs | 131 ++++ .../TokenizingTextBoxItem.AutoSuggestBox.cs | 412 +++++++++++++ .../TokenizingTextBoxItem.AutoSuggestBox.xaml | 386 ++++++++++++ .../src/TokenizingTextBoxItem.Token.xaml | 125 ++++ .../src/TokenizingTextBoxItem.cs | 121 ++++ .../src/TokenizingTextBoxStyleSelector.cs | 43 ++ .../ExampleTokenizingTextBoxTestClass.cs | 134 ++++ .../ExampleTokenizingTextBoxTestPage.xaml | 14 + .../ExampleTokenizingTextBoxTestPage.xaml.cs | 16 + .../tests/TokenizingTextBox.Tests.projitems | 23 + .../tests/TokenizingTextBox.Tests.shproj | 13 + 32 files changed, 3700 insertions(+) create mode 100644 components/TokenizingTextBox/OpenSolution.bat create mode 100644 components/TokenizingTextBox/samples/Dependencies.props create mode 100644 components/TokenizingTextBox/samples/TokenizingTextBox.Samples.csproj create mode 100644 components/TokenizingTextBox/samples/TokenizingTextBox.md create mode 100644 components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml create mode 100644 components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml.cs create mode 100644 components/TokenizingTextBox/src/AdditionalAssemblyInfo.cs create mode 100644 components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj create mode 100644 components/TokenizingTextBox/src/Dependencies.props create mode 100644 components/TokenizingTextBox/src/ITokenStringContainer.cs create mode 100644 components/TokenizingTextBox/src/InterspersedObservableCollection.cs create mode 100644 components/TokenizingTextBox/src/MultiTarget.props create mode 100644 components/TokenizingTextBox/src/PretokenStringContainer.cs create mode 100644 components/TokenizingTextBox/src/Themes/Generic.xaml create mode 100644 components/TokenizingTextBox/src/TokenItemAddingEventArgs.cs create mode 100644 components/TokenizingTextBox/src/TokenItemRemovingEventArgs.cs create mode 100644 components/TokenizingTextBox/src/TokenizingTextBox.Events.cs create mode 100644 components/TokenizingTextBox/src/TokenizingTextBox.Properties.cs create mode 100644 components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs create mode 100644 components/TokenizingTextBox/src/TokenizingTextBox.cs create mode 100644 components/TokenizingTextBox/src/TokenizingTextBox.xaml create mode 100644 components/TokenizingTextBox/src/TokenizingTextBoxAutomationPeer.cs create mode 100644 components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs create mode 100644 components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.xaml create mode 100644 components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml create mode 100644 components/TokenizingTextBox/src/TokenizingTextBoxItem.cs create mode 100644 components/TokenizingTextBox/src/TokenizingTextBoxStyleSelector.cs create mode 100644 components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestClass.cs create mode 100644 components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestPage.xaml create mode 100644 components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestPage.xaml.cs create mode 100644 components/TokenizingTextBox/tests/TokenizingTextBox.Tests.projitems create mode 100644 components/TokenizingTextBox/tests/TokenizingTextBox.Tests.shproj diff --git a/components/TokenizingTextBox/OpenSolution.bat b/components/TokenizingTextBox/OpenSolution.bat new file mode 100644 index 00000000..814a56d4 --- /dev/null +++ b/components/TokenizingTextBox/OpenSolution.bat @@ -0,0 +1,3 @@ +@ECHO OFF + +powershell ..\..\tooling\ProjectHeads\GenerateSingleSampleHeads.ps1 -componentPath %CD% %* \ No newline at end of file diff --git a/components/TokenizingTextBox/samples/Dependencies.props b/components/TokenizingTextBox/samples/Dependencies.props new file mode 100644 index 00000000..e622e1df --- /dev/null +++ b/components/TokenizingTextBox/samples/Dependencies.props @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/TokenizingTextBox/samples/TokenizingTextBox.Samples.csproj b/components/TokenizingTextBox/samples/TokenizingTextBox.Samples.csproj new file mode 100644 index 00000000..7f375002 --- /dev/null +++ b/components/TokenizingTextBox/samples/TokenizingTextBox.Samples.csproj @@ -0,0 +1,8 @@ + + + TokenizingTextBox + + + + + diff --git a/components/TokenizingTextBox/samples/TokenizingTextBox.md b/components/TokenizingTextBox/samples/TokenizingTextBox.md new file mode 100644 index 00000000..b81b45ac --- /dev/null +++ b/components/TokenizingTextBox/samples/TokenizingTextBox.md @@ -0,0 +1,32 @@ +--- +title: TokenizingTextBox +author: githubaccount +description: TODO: Your experiment's description here +keywords: TokenizingTextBox, Control, Layout +dev_langs: + - csharp +category: Controls +subcategory: Layout +discussion-id: 0 +issue-id: 0 +--- + + + + + + + + + +# TokenizingTextBox + +TODO: Fill in information about this experiment and how to get started here... + +## Custom Control + +You can inherit from an existing component as well, like `Panel`, this example shows a control without a +XAML Style that will be more light-weight to consume by an app developer: + +> [!Sample TokenizingTextBoxCustomSample] + diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml b/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml new file mode 100644 index 00000000..8ee4da44 --- /dev/null +++ b/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml.cs b/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml.cs new file mode 100644 index 00000000..2a040617 --- /dev/null +++ b/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.WinUI.Controls; + +namespace TokenizingTextBoxExperiment.Samples; + +/// +/// An example sample page of a custom control inheriting from Panel. +/// +[ToolkitSampleTextOption("TitleText", "This is a title", Title = "Input the text")] +[ToolkitSampleMultiChoiceOption("LayoutOrientation", "Horizontal", "Vertical", Title = "Orientation")] + +[ToolkitSample(id: nameof(TokenizingTextBoxCustomSample), "Custom control", description: $"A sample for showing how to create and use a {nameof(TokenizingTextBox)} custom control.")] +public sealed partial class TokenizingTextBoxCustomSample : Page +{ + public TokenizingTextBoxCustomSample() + { + this.InitializeComponent(); + } + + // TODO: See https://github.com/CommunityToolkit/Labs-Windows/issues/149 + public static Orientation ConvertStringToOrientation(string orientation) => orientation switch + { + "Vertical" => Orientation.Vertical, + "Horizontal" => Orientation.Horizontal, + _ => throw new System.NotImplementedException(), + }; +} diff --git a/components/TokenizingTextBox/src/AdditionalAssemblyInfo.cs b/components/TokenizingTextBox/src/AdditionalAssemblyInfo.cs new file mode 100644 index 00000000..cfffc6c1 --- /dev/null +++ b/components/TokenizingTextBox/src/AdditionalAssemblyInfo.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; + +// These `InternalsVisibleTo` calls are intended to make it easier for +// for any internal code to be testable in all the different test projects +// used with the Labs infrastructure. +[assembly: InternalsVisibleTo("TokenizingTextBox.Tests.Uwp")] +[assembly: InternalsVisibleTo("TokenizingTextBox.Tests.WinAppSdk")] +[assembly: InternalsVisibleTo("CommunityToolkit.Tests.Uwp")] +[assembly: InternalsVisibleTo("CommunityToolkit.Tests.WinAppSdk")] diff --git a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj new file mode 100644 index 00000000..447b4301 --- /dev/null +++ b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj @@ -0,0 +1,13 @@ + + + TokenizingTextBox + This package contains TokenizingTextBox. + 0.0.1 + + + CommunityToolkit.WinUI.Controls.TokenizingTextBoxRns + + + + + diff --git a/components/TokenizingTextBox/src/Dependencies.props b/components/TokenizingTextBox/src/Dependencies.props new file mode 100644 index 00000000..e622e1df --- /dev/null +++ b/components/TokenizingTextBox/src/Dependencies.props @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/TokenizingTextBox/src/ITokenStringContainer.cs b/components/TokenizingTextBox/src/ITokenStringContainer.cs new file mode 100644 index 00000000..359af203 --- /dev/null +++ b/components/TokenizingTextBox/src/ITokenStringContainer.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.Toolkit.Uwp.UI.Controls +{ + /// + /// Provides access to unresolved token string values within the tokenizing text box control + /// + public interface ITokenStringContainer + { + /// + /// Gets or sets the string text for this unresolved token + /// + string Text { get; set; } + + /// + /// Gets a value indicating whether this is the last text based token in the collection as it will always remain at the end. + /// + bool IsLast { get; } + } +} \ No newline at end of file diff --git a/components/TokenizingTextBox/src/InterspersedObservableCollection.cs b/components/TokenizingTextBox/src/InterspersedObservableCollection.cs new file mode 100644 index 00000000..c9e92ae2 --- /dev/null +++ b/components/TokenizingTextBox/src/InterspersedObservableCollection.cs @@ -0,0 +1,428 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using CommunityToolkit.WinUI.Helpers; + +namespace CommunityToolkit.WinUI.UI.Controls +{ + //// We need to implement the IList interface here for ListViewBase to listen to changes - https://github.com/microsoft/microsoft-ui-xaml/issues/1809 + + internal class InterspersedObservableCollection : IList, IEnumerable, INotifyCollectionChanged + { + 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 object(); + + public object this[int index] + { + get + { + if (_interspersedObjects.TryGetValue(index, out var value)) + { + return value; + } + else + { + // Find out the number of elements in our dictionary with keys below ours. + return ItemsSource[ToInnerIndex(index)]; + } + } + set => throw new NotImplementedException(); + } + + private Dictionary _interspersedObjects = new Dictionary(); + private bool _isInsertingOriginal = false; + + public event NotifyCollectionChangedEventHandler CollectionChanged; + + public InterspersedObservableCollection(object itemsSource) + { + if (!(itemsSource is IList list)) + { + ThrowArgumentException(); + } + + ItemsSource = list; + + if (ItemsSource is INotifyCollectionChanged notifier) + { + var weakPropertyChangedListener = new WeakEventListener(this) + { + OnEventAction = (instance, source, eventArgs) => instance.ItemsSource_CollectionChanged(source, eventArgs), + OnDetachAction = (weakEventListener) => notifier.CollectionChanged -= weakEventListener.OnEvent // Use Local Reference Only + }; + notifier.CollectionChanged += weakPropertyChangedListener.OnEvent; + } + + static void ThrowArgumentException() => throw new ArgumentNullException("The input items source must be assignable to the System.Collections.IList type."); + } + + private void ItemsSource_CollectionChanged(object source, NotifyCollectionChangedEventArgs eventArgs) + { + switch (eventArgs.Action) + { + case NotifyCollectionChangedAction.Add: + // Shift any existing interspersed items after the inserted item + var count = eventArgs.NewItems.Count; + + if (count > 0) + { + if (!_isInsertingOriginal) + { + MoveKeysForward(eventArgs.NewStartingIndex, count); + } + + _isInsertingOriginal = false; + + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Add, + eventArgs.NewItems, + ToOuterIndex(eventArgs.NewStartingIndex))); + } + + break; + case NotifyCollectionChangedAction.Remove: + count = eventArgs.OldItems.Count; + + if (count > 0) + { + var outerIndex = ToOuterIndexAfterRemoval(eventArgs.OldStartingIndex); + + MoveKeysBackward(outerIndex, count); + + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs( + 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; + } + } + + /// + /// Moves our interspersed keys at or past the given index forward by the amount. + /// + /// index of added item + /// by how many + private void MoveKeysForward(int pivot, int amount) + { + // Sort in reverse order to work from highest to lowest + var keys = _interspersedObjects.Keys.OrderByDescending(v => v).ToArray(); + foreach (var key in keys) + { + 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); + } + } + + /// + /// Moves our interspersed keys at or past the given index backward by the amount. + /// + /// index of removed item + /// by how many + private void MoveKeysBackward(int pivot, int amount) + { + // Sort in regular order to work from the earliest indices onwards + var keys = _interspersedObjects.Keys.OrderBy(v => v).ToArray(); + foreach (var key in keys) + { + // 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); + } + } + + /// + /// Condenses our interspersed keys around any remaining items, mainly for when the original collection is reset. + /// + private void ReadjustKeys() + { + var count = ItemsSource.Count; + var existing = 0; + + var keys = _interspersedObjects.Keys.OrderBy(v => v).ToArray(); + foreach (var key in keys) + { + if (key <= count) + { + existing++; + continue; + } + + _interspersedObjects[count + existing++] = _interspersedObjects[key]; + _interspersedObjects.Remove(key); + } + } + + /// + /// Takes an index from the entire collection and maps it to the inner collection index. Assumes, mapping is valid. + /// + /// Index into the entire collection. + /// Inner ItemsSource Index. + private int ToInnerIndex(int outerIndex) + { + if ((uint)outerIndex >= Count) + { + ThrowArgumentOutOfRangeException(); + } + + if (_interspersedObjects.ContainsKey(outerIndex)) + { + ThrowArgumentException(); + } + + return outerIndex - _interspersedObjects.Keys.Count(key => key.Value <= outerIndex); + + static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(outerIndex)); + static void ThrowArgumentException() => throw new ArgumentException("The outer index can't be inserted as a key to the original collection."); + } + + /// + /// Takes an index from the inner collection and maps it to an index for this entire collection. + /// + /// Index into the ItemsSource. + /// Index into the entire collection. + private int ToOuterIndex(int innerIndex) + { + if ((uint)innerIndex >= ItemsSource.Count) + { + ThrowArgumentOutOfRangeException(); + } + + var keys = _interspersedObjects.OrderBy(v => v.Key); + + foreach (var key in keys) + { + if (innerIndex >= key.Key) + { + innerIndex++; + } + else + { + break; + } + } + + return innerIndex; + + static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(innerIndex)); + } + + /// + /// Takes an index from the inner collection and maps it to an index for this entire collection, projects as if an element from the provided index was still in the collection. + /// + /// Previous index from ItemsSource + /// Projected index in the entire collection. + private int ToOuterIndexAfterRemoval(int innerIndexToProject) + { + if ((uint)innerIndexToProject >= ItemsSource.Count + 1) + { + ThrowArgumentOutOfRangeException(); + } + + //// TODO: Deal with bounds (0 / Count)? Or is it the same? + + var keys = _interspersedObjects.OrderBy(v => v.Key); + + foreach (var key in keys) + { + if (innerIndexToProject >= key.Key) + { + innerIndexToProject++; + } + else + { + break; + } + } + + return innerIndexToProject; + + static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(innerIndexToProject)); + } + + /// + /// Inserts an item to intersperse with the underlying collection, but not be part of the underlying collection itself. + /// + /// Position to insert the item at. + /// Item to intersperse + public void Insert(int index, object obj) + { + MoveKeysForward(index, 1); // Move existing keys at index over to make room for new item + + _interspersedObjects[index] = obj; + + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, obj, index)); + } + + /// + /// Inserts an item into the underlying collection and moves interspersed items such that the provide item will appear at the provided index as part of the whole collection. + /// + /// Position to insert the item at. + /// Item to place in wrapped collection. + public void InsertAt(int outerIndex, object obj) + { + // Find out our closest index based on interspersed keys + var 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 (var element in ItemsSource) + { + while (_interspersedObjects.TryGetValue(i++, out var 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 (var keyValue in _interspersedObjects.Where(kvp => kvp.Key >= i).OrderBy(kvp => kvp.Key)) + { + yield return keyValue.Value; + } + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + public int Add(object value) + { + var 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) + { + return _interspersedObjects.ContainsValue(value) || ItemsSource.Contains(value); + } + + /// + /// Looks up an item's key in the _interspersedObject dictionary by its value. Handles nulls. + /// + /// Search value + /// KeyValuePair or default KeyValuePair + private KeyValuePair ItemKeySearch(object value) + { + if (value == null) + { + return _interspersedObjects.FirstOrDefault(kvp => kvp.Value == null); + } + + return _interspersedObjects.FirstOrDefault(kvp => kvp.Value?.Equals(value) == true); + } + + public int IndexOf(object value) + { + var item = ItemKeySearch(value); + + if (item.Key != null) + { + return item.Key.Value; + } + else + { + 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) + { + var item = ItemKeySearch(value); + + if (item.Key != null) + { + _interspersedObjects.Remove(item.Key); + + MoveKeysBackward(item.Key.Value, 1); // Move other interspersed items back + + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item.Value, item.Key.Value)); + } + 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(); + } + } +} \ No newline at end of file diff --git a/components/TokenizingTextBox/src/MultiTarget.props b/components/TokenizingTextBox/src/MultiTarget.props new file mode 100644 index 00000000..b11c1942 --- /dev/null +++ b/components/TokenizingTextBox/src/MultiTarget.props @@ -0,0 +1,9 @@ + + + + uwp;wasdk;wpf;wasm;linuxgtk;macos;ios;android; + + \ No newline at end of file diff --git a/components/TokenizingTextBox/src/PretokenStringContainer.cs b/components/TokenizingTextBox/src/PretokenStringContainer.cs new file mode 100644 index 00000000..c61cdc62 --- /dev/null +++ b/components/TokenizingTextBox/src/PretokenStringContainer.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; + +namespace CommunityToolkit.WinUI.UI.Controls +{ + /// + /// support class + /// + internal partial class PretokenStringContainer : DependencyObject, ITokenStringContainer + { + public string Text + { + get { return (string)GetValue(TextProperty); } + set { SetValue(TextProperty, value); } + } + + // Using a DependencyProperty as the backing store for Text. This enables animation, styling, binding, etc... + public static readonly DependencyProperty TextProperty = + DependencyProperty.Register(nameof(Text), typeof(string), typeof(PretokenStringContainer), new PropertyMetadata(string.Empty)); + + public bool IsLast { get; private set; } + + public PretokenStringContainer(bool isLast = false) + { + IsLast = isLast; + } + + public PretokenStringContainer(string text) + { + Text = text; + } + + /// + /// Override and provide the content of the container on ToString() so the calling app can access the token string + /// + /// The content of the string token + public override string ToString() + { + return Text; + } + } +} \ No newline at end of file diff --git a/components/TokenizingTextBox/src/Themes/Generic.xaml b/components/TokenizingTextBox/src/Themes/Generic.xaml new file mode 100644 index 00000000..6492bee9 --- /dev/null +++ b/components/TokenizingTextBox/src/Themes/Generic.xaml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/components/TokenizingTextBox/src/TokenItemAddingEventArgs.cs b/components/TokenizingTextBox/src/TokenItemAddingEventArgs.cs new file mode 100644 index 00000000..e86e0842 --- /dev/null +++ b/components/TokenizingTextBox/src/TokenItemAddingEventArgs.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Common.Deferred; + +namespace CommunityToolkit.WinUI.UI.Controls +{ + /// + /// Event arguments for event. + /// + public class TokenItemAddingEventArgs : DeferredCancelEventArgs + { + /// + /// Initializes a new instance of the class. + /// + /// User entered string. + public TokenItemAddingEventArgs(string token) + { + TokenText = token; + } + + /// + /// Gets token as typed by the user. + /// + public string TokenText { get; private set; } + + /// + /// Gets or sets the item to be added to the . If null, string will be added. + /// + public object Item { get; set; } = null; + } +} \ No newline at end of file diff --git a/components/TokenizingTextBox/src/TokenItemRemovingEventArgs.cs b/components/TokenizingTextBox/src/TokenItemRemovingEventArgs.cs new file mode 100644 index 00000000..f1222021 --- /dev/null +++ b/components/TokenizingTextBox/src/TokenItemRemovingEventArgs.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Common.Deferred; + +namespace CommunityToolkit.WinUI.UI.Controls +{ + /// + /// Event arguments for event. + /// + public class TokenItemRemovingEventArgs : DeferredCancelEventArgs + { + /// + /// Initializes a new instance of the class. + /// + /// Item being removed. + /// container being closed. + public TokenItemRemovingEventArgs(object item, TokenizingTextBoxItem token) + { + Item = item; + Token = token; + } + + /// + /// Gets the Item being closed. + /// + public object Item { get; private set; } + + /// + /// Gets the being removed. + /// + public TokenizingTextBoxItem Token { get; private set; } + } +} \ No newline at end of file diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.Events.cs b/components/TokenizingTextBox/src/TokenizingTextBox.Events.cs new file mode 100644 index 00000000..bc8577a5 --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBox.Events.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; + +namespace CommunityToolkit.WinUI.UI.Controls +{ + /// + /// A text input control that auto-suggests and displays token items. + /// + public partial class TokenizingTextBox : ListViewBase + { + /// + /// Event raised when the text input value has changed. + /// + public event TypedEventHandler TextChanged; + + /// + /// Event raised when a suggested item is chosen by the user. + /// + public event TypedEventHandler SuggestionChosen; + + /// + /// Event raised when the user submits the text query. + /// + public event TypedEventHandler QuerySubmitted; + + /// + /// Event raised before a new token item is created from a string, can be used to transform data type from text user entered. + /// + public event TypedEventHandler TokenItemAdding; + + /// + /// Event raised when a new token item has been added. + /// + public event TypedEventHandler TokenItemAdded; + + /// + /// Event raised when a token item is about to be removed. Can be canceled to prevent removal of a token. + /// + public event TypedEventHandler TokenItemRemoving; + + /// + /// Event raised after a token has been removed. + /// + public event TypedEventHandler TokenItemRemoved; + } +} \ No newline at end of file diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.Properties.cs b/components/TokenizingTextBox/src/TokenizingTextBox.Properties.cs new file mode 100644 index 00000000..86d5f005 --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBox.Properties.cs @@ -0,0 +1,351 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; + +namespace Microsoft.Toolkit.Uwp.UI.Controls +{ + /// + /// A text input control that auto-suggests and displays token items. + /// + public partial class TokenizingTextBox : ListViewBase + { + /// + /// Identifies the property. + /// + public static readonly DependencyProperty AutoSuggestBoxStyleProperty = DependencyProperty.Register( + nameof(AutoSuggestBoxStyle), + typeof(Style), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty AutoSuggestBoxTextBoxStyleProperty = DependencyProperty.Register( + nameof(AutoSuggestBoxTextBoxStyle), + typeof(Style), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TextMemberPathProperty = DependencyProperty.Register( + nameof(TextMemberPath), + typeof(string), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TokenItemTemplateProperty = DependencyProperty.Register( + nameof(TokenItemTemplate), + typeof(DataTemplate), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TokenItemTemplateSelectorProperty = DependencyProperty.Register( + nameof(TokenItemTemplateSelector), + typeof(DataTemplateSelector), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TokenDelimiterProperty = DependencyProperty.Register( + nameof(TokenDelimiter), + typeof(string), + typeof(TokenizingTextBox), + new PropertyMetadata(" ")); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TokenSpacingProperty = DependencyProperty.Register( + nameof(TokenSpacing), + typeof(double), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty PlaceholderTextProperty = DependencyProperty.Register( + nameof(PlaceholderText), + typeof(string), + typeof(TokenizingTextBox), + new PropertyMetadata(string.Empty)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty QueryIconProperty = DependencyProperty.Register( + nameof(QueryIcon), + typeof(IconSource), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TextProperty = DependencyProperty.Register( + nameof(Text), + typeof(string), + typeof(TokenizingTextBox), + new PropertyMetadata(string.Empty, TextPropertyChanged)); + + private static void TextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TokenizingTextBox ttb && ttb._currentTextEdit != null) + { + ttb._currentTextEdit.Text = e.NewValue as string; + + // Notify inner container of text change, see issue #4749 + var item = ttb.ContainerFromItem(ttb._currentTextEdit) as TokenizingTextBoxItem; + item?.UpdateText(ttb._currentTextEdit.Text); + } + } + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty SuggestedItemsSourceProperty = DependencyProperty.Register( + nameof(SuggestedItemsSource), + typeof(object), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty SuggestedItemTemplateProperty = DependencyProperty.Register( + nameof(SuggestedItemTemplate), + typeof(DataTemplate), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty SuggestedItemTemplateSelectorProperty = DependencyProperty.Register( + nameof(SuggestedItemTemplateSelector), + typeof(DataTemplateSelector), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty SuggestedItemContainerStyleProperty = DependencyProperty.Register( + nameof(SuggestedItemContainerStyle), + typeof(Style), + typeof(TokenizingTextBox), + new PropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty TabNavigateBackOnArrowProperty = DependencyProperty.Register( + nameof(TabNavigateBackOnArrow), + typeof(bool), + typeof(TokenizingTextBox), + new PropertyMetadata(false)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty MaximumTokensProperty = DependencyProperty.Register( + nameof(MaximumTokens), + typeof(int), + typeof(TokenizingTextBox), + new PropertyMetadata(null, OnMaximumTokensChanged)); + + private static void OnMaximumTokensChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TokenizingTextBox ttb && ttb.ReadLocalValue(MaximumTokensProperty) != DependencyProperty.UnsetValue && e.NewValue is int newMaxTokens) + { + var 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 (var i = tokenCount; i > tokenCount - tokensToRemove; --i) + { + var token = ttb._innerItemsSource.ItemsSource[i - 1]; + + // Force remove the items. No warning and no option to cancel. + ttb._innerItemsSource.Remove(token); + ttb.TokenItemRemoved?.Invoke(ttb, token); + } + } + } + } + + /// + /// Gets or sets the Style for the contained AutoSuggestBox template part. + /// + public Style AutoSuggestBoxStyle + { + get => (Style)GetValue(AutoSuggestBoxStyleProperty); + set => SetValue(AutoSuggestBoxStyleProperty, value); + } + + /// + /// Gets or sets the Style for the TextBox part of the AutoSuggestBox template part. + /// + public Style AutoSuggestBoxTextBoxStyle + { + get => (Style)GetValue(AutoSuggestBoxStyleProperty); + set => SetValue(AutoSuggestBoxStyleProperty, value); + } + + /// + /// Gets or sets the TextMemberPath of the AutoSuggestBox template part. + /// + public string TextMemberPath + { + get => (string)GetValue(TextMemberPathProperty); + set => SetValue(TextMemberPathProperty, value); + } + + /// + /// Gets or sets the template for token items. + /// + public DataTemplate TokenItemTemplate + { + get => (DataTemplate)GetValue(TokenItemTemplateProperty); + set => SetValue(TokenItemTemplateProperty, value); + } + + /// + /// Gets or sets the template selector for token items. + /// + public DataTemplateSelector TokenItemTemplateSelector + { + get => (DataTemplateSelector)GetValue(TokenItemTemplateSelectorProperty); + set => SetValue(TokenItemTemplateSelectorProperty, value); + } + + /// + /// Gets or sets delimiter used to determine when to process text input as a new token item. + /// + public string TokenDelimiter + { + get => (string)GetValue(TokenDelimiterProperty); + set => SetValue(TokenDelimiterProperty, value); + } + + /// + /// Gets or sets the spacing value used to separate token items. + /// + public double TokenSpacing + { + get => (double)GetValue(TokenSpacingProperty); + set => SetValue(TokenSpacingProperty, value); + } + + /// + /// Gets or sets the PlaceholderText for the AutoSuggestBox template part. + /// + public string PlaceholderText + { + get => (string)GetValue(PlaceholderTextProperty); + set => SetValue(PlaceholderTextProperty, value); + } + + /// + /// Gets or sets the icon to display in the AutoSuggestBox template part. + /// + public IconSource QueryIcon + { + get => (IconSource)GetValue(QueryIconProperty); + set => SetValue(QueryIconProperty, value); + } + + /// + /// Gets or sets the input text of the currently active text edit. + /// + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + /// + /// Gets or sets the items source for token suggestions. + /// + public object SuggestedItemsSource + { + get => GetValue(SuggestedItemsSourceProperty); + set => SetValue(SuggestedItemsSourceProperty, value); + } + + /// + /// Gets or sets the template for displaying suggested tokens. + /// + public DataTemplate SuggestedItemTemplate + { + get => (DataTemplate)GetValue(SuggestedItemTemplateProperty); + set => SetValue(SuggestedItemTemplateProperty, value); + } + + /// + /// Gets or sets the template selector for displaying suggested tokens. + /// + public DataTemplateSelector SuggestedItemTemplateSelector + { + get => (DataTemplateSelector)GetValue(SuggestedItemTemplateSelectorProperty); + set => SetValue(SuggestedItemTemplateSelectorProperty, value); + } + + /// + /// Gets or sets the item container style for displaying suggested tokens. + /// + public Style SuggestedItemContainerStyle + { + get => (Style)GetValue(SuggestedItemContainerStyleProperty); + set => SetValue(SuggestedItemContainerStyleProperty, value); + } + + /// + /// Gets or sets a value indicating whether the control will move focus to the previous + /// control when an arrow key is pressed and selection is at one of the limits in the control. + /// + public bool TabNavigateBackOnArrow + { + get => (bool)GetValue(TabNavigateBackOnArrowProperty); + set => SetValue(TabNavigateBackOnArrowProperty, value); + } + + /// + /// Gets the complete text value of any selection in the control. The result is the same text as would be copied to the clipboard. + /// + public string SelectedTokenText + { + get + { + return PrepareSelectionForClipboard(); + } + } + + /// + /// Gets or sets the maximum number of token results allowed at a time. + /// + public int MaximumTokens + { + get => (int)GetValue(MaximumTokensProperty); + set => SetValue(MaximumTokensProperty, value); + } + } +} \ No newline at end of file diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs b/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs new file mode 100644 index 00000000..841076c6 --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs @@ -0,0 +1,365 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Windows.ApplicationModel.DataTransfer; + +namespace CommunityToolkit.WinUI.UI.Controls +{ + /// + /// Methods related to Selection of items in the . + /// + public partial class TokenizingTextBox + { + private enum MoveDirection + { + Next, + Previous + } + + /// + /// Adjust the selected item and range based on keyboard input. + /// This is used to override the listview behaviors for up/down arrow manipulation vs left/right for a horizontal control + /// + /// direction to move the selection + /// True if the focus was moved, false otherwise + private bool MoveFocusAndSelection(MoveDirection direction) + { + bool retVal = false; + var currentContainerItem = GetCurrentContainerItem(); + + if (currentContainerItem != null) + { + var currentItem = ItemFromContainer(currentContainerItem); + var previousIndex = Items.IndexOf(currentItem); + var 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) + { + var newItem = ContainerFromIndex(index) as TokenizingTextBoxItem; + + // 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 (XamlRoot != null) + { + return FocusManager.GetFocusedElement(XamlRoot) as TokenizingTextBoxItem; + } + else + { + return FocusManager.GetFocusedElement() as TokenizingTextBoxItem; + } + } + + internal void SelectAllTokensAndText() + { + _ = DispatcherQueue.EnqueueAsync( + () => + { + 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 (var item in Items) + { + if (item is ITokenStringContainer) + { + // grab any selected text + var pretoken = ContainerFromItem(item) as TokenizingTextBoxItem; + pretoken._autoSuggestTextBox.SelectionStart = 0; + pretoken._autoSuggestTextBox.SelectionLength = pretoken._autoSuggestTextBox.Text.Length; + } + } + + (ContainerFromIndex(Items.Count - 1) as TokenizingTextBoxItem).Focus(FocusState.Programmatic); + }, Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal); + } + + internal void DeselectAllTokensAndText(TokenizingTextBoxItem ignoreItem = null) + { + this.DeselectAll(); + ClearAllTextSelections(ignoreItem); + } + + private void ClearAllTextSelections(TokenizingTextBoxItem ignoreItem) + { + // Clear any selection in the text box + foreach (var item in Items) + { + if (item is ITokenStringContainer) + { + var container = ContainerFromItem(item) as TokenizingTextBoxItem; + + if (container != ignoreItem) + { + container._autoSuggestTextBox.SelectionLength = 0; + } + } + } + } + + /// + /// Select the previous item in the list, if one is available. Called when moving from textbox to token. + /// + /// identifies the current item + /// a value indicating whether the previous item was successfully selected + internal bool SelectPreviousItem(TokenizingTextBoxItem item) + { + return SelectNewItem(item, -1, i => i > 0); + } + + /// + /// Select the next item in the list, if one is available. Called when moving from textbox to token. + /// + /// identifies the current item + /// a value indicating whether the next item was successfully selected, false if nothing was changed + internal bool SelectNextItem(TokenizingTextBoxItem item) + { + return SelectNewItem(item, 1, i => i < Items.Count - 1); + } + + private bool SelectNewItem(TokenizingTextBoxItem item, int increment, Func testFunc) + { + bool returnVal = false; + + // find the item in the list + var currentIndex = IndexFromContainer(item); + + // Select previous token item (if there is one). + if (testFunc(currentIndex)) + { + var newItem = ContainerFromItem(Items[currentIndex + increment]) as ListViewItem; + newItem.Focus(FocusState.Keyboard); + SelectedItems.Add(Items[currentIndex + increment]); + returnVal = true; + } + + return returnVal; + } + + 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(); + + SelectedIndex = newSelectedIndex; + + if (newSelectedIndex == -1) + { + newSelectedIndex = Items.Count - 1; + } + + // focus the item prior to the first selected item + (ContainerFromIndex(newSelectedIndex) as TokenizingTextBoxItem).Focus(FocusState.Keyboard); + } + + private async void TokenizingTextBoxItem_ClearClicked(TokenizingTextBoxItem sender, RoutedEventArgs args) + { + await RemoveTokenAsync(sender); + } + + /// + /// Remove any tokens that are in the selected list, except for the last text box or the currently selected item + /// + /// async task + internal async Task RemoveAllSelectedTokens() + { + var currentContainerItem = GetCurrentContainerItem(); + + while (SelectedItems.Count > 0) + { + var container = ContainerFromItem(SelectedItems[0]) as TokenizingTextBoxItem; + + 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) + { + var asb = container._autoSuggestTextBox; + + // grab any selected text + var tempStr = asb.SelectionStart == 0 + ? string.Empty + : asb.Text.Substring( + 0, + asb.SelectionStart); + tempStr += + asb.SelectionStart + + asb.SelectionLength < asb.Text.Length + ? asb.Text.Substring( + asb.SelectionStart + + asb.SelectionLength) + : string.Empty; + + if (tempStr.Length == 0) + { + // Need to be careful not to remove the last item in the list + await RemoveTokenAsync(container); + } + else + { + asb.Text = tempStr; + } + } + else + { + // if the item is a token just remove it. + await RemoveTokenAsync(container); + } + } + else + { + if (SelectedItems.Count == 1) + { + // at this point we have one selection and its the default textbox. + // stop the iteration here + break; + } + } + } + } + + private void CopySelectedToClipboard() + { + DataPackage dataPackage = new DataPackage(); + dataPackage.RequestedOperation = DataPackageOperation.Copy; + + var 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 (var item in SelectedItems.Count > 0 ? SelectedItems : Items) + { + if (addSeparator) + { + tokenString += TokenDelimiter; + } + else + { + addSeparator = true; + } + + if (item is ITokenStringContainer) + { + // grab any selected text + var pretoken = ContainerFromItem(item) as TokenizingTextBoxItem; + tokenString += pretoken._autoSuggestTextBox.Text.Substring( + pretoken._autoSuggestTextBox.SelectionStart, + pretoken._autoSuggestTextBox.SelectionLength); + } + else + { + tokenString += item.ToString(); + } + } + + return tokenString; + } + } +} \ No newline at end of file diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.cs b/components/TokenizingTextBox/src/TokenizingTextBox.cs new file mode 100644 index 00000000..fcf81cf6 --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBox.cs @@ -0,0 +1,576 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.WinUI.Deferred; +using CommunityToolkit.WinUI.UI.Automation.Peers; +using CommunityToolkit.WinUI.UI.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 Windows.System; +using Windows.UI.Core; + +namespace CommunityToolkit.WinUI.UI.Controls +{ + /// + /// A text input control that auto-suggests and displays token items. + /// + [global::System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1124:Do not use regions", Justification = "Organization")] + [TemplatePart(Name = PART_NormalState, Type = typeof(VisualState))] + [TemplatePart(Name = PART_PointerOverState, Type = typeof(VisualState))] + [TemplatePart(Name = PART_FocusedState, Type = typeof(VisualState))] + [TemplatePart(Name = PART_UnfocusedState, Type = typeof(VisualState))] + public partial class TokenizingTextBox : ListViewBase + { + internal const string PART_NormalState = "Normal"; + internal const string PART_PointerOverState = "PointerOver"; + internal const string PART_FocusedState = "Focused"; + internal const string PART_UnfocusedState = "Unfocused"; + + /// + /// Gets a value indicating whether the shift key is currently in a pressed state + /// + internal static bool IsShiftPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); + + /// + /// Gets a value indicating whether the control key is currently in a pressed state + /// + internal bool IsControlPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + + internal bool PauseTokenClearOnFocus { get; set; } + + internal bool IsClearingForClick { get; set; } + + private InterspersedObservableCollection _innerItemsSource; + private ITokenStringContainer _currentTextEdit; // Don't update this directly outside of initialization, use UpdateCurrentTextEdit Method - in future see https://github.com/dotnet/csharplang/issues/140#issuecomment-625012514 + private ITokenStringContainer _lastTextEdit; + + /// + /// Initializes a new instance of the class. + /// + 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; + } + + 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 != null && ItemsSource.GetType() != typeof(InterspersedObservableCollection)) + { + _innerItemsSource = new InterspersedObservableCollection(ItemsSource); + + if (ReadLocalValue(MaximumTokensProperty) != DependencyProperty.UnsetValue && _innerItemsSource.ItemsSource.Count >= MaximumTokens) + { + // Reduce down to below the max as necessary. + var endCount = MaximumTokens > 0 ? MaximumTokens : 0; + for (var 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(null); + } + } + + private void TokenizingTextBox_PreviewKeyUp(object sender, KeyRoutedEventArgs e) + { + TokenizingTextBox_PreviewKeyUp(e.Key); + } + + internal void TokenizingTextBox_PreviewKeyUp(VirtualKey key) + { + switch (key) + { + case VirtualKey.Escape: + { + // Clear any selection and place the focus back into the text box + DeselectAllTokensAndText(); + FocusPrimaryAutoSuggestBox(); + break; + } + } + } + + /// + /// Set the focus to the last item in the collection + /// + private void FocusPrimaryAutoSuggestBox() + { + if (Items?.Count > 0) + { + (ContainerFromIndex(Items.Count - 1) as TokenizingTextBoxItem).Focus(FocusState.Programmatic); + } + } + + private async void TokenizingTextBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + e.Handled = await TokenizingTextBox_PreviewKeyDown(e.Key); + } + + internal async Task TokenizingTextBox_PreviewKeyDown(VirtualKey key) + { + // Global handlers on control regardless if focused on item or in textbox. + switch (key) + { + case VirtualKey.C: + if (IsControlPressed) + { + CopySelectedToClipboard(); + return true; + } + + break; + + case VirtualKey.X: + if (IsControlPressed) + { + CopySelectedToClipboard(); + + // now clear all selected tokens and text, or all if none are selected + await RemoveAllSelectedTokens(); + } + + break; + + // For moving between tokens + case VirtualKey.Left: + return MoveFocusAndSelection(MoveDirection.Previous); + + case VirtualKey.Right: + return MoveFocusAndSelection(MoveDirection.Next); + + case VirtualKey.A: + // modify the select-all behavior to ensure the text in the edit box gets selected. + if (IsControlPressed) + { + this.SelectAllTokensAndText(); + return true; + } + + break; + } + + return false; + } + + /// + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + var selectAllMenuItem = new MenuFlyoutItem + { + Text = "WCT_TokenizingTextBox_MenuFlyout_SelectAll".GetLocalized("CommunityToolkit.WinUI.UI.Controls.Input/Resources") + }; + selectAllMenuItem.Click += (s, e) => this.SelectAllTokensAndText(); + var menuFlyout = new MenuFlyout(); + menuFlyout.Items.Add(selectAllMenuItem); + if (XamlRoot != null) + { + menuFlyout.XamlRoot = XamlRoot; + } + + ContextFlyout = menuFlyout; + } + + internal void RaiseQuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) + { + QuerySubmitted?.Invoke(sender, args); + } + + internal void RaiseSuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + SuggestionChosen?.Invoke(sender, args); + } + + internal void RaiseTextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + TextChanged?.Invoke(sender, args); + } + + private async void TokenizingTextBox_CharacterReceived(UIElement sender, CharacterReceivedRoutedEventArgs args) + { + var container = ContainerFromItem(_currentTextEdit) as TokenizingTextBoxItem; + + if (container != null && !(GetFocusedElement().Equals(container._autoSuggestTextBox) || char.IsControl(args.Character))) + { + if (SelectedItems.Count > 0) + { + var index = _innerItemsSource.IndexOf(SelectedItems.First()); + + await RemoveAllSelectedTokens(); + + // Wait for removal of old items + _ = DispatcherQueue.EnqueueAsync( + () => + { + // 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)) + { + var lastContainer = ContainerFromItem(_lastTextEdit) as TokenizingTextBoxItem; + + 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); + + // Need to wait for containerization + _ = DispatcherQueue.EnqueueAsync( + () => + { + var newContainer = ContainerFromIndex(index) as TokenizingTextBoxItem; // 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 != null) + { + newContainer._autoSuggestTextBox.SelectionStart = 1; // Set position to after our new character inserted + + newContainer._autoSuggestTextBox.Focus(FocusState.Keyboard); + } + + newContainer.Loaded -= WaitForLoad; + } + + newContainer.AutoSuggestTextBoxLoaded += WaitForLoad; + }, Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal); + } + }, Microsoft.UI.Dispatching.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[_innerItemsSource.Count - 1] is ITokenStringContainer textToken) + { + var last = ContainerFromIndex(Items.Count - 1) as TokenizingTextBoxItem; // Should be our last text box + var text = last._autoSuggestTextBox.Text; + var selectionStart = last._autoSuggestTextBox.SelectionStart; + var position = selectionStart > text.Length ? text.Length : selectionStart; + textToken.Text = text.Substring(0, position) + args.Character + + text.Substring(position); + + last._autoSuggestTextBox.SelectionStart = position + 1; // Set position to after our new character inserted + + last._autoSuggestTextBox.Focus(FocusState.Keyboard); + } + } + } + } + + private object GetFocusedElement() + { + if (XamlRoot != null) + { + return FocusManager.GetFocusedElement(XamlRoot); + } + else + { + return FocusManager.GetFocusedElement(); + } + } + + #region ItemsControl Container Methods + + /// + protected override DependencyObject GetContainerForItemOverride() => new TokenizingTextBoxItem(); + + /// + protected override bool IsItemItsOwnContainerOverride(object item) + { + return item is TokenizingTextBoxItem; + } + + /// + protected override void PrepareContainerForItemOverride(DependencyObject element, object item) + { + base.PrepareContainerForItemOverride(element, item); + + var tokenitem = element as TokenizingTextBoxItem; + + 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; + + var menuFlyout = new MenuFlyout(); + + var removeMenuItem = new MenuFlyoutItem + { + Text = "WCT_TokenizingTextBoxItem_MenuFlyout_Remove".GetLocalized("CommunityToolkit.WinUI.UI.Controls.Input/Resources") + }; + removeMenuItem.Click += (s, e) => TokenizingTextBoxItem_ClearClicked(tokenitem, null); + + menuFlyout.Items.Add(removeMenuItem); + if (XamlRoot != null) + { + menuFlyout.XamlRoot = XamlRoot; + } + + var selectAllMenuItem = new MenuFlyoutItem + { + Text = "WCT_TokenizingTextBox_MenuFlyout_SelectAll".GetLocalized("CommunityToolkit.WinUI.UI.Controls.Input/Resources") + }; + selectAllMenuItem.Click += (s, e) => this.SelectAllTokensAndText(); + + menuFlyout.Items.Add(selectAllMenuItem); + + tokenitem.ContextFlyout = menuFlyout; + } + #endregion + + private void TokenizingTextBoxItem_GotFocus(object sender, RoutedEventArgs e) + { + // Keep track of our currently focused textbox + if (sender is TokenizingTextBoxItem ttbi && ttbi.Content is ITokenStringContainer text) + { + UpdateCurrentTextEdit(text); + } + } + + private void TokenizingTextBoxItem_LostFocus(object sender, RoutedEventArgs e) + { + // Keep track of our currently focused textbox + if (sender is TokenizingTextBoxItem ttbi && ttbi.Content is 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(); + } + } + + /// + /// Adds the specified data item as a new token to the collection, will raise the event asynchronously still for confirmation. + /// + /// + /// The will automatically handle adding items for you, or you can add items to your backing collection. This method is provide for other cases where you may need to drive inserting a new token item to where the user is currently inserting text between tokens. + /// + /// Item to add as a token. + /// Flag to indicate if the item should be inserted in the last used textbox (inserted) or placed at end of the token list. + public void AddTokenItem(object data, bool atEnd = false) + { + _ = AddTokenAsync(data, atEnd); + } + + /// + /// Clears the whole collection, will raise the event asynchronously for each item. + /// + /// async task + public async Task ClearAsync() + { + while (_innerItemsSource.Count > 1) + { + var container = ContainerFromItem(_innerItemsSource[0]) as TokenizingTextBoxItem; + if (!await RemoveTokenAsync(container, _innerItemsSource[0])) + { + // 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; + } + + internal async Task AddTokenAsync(object data, bool? atEnd = null) + { + if (ReadLocalValue(MaximumTokensProperty) != DependencyProperty.UnsetValue && (MaximumTokens <= 0 || MaximumTokens <= _innerItemsSource.ItemsSource.Count)) + { + // No tokens for you + return; + } + + if (data is string str && TokenItemAdding != null) + { + var tiaea = new TokenItemAddingEventArgs(str); + await TokenItemAdding.InvokeAsync(this, tiaea); + + if (tiaea.Cancel) + { + return; + } + + if (tiaea.Item != 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 + var edit = _currentTextEdit; + var 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. + var last = ContainerFromItem(_lastTextEdit) as TokenizingTextBoxItem; + last?._autoSuggestTextBox.Focus(FocusState.Keyboard); + + TokenItemAdded?.Invoke(this, data); + + GuardAgainstPlaceholderTextLayoutIssue(); + } + + /// + /// Helper to change out the currently focused text element in the control. + /// + /// element which is now the main edited text. + protected void UpdateCurrentTextEdit(ITokenStringContainer edit) + { + _currentTextEdit = edit; + + Text = edit.Text; // Update our text property. + } + + /// + /// Creates AutomationPeer () + /// + /// An automation peer for this . + protected override AutomationPeer OnCreateAutomationPeer() + { + return new TokenizingTextBoxAutomationPeer(this); + } + + /// + /// Remove the specified token from the list. + /// + /// Item in the list to delete + /// data + /// + /// the data parameter is passed in optionally to support UX UTs. When running in the UT the Container items are not manifest. + /// + /// true if the item was removed successfully, false otherwise + private async Task RemoveTokenAsync(TokenizingTextBoxItem item, object data = null) + { + if (data == null) + { + data = ItemFromContainer(item); + } + + if (TokenItemRemoving != null) + { + var tirea = new TokenItemRemovingEventArgs(data, item); + await TokenItemRemoving.InvokeAsync(this, tirea); + + 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 + var 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; + }); + } + } + } +} \ No newline at end of file diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.xaml b/components/TokenizingTextBox/src/TokenizingTextBox.xaml new file mode 100644 index 00000000..225434c1 --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBox.xaml @@ -0,0 +1,169 @@ + + + + + + + + 4 + 0,0,6,0 + 2 + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxAutomationPeer.cs b/components/TokenizingTextBox/src/TokenizingTextBoxAutomationPeer.cs new file mode 100644 index 00000000..5c4beba7 --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBoxAutomationPeer.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using CommunityToolkit.WinUI.UI.Controls; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; +using Microsoft.UI.Xaml.Automation.Provider; +using Microsoft.UI.Xaml.Controls; + +namespace CommunityToolkit.WinUI.UI.Automation.Peers +{ + /// + /// Defines a framework element automation peer for the control. + /// + public class TokenizingTextBoxAutomationPeer : ListViewBaseAutomationPeer, IValueProvider + { + /// + /// Initializes a new instance of the class. + /// + /// + /// The that is associated with this . + /// + public TokenizingTextBoxAutomationPeer(TokenizingTextBox owner) + : base(owner) + { + } + + /// Gets a value indicating whether the value of a control is read-only. + /// **true** if the value is read-only; **false** if it can be modified. + public bool IsReadOnly => !this.OwningTokenizingTextBox.IsEnabled; + + /// Gets the value of the control. + /// The value of the control. + public string Value => this.OwningTokenizingTextBox.Text; + + private TokenizingTextBox OwningTokenizingTextBox + { + get + { + return Owner as TokenizingTextBox; + } + } + + /// Sets the value of a control. + /// The value to set. The provider is responsible for converting the value to the appropriate data type. + /// Thrown if the control is in a read-only state. + public void SetValue(string value) + { + if (IsReadOnly) + { + throw new ElementNotEnabledException($"Could not set the value of the {nameof(TokenizingTextBox)} "); + } + + this.OwningTokenizingTextBox.Text = value; + } + + /// + /// Called by GetClassName that gets a human readable name that, in addition to AutomationControlType, + /// differentiates the control represented by this AutomationPeer. + /// + /// The string that contains the name. + protected override string GetClassNameCore() + { + return Owner.GetType().Name; + } + + /// + /// Called by GetName. + /// + /// + /// Returns the first of these that is not null or empty: + /// - Value returned by the base implementation + /// - Name of the owning TokenizingTextBox + /// - TokenizingTextBox class name + /// + protected override string GetNameCore() + { + string name = this.OwningTokenizingTextBox.Name; + if (!string.IsNullOrWhiteSpace(name)) + { + return name; + } + + name = AutomationProperties.GetName(this.OwningTokenizingTextBox); + return !string.IsNullOrWhiteSpace(name) ? name : base.GetNameCore(); + } + + /// + /// Gets the control pattern that is associated with the specified Windows.UI.Xaml.Automation.Peers.PatternInterface. + /// + /// A value from the Windows.UI.Xaml.Automation.Peers.PatternInterface enumeration. + /// The object that supports the specified pattern, or null if unsupported. + protected override object GetPatternCore(PatternInterface patternInterface) + { + return patternInterface switch + { + PatternInterface.Value => this, + _ => base.GetPatternCore(patternInterface) + }; + } + + /// + /// Gets the collection of elements that are represented in the UI Automation tree as immediate + /// child elements of the automation peer. + /// + /// The children elements. + protected override IList GetChildrenCore() + { + TokenizingTextBox owner = this.OwningTokenizingTextBox; + + ItemCollection items = owner.Items; + if (items.Count <= 0) + { + return null; + } + + List peers = new List(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; + } + } +} \ No newline at end of file diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs new file mode 100644 index 00000000..6b58b24b --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs @@ -0,0 +1,412 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Windows.Foundation; +using Windows.System; +using Windows.UI; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Data; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; + +namespace Microsoft.Toolkit.Uwp.UI.Controls +{ + /// + /// A control that manages as the item logic for the control. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1124:Do not use regions", Justification = "Organization")] + [TemplatePart(Name = PART_AutoSuggestBox, Type = typeof(AutoSuggestBox))] //// String case + [TemplatePart(Name = PART_TokensCounter, Type = typeof(TextBlock))] + public partial class TokenizingTextBoxItem + { + private const string PART_AutoSuggestBox = "PART_AutoSuggestBox"; + private const string PART_TokensCounter = "PART_TokensCounter"; + + private AutoSuggestBox _autoSuggestBox; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Tight Coupling with Parent for Selection control.")] + internal TextBox _autoSuggestTextBox; + + /// + /// Event raised when the 'Clear' Button is clicked. + /// + internal event TypedEventHandler AutoSuggestTextBoxLoaded; + + internal bool UseCharacterAsUser { get; set; } + + /// + /// Gets a value indicating whether the textbox caret is in the first position. False otherwise + /// + private bool IsCaretAtStart => _autoSuggestTextBox?.SelectionStart == 0; + + /// + /// Gets a value indicating whether the textbox caret is in the last position. False otherwise + /// + private bool IsCaretAtEnd => _autoSuggestTextBox?.SelectionStart == _autoSuggestTextBox?.Text.Length || + _autoSuggestTextBox?.SelectionStart + _autoSuggestTextBox?.SelectionLength == _autoSuggestTextBox?.Text.Length; + + /// + /// Gets a value indicating whether all text in the text box is currently selected. False otherwise. + /// + private bool IsAllSelected => _autoSuggestTextBox?.SelectedText == _autoSuggestTextBox?.Text && !string.IsNullOrEmpty(_autoSuggestTextBox?.Text); + + /// + /// Used to track if we're on the first character of the textbox while there is selected text + /// + private bool _isSelectedFocusOnFirstCharacter = false; + + /// + /// Used to track if we're on the last character of the textbox while there is selected text + /// + private bool _isSelectedFocusOnLastCharacter = false; + + /// Called from + private void OnApplyTemplateAutoSuggestBox(AutoSuggestBox auto) + { + if (_autoSuggestBox != 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 = null; + } + + _autoSuggestBox = auto; + + if (_autoSuggestBox != 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; + } + + var iconBinding = new Binding() + { + Source = Owner, + Path = new PropertyPath(nameof(Owner.QueryIcon)), + RelativeSource = new RelativeSource() { Mode = RelativeSourceMode.TemplatedParent } + }; + + var iconSourceElement = new IconSourceElement(); + + iconSourceElement.SetBinding(IconSourceElement.IconSourceProperty, iconBinding); + + _autoSuggestBox.QueryIcon = iconSourceElement; + } + } + } + } + + #region AutoSuggestBox + private async void AutoSuggestBox_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) + { + Owner.RaiseQuerySubmitted(sender, args); + + object chosenItem = null; + if (args.ChosenSuggestion != null) + { + chosenItem = args.ChosenSuggestion; + } + else if (!string.IsNullOrWhiteSpace(args.QueryText)) + { + chosenItem = args.QueryText; + } + + if (chosenItem != null) + { + await Owner.AddTokenAsync(chosenItem); // TODO: Need to pass index? + sender.Text = string.Empty; + Owner.Text = string.Empty; + sender.Focus(FocusState.Programmatic); + } + } + + private void AutoSuggestBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + Owner.RaiseSuggestionChosen(sender, args); + } + + // Called to update text by link:TokenizingTextBox.Properties.cs:TextPropertyChanged + internal void UpdateText(string text) + { + if (_autoSuggestBox != null) + { + _autoSuggestBox.Text = text; + } + else + { + void WaitForLoad(object s, RoutedEventArgs eargs) + { + if (_autoSuggestTextBox != null) + { + _autoSuggestTextBox.Text = text; + } + + AutoSuggestTextBoxLoaded -= WaitForLoad; + } + + AutoSuggestTextBoxLoaded += WaitForLoad; + } + } + + private void AutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + 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); + + var 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)) + { + bool lastDelimited = t[t.Length - 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[tokens.Length - 1].Trim(); + } + } + } + #endregion + + #region Visual State Management for Parent + private void AutoSuggestBox_PointerEntered(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_PointerOverState, true); + } + + private void AutoSuggestBox_PointerExited(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_NormalState, true); + } + + private void AutoSuggestBox_LostFocus(object sender, RoutedEventArgs e) + { + VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_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.PART_FocusedState, true); + } + #endregion + + #region Inner TextBox + private void OnASBLoaded(object sender, RoutedEventArgs e) + { + 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(); + } + } + + if (_autoSuggestTextBox != null) + { + _autoSuggestTextBox.PreviewKeyDown -= this.AutoSuggestTextBox_PreviewKeyDown; + _autoSuggestTextBox.TextChanging -= AutoSuggestTextBox_TextChangingAsync; + _autoSuggestTextBox.SelectionChanged -= AutoSuggestTextBox_SelectionChanged; + _autoSuggestTextBox.SelectionChanging -= AutoSuggestTextBox_SelectionChanging; + } + + _autoSuggestTextBox = _autoSuggestBox.FindDescendant() as TextBox; + + if (_autoSuggestTextBox != null) + { + _autoSuggestTextBox.PreviewKeyDown += this.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 == 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 == VirtualKey.Back || + e.Key == 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 == VirtualKey.Left && _isSelectedFocusOnFirstCharacter) || + _autoSuggestTextBox.SelectionLength == 0) + { + if (Owner.SelectPreviousItem(this)) + { + if (!TokenizingTextBox.IsShiftPressed) + { + // Clear any text box selection + _autoSuggestTextBox.SelectionLength = 0; + } + + e.Handled = true; + } + } + } + else if (IsCaretAtEnd && e.Key == 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 == 0) + { + if (Owner.SelectNextItem(this)) + { + if (!TokenizingTextBox.IsShiftPressed) + { + // Clear any text box selection + _autoSuggestTextBox.SelectionLength = 0; + } + + e.Handled = true; + } + } + } + else if (e.Key == VirtualKey.A && Owner.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) + { + var maxTokensCounter = (TextBlock)_autoSuggestBox?.FindDescendant(PART_TokensCounter); + if (maxTokensCounter == null) + { + return; + } + + void OnTokenCountChanged(TokenizingTextBox ttb, object value = null) + { + var itemsSource = ttb.ItemsSource as InterspersedObservableCollection; + var currentTokens = itemsSource.ItemsSource.Count; + var maxTokens = ttb.MaximumTokens; + + maxTokensCounter.Text = $"{currentTokens}/{maxTokens}"; + maxTokensCounter.Visibility = Visibility.Visible; + + maxTokensCounter.Foreground = (currentTokens >= maxTokens) + ? new SolidColorBrush(Colors.Red) + : _autoSuggestBox.Foreground; + } + + ttbi.Owner.TokenItemAdded -= OnTokenCountChanged; + ttbi.Owner.TokenItemRemoved -= OnTokenCountChanged; + + if (Content is ITokenStringContainer str && str.IsLast && ttbi?.Owner != null && ttbi.Owner.ReadLocalValue(TokenizingTextBox.MaximumTokensProperty) != DependencyProperty.UnsetValue) + { + ttbi.Owner.TokenItemAdded += OnTokenCountChanged; + ttbi.Owner.TokenItemRemoved += OnTokenCountChanged; + OnTokenCountChanged(ttbi.Owner); + } + else + { + maxTokensCounter.Visibility = Visibility.Collapsed; + maxTokensCounter.Text = string.Empty; + } + } + #endregion + } +} \ No newline at end of file diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.xaml b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.xaml new file mode 100644 index 00000000..edbc1902 --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.xaml @@ -0,0 +1,386 @@ + + + + 10,3,6,6 + 16 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml b/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml new file mode 100644 index 00000000..b35bde9e --- /dev/null +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml @@ -0,0 +1,125 @@ + + + + 28 + 8,0,0,0 + 0 + 0,-2,0,1 + Center + Center + + + + + + @@ -66,60 +68,11 @@ TargetType="controls:TokenizingTextBox"> - - - + + + - - - - - - - - - - - - @@ -164,6 +117,56 @@ + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxAutomationPeer.cs b/components/TokenizingTextBox/src/TokenizingTextBoxAutomationPeer.cs index 5c4beba7..7d758b27 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBoxAutomationPeer.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBoxAutomationPeer.cs @@ -2,130 +2,125 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Collections.Generic; -using CommunityToolkit.WinUI.UI.Controls; -using Microsoft.UI.Xaml.Automation; -using Microsoft.UI.Xaml.Automation.Peers; +using CommunityToolkit.WinUI.Controls; using Microsoft.UI.Xaml.Automation.Provider; -using Microsoft.UI.Xaml.Controls; -namespace CommunityToolkit.WinUI.UI.Automation.Peers +namespace CommunityToolkit.WinUI.Automation.Peers; + +/// +/// Defines a framework element automation peer for the control. +/// +public class TokenizingTextBoxAutomationPeer : ListViewBaseAutomationPeer, IValueProvider { /// - /// Defines a framework element automation peer for the control. + /// Initializes a new instance of the class. /// - public class TokenizingTextBoxAutomationPeer : ListViewBaseAutomationPeer, IValueProvider + /// + /// The that is associated with this . + /// + public TokenizingTextBoxAutomationPeer(TokenizingTextBox owner) + : base(owner) { - /// - /// Initializes a new instance of the class. - /// - /// - /// The that is associated with this . - /// - public TokenizingTextBoxAutomationPeer(TokenizingTextBox owner) - : base(owner) - { - } + } - /// Gets a value indicating whether the value of a control is read-only. - /// **true** if the value is read-only; **false** if it can be modified. - public bool IsReadOnly => !this.OwningTokenizingTextBox.IsEnabled; + /// Gets a value indicating whether the value of a control is read-only. + /// **true** if the value is read-only; **false** if it can be modified. + public bool IsReadOnly => !this.OwningTokenizingTextBox.IsEnabled; - /// Gets the value of the control. - /// The value of the control. - public string Value => this.OwningTokenizingTextBox.Text; + /// Gets the value of the control. + /// The value of the control. + public string Value => this.OwningTokenizingTextBox.Text; - private TokenizingTextBox OwningTokenizingTextBox + private TokenizingTextBox OwningTokenizingTextBox + { + get { - get - { - return Owner as TokenizingTextBox; - } + return Owner as TokenizingTextBox; } + } - /// Sets the value of a control. - /// The value to set. The provider is responsible for converting the value to the appropriate data type. - /// Thrown if the control is in a read-only state. - public void SetValue(string value) + /// Sets the value of a control. + /// The value to set. The provider is responsible for converting the value to the appropriate data type. + /// Thrown if the control is in a read-only state. + public void SetValue(string value) + { + if (IsReadOnly) { - if (IsReadOnly) - { - throw new ElementNotEnabledException($"Could not set the value of the {nameof(TokenizingTextBox)} "); - } - - this.OwningTokenizingTextBox.Text = value; + throw new ElementNotEnabledException($"Could not set the value of the {nameof(TokenizingTextBox)} "); } - /// - /// Called by GetClassName that gets a human readable name that, in addition to AutomationControlType, - /// differentiates the control represented by this AutomationPeer. - /// - /// The string that contains the name. - protected override string GetClassNameCore() + this.OwningTokenizingTextBox.Text = value; + } + + /// + /// Called by GetClassName that gets a human readable name that, in addition to AutomationControlType, + /// differentiates the control represented by this AutomationPeer. + /// + /// The string that contains the name. + protected override string GetClassNameCore() + { + return Owner.GetType().Name; + } + + /// + /// Called by GetName. + /// + /// + /// Returns the first of these that is not null or empty: + /// - Value returned by the base implementation + /// - Name of the owning TokenizingTextBox + /// - TokenizingTextBox class name + /// + protected override string GetNameCore() + { + string name = this.OwningTokenizingTextBox.Name; + if (!string.IsNullOrWhiteSpace(name)) { - return Owner.GetType().Name; + return name; } - /// - /// Called by GetName. - /// - /// - /// Returns the first of these that is not null or empty: - /// - Value returned by the base implementation - /// - Name of the owning TokenizingTextBox - /// - TokenizingTextBox class name - /// - protected override string GetNameCore() + name = AutomationProperties.GetName(this.OwningTokenizingTextBox); + return !string.IsNullOrWhiteSpace(name) ? name : base.GetNameCore(); + } + + /// + /// Gets the control pattern that is associated with the specified Windows.UI.Xaml.Automation.Peers.PatternInterface. + /// + /// A value from the Windows.UI.Xaml.Automation.Peers.PatternInterface enumeration. + /// The object that supports the specified pattern, or null if unsupported. + protected override object GetPatternCore(PatternInterface patternInterface) + { + return patternInterface switch { - string name = this.OwningTokenizingTextBox.Name; - if (!string.IsNullOrWhiteSpace(name)) - { - return name; - } + PatternInterface.Value => this, + _ => base.GetPatternCore(patternInterface) + }; + } - name = AutomationProperties.GetName(this.OwningTokenizingTextBox); - return !string.IsNullOrWhiteSpace(name) ? name : base.GetNameCore(); - } + /// + /// Gets the collection of elements that are represented in the UI Automation tree as immediate + /// child elements of the automation peer. + /// + /// The children elements. + protected override IList GetChildrenCore() + { + TokenizingTextBox owner = this.OwningTokenizingTextBox; - /// - /// Gets the control pattern that is associated with the specified Windows.UI.Xaml.Automation.Peers.PatternInterface. - /// - /// A value from the Windows.UI.Xaml.Automation.Peers.PatternInterface enumeration. - /// The object that supports the specified pattern, or null if unsupported. - protected override object GetPatternCore(PatternInterface patternInterface) + ItemCollection items = owner.Items; + if (items.Count <= 0) { - return patternInterface switch - { - PatternInterface.Value => this, - _ => base.GetPatternCore(patternInterface) - }; + return null; } - /// - /// Gets the collection of elements that are represented in the UI Automation tree as immediate - /// child elements of the automation peer. - /// - /// The children elements. - protected override IList GetChildrenCore() + List peers = new List(items.Count); + for (int i = 0; i < items.Count; i++) { - TokenizingTextBox owner = this.OwningTokenizingTextBox; - - ItemCollection items = owner.Items; - if (items.Count <= 0) + if (owner.ContainerFromIndex(i) is TokenizingTextBoxItem element) { - return null; + peers.Add(FromElement(element) ?? CreatePeerForElement(element)); } - - List peers = new List(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; } + + return peers; } -} \ No newline at end of file +} diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs index 6b58b24b..a189528c 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs @@ -2,411 +2,403 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Collections.Generic; -using Windows.Foundation; using Windows.System; using Windows.UI; -using Windows.UI.Xaml; -using Windows.UI.Xaml.Controls; -using Windows.UI.Xaml.Data; -using Windows.UI.Xaml.Input; -using Windows.UI.Xaml.Media; -namespace Microsoft.Toolkit.Uwp.UI.Controls +namespace CommunityToolkit.WinUI.Controls; + +/// +/// A control that manages as the item logic for the control. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1124:Do not use regions", Justification = "Organization")] +[TemplatePart(Name = PART_AutoSuggestBox, Type = typeof(AutoSuggestBox))] //// String case +[TemplatePart(Name = PART_TokensCounter, Type = typeof(TextBlock))] +public partial class TokenizingTextBoxItem { - /// - /// A control that manages as the item logic for the control. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1124:Do not use regions", Justification = "Organization")] - [TemplatePart(Name = PART_AutoSuggestBox, Type = typeof(AutoSuggestBox))] //// String case - [TemplatePart(Name = PART_TokensCounter, Type = typeof(TextBlock))] - public partial class TokenizingTextBoxItem - { - private const string PART_AutoSuggestBox = "PART_AutoSuggestBox"; - private const string PART_TokensCounter = "PART_TokensCounter"; + private const string PART_AutoSuggestBox = "PART_AutoSuggestBox"; + private const string PART_TokensCounter = "PART_TokensCounter"; - private AutoSuggestBox _autoSuggestBox; + private AutoSuggestBox _autoSuggestBox; - [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Tight Coupling with Parent for Selection control.")] - internal TextBox _autoSuggestTextBox; + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Tight Coupling with Parent for Selection control.")] + internal TextBox _autoSuggestTextBox; - /// - /// Event raised when the 'Clear' Button is clicked. - /// - internal event TypedEventHandler AutoSuggestTextBoxLoaded; + /// + /// Event raised when the 'Clear' Button is clicked. + /// + internal event TypedEventHandler AutoSuggestTextBoxLoaded; - internal bool UseCharacterAsUser { get; set; } + internal bool UseCharacterAsUser { get; set; } - /// - /// Gets a value indicating whether the textbox caret is in the first position. False otherwise - /// - private bool IsCaretAtStart => _autoSuggestTextBox?.SelectionStart == 0; + /// + /// Gets a value indicating whether the textbox caret is in the first position. False otherwise + /// + private bool IsCaretAtStart => _autoSuggestTextBox?.SelectionStart == 0; - /// - /// Gets a value indicating whether the textbox caret is in the last position. False otherwise - /// - private bool IsCaretAtEnd => _autoSuggestTextBox?.SelectionStart == _autoSuggestTextBox?.Text.Length || - _autoSuggestTextBox?.SelectionStart + _autoSuggestTextBox?.SelectionLength == _autoSuggestTextBox?.Text.Length; + /// + /// Gets a value indicating whether the textbox caret is in the last position. False otherwise + /// + private bool IsCaretAtEnd => _autoSuggestTextBox?.SelectionStart == _autoSuggestTextBox?.Text.Length || + _autoSuggestTextBox?.SelectionStart + _autoSuggestTextBox?.SelectionLength == _autoSuggestTextBox?.Text.Length; - /// - /// Gets a value indicating whether all text in the text box is currently selected. False otherwise. - /// - private bool IsAllSelected => _autoSuggestTextBox?.SelectedText == _autoSuggestTextBox?.Text && !string.IsNullOrEmpty(_autoSuggestTextBox?.Text); + /// + /// Gets a value indicating whether all text in the text box is currently selected. False otherwise. + /// + private bool IsAllSelected => _autoSuggestTextBox?.SelectedText == _autoSuggestTextBox?.Text && !string.IsNullOrEmpty(_autoSuggestTextBox?.Text); - /// - /// Used to track if we're on the first character of the textbox while there is selected text - /// - private bool _isSelectedFocusOnFirstCharacter = false; + /// + /// Used to track if we're on the first character of the textbox while there is selected text + /// + private bool _isSelectedFocusOnFirstCharacter = false; - /// - /// Used to track if we're on the last character of the textbox while there is selected text - /// - private bool _isSelectedFocusOnLastCharacter = false; + /// + /// Used to track if we're on the last character of the textbox while there is selected text + /// + private bool _isSelectedFocusOnLastCharacter = false; - /// Called from - private void OnApplyTemplateAutoSuggestBox(AutoSuggestBox auto) + /// Called from + private void OnApplyTemplateAutoSuggestBox(AutoSuggestBox auto) + { + if (_autoSuggestBox != null) { - if (_autoSuggestBox != 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 = 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 = null; + } - _autoSuggestBox = auto; + _autoSuggestBox = auto; - if (_autoSuggestBox != null) + if (_autoSuggestBox != 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) { - _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 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) + // 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) { - // 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; - } - - var iconBinding = new Binding() - { - Source = Owner, - Path = new PropertyPath(nameof(Owner.QueryIcon)), - RelativeSource = new RelativeSource() { Mode = RelativeSourceMode.TemplatedParent } - }; - - var iconSourceElement = new IconSourceElement(); - - iconSourceElement.SetBinding(IconSourceElement.IconSourceProperty, iconBinding); - - _autoSuggestBox.QueryIcon = iconSourceElement; + // 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; } + + var iconBinding = new Binding() + { + Source = Owner, + Path = new PropertyPath(nameof(Owner.QueryIcon)), + RelativeSource = new RelativeSource() { Mode = RelativeSourceMode.TemplatedParent } + }; + + var iconSourceElement = new IconSourceElement(); + + iconSourceElement.SetBinding(IconSourceElement.IconSourceProperty, iconBinding); + + _autoSuggestBox.QueryIcon = iconSourceElement; } } } + } - #region AutoSuggestBox - private async void AutoSuggestBox_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) - { - Owner.RaiseQuerySubmitted(sender, args); - - object chosenItem = null; - if (args.ChosenSuggestion != null) - { - chosenItem = args.ChosenSuggestion; - } - else if (!string.IsNullOrWhiteSpace(args.QueryText)) - { - chosenItem = args.QueryText; - } + #region AutoSuggestBox + private async void AutoSuggestBox_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) + { + Owner.RaiseQuerySubmitted(sender, args); - if (chosenItem != null) - { - await Owner.AddTokenAsync(chosenItem); // TODO: Need to pass index? - sender.Text = string.Empty; - Owner.Text = string.Empty; - sender.Focus(FocusState.Programmatic); - } + object chosenItem = null; + if (args.ChosenSuggestion != null) + { + chosenItem = args.ChosenSuggestion; + } + else if (!string.IsNullOrWhiteSpace(args.QueryText)) + { + chosenItem = args.QueryText; } - private void AutoSuggestBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + if (chosenItem != null) { - Owner.RaiseSuggestionChosen(sender, args); + await Owner.AddTokenAsync(chosenItem); // TODO: Need to pass index? + sender.Text = string.Empty; + Owner.Text = string.Empty; + sender.Focus(FocusState.Programmatic); } + } - // Called to update text by link:TokenizingTextBox.Properties.cs:TextPropertyChanged - internal void UpdateText(string text) + private void AutoSuggestBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + Owner.RaiseSuggestionChosen(sender, args); + } + + // Called to update text by link:TokenizingTextBox.Properties.cs:TextPropertyChanged + internal void UpdateText(string text) + { + if (_autoSuggestBox != null) { - if (_autoSuggestBox != null) - { - _autoSuggestBox.Text = text; - } - else + _autoSuggestBox.Text = text; + } + else + { + void WaitForLoad(object s, RoutedEventArgs eargs) { - void WaitForLoad(object s, RoutedEventArgs eargs) + if (_autoSuggestTextBox != null) { - if (_autoSuggestTextBox != null) - { - _autoSuggestTextBox.Text = text; - } - - AutoSuggestTextBoxLoaded -= WaitForLoad; + _autoSuggestTextBox.Text = text; } - AutoSuggestTextBoxLoaded += WaitForLoad; + AutoSuggestTextBoxLoaded -= WaitForLoad; } + + AutoSuggestTextBoxLoaded += WaitForLoad; } + } - private void AutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + private void AutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + if (!EqualityComparer.Default.Equals(sender.Text, Owner.Text)) { - if (!EqualityComparer.Default.Equals(sender.Text, Owner.Text)) - { - Owner.Text = sender.Text; // Update parent text property, if different - } + 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; + // Override our programmatic manipulation as we're redirecting input for the user + if (UseCharacterAsUser) + { + UseCharacterAsUser = false; - args.Reason = AutoSuggestionBoxTextChangeReason.UserInput; - } + args.Reason = AutoSuggestionBoxTextChangeReason.UserInput; + } - Owner.RaiseTextChanged(sender, args); + Owner.RaiseTextChanged(sender, args); - var t = sender.Text?.Trim() ?? string.Empty; + var 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)) - { - bool lastDelimited = t[t.Length - 1] == Owner.TokenDelimiter[0]; + // Look for Token Delimiters to create new tokens when text changes. + if (!string.IsNullOrEmpty(Owner.TokenDelimiter) && t.Contains(Owner.TokenDelimiter)) + { + bool lastDelimited = t[t.Length - 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[] 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) { - string token = tokens[position]; - token = token.Trim(); - if (token.Length > 0) - { - _ = Owner.AddTokenAsync(token); //// TODO: Pass Index? - } + _ = Owner.AddTokenAsync(token); //// TODO: Pass Index? } + } - if (lastDelimited) - { - sender.Text = string.Empty; - } - else - { - sender.Text = tokens[tokens.Length - 1].Trim(); - } + if (lastDelimited) + { + sender.Text = string.Empty; + } + else + { + sender.Text = tokens[tokens.Length - 1].Trim(); } } - #endregion + } + #endregion - #region Visual State Management for Parent - private void AutoSuggestBox_PointerEntered(object sender, PointerRoutedEventArgs e) - { - VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_PointerOverState, true); - } + #region Visual State Management for Parent + private void AutoSuggestBox_PointerEntered(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_PointerOverState, true); + } - private void AutoSuggestBox_PointerExited(object sender, PointerRoutedEventArgs e) - { - VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_NormalState, true); - } + private void AutoSuggestBox_PointerExited(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_NormalState, true); + } - private void AutoSuggestBox_LostFocus(object sender, RoutedEventArgs e) + private void AutoSuggestBox_LostFocus(object sender, RoutedEventArgs e) + { + VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_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) { - VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_UnfocusedState, true); + // Clear any selected tokens + Owner.DeselectAll(); } - 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; - Owner.PauseTokenClearOnFocus = false; + VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_FocusedState, true); + } + #endregion - VisualStateManager.GoToState(Owner, TokenizingTextBox.PART_FocusedState, true); - } - #endregion + #region Inner TextBox + private void OnASBLoaded(object sender, RoutedEventArgs e) + { + UpdateTokensCounter(this); - #region Inner TextBox - private void OnASBLoaded(object sender, RoutedEventArgs e) + // Local function for Selection changed + void AutoSuggestTextBox_SelectionChanged(object box, RoutedEventArgs args) { - UpdateTokensCounter(this); - - // Local function for Selection changed - void AutoSuggestTextBox_SelectionChanged(object box, RoutedEventArgs args) + if (!(IsAllSelected || TokenizingTextBox.IsShiftPressed || Owner.IsClearingForClick)) { - if (!(IsAllSelected || TokenizingTextBox.IsShiftPressed || Owner.IsClearingForClick)) - { - Owner.DeselectAllTokensAndText(this); - } - - // Ensure flag is always reset - Owner.IsClearingForClick = false; + Owner.DeselectAllTokensAndText(this); } - // 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(); - } - } + // Ensure flag is always reset + Owner.IsClearingForClick = false; + } - if (_autoSuggestTextBox != null) + // 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) { - _autoSuggestTextBox.PreviewKeyDown -= this.AutoSuggestTextBox_PreviewKeyDown; - _autoSuggestTextBox.TextChanging -= AutoSuggestTextBox_TextChangingAsync; - _autoSuggestTextBox.SelectionChanged -= AutoSuggestTextBox_SelectionChanged; - _autoSuggestTextBox.SelectionChanging -= AutoSuggestTextBox_SelectionChanging; + await Owner.RemoveAllSelectedTokens(); } + } - _autoSuggestTextBox = _autoSuggestBox.FindDescendant() as TextBox; - - if (_autoSuggestTextBox != null) - { - _autoSuggestTextBox.PreviewKeyDown += this.AutoSuggestTextBox_PreviewKeyDown; - _autoSuggestTextBox.TextChanging += AutoSuggestTextBox_TextChangingAsync; - _autoSuggestTextBox.SelectionChanged += AutoSuggestTextBox_SelectionChanged; - _autoSuggestTextBox.SelectionChanging += AutoSuggestTextBox_SelectionChanging; - - AutoSuggestTextBoxLoaded?.Invoke(this, e); - } + if (_autoSuggestTextBox != null) + { + _autoSuggestTextBox.PreviewKeyDown -= this.AutoSuggestTextBox_PreviewKeyDown; + _autoSuggestTextBox.TextChanging -= AutoSuggestTextBox_TextChangingAsync; + _autoSuggestTextBox.SelectionChanged -= AutoSuggestTextBox_SelectionChanged; + _autoSuggestTextBox.SelectionChanging -= AutoSuggestTextBox_SelectionChanging; } - private void AutoSuggestTextBox_SelectionChanging(TextBox sender, TextBoxSelectionChangingEventArgs args) + _autoSuggestTextBox = _autoSuggestBox.FindDescendant() as TextBox; + + if (_autoSuggestTextBox != null) { - _isSelectedFocusOnFirstCharacter = args.SelectionLength > 0 && args.SelectionStart == 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); + _autoSuggestTextBox.PreviewKeyDown += this.AutoSuggestTextBox_PreviewKeyDown; + _autoSuggestTextBox.TextChanging += AutoSuggestTextBox_TextChangingAsync; + _autoSuggestTextBox.SelectionChanged += AutoSuggestTextBox_SelectionChanged; + _autoSuggestTextBox.SelectionChanging += AutoSuggestTextBox_SelectionChanging; + + AutoSuggestTextBoxLoaded?.Invoke(this, e); } + } - private void AutoSuggestTextBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + private void AutoSuggestTextBox_SelectionChanging(TextBox sender, TextBoxSelectionChangingEventArgs args) + { + _isSelectedFocusOnFirstCharacter = args.SelectionLength > 0 && args.SelectionStart == 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 == VirtualKey.Back || + e.Key == VirtualKey.Left)) { - if (IsCaretAtStart && - (e.Key == VirtualKey.Back || - e.Key == 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 == VirtualKey.Left && _isSelectedFocusOnFirstCharacter) || + _autoSuggestTextBox.SelectionLength == 0) { - // 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 == VirtualKey.Left && _isSelectedFocusOnFirstCharacter) || - _autoSuggestTextBox.SelectionLength == 0) + if (Owner.SelectPreviousItem(this)) { - if (Owner.SelectPreviousItem(this)) + if (!TokenizingTextBox.IsShiftPressed) { - if (!TokenizingTextBox.IsShiftPressed) - { - // Clear any text box selection - _autoSuggestTextBox.SelectionLength = 0; - } - - e.Handled = true; + // Clear any text box selection + _autoSuggestTextBox.SelectionLength = 0; } + + e.Handled = true; } } - else if (IsCaretAtEnd && e.Key == VirtualKey.Right) + } + else if (IsCaretAtEnd && e.Key == 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 == 0) { - // 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 == 0) + if (Owner.SelectNextItem(this)) { - if (Owner.SelectNextItem(this)) + if (!TokenizingTextBox.IsShiftPressed) { - if (!TokenizingTextBox.IsShiftPressed) - { - // Clear any text box selection - _autoSuggestTextBox.SelectionLength = 0; - } - - e.Handled = true; + // Clear any text box selection + _autoSuggestTextBox.SelectionLength = 0; } + + e.Handled = true; } } - else if (e.Key == VirtualKey.A && Owner.IsControlPressed) - { - // Need to provide this shortcut from the textbox only, as ListViewBase will do it for us on token. - Owner.SelectAllTokensAndText(); - } } + else if (e.Key == VirtualKey.A && Owner.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) + private void UpdateTokensCounter(TokenizingTextBoxItem ttbi) + { + var maxTokensCounter = (TextBlock)_autoSuggestBox?.FindDescendant(PART_TokensCounter); + if (maxTokensCounter == null) { - var maxTokensCounter = (TextBlock)_autoSuggestBox?.FindDescendant(PART_TokensCounter); - if (maxTokensCounter == null) - { - return; - } + return; + } - void OnTokenCountChanged(TokenizingTextBox ttb, object value = null) - { - var itemsSource = ttb.ItemsSource as InterspersedObservableCollection; - var currentTokens = itemsSource.ItemsSource.Count; - var maxTokens = ttb.MaximumTokens; + void OnTokenCountChanged(TokenizingTextBox ttb, object value = null) + { + var itemsSource = ttb.ItemsSource as InterspersedObservableCollection; + var currentTokens = itemsSource.ItemsSource.Count; + var maxTokens = ttb.MaximumTokens; - maxTokensCounter.Text = $"{currentTokens}/{maxTokens}"; - maxTokensCounter.Visibility = Visibility.Visible; + maxTokensCounter.Text = $"{currentTokens}/{maxTokens}"; + maxTokensCounter.Visibility = Visibility.Visible; - maxTokensCounter.Foreground = (currentTokens >= maxTokens) - ? new SolidColorBrush(Colors.Red) - : _autoSuggestBox.Foreground; - } + maxTokensCounter.Foreground = (currentTokens >= maxTokens) + ? new SolidColorBrush(Colors.Red) + : _autoSuggestBox.Foreground; + } - ttbi.Owner.TokenItemAdded -= OnTokenCountChanged; - ttbi.Owner.TokenItemRemoved -= OnTokenCountChanged; + ttbi.Owner.TokenItemAdded -= OnTokenCountChanged; + ttbi.Owner.TokenItemRemoved -= OnTokenCountChanged; - if (Content is ITokenStringContainer str && str.IsLast && ttbi?.Owner != null && ttbi.Owner.ReadLocalValue(TokenizingTextBox.MaximumTokensProperty) != DependencyProperty.UnsetValue) - { - ttbi.Owner.TokenItemAdded += OnTokenCountChanged; - ttbi.Owner.TokenItemRemoved += OnTokenCountChanged; - OnTokenCountChanged(ttbi.Owner); - } - else - { - maxTokensCounter.Visibility = Visibility.Collapsed; - maxTokensCounter.Text = string.Empty; - } + if (Content is ITokenStringContainer str && str.IsLast && ttbi?.Owner != null && ttbi.Owner.ReadLocalValue(TokenizingTextBox.MaximumTokensProperty) != DependencyProperty.UnsetValue) + { + ttbi.Owner.TokenItemAdded += OnTokenCountChanged; + ttbi.Owner.TokenItemRemoved += OnTokenCountChanged; + OnTokenCountChanged(ttbi.Owner); + } + else + { + maxTokensCounter.Visibility = Visibility.Collapsed; + maxTokensCounter.Text = string.Empty; } - #endregion } -} \ No newline at end of file + #endregion +} diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.xaml b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.xaml index edbc1902..dc5bff28 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.xaml +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.xaml @@ -1,12 +1,14 @@ + xmlns:controls="using:CommunityToolkit.WinUI.Controls"> - 10,3,6,6 - 16 + 10,3,6,6 + + 16 + - + - + - - - - \ No newline at end of file + diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.cs b/components/TokenizingTextBox/src/TokenizingTextBoxItem.cs index 7d2d78a1..e0cd25ce 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBoxItem.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.cs @@ -2,14 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Controls.Primitives; -using Microsoft.UI.Xaml.Input; -using Windows.Foundation; using Windows.System; -namespace CommunityToolkit.WinUI.UI.Controls +namespace CommunityToolkit.WinUI.Controls { /// /// A control that manages as the item logic for the control. @@ -118,4 +113,4 @@ private void TokenizingTextBoxItem_KeyDown(object sender, KeyRoutedEventArgs e) } } } -} \ No newline at end of file +} diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxStyleSelector.cs b/components/TokenizingTextBox/src/TokenizingTextBoxStyleSelector.cs index 5e13d38c..bdb8dfbf 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBoxStyleSelector.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBoxStyleSelector.cs @@ -2,10 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; - -namespace CommunityToolkit.WinUI.UI.Controls +namespace CommunityToolkit.WinUI.Controls { /// /// used by to choose the proper container style (text entry or token). @@ -40,4 +37,4 @@ protected override Style SelectStyleCore(object item, DependencyObject container return TokenStyle; } } -} \ No newline at end of file +} From f80f1854994167e293a3a83044a9c4c7b983fa01 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Thu, 1 Jun 2023 15:35:08 +0200 Subject: [PATCH 03/25] resolving platform diferences --- Directory.Build.props | 1 + ...it.WinUI.Controls.TokenizingTextBox.csproj | 4 +- .../src/InterspersedObservableCollection.cs | 621 +++++++++--------- .../src/TokenItemAddingEventArgs.cs | 6 +- .../src/TokenItemRemovingEventArgs.cs | 2 +- .../src/TokenizingTextBox.Properties.cs | 20 +- .../src/TokenizingTextBox.Selection.cs | 378 ++++++----- .../src/TokenizingTextBox.cs | 208 +++--- .../src/TokenizingTextBox.xaml | 4 +- .../src/TokenizingTextBoxAutomationPeer.cs | 8 +- .../TokenizingTextBoxItem.AutoSuggestBox.cs | 37 +- .../src/TokenizingTextBoxItem.Token.xaml | 61 +- .../src/TokenizingTextBoxItem.cs | 168 ++--- .../src/TokenizingTextBoxStyleSelector.cs | 53 +- .../ExampleTokenizingTextBoxTestClass.cs | 10 +- .../ExampleTokenizingTextBoxTestPage.xaml | 4 +- 16 files changed, 845 insertions(+), 740 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 6ac5fe32..1fccf318 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,6 +9,7 @@ $(RepositoryDirectory)\components\Effects\src\CommunityToolkit.WinUI.Effects.csproj $(RepositoryDirectory)\components\Behaviors\src\CommunityToolkit.WinUI.Behaviors.csproj $(RepositoryDirectory)\components\Animations\src\CommunityToolkit.WinUI.Animations.csproj + $(RepositoryDirectory)\components\Primitives\src\CommunityToolkit.WinUI.Primitives.csproj diff --git a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj index 1183c0da..11d319f0 100644 --- a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj +++ b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj @@ -10,8 +10,10 @@ + + - + diff --git a/components/TokenizingTextBox/src/InterspersedObservableCollection.cs b/components/TokenizingTextBox/src/InterspersedObservableCollection.cs index c9e92ae2..93b2d368 100644 --- a/components/TokenizingTextBox/src/InterspersedObservableCollection.cs +++ b/components/TokenizingTextBox/src/InterspersedObservableCollection.cs @@ -2,427 +2,432 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; using System.Collections; -using System.Collections.Generic; using System.Collections.Specialized; -using System.Linq; using CommunityToolkit.WinUI.Helpers; -namespace CommunityToolkit.WinUI.UI.Controls -{ - //// We need to implement the IList interface here for ListViewBase to listen to changes - https://github.com/microsoft/microsoft-ui-xaml/issues/1809 +namespace CommunityToolkit.WinUI.Controls; - internal class InterspersedObservableCollection : IList, IEnumerable, INotifyCollectionChanged - { - public IList ItemsSource { get; private set; } +//// We need to implement the IList interface here for ListViewBase to listen to changes - https://github.com/microsoft/microsoft-ui-xaml/issues/1809 + +#pragma warning disable CS8767 // Nullability of reference types in type of parameter doesn't match implicitly implemented member (possibly because of nullability attributes). +#pragma warning disable CS8622 +#pragma warning disable CS8603 +#pragma warning disable CS8714 +internal class InterspersedObservableCollection : IList, IEnumerable, INotifyCollectionChanged +{ + public IList ItemsSource { get; private set; } - public bool IsFixedSize => false; + public bool IsFixedSize => false; - public bool IsReadOnly => false; + public bool IsReadOnly => false; - public int Count => ItemsSource.Count + _interspersedObjects.Count; + public int Count => ItemsSource.Count + _interspersedObjects.Count; - public bool IsSynchronized => false; + public bool IsSynchronized => false; - public object SyncRoot => new object(); + public object SyncRoot => new object(); - public object this[int index] + public object this[int index] + { + get { - get + if (_interspersedObjects.TryGetValue(index, out var value)) { - if (_interspersedObjects.TryGetValue(index, out var value)) - { - return value; - } - else - { - // Find out the number of elements in our dictionary with keys below ours. - return ItemsSource[ToInnerIndex(index)]; - } + return value; + } + else + { + // Find out the number of elements in our dictionary with keys below ours. + return ItemsSource[ToInnerIndex(index)]; } - set => throw new NotImplementedException(); } + set => throw new NotImplementedException(); + } - private Dictionary _interspersedObjects = new Dictionary(); - private bool _isInsertingOriginal = false; + private Dictionary _interspersedObjects = new Dictionary(); + private bool _isInsertingOriginal = false; - public event NotifyCollectionChangedEventHandler CollectionChanged; + public event NotifyCollectionChangedEventHandler? CollectionChanged; - public InterspersedObservableCollection(object itemsSource) + public InterspersedObservableCollection(object itemsSource) + { + if (!(itemsSource is IList list)) { - if (!(itemsSource is IList list)) - { - ThrowArgumentException(); - } + ThrowArgumentException(); + } - ItemsSource = list; + ItemsSource = list; - if (ItemsSource is INotifyCollectionChanged notifier) + if (ItemsSource is INotifyCollectionChanged notifier) + { + var weakPropertyChangedListener = new WeakEventListener(this) { - var weakPropertyChangedListener = new WeakEventListener(this) - { - OnEventAction = (instance, source, eventArgs) => instance.ItemsSource_CollectionChanged(source, eventArgs), - OnDetachAction = (weakEventListener) => notifier.CollectionChanged -= weakEventListener.OnEvent // Use Local Reference Only - }; - notifier.CollectionChanged += weakPropertyChangedListener.OnEvent; - } - - static void ThrowArgumentException() => throw new ArgumentNullException("The input items source must be assignable to the System.Collections.IList type."); + OnEventAction = (instance, source, eventArgs) => instance.ItemsSource_CollectionChanged(source, eventArgs), + OnDetachAction = (weakEventListener) => notifier.CollectionChanged -= weakEventListener.OnEvent // Use Local Reference Only + }; + notifier.CollectionChanged += weakPropertyChangedListener.OnEvent; } - private void ItemsSource_CollectionChanged(object source, NotifyCollectionChangedEventArgs eventArgs) + static void ThrowArgumentException() => throw new ArgumentNullException("The input items source must be assignable to the System.Collections.IList type."); + } + + private void ItemsSource_CollectionChanged(object source, NotifyCollectionChangedEventArgs eventArgs) + { + switch (eventArgs.Action) { - switch (eventArgs.Action) - { - case NotifyCollectionChangedAction.Add: - // Shift any existing interspersed items after the inserted item - var count = eventArgs.NewItems.Count; + case NotifyCollectionChangedAction.Add: + // Shift any existing interspersed items after the inserted item + var count = eventArgs.NewItems!.Count; - if (count > 0) + if (count > 0) + { + if (!_isInsertingOriginal) { - if (!_isInsertingOriginal) - { - MoveKeysForward(eventArgs.NewStartingIndex, count); - } + MoveKeysForward(eventArgs.NewStartingIndex, count); + } - _isInsertingOriginal = false; + _isInsertingOriginal = false; - CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs( - NotifyCollectionChangedAction.Add, - eventArgs.NewItems, - ToOuterIndex(eventArgs.NewStartingIndex))); - } + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Add, + eventArgs.NewItems, + ToOuterIndex(eventArgs.NewStartingIndex))); + } - break; - case NotifyCollectionChangedAction.Remove: - count = eventArgs.OldItems.Count; + break; + case NotifyCollectionChangedAction.Remove: + count = eventArgs.OldItems!.Count; - if (count > 0) - { - var outerIndex = ToOuterIndexAfterRemoval(eventArgs.OldStartingIndex); + if (count > 0) + { + var outerIndex = ToOuterIndexAfterRemoval(eventArgs.OldStartingIndex); - MoveKeysBackward(outerIndex, count); + MoveKeysBackward(outerIndex, count); - CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs( - NotifyCollectionChangedAction.Remove, - eventArgs.OldItems, - outerIndex)); - } + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Remove, + eventArgs.OldItems, + outerIndex)); + } - break; - case NotifyCollectionChangedAction.Reset: + break; + case NotifyCollectionChangedAction.Reset: - ReadjustKeys(); + 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; - } + // 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; } + } - /// - /// Moves our interspersed keys at or past the given index forward by the amount. - /// - /// index of added item - /// by how many - private void MoveKeysForward(int pivot, int amount) + /// + /// Moves our interspersed keys at or past the given index forward by the amount. + /// + /// index of added item + /// by how many + private void MoveKeysForward(int pivot, int amount) + { + // Sort in reverse order to work from highest to lowest + var keys = _interspersedObjects.Keys.OrderByDescending(v => v).ToArray(); + foreach (var key in keys) { - // Sort in reverse order to work from highest to lowest - var keys = _interspersedObjects.Keys.OrderByDescending(v => v).ToArray(); - foreach (var key in keys) + if (key < pivot) //// If it's the last item in the collection, we still want to move our last key, otherwise we'd use <= { - 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); + break; } + + _interspersedObjects[key + amount] = _interspersedObjects[key]; + _interspersedObjects.Remove(key); } + } - /// - /// Moves our interspersed keys at or past the given index backward by the amount. - /// - /// index of removed item - /// by how many - private void MoveKeysBackward(int pivot, int amount) + /// + /// Moves our interspersed keys at or past the given index backward by the amount. + /// + /// index of removed item + /// by how many + private void MoveKeysBackward(int pivot, int amount) + { + // Sort in regular order to work from the earliest indices onwards + var keys = _interspersedObjects.Keys.OrderBy(v => v).ToArray(); + foreach (var key in keys) { - // Sort in regular order to work from the earliest indices onwards - var keys = _interspersedObjects.Keys.OrderBy(v => v).ToArray(); - foreach (var key in keys) + // Skip elements before the pivot point + if (key <= pivot) //// Include pivot point as that's the point where we start modifying beyond { - // 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); + continue; } + + _interspersedObjects[key - amount] = _interspersedObjects[key]; + _interspersedObjects.Remove(key); } + } - /// - /// Condenses our interspersed keys around any remaining items, mainly for when the original collection is reset. - /// - private void ReadjustKeys() - { - var count = ItemsSource.Count; - var existing = 0; + /// + /// Condenses our interspersed keys around any remaining items, mainly for when the original collection is reset. + /// + private void ReadjustKeys() + { + var count = ItemsSource.Count; + var existing = 0; - var keys = _interspersedObjects.Keys.OrderBy(v => v).ToArray(); - foreach (var key in keys) + var keys = _interspersedObjects.Keys.OrderBy(v => v).ToArray(); + foreach (var key in keys) + { + if (key <= count) { - if (key <= count) - { - existing++; - continue; - } - - _interspersedObjects[count + existing++] = _interspersedObjects[key]; - _interspersedObjects.Remove(key); + existing++; + continue; } + + _interspersedObjects[count + existing++] = _interspersedObjects[key]; + _interspersedObjects.Remove(key); } + } - /// - /// Takes an index from the entire collection and maps it to the inner collection index. Assumes, mapping is valid. - /// - /// Index into the entire collection. - /// Inner ItemsSource Index. - private int ToInnerIndex(int outerIndex) + /// + /// Takes an index from the entire collection and maps it to the inner collection index. Assumes, mapping is valid. + /// + /// Index into the entire collection. + /// Inner ItemsSource Index. + private int ToInnerIndex(int outerIndex) + { + if ((uint)outerIndex >= Count) { - if ((uint)outerIndex >= Count) - { - ThrowArgumentOutOfRangeException(); - } + ThrowArgumentOutOfRangeException(); + } - if (_interspersedObjects.ContainsKey(outerIndex)) - { - ThrowArgumentException(); - } + if (_interspersedObjects.ContainsKey(outerIndex)) + { + ThrowArgumentException(); + } - return outerIndex - _interspersedObjects.Keys.Count(key => key.Value <= outerIndex); + return outerIndex - _interspersedObjects.Keys.Count(key => key!.Value <= outerIndex); + + static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(outerIndex)); + static void ThrowArgumentException() => throw new ArgumentException("The outer index can't be inserted as a key to the original collection."); + } - static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(outerIndex)); - static void ThrowArgumentException() => throw new ArgumentException("The outer index can't be inserted as a key to the original collection."); + /// + /// Takes an index from the inner collection and maps it to an index for this entire collection. + /// + /// Index into the ItemsSource. + /// Index into the entire collection. + private int ToOuterIndex(int innerIndex) + { + if ((uint)innerIndex >= ItemsSource.Count) + { + ThrowArgumentOutOfRangeException(); } - /// - /// Takes an index from the inner collection and maps it to an index for this entire collection. - /// - /// Index into the ItemsSource. - /// Index into the entire collection. - private int ToOuterIndex(int innerIndex) + var keys = _interspersedObjects.OrderBy(v => v.Key); + + foreach (var key in keys) { - if ((uint)innerIndex >= ItemsSource.Count) + if (innerIndex >= key.Key) { - ThrowArgumentOutOfRangeException(); + innerIndex++; } - - var keys = _interspersedObjects.OrderBy(v => v.Key); - - foreach (var key in keys) + else { - if (innerIndex >= key.Key) - { - innerIndex++; - } - else - { - break; - } + break; } + } + + return innerIndex; - return innerIndex; + static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(innerIndex)); + } - static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(innerIndex)); + /// + /// Takes an index from the inner collection and maps it to an index for this entire collection, projects as if an element from the provided index was still in the collection. + /// + /// Previous index from ItemsSource + /// Projected index in the entire collection. + private int ToOuterIndexAfterRemoval(int innerIndexToProject) + { + if ((uint)innerIndexToProject >= ItemsSource.Count + 1) + { + ThrowArgumentOutOfRangeException(); } - /// - /// Takes an index from the inner collection and maps it to an index for this entire collection, projects as if an element from the provided index was still in the collection. - /// - /// Previous index from ItemsSource - /// Projected index in the entire collection. - private int ToOuterIndexAfterRemoval(int innerIndexToProject) + //// TODO: Deal with bounds (0 / Count)? Or is it the same? + + var keys = _interspersedObjects.OrderBy(v => v.Key); + + foreach (var key in keys) { - if ((uint)innerIndexToProject >= ItemsSource.Count + 1) + if (innerIndexToProject >= key.Key) + { + innerIndexToProject++; + } + else { - ThrowArgumentOutOfRangeException(); + break; } + } - //// TODO: Deal with bounds (0 / Count)? Or is it the same? + return innerIndexToProject; - var keys = _interspersedObjects.OrderBy(v => v.Key); + static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(innerIndexToProject)); + } - foreach (var key in keys) - { - if (innerIndexToProject >= key.Key) - { - innerIndexToProject++; - } - else - { - break; - } - } + /// + /// Inserts an item to intersperse with the underlying collection, but not be part of the underlying collection itself. + /// + /// Position to insert the item at. + /// Item to intersperse + public void Insert(int index, object obj) + { + MoveKeysForward(index, 1); // Move existing keys at index over to make room for new item - return innerIndexToProject; + _interspersedObjects[index] = obj; - static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(innerIndexToProject)); - } + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, obj, index)); + } - /// - /// Inserts an item to intersperse with the underlying collection, but not be part of the underlying collection itself. - /// - /// Position to insert the item at. - /// Item to intersperse - public void Insert(int index, object obj) - { - MoveKeysForward(index, 1); // Move existing keys at index over to make room for new item + /// + /// Inserts an item into the underlying collection and moves interspersed items such that the provide item will appear at the provided index as part of the whole collection. + /// + /// Position to insert the item at. + /// Item to place in wrapped collection. + public void InsertAt(int outerIndex, object obj) + { + // Find out our closest index based on interspersed keys + var index = outerIndex - _interspersedObjects.Keys.Count(key => key!.Value < outerIndex); // Note: we exclude the = from ToInnerIndex here - _interspersedObjects[index] = obj; + // 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 - CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, obj, index)); + _isInsertingOriginal = true; // Prevent Collection callback from moving keys forward on insert } - /// - /// Inserts an item into the underlying collection and moves interspersed items such that the provide item will appear at the provided index as part of the whole collection. - /// - /// Position to insert the item at. - /// Item to place in wrapped collection. - public void InsertAt(int outerIndex, object obj) - { - // Find out our closest index based on interspersed keys - var index = outerIndex - _interspersedObjects.Keys.Count(key => key.Value < outerIndex); // Note: we exclude the = from ToInnerIndex here + // Insert into original collection + ItemsSource.Insert(index, obj); + + // TODO: handle manipulation/notification if not observable + } - // 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) + public IEnumerator GetEnumerator() + { + int i = 0; // Index of our current 'virtual' position + int count = 0; + int realized = 0; + + foreach (var element in ItemsSource) + { + while (_interspersedObjects.TryGetValue(i++, out var obj)) { - MoveKeysForward(outerIndex, 1); // Skip over until the current spot unlike normal + realized++; // Track interspersed items used - _isInsertingOriginal = true; // Prevent Collection callback from moving keys forward on insert + yield return obj; } - // Insert into original collection - ItemsSource.Insert(index, obj); + count++; // Track original items used - // TODO: handle manipulation/notification if not observable + yield return element; } - public IEnumerator GetEnumerator() + // Add any remaining items in our interspersed collection past the index we reached in the original collection + if (realized < _interspersedObjects.Count) { - int i = 0; // Index of our current 'virtual' position - int count = 0; - int realized = 0; - - foreach (var element in ItemsSource) + // Only select items past our current index, but make sure we've sorted them by their index as well. + foreach (var keyValue in _interspersedObjects.Where(kvp => kvp.Key >= i).OrderBy(kvp => kvp.Key)) { - while (_interspersedObjects.TryGetValue(i++, out var obj)) - { - realized++; // Track interspersed items used + yield return keyValue.Value; + } + } + } - yield return obj; - } + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } - count++; // Track original items used + public int Add(object value) + { + var index = ItemsSource.Add(value); //// TODO: If the collection isn't observable, we should do manipulations/notifications here...? + return ToOuterIndex(index); + } - yield return element; - } + public void Clear() + { + ItemsSource.Clear(); + _interspersedObjects.Clear(); + } - // 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 (var keyValue in _interspersedObjects.Where(kvp => kvp.Key >= i).OrderBy(kvp => kvp.Key)) - { - yield return keyValue.Value; - } - } - } + public bool Contains(object value) + { + return _interspersedObjects.ContainsValue(value) || ItemsSource.Contains(value); + } - IEnumerator IEnumerable.GetEnumerator() + /// + /// Looks up an item's key in the _interspersedObject dictionary by its value. Handles nulls. + /// + /// Search value + /// KeyValuePair or default KeyValuePair + private KeyValuePair ItemKeySearch(object value) + { + if (value == null) { - return this.GetEnumerator(); + return _interspersedObjects.FirstOrDefault(kvp => kvp.Value == null); } - public int Add(object value) - { - var index = ItemsSource.Add(value); //// TODO: If the collection isn't observable, we should do manipulations/notifications here...? - return ToOuterIndex(index); - } + return _interspersedObjects.FirstOrDefault(kvp => kvp.Value?.Equals(value) == true); + } - public void Clear() - { - ItemsSource.Clear(); - _interspersedObjects.Clear(); - } + public int IndexOf(object value) + { + var item = ItemKeySearch(value); - public bool Contains(object value) + if (item.Key != null) { - return _interspersedObjects.ContainsValue(value) || ItemsSource.Contains(value); + return item.Key.Value; } - - /// - /// Looks up an item's key in the _interspersedObject dictionary by its value. Handles nulls. - /// - /// Search value - /// KeyValuePair or default KeyValuePair - private KeyValuePair ItemKeySearch(object value) + else { - if (value == null) - { - return _interspersedObjects.FirstOrDefault(kvp => kvp.Value == null); - } + int index = ItemsSource.IndexOf(value); - return _interspersedObjects.FirstOrDefault(kvp => kvp.Value?.Equals(value) == true); + // Find out the number of elements in our dictionary with keys below ours. + return index == -1 ? -1 : ToOuterIndex(index); } + } - public int IndexOf(object value) - { - var item = ItemKeySearch(value); - - if (item.Key != null) - { - return item.Key.Value; - } - else - { - 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) + { + var item = ItemKeySearch(value); - public void Remove(object value) + if (item.Key != null) { - var item = ItemKeySearch(value); + _interspersedObjects.Remove(item.Key); - if (item.Key != null) - { - _interspersedObjects.Remove(item.Key); - - MoveKeysBackward(item.Key.Value, 1); // Move other interspersed items back + MoveKeysBackward(item.Key.Value, 1); // Move other interspersed items back - CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item.Value, item.Key.Value)); - } - else - { - ItemsSource.Remove(value); // TODO: If not observable, update indices? - } + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item.Value, item.Key.Value)); } - - public void RemoveAt(int index) + else { - throw new NotImplementedException(); + ItemsSource.Remove(value); // TODO: If not observable, update indices? } + } - public void CopyTo(Array array, int index) - { - throw new NotImplementedException(); - } + public void RemoveAt(int index) + { + throw new NotImplementedException(); } -} \ No newline at end of file + + public void CopyTo(Array array, int index) + { + throw new NotImplementedException(); + } +} + +#pragma warning restore CS8767 // Nullability of reference types in type of parameter doesn't match implicitly implemented member (possibly because of nullability attributes). +#pragma warning disable CS8622 +#pragma warning disable CS8603 +#pragma warning disable CS8714 diff --git a/components/TokenizingTextBox/src/TokenItemAddingEventArgs.cs b/components/TokenizingTextBox/src/TokenItemAddingEventArgs.cs index ec13df21..fa638ea5 100644 --- a/components/TokenizingTextBox/src/TokenItemAddingEventArgs.cs +++ b/components/TokenizingTextBox/src/TokenItemAddingEventArgs.cs @@ -2,9 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using CommunityToolkit.WinUI.Deferred; +using CommunityToolkit.Common.Deferred; -namespace CommunityToolkit.WinUI.UI.Controls; +namespace CommunityToolkit.WinUI.Controls; /// /// Event arguments for event. @@ -28,5 +28,5 @@ public TokenItemAddingEventArgs(string token) /// /// Gets or sets the item to be added to the . If null, string will be added. /// - public object Item { get; set; } = null; + public object? Item { get; set; } = null; } diff --git a/components/TokenizingTextBox/src/TokenItemRemovingEventArgs.cs b/components/TokenizingTextBox/src/TokenItemRemovingEventArgs.cs index a44ce6b3..78e7959e 100644 --- a/components/TokenizingTextBox/src/TokenItemRemovingEventArgs.cs +++ b/components/TokenizingTextBox/src/TokenItemRemovingEventArgs.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using CommunityToolkit.WinUI.Deferred; +using CommunityToolkit.Common.Deferred; namespace CommunityToolkit.WinUI.Controls; diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.Properties.cs b/components/TokenizingTextBox/src/TokenizingTextBox.Properties.cs index 23226446..7c73057b 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBox.Properties.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBox.Properties.cs @@ -103,11 +103,14 @@ private static void TextPropertyChanged(DependencyObject d, DependencyPropertyCh { if (d is TokenizingTextBox ttb && ttb._currentTextEdit != null) { - ttb._currentTextEdit.Text = e.NewValue as string; + if (e.NewValue is string newValue) + { + ttb._currentTextEdit.Text = newValue; - // Notify inner container of text change, see issue #4749 - var item = ttb.ContainerFromItem(ttb._currentTextEdit) as TokenizingTextBoxItem; - item?.UpdateText(ttb._currentTextEdit.Text); + // Notify inner container of text change, see issue #4749 + var item = ttb.ContainerFromItem(ttb._currentTextEdit) as TokenizingTextBoxItem; + item?.UpdateText(ttb._currentTextEdit.Text); + } } } @@ -179,9 +182,12 @@ private static void OnMaximumTokensChanged(DependencyObject d, DependencyPropert { var token = ttb._innerItemsSource.ItemsSource[i - 1]; - // Force remove the items. No warning and no option to cancel. - ttb._innerItemsSource.Remove(token); - ttb.TokenItemRemoved?.Invoke(ttb, token); + if (token != null) + { + // Force remove the items. No warning and no option to cancel. + ttb._innerItemsSource.Remove(token!); + ttb.TokenItemRemoved?.Invoke(ttb, token); + } } } } diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs b/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs index 92ab03fb..d0023d1c 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs @@ -4,68 +4,79 @@ using Windows.ApplicationModel.DataTransfer; -namespace CommunityToolkit.WinUI.Controls +#if WINAPPSDK +using Microsoft.UI.Dispatching; +#else +using Windows.System; +#endif + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// Methods related to Selection of items in the . +/// +public partial class TokenizingTextBox { + private enum MoveDirection + { + Next, + Previous + } + /// - /// Methods related to Selection of items in the . + /// Adjust the selected item and range based on keyboard input. + /// This is used to override the listview behaviors for up/down arrow manipulation vs left/right for a horizontal control /// - public partial class TokenizingTextBox + /// direction to move the selection + /// True if the focus was moved, false otherwise + private bool MoveFocusAndSelection(MoveDirection direction) { - private enum MoveDirection - { - Next, - Previous - } + bool retVal = false; + var currentContainerItem = GetCurrentContainerItem(); - /// - /// Adjust the selected item and range based on keyboard input. - /// This is used to override the listview behaviors for up/down arrow manipulation vs left/right for a horizontal control - /// - /// direction to move the selection - /// True if the focus was moved, false otherwise - private bool MoveFocusAndSelection(MoveDirection direction) + if (currentContainerItem != null) { - bool retVal = false; - var currentContainerItem = GetCurrentContainerItem(); + var currentItem = ItemFromContainer(currentContainerItem); + var previousIndex = Items.IndexOf(currentItem); + var index = previousIndex; - if (currentContainerItem != null) + if (direction == MoveDirection.Previous) { - var currentItem = ItemFromContainer(currentContainerItem); - var previousIndex = Items.IndexOf(currentItem); - var index = previousIndex; - - if (direction == MoveDirection.Previous) + if (previousIndex > 0) { - if (previousIndex > 0) - { - index -= 1; - } - else - { - if (TabNavigateBackOnArrow) - { - FocusManager.TryMoveFocus(FocusNavigationDirection.Previous, new FindNextElementOptions - { - SearchRoot = XamlRoot.Content - }); - } + index -= 1; + } + else + { + if (TabNavigateBackOnArrow) - retVal = true; +#if WINAPPSDK +{ +FocusManager.TryMoveFocus(FocusNavigationDirection.Previous, new FindNextElementOptions + { + SearchRoot = XamlRoot.Content! + }); } +#else + FocusManager.TryMoveFocus(FocusNavigationDirection.Previous); +#endif + + retVal = true; } - else if (direction == MoveDirection.Next) + } + else if (direction == MoveDirection.Next) + { + if (previousIndex < Items.Count - 1) { - if (previousIndex < Items.Count - 1) - { - index += 1; - } + index += 1; } + } - // Only do stuff if the index is actually changing - if (index != previousIndex) + // Only do stuff if the index is actually changing + if (index != previousIndex) + { + if (ContainerFromIndex(index) is TokenizingTextBoxItem newItem) { - var newItem = ContainerFromIndex(index) as TokenizingTextBoxItem; - // 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 @@ -115,64 +126,75 @@ private bool MoveFocusAndSelection(MoveDirection direction) retVal = true; } } - - return retVal; } - private TokenizingTextBoxItem GetCurrentContainerItem() + return retVal; + } + + private TokenizingTextBoxItem? GetCurrentContainerItem() + { + if (IsXamlRootAvailable && XamlRoot != null) { - if (XamlRoot != null) - { - return FocusManager.GetFocusedElement(XamlRoot) as TokenizingTextBoxItem; - } - else - { - return FocusManager.GetFocusedElement() as TokenizingTextBoxItem; - } + return FocusManager.GetFocusedElement(XamlRoot) as TokenizingTextBoxItem; } - - internal void SelectAllTokensAndText() + else { + return FocusManager.GetFocusedElement() as TokenizingTextBoxItem; + } + } + + internal void SelectAllTokensAndText() + { +#if WINAPPSDK _ = DispatcherQueue.EnqueueAsync( - () => - { - this.SelectAllSafe(); +#else + var dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _ = dispatcherQueue.EnqueueAsync( +#endif + () => + { + 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; + // 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 (var item in Items) + foreach (var item in Items) + { + if (item is ITokenStringContainer) { - if (item is ITokenStringContainer) + // grab any selected text + if (ContainerFromItem(item) is TokenizingTextBoxItem pretoken) { - // grab any selected text - var pretoken = ContainerFromItem(item) as TokenizingTextBoxItem; pretoken._autoSuggestTextBox.SelectionStart = 0; pretoken._autoSuggestTextBox.SelectionLength = pretoken._autoSuggestTextBox.Text.Length; } } + } - (ContainerFromIndex(Items.Count - 1) as TokenizingTextBoxItem).Focus(FocusState.Programmatic); - }, Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal); - } + if (ContainerFromIndex(Items.Count - 1) is TokenizingTextBoxItem container) + { + container.Focus(FocusState.Programmatic); + } + }, DispatcherQueuePriority.Normal); + } - internal void DeselectAllTokensAndText(TokenizingTextBoxItem ignoreItem = null) - { - this.DeselectAll(); - ClearAllTextSelections(ignoreItem); - } + internal void DeselectAllTokensAndText(TokenizingTextBoxItem? ignoreItem = null) + { + this.DeselectAll(); + ClearAllTextSelections(ignoreItem); + } - private void ClearAllTextSelections(TokenizingTextBoxItem ignoreItem) + private void ClearAllTextSelections(TokenizingTextBoxItem? ignoreItem) + { + // Clear any selection in the text box + foreach (var item in Items) { - // Clear any selection in the text box - foreach (var item in Items) + if (item is ITokenStringContainer) { - if (item is ITokenStringContainer) + if (ContainerFromItem(item) is TokenizingTextBoxItem container) { - var container = ContainerFromItem(item) as TokenizingTextBoxItem; - if (container != ignoreItem) { container._autoSuggestTextBox.SelectionLength = 0; @@ -180,85 +202,91 @@ private void ClearAllTextSelections(TokenizingTextBoxItem ignoreItem) } } } + } + /// + /// Select the previous item in the list, if one is available. Called when moving from textbox to token. + /// + /// identifies the current item + /// a value indicating whether the previous item was successfully selected + internal bool SelectPreviousItem(TokenizingTextBoxItem item) + { + return SelectNewItem(item, -1, i => i > 0); + } - /// - /// Select the previous item in the list, if one is available. Called when moving from textbox to token. - /// - /// identifies the current item - /// a value indicating whether the previous item was successfully selected - internal bool SelectPreviousItem(TokenizingTextBoxItem item) - { - return SelectNewItem(item, -1, i => i > 0); - } - - /// - /// Select the next item in the list, if one is available. Called when moving from textbox to token. - /// - /// identifies the current item - /// a value indicating whether the next item was successfully selected, false if nothing was changed - internal bool SelectNextItem(TokenizingTextBoxItem item) - { - return SelectNewItem(item, 1, i => i < Items.Count - 1); - } + /// + /// Select the next item in the list, if one is available. Called when moving from textbox to token. + /// + /// identifies the current item + /// a value indicating whether the next item was successfully selected, false if nothing was changed + internal bool SelectNextItem(TokenizingTextBoxItem item) + { + return SelectNewItem(item, 1, i => i < Items.Count - 1); + } - private bool SelectNewItem(TokenizingTextBoxItem item, int increment, Func testFunc) - { - bool returnVal = false; + private bool SelectNewItem(TokenizingTextBoxItem item, int increment, Func testFunc) + { + bool returnVal = false; - // find the item in the list - var currentIndex = IndexFromContainer(item); + // find the item in the list + var currentIndex = IndexFromContainer(item); - // Select previous token item (if there is one). - if (testFunc(currentIndex)) + // Select previous token item (if there is one). + if (testFunc(currentIndex)) + { + if (ContainerFromItem(Items[currentIndex + increment]) is ListViewItem newItem) { - var newItem = ContainerFromItem(Items[currentIndex + increment]) as ListViewItem; newItem.Focus(FocusState.Keyboard); SelectedItems.Add(Items[currentIndex + increment]); returnVal = true; } - - return returnVal; } - private async void TokenizingTextBoxItem_ClearAllAction(TokenizingTextBoxItem sender, RoutedEventArgs args) - { - // find the first item selected - int newSelectedIndex = -1; + return returnVal; + } - if (SelectedRanges.Count > 0) - { - newSelectedIndex = SelectedRanges[0].FirstIndex - 1; - } + private async void TokenizingTextBoxItem_ClearAllAction(TokenizingTextBoxItem sender, RoutedEventArgs args) + { + // find the first item selected + int newSelectedIndex = -1; - await RemoveAllSelectedTokens(); + if (SelectedRanges.Count > 0) + { + newSelectedIndex = SelectedRanges[0].FirstIndex - 1; + } - SelectedIndex = newSelectedIndex; + await RemoveAllSelectedTokens(); - if (newSelectedIndex == -1) - { - newSelectedIndex = Items.Count - 1; - } + SelectedIndex = newSelectedIndex; - // focus the item prior to the first selected item - (ContainerFromIndex(newSelectedIndex) as TokenizingTextBoxItem).Focus(FocusState.Keyboard); + if (newSelectedIndex == -1) + { + newSelectedIndex = Items.Count - 1; } - private async void TokenizingTextBoxItem_ClearClicked(TokenizingTextBoxItem sender, RoutedEventArgs args) + // focus the item prior to the first selected item + if (ContainerFromIndex(newSelectedIndex) is TokenizingTextBoxItem container) { - await RemoveTokenAsync(sender); + container.Focus(FocusState.Keyboard); } + } - /// - /// Remove any tokens that are in the selected list, except for the last text box or the currently selected item - /// - /// async task - internal async Task RemoveAllSelectedTokens() - { - var currentContainerItem = GetCurrentContainerItem(); + private async void TokenizingTextBoxItem_ClearClicked(TokenizingTextBoxItem sender, RoutedEventArgs? args) + { + await RemoveTokenAsync(sender); + } + + /// + /// Remove any tokens that are in the selected list, except for the last text box or the currently selected item + /// + /// async task + internal async Task RemoveAllSelectedTokens() + { + var currentContainerItem = GetCurrentContainerItem(); - while (SelectedItems.Count > 0) + while (SelectedItems.Count > 0) + { + if (ContainerFromItem(SelectedItems[0]) is TokenizingTextBoxItem container) { - var container = ContainerFromItem(SelectedItems[0]) as TokenizingTextBoxItem; if (IndexFromContainer(container) != Items.Count - 1) { @@ -308,53 +336,55 @@ internal async Task RemoveAllSelectedTokens() } } } + } - private void CopySelectedToClipboard() - { - DataPackage dataPackage = new DataPackage(); - dataPackage.RequestedOperation = DataPackageOperation.Copy; + private void CopySelectedToClipboard() + { + DataPackage dataPackage = new DataPackage(); + dataPackage.RequestedOperation = DataPackageOperation.Copy; - var tokenString = PrepareSelectionForClipboard(); + var tokenString = PrepareSelectionForClipboard(); - if (!string.IsNullOrEmpty(tokenString)) - { - dataPackage.SetText(tokenString); - Clipboard.SetContent(dataPackage); - } + if (!string.IsNullOrEmpty(tokenString)) + { + dataPackage.SetText(tokenString); + Clipboard.SetContent(dataPackage); } + } - private string PrepareSelectionForClipboard() - { - string tokenString = string.Empty; - bool addSeparator = false; + private string PrepareSelectionForClipboard() + { + string tokenString = string.Empty; + bool addSeparator = false; - // Copy all items if none selected (and no text selected) - foreach (var item in SelectedItems.Count > 0 ? SelectedItems : Items) + // Copy all items if none selected (and no text selected) + foreach (var item in SelectedItems.Count > 0 ? SelectedItems : Items) + { + if (addSeparator) { - if (addSeparator) - { - tokenString += TokenDelimiter; - } - else - { - addSeparator = true; - } + tokenString += TokenDelimiter; + } + else + { + addSeparator = true; + } - if (item is ITokenStringContainer) + if (item is ITokenStringContainer) + { + // grab any selected text + if (ContainerFromItem(item) is TokenizingTextBoxItem pretoken) { - // grab any selected text - var pretoken = ContainerFromItem(item) as TokenizingTextBoxItem; tokenString += pretoken._autoSuggestTextBox.Text.Substring( pretoken._autoSuggestTextBox.SelectionStart, pretoken._autoSuggestTextBox.SelectionLength); } - else - { - tokenString += item.ToString(); - } } - - return tokenString; + else + { + tokenString += item.ToString(); + } } + + return tokenString; } -} +} diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.cs b/components/TokenizingTextBox/src/TokenizingTextBox.cs index eda42601..bb33dbe4 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBox.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBox.cs @@ -2,19 +2,21 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Collections.ObjectModel; -using System.Linq; -using System.Threading.Tasks; + +#if WINAPPSDK using CommunityToolkit.WinUI.Deferred; -using CommunityToolkit.WinUI.UI.Automation.Peers; -using CommunityToolkit.WinUI.UI.Helpers; +using Microsoft.UI.Dispatching; 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 VirtualKey = Windows.System.VirtualKey; +using DispatcherQueuePriority = Microsoft.UI.Dispatching.DispatcherQueuePriority; +#else +using DispatcherQueuePriority = Windows.System.DispatcherQueuePriority; +#endif using Windows.System; using Windows.UI.Core; +using Windows.Foundation.Metadata; +using CommunityToolkit.WinUI.Automation.Peers; +using CommunityToolkit.WinUI.Helpers; namespace CommunityToolkit.WinUI.Controls; @@ -36,13 +38,21 @@ public partial class TokenizingTextBox : ListViewBase /// /// Gets a value indicating whether the shift key is currently in a pressed state /// - internal static bool IsShiftPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); - /// - /// Gets a value indicating whether the control key is currently in a pressed state - /// - internal bool IsControlPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); +#if WINAPPSDK + internal static bool IsShiftPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); +#else + internal static bool IsShiftPressed => CoreWindow.GetForCurrentThread()!.GetKeyState(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); +#endif +/// +/// Gets a value indicating whether the control key is currently in a pressed state +/// +#if WINAPPSDK + internal bool IsControlPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); +#else + internal bool IsControlPressed => CoreWindow.GetForCurrentThread()!.GetKeyState(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); +#endif internal bool PauseTokenClearOnFocus { get; set; } internal bool IsClearingForClick { get; set; } @@ -54,7 +64,9 @@ public partial class TokenizingTextBox : ListViewBase /// /// Initializes a new instance of the class. /// +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public TokenizingTextBox() +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. { // Setup our base state of our collection _innerItemsSource = new InterspersedObservableCollection(new ObservableCollection()); // TODO: Test this still will let us bind to ItemsSource in XAML? @@ -90,8 +102,8 @@ private void ItemsSource_PropertyChanged(DependencyObject sender, DependencyProp } } -// 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 }; + // 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; } @@ -135,7 +147,10 @@ private void FocusPrimaryAutoSuggestBox() { if (Items?.Count > 0) { - (ContainerFromIndex(Items.Count - 1) as TokenizingTextBoxItem).Focus(FocusState.Programmatic); + if (ContainerFromIndex(Items.Count - 1) is TokenizingTextBoxItem container) + { + container.Focus(FocusState.Programmatic); + } } } @@ -197,12 +212,13 @@ protected override void OnApplyTemplate() var selectAllMenuItem = new MenuFlyoutItem { - Text = "WCT_TokenizingTextBox_MenuFlyout_SelectAll".GetLocalized("CommunityToolkit.WinUI.UI.Controls.Input/Resources") + // TO DO: "WCT_TokenizingTextBox_MenuFlyout_SelectAll".GetLocalized("Microsoft.Toolkit.Uwp.UI.Controls.Input/Resources") + Text = "Select all" }; selectAllMenuItem.Click += (s, e) => this.SelectAllTokensAndText(); var menuFlyout = new MenuFlyout(); menuFlyout.Items.Add(selectAllMenuItem); - if (XamlRoot != null) + if (IsXamlRootAvailable && XamlRoot != null) { menuFlyout.XamlRoot = XamlRoot; } @@ -238,23 +254,29 @@ private async void TokenizingTextBox_CharacterReceived(UIElement sender, Charact await RemoveAllSelectedTokens(); // Wait for removal of old items +#if WINAPPSDK _ = DispatcherQueue.EnqueueAsync( +#else + var dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _ = dispatcherQueue.EnqueueAsync( +#endif () => { // 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)) { - var lastContainer = ContainerFromItem(_lastTextEdit) as TokenizingTextBoxItem; + if (ContainerFromItem(_lastTextEdit) is TokenizingTextBoxItem lastContainer) + { + lastContainer.UseCharacterAsUser = true; // Make sure we trigger a refresh of suggested items. - lastContainer.UseCharacterAsUser = true; // Make sure we trigger a refresh of suggested items. + _lastTextEdit.Text = string.Empty + args.Character; - _lastTextEdit.Text = string.Empty + args.Character; + UpdateCurrentTextEdit(_lastTextEdit); - UpdateCurrentTextEdit(_lastTextEdit); + lastContainer._autoSuggestTextBox.SelectionStart = 1; // Set position to after our new character inserted - lastContainer._autoSuggestTextBox.SelectionStart = 1; // Set position to after our new character inserted - - lastContainer._autoSuggestTextBox.Focus(FocusState.Keyboard); + lastContainer._autoSuggestTextBox.Focus(FocusState.Keyboard); + } } else { @@ -265,29 +287,34 @@ private async void TokenizingTextBox_CharacterReceived(UIElement sender, Charact _innerItemsSource.Insert(index, _currentTextEdit); // Need to wait for containerization +#if WINAPPSDK _ = DispatcherQueue.EnqueueAsync( +#else + _ = dispatcherQueue.EnqueueAsync( +#endif () => { - var newContainer = ContainerFromIndex(index) as TokenizingTextBoxItem; // 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 (ContainerFromIndex(index) is TokenizingTextBoxItem newContainer) // Should be our last text box { - if (newContainer._autoSuggestTextBox != null) + newContainer.UseCharacterAsUser = true; // Make sure we trigger a refresh of suggested items. + + void WaitForLoad(object s, RoutedEventArgs eargs) { - newContainer._autoSuggestTextBox.SelectionStart = 1; // Set position to after our new character inserted + if (newContainer._autoSuggestTextBox != null) + { + newContainer._autoSuggestTextBox.SelectionStart = 1; // Set position to after our new character inserted + + newContainer._autoSuggestTextBox.Focus(FocusState.Keyboard); + } - newContainer._autoSuggestTextBox.Focus(FocusState.Keyboard); + newContainer.Loaded -= WaitForLoad; } - newContainer.Loaded -= WaitForLoad; + newContainer.AutoSuggestTextBoxLoaded += WaitForLoad; } - - newContainer.AutoSuggestTextBoxLoaded += WaitForLoad; - }, Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal); + }, DispatcherQueuePriority.Normal); } - }, Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal); + }, DispatcherQueuePriority.Normal); } else { @@ -295,16 +322,18 @@ void WaitForLoad(object s, RoutedEventArgs eargs) // 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[_innerItemsSource.Count - 1] is ITokenStringContainer textToken) { - var last = ContainerFromIndex(Items.Count - 1) as TokenizingTextBoxItem; // Should be our last text box - var text = last._autoSuggestTextBox.Text; - var selectionStart = last._autoSuggestTextBox.SelectionStart; - var position = selectionStart > text.Length ? text.Length : selectionStart; - textToken.Text = text.Substring(0, position) + args.Character + - text.Substring(position); + if (ContainerFromIndex(Items.Count - 1) is TokenizingTextBoxItem last) // Should be our last text box + { + var text = last._autoSuggestTextBox.Text; + var selectionStart = last._autoSuggestTextBox.SelectionStart; + var position = selectionStart > text.Length ? text.Length : selectionStart; + textToken.Text = text.Substring(0, position) + args.Character + + text.Substring(position); - last._autoSuggestTextBox.SelectionStart = position + 1; // Set position to after our new character inserted + last._autoSuggestTextBox.SelectionStart = position + 1; // Set position to after our new character inserted - last._autoSuggestTextBox.Focus(FocusState.Keyboard); + last._autoSuggestTextBox.Focus(FocusState.Keyboard); + } } } } @@ -312,13 +341,13 @@ void WaitForLoad(object s, RoutedEventArgs eargs) private object GetFocusedElement() { - if (XamlRoot != null) + if (IsXamlRootAvailable && XamlRoot != null) { - return FocusManager.GetFocusedElement(XamlRoot); + return FocusManager.GetFocusedElement(XamlRoot)!; } else { - return FocusManager.GetFocusedElement(); + return FocusManager.GetFocusedElement()!; } } @@ -338,48 +367,51 @@ protected override void PrepareContainerForItemOverride(DependencyObject element { base.PrepareContainerForItemOverride(element, item); - var tokenitem = element as TokenizingTextBoxItem; - - tokenitem.Owner = this; + if (element is TokenizingTextBoxItem tokenitem) + { + tokenitem.Owner = this; - tokenitem.ContentTemplateSelector = TokenItemTemplateSelector; - tokenitem.ContentTemplate = TokenItemTemplate; + tokenitem.ContentTemplateSelector = TokenItemTemplateSelector; + tokenitem.ContentTemplate = TokenItemTemplate; - tokenitem.ClearClicked -= TokenizingTextBoxItem_ClearClicked; - tokenitem.ClearClicked += TokenizingTextBoxItem_ClearClicked; + tokenitem.ClearClicked -= TokenizingTextBoxItem_ClearClicked; + tokenitem.ClearClicked += TokenizingTextBoxItem_ClearClicked; - tokenitem.ClearAllAction -= TokenizingTextBoxItem_ClearAllAction; - tokenitem.ClearAllAction += TokenizingTextBoxItem_ClearAllAction; + tokenitem.ClearAllAction -= TokenizingTextBoxItem_ClearAllAction; + tokenitem.ClearAllAction += TokenizingTextBoxItem_ClearAllAction; - tokenitem.GotFocus -= TokenizingTextBoxItem_GotFocus; - tokenitem.GotFocus += TokenizingTextBoxItem_GotFocus; + tokenitem.GotFocus -= TokenizingTextBoxItem_GotFocus; + tokenitem.GotFocus += TokenizingTextBoxItem_GotFocus; - tokenitem.LostFocus -= TokenizingTextBoxItem_LostFocus; - tokenitem.LostFocus += TokenizingTextBoxItem_LostFocus; + tokenitem.LostFocus -= TokenizingTextBoxItem_LostFocus; + tokenitem.LostFocus += TokenizingTextBoxItem_LostFocus; - var menuFlyout = new MenuFlyout(); + var menuFlyout = new MenuFlyout(); - var removeMenuItem = new MenuFlyoutItem - { - Text = "WCT_TokenizingTextBoxItem_MenuFlyout_Remove".GetLocalized("CommunityToolkit.WinUI.UI.Controls.Input/Resources") - }; - removeMenuItem.Click += (s, e) => TokenizingTextBoxItem_ClearClicked(tokenitem, null); + var removeMenuItem = new MenuFlyoutItem + { + // TO DO: Localize - "WCT_TokenizingTextBoxItem_MenuFlyout_Remove".GetLocalized("Microsoft.Toolkit.Uwp.UI.Controls.Input/Resources") + Text = "Remove" + }; + removeMenuItem.Click += (s, e) => TokenizingTextBoxItem_ClearClicked(tokenitem, null); - menuFlyout.Items.Add(removeMenuItem); - if (XamlRoot != null) - { - menuFlyout.XamlRoot = XamlRoot; - } + menuFlyout.Items.Add(removeMenuItem); + if (IsXamlRootAvailable && XamlRoot != null) + { + menuFlyout.XamlRoot = XamlRoot; + } - var selectAllMenuItem = new MenuFlyoutItem - { - Text = "WCT_TokenizingTextBox_MenuFlyout_SelectAll".GetLocalized("CommunityToolkit.WinUI.UI.Controls.Input/Resources") - }; - selectAllMenuItem.Click += (s, e) => this.SelectAllTokensAndText(); + var selectAllMenuItem = new MenuFlyoutItem + { + // TO DO: Localize - "WCT_TokenizingTextBox_MenuFlyout_SelectAll".GetLocalized("Microsoft.Toolkit.Uwp.UI.Controls.Input/Resources") + Text = "Select all" + }; + selectAllMenuItem.Click += (s, e) => this.SelectAllTokensAndText(); - menuFlyout.Items.Add(selectAllMenuItem); + menuFlyout.Items.Add(selectAllMenuItem); - tokenitem.ContextFlyout = menuFlyout; + tokenitem.ContextFlyout = menuFlyout; + } } #endregion @@ -428,11 +460,13 @@ public async Task ClearAsync() { while (_innerItemsSource.Count > 1) { - var container = ContainerFromItem(_innerItemsSource[0]) as TokenizingTextBoxItem; - if (!await RemoveTokenAsync(container, _innerItemsSource[0])) + if (ContainerFromItem(_innerItemsSource[0]) is TokenizingTextBoxItem container) { - // if a removal operation fails then stop the clear process - break; + if (!await RemoveTokenAsync(container, _innerItemsSource[0])) + { + // if a removal operation fails then stop the clear process + break; + } } } @@ -521,7 +555,7 @@ protected override AutomationPeer OnCreateAutomationPeer() /// the data parameter is passed in optionally to support UX UTs. When running in the UT the Container items are not manifest. /// /// true if the item was removed successfully, false otherwise - private async Task RemoveTokenAsync(TokenizingTextBoxItem item, object data = null) + private async Task RemoveTokenAsync(TokenizingTextBoxItem item, object? data = null) { if (data == null) { @@ -572,4 +606,6 @@ private void GuardAgainstPlaceholderTextLayoutIssue() }); } } + + public static bool IsXamlRootAvailable { get; } = ApiInformation.IsPropertyPresent("Windows.UI.Xaml.UIElement", "XamlRoot"); } diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.xaml b/components/TokenizingTextBox/src/TokenizingTextBox.xaml index 41bbcf4a..fb41f2df 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBox.xaml +++ b/components/TokenizingTextBox/src/TokenizingTextBox.xaml @@ -1,6 +1,6 @@ @@ -137,7 +137,7 @@ Background="{ThemeResource SystemControlBackgroundAltHighBrush}" BorderBrush="{ThemeResource TextControlBorderBrushFocused}" BorderThickness="{TemplateBinding BorderThickness}" - Opacity="0" /> + Opacity="0" /> GetChildrenCore() ItemCollection items = owner.Items; if (items.Count <= 0) { - return null; + return null!; } List peers = new List(items.Count); diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs index a189528c..6cecdae7 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs @@ -3,7 +3,12 @@ // See the LICENSE file in the project root for more information. using Windows.System; + +#if WINAPPSDK +using Microsoft.UI; +#else using Windows.UI; +#endif namespace CommunityToolkit.WinUI.Controls; @@ -133,7 +138,7 @@ private async void AutoSuggestBox_QuerySubmitted(AutoSuggestBox sender, AutoSugg { Owner.RaiseQuerySubmitted(sender, args); - object chosenItem = null; + object? chosenItem = null; if (args.ChosenSuggestion != null) { chosenItem = args.ChosenSuggestion; @@ -204,7 +209,11 @@ private void AutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTex { bool lastDelimited = t[t.Length - 1] == Owner.TokenDelimiter[0]; +#if HAS_UNO + string[] tokens = t.Split(new[] { Owner.TokenDelimiter }, System.StringSplitOptions.RemoveEmptyEntries); +#else string[] tokens = t.Split(Owner.TokenDelimiter); +#endif int numberToProcess = lastDelimited ? tokens.Length : tokens.Length - 1; for (int position = 0; position < numberToProcess; position++) { @@ -226,7 +235,7 @@ private void AutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTex } } } - #endregion +#endregion #region Visual State Management for Parent private void AutoSuggestBox_PointerEntered(object sender, PointerRoutedEventArgs e) @@ -294,7 +303,7 @@ async void AutoSuggestTextBox_TextChangingAsync(TextBox o, TextBoxTextChangingEv _autoSuggestTextBox.SelectionChanging -= AutoSuggestTextBox_SelectionChanging; } - _autoSuggestTextBox = _autoSuggestBox.FindDescendant() as TextBox; + _autoSuggestTextBox = _autoSuggestBox.FindDescendant()!; if (_autoSuggestTextBox != null) { @@ -365,24 +374,28 @@ private void AutoSuggestTextBox_PreviewKeyDown(object sender, KeyRoutedEventArgs private void UpdateTokensCounter(TokenizingTextBoxItem ttbi) { +#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type. var maxTokensCounter = (TextBlock)_autoSuggestBox?.FindDescendant(PART_TokensCounter); +#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type. if (maxTokensCounter == null) { return; } - void OnTokenCountChanged(TokenizingTextBox ttb, object value = null) + void OnTokenCountChanged(TokenizingTextBox ttb, object? value = null) { - var itemsSource = ttb.ItemsSource as InterspersedObservableCollection; - var currentTokens = itemsSource.ItemsSource.Count; - var maxTokens = ttb.MaximumTokens; + if (ttb.ItemsSource is InterspersedObservableCollection itemsSource) + { + var currentTokens = itemsSource.ItemsSource.Count; + var maxTokens = ttb.MaximumTokens; - maxTokensCounter.Text = $"{currentTokens}/{maxTokens}"; - maxTokensCounter.Visibility = Visibility.Visible; + maxTokensCounter.Text = $"{currentTokens}/{maxTokens}"; + maxTokensCounter.Visibility = Visibility.Visible; - maxTokensCounter.Foreground = (currentTokens >= maxTokens) - ? new SolidColorBrush(Colors.Red) - : _autoSuggestBox.Foreground; + maxTokensCounter.Foreground = (currentTokens >= maxTokens) + ? new SolidColorBrush(Colors.Red) + : _autoSuggestBox!.Foreground; + } } ttbi.Owner.TokenItemAdded -= OnTokenCountChanged; diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml b/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml index b35bde9e..1b208ae4 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml @@ -1,6 +1,6 @@ + xmlns:controls="using:CommunityToolkit.WinUI.Controls"> 28 @@ -17,7 +17,8 @@ - - + + + + diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.cs b/components/TokenizingTextBox/src/TokenizingTextBoxItem.cs index 12b790e6..1bdaa519 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBoxItem.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.cs @@ -12,7 +12,7 @@ namespace CommunityToolkit.WinUI.Controls; [TemplatePart(Name = PART_ClearButton, Type = typeof(ButtonBase))] //// Token case public partial class TokenizingTextBoxItem : ListViewItem { - private const string PART_ClearButton = "PART_ClearButton"; + private const string PART_ClearButton = "PART_RemoveButton"; private Button _clearButton; From cafa1a39586e6d2018021993f25f21675cb93765 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Sat, 3 Jun 2023 14:34:44 +0200 Subject: [PATCH 09/25] Update tooling --- tooling | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tooling b/tooling index f5ca2c47..6bc1f28e 160000 --- a/tooling +++ b/tooling @@ -1 +1 @@ -Subproject commit f5ca2c47036a84d2c3d5983c8b17008daac39a45 +Subproject commit 6bc1f28e5e3dc5af9432316e700a761fef14a8d2 From 44d9bd92380aeebc6911d804efd7816ce11c7ed2 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Mon, 5 Jun 2023 10:57:15 +0200 Subject: [PATCH 10/25] More changes --- .../samples/TokenizingTextBoxCustomSample.xaml | 5 +++-- .../samples/TokenizingTextBoxCustomSample.xaml.cs | 5 ----- components/TokenizingTextBox/src/TokenizingTextBox.xaml | 2 +- .../TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml | 7 ++++--- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml b/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml index fa12aa67..00a8c635 100644 --- a/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml +++ b/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml @@ -1,4 +1,4 @@ - + - 4 + 3,2,3,2 0,0,6,0 2 diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml b/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml index 06f62add..cadcb920 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.Token.xaml @@ -1,4 +1,4 @@ - + Value="{ThemeResource ControlFillColorTransparentBrush}" /> + + Value="{ThemeResource ControlFillColorDefaultBrush}" /> From f156c4e88cfd19b89fd0d08e2148dc405003be30 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Mon, 5 Jun 2023 16:17:45 +0200 Subject: [PATCH 11/25] Updating samples and tests --- .../samples/TokenizingTextBox.md | 70 +++- ...xaml => TokenizingTextBoxBasicSample.xaml} | 50 +-- ...s => TokenizingTextBoxBasicSample.xaml.cs} | 36 +- .../samples/TokenizingTextBoxEmailSample.xaml | 72 ++++ .../TokenizingTextBoxEmailSample.xaml.cs | 112 ++++++ .../TokenizingTextBoxItem.AutoSuggestBox.cs | 5 + .../src/WrapPanel/WrapPanel.cs | 8 +- .../ExampleTokenizingTextBoxTestClass.cs | 134 ------- .../ExampleTokenizingTextBoxTestPage.xaml | 14 - .../ExampleTokenizingTextBoxTestPage.xaml.cs | 16 - .../Test_TokenizingTextBox_AutomationPeer.cs | 117 ++++++ .../tests/Test_TokenizingTextBox_General.cs | 363 ++++++++++++++++++ .../tests/TokenizingTextBox.Tests.projitems | 12 +- 13 files changed, 779 insertions(+), 230 deletions(-) rename components/TokenizingTextBox/samples/{TokenizingTextBoxCustomSample.xaml => TokenizingTextBoxBasicSample.xaml} (66%) rename components/TokenizingTextBox/samples/{TokenizingTextBoxCustomSample.xaml.cs => TokenizingTextBoxBasicSample.xaml.cs} (78%) create mode 100644 components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml create mode 100644 components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml.cs delete mode 100644 components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestClass.cs delete mode 100644 components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestPage.xaml delete mode 100644 components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestPage.xaml.cs create mode 100644 components/TokenizingTextBox/tests/Test_TokenizingTextBox_AutomationPeer.cs create mode 100644 components/TokenizingTextBox/tests/Test_TokenizingTextBox_General.cs diff --git a/components/TokenizingTextBox/samples/TokenizingTextBox.md b/components/TokenizingTextBox/samples/TokenizingTextBox.md index b81b45ac..c6ac0d5a 100644 --- a/components/TokenizingTextBox/samples/TokenizingTextBox.md +++ b/components/TokenizingTextBox/samples/TokenizingTextBox.md @@ -1,32 +1,70 @@ --- title: TokenizingTextBox -author: githubaccount -description: TODO: Your experiment's description here -keywords: TokenizingTextBox, Control, Layout +author: michael-hawker +description: A text input control that auto-suggests and displays token items. +keywords: TokenizingTextBox, control, tokens dev_langs: - csharp category: Controls -subcategory: Layout +subcategory: Input discussion-id: 0 issue-id: 0 --- - - - - - +# TokenizingTextBox - +The [TokenizingTextBox](/dotnet/api/microsoft.toolkit.uwp.ui.controls.tokenizingtextbox) is an advanced [AutoSuggestBox](/uwp/api/Windows.UI.Xaml.Controls.AutoSuggestBox) which will display selected items as tokens within the textbox. A user can easily see the picked items or remove them easily. -# TokenizingTextBox +> [!Sample TokenizingTextBoxBasicSample] + +## Syntax + +```xaml + +``` + +## Properties -TODO: Fill in information about this experiment and how to get started here... +| Property | Type | Description | +| -- | -- | -- | +| AutoSuggestBoxStyle | Style | Inner AutoSuggestBox style | +| AutoSuggestBoxTextBoxStyle | Style | Inner TextBox style of the AutoSuggestBox | +| PlaceholderText | string | Placeholder text to display when there's no text in the textbox | +| QueryIcon | IconSource | +| QueryText | string | Gets or sets the text query of the AutoSuggestBox | +| SelectedItems | IList<object> | Collection of items selected by the user | +| SelectedTokenText | string | Complete set of text for any selection in the control | +| SuggestedItemsSource | object | List of suggested items | +| SuggestedItemTemplate | DataTemplate | Template for suggested items | +| SuggestedItemTemplateSelector | DataTemplateSelector | Template selector for suggested items | +| SuggestedItemContainerStyle | Style for suggested item's container | +| TabNavigateBackOnArrow | bool | Value indicating whether the control will move focus to the previous control when an arrow key is pressed and selection is at one of the limits in the control. | +| Text | string | Text of currently focused text box part | +| TextMemberPath | string | Path of property for item display | +| TokenDelimiter | string | Character delimiter for recognizing a token | +| TokenItemTemplate | DataTemplate | Template for a token item | +| TokenItemTemplateSelector | DataTemplateSelector | Template selector for token items | +| TokenItemStyle | Style | Style for a token item | +| TokenSpacing | double | Amount of spacing between tokens | -## Custom Control +## Methods -You can inherit from an existing component as well, like `Panel`, this example shows a control without a -XAML Style that will be more light-weight to consume by an app developer: +| Methods | Return Type | Description | +| -- | -- | -- | +| AddTokenItem(data, bool) | void | Used in special cases where you want to add a token manually to the control | +| ClearAsync() | Task | Clears everything from the control, tokens and text. | +| GetUntokenizedText(string) | string | Returns the string representation of each token item, concatenated and delimited. | -> [!Sample TokenizingTextBoxCustomSample] +## Events +| Events | Description | +| -- | -- | +| QuerySubmitted | Event raised when the user submits the text query. | +| SuggestionChosen | Event raised when a suggested item is chosen by the user. | +| TextChanged | Event raised when the text input value has changed. | +| TokenItemAdding | Event raised before a new token item has been added. Can be used to transform user text into an object. | +| TokenItemRemoving | Event raised before a token item is removed (cancelable). | +| TokenItemRemoved | Event raised after a token item has been removed. | diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml b/components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml similarity index 66% rename from components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml rename to components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml index 00a8c635..82ad7a42 100644 --- a/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml +++ b/components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml @@ -1,5 +1,5 @@ - - - - - - - + + + + + + + + - - + + + @@ -54,13 +59,14 @@ - - - Current Edit: - - + + + + diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml.cs b/components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml.cs similarity index 78% rename from components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml.cs rename to components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml.cs index 9743aacc..81a4716a 100644 --- a/components/TokenizingTextBox/samples/TokenizingTextBoxCustomSample.xaml.cs +++ b/components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml.cs @@ -6,23 +6,17 @@ namespace TokenizingTextBoxExperiment.Samples; -/// -/// An example sample page of a custom control inheriting from Panel. -/// -[ToolkitSampleTextOption("TitleText", "This is a title", Title = "Input the text")] -[ToolkitSampleMultiChoiceOption("LayoutOrientation", "Horizontal", "Vertical", Title = "Orientation")] - -[ToolkitSample(id: nameof(TokenizingTextBoxCustomSample), "Custom control", description: $"A sample for showing how to create and use a {nameof(TokenizingTextBox)} custom control.")] -public sealed partial class TokenizingTextBoxCustomSample : Page +[ToolkitSample(id: nameof(TokenizingTextBoxBasicSample), "Basic sample", description: $"A sample for showing how to create and use a {nameof(TokenizingTextBox)}.")] +public sealed partial class TokenizingTextBoxBasicSample : Page { - private readonly List _samples = new List() + public readonly List _samples = new List() { new SampleDataType() { Text = "Account", Icon = Symbol.Account }, - new SampleDataType() { Text = "Add Friend", Icon = Symbol.AddFriend }, + new SampleDataType() { Text = "Add friend", Icon = Symbol.AddFriend }, new SampleDataType() { Text = "Attach", Icon = Symbol.Attach }, - new SampleDataType() { Text = "Attach Camera", Icon = Symbol.AttachCamera }, + new SampleDataType() { Text = "Attach camera", Icon = Symbol.AttachCamera }, new SampleDataType() { Text = "Audio", Icon = Symbol.Audio }, - new SampleDataType() { Text = "Block Contact", Icon = Symbol.BlockContact }, + new SampleDataType() { Text = "Block contact", Icon = Symbol.BlockContact }, new SampleDataType() { Text = "Calculator", Icon = Symbol.Calculator }, new SampleDataType() { Text = "Calendar", Icon = Symbol.Calendar }, new SampleDataType() { Text = "Camera", Icon = Symbol.Camera }, @@ -34,7 +28,7 @@ public sealed partial class TokenizingTextBoxCustomSample : Page new SampleDataType() { Text = "Phone", Icon = Symbol.Phone }, new SampleDataType() { Text = "Pin", Icon = Symbol.Pin }, new SampleDataType() { Text = "Rotate", Icon = Symbol.Rotate }, - new SampleDataType() { Text = "Rotate Camera", Icon = Symbol.RotateCamera }, + new SampleDataType() { Text = "Rotate camera", Icon = Symbol.RotateCamera }, new SampleDataType() { Text = "Send", Icon = Symbol.Send }, new SampleDataType() { Text = "Tags", Icon = Symbol.Tag }, new SampleDataType() { Text = "UnFavorite", Icon = Symbol.UnFavorite }, @@ -46,12 +40,12 @@ public sealed partial class TokenizingTextBoxCustomSample : Page public ObservableCollection SelectedTokens { get; set; } - public TokenizingTextBoxCustomSample() + public TokenizingTextBoxBasicSample() { this.InitializeComponent(); SelectedTokens = new(); - TokenBox.SuggestedItemsSource = _samples; - + SelectedTokens.Add(_samples[0]); + SelectedTokens.Add(_samples[1]); } private void TokenItemAdded(TokenizingTextBox sender, object data) { @@ -82,7 +76,7 @@ private void TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventAr { if (args.CheckCurrent() && args.Reason == AutoSuggestionBoxTextChangeReason.UserInput) { - // _acv.RefreshFilter(); + // TO DO: Filter items } } @@ -104,4 +98,12 @@ private void TokenItemCreating(object sender, TokenItemAddingEventArgs e) }; } } + + private void TokenBox_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is SampleDataType selectedItem) + { + clickedItem.Text = selectedItem.Text!; + } + } } diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml b/components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml new file mode 100644 index 00000000..0c409cb6 --- /dev/null +++ b/components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml.cs b/components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml.cs new file mode 100644 index 00000000..739262d9 --- /dev/null +++ b/components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.WinUI.Controls; + +namespace TokenizingTextBoxExperiment.Samples; + +[ToolkitSample(id: nameof(TokenizingTextBoxEmailSample), "Email sample", description: $"A sample for showing how to create and use a {nameof(TokenizingTextBox)}.")] +public sealed partial class TokenizingTextBoxEmailSample : Page +{ + private readonly List _Samples = new List() + { + new SampleEmailDataType() { FirstName = "Marcus", FamilyName = "Perryman" }, + new SampleEmailDataType() { FirstName = "Michael", FamilyName = "Hawker" }, + new SampleEmailDataType() { FirstName = "Matt", FamilyName = "Lacey" }, + new SampleEmailDataType() { FirstName = "Alexandre", FamilyName = "Chohfi" }, + new SampleEmailDataType() { FirstName = "Filip", FamilyName = "Wallberg" }, + new SampleEmailDataType() { FirstName = "Shane", FamilyName = "Weaver" }, + new SampleEmailDataType() { FirstName = "Vincent", FamilyName = "Gromfeld" }, + new SampleEmailDataType() { FirstName = "Sergio", FamilyName = "Pedri" }, + new SampleEmailDataType() { FirstName = "Alex", FamilyName = "Wilber" }, + new SampleEmailDataType() { FirstName = "Allan", FamilyName = "Deyoung" }, + new SampleEmailDataType() { FirstName = "Adele", FamilyName = "Vance" }, + new SampleEmailDataType() { FirstName = "Grady", FamilyName = "Archie" }, + new SampleEmailDataType() { FirstName = "Megan", FamilyName = "Bowen" }, + new SampleEmailDataType() { FirstName = "Ben", FamilyName = "Walters" }, + new SampleEmailDataType() { FirstName = "Debra", FamilyName = "Berger" }, + new SampleEmailDataType() { FirstName = "Emily", FamilyName = "Braun" }, + new SampleEmailDataType() { FirstName = "Christine", FamilyName = "Cline" }, + new SampleEmailDataType() { FirstName = "Enrico", FamilyName = "Catteneo" }, + new SampleEmailDataType() { FirstName = "Davit", FamilyName = "Badalyan" }, + new SampleEmailDataType() { FirstName = "Diego", FamilyName = "Siciliani" }, + new SampleEmailDataType() { FirstName = "Raul", FamilyName = "Razo" }, + new SampleEmailDataType() { FirstName = "Miriam", FamilyName = "Graham" }, + new SampleEmailDataType() { FirstName = "Lynne", FamilyName = "Robbins" }, + new SampleEmailDataType() { FirstName = "Lydia", FamilyName = "Holloway" }, + new SampleEmailDataType() { FirstName = "Nestor", FamilyName = "Wilke" }, + new SampleEmailDataType() { FirstName = "Patti", FamilyName = "Fernandez" }, + new SampleEmailDataType() { FirstName = "Pradeep", FamilyName = "Gupta" }, + new SampleEmailDataType() { FirstName = "Joni", FamilyName = "Sherman" }, + new SampleEmailDataType() { FirstName = "Isaiah", FamilyName = "Langer" }, + new SampleEmailDataType() { FirstName = "Irvin", FamilyName = "Sayers" }, + }; + + public ObservableCollection SelectedTokens { get; set; } + + public TokenizingTextBoxEmailSample() + { + this.InitializeComponent(); + SelectedTokens = new(); + } + private void TokenItemAdded(TokenizingTextBox sender, object data) + { + // TODO: Add InApp Notification? + if (data is SampleDataType sample) + { + Debug.WriteLine("Added Token: " + sample.Text); + } + else + { + Debug.WriteLine("Added Token: " + data); + } + } + + private void TokenItemRemoved(TokenizingTextBox sender, TokenItemRemovingEventArgs args) + { + if (args.Item is SampleDataType sample) + { + Debug.WriteLine("Removed Token: " + sample.Text); + } + else + { + Debug.WriteLine("Removed Token: " + args.Item); + } + } + + private void TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + if (args.CheckCurrent() && args.Reason == AutoSuggestionBoxTextChangeReason.UserInput) + { + // TO DO: Filter items + } + } + + private void TokenItemCreating(object sender, TokenItemAddingEventArgs e) + { + // Take the user's text and convert it to our data type (if we have a matching one). +#if !HAS_UNO + e.Item = _samples.FirstOrDefault((item) => item.Text!.Contains(e.TokenText, StringComparison.CurrentCultureIgnoreCase)); +#else + e.Item = _samples.FirstOrDefault((item) => item.Text!.Contains(e.TokenText)); +#endif + // Otherwise, create a new version of our data type + if (e.Item == null) + { + e.Item = new SampleDataType() + { + Text = e.TokenText, + Icon = Symbol.OutlineStar + }; + } + } + + private void TokenBox_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is SampleDataType selectedItem) + { + clickedItem.Text = selectedItem.Text!; + } + } +} diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs index 73c0a915..0d3b2842 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs @@ -188,6 +188,11 @@ void WaitForLoad(object s, RoutedEventArgs eargs) private void AutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) { + if (sender.Text == null) + { + return; + } + if (!EqualityComparer.Default.Equals(sender.Text, Owner.Text)) { Owner.Text = sender.Text; // Update parent text property, if different diff --git a/components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs b/components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs index 58561458..27035df6 100644 --- a/components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs +++ b/components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs @@ -131,9 +131,15 @@ protected override Size MeasureOverride(Size availableSize) var childAvailableSize = new Size( availableSize.Width - Padding.Left - Padding.Right, availableSize.Height - Padding.Top - Padding.Bottom); + + if (double.IsInfinity(childAvailableSize.Width) || double.IsInfinity(childAvailableSize.Height)) + { + childAvailableSize.Width = 100; + childAvailableSize.Height = 100; + } foreach (var child in Children) { - child.Measure(childAvailableSize); + child.Measure(availableSize); } var requiredSize = UpdateRows(availableSize); diff --git a/components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestClass.cs b/components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestClass.cs deleted file mode 100644 index 93ceaf2e..00000000 --- a/components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestClass.cs +++ /dev/null @@ -1,134 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using CommunityToolkit.Tooling.TestGen; -using CommunityToolkit.Tests; -using CommunityToolkit.WinUI.Controls; - -namespace TokenizingTextBoxExperiment.Tests; - -[TestClass] -public partial class ExampleTokenizingTextBoxTestClass : VisualUITestBase -{ - // If you don't need access to UI objects directly or async code, use this pattern. - [TestMethod] - public void SimpleSynchronousExampleTest() - { - var assembly = typeof(TokenizingTextBox).Assembly; - var type = assembly.GetType(typeof(TokenizingTextBox).FullName ?? string.Empty); - - Assert.IsNotNull(type, "Could not find TokenizingTextBox type."); - Assert.AreEqual(typeof(TokenizingTextBox), type, "Type of TokenizingTextBox does not match expected type."); - } - - // If you don't need access to UI objects directly, use this pattern. - [TestMethod] - public async Task SimpleAsyncExampleTest() - { - await Task.Delay(250); - - Assert.IsTrue(true); - } - - // Example that shows how to check for exception throwing. - [TestMethod] - public void SimpleExceptionCheckTest() - { - // If you need to check exceptions occur for invalid inputs, etc... - // Use Assert.ThrowsException to limit the scope to where you expect the error to occur. - // Otherwise, using the ExpectedException attribute could swallow or - // catch other issues in setup code. - Assert.ThrowsException(() => throw new NotImplementedException()); - } - - // The UIThreadTestMethod automatically dispatches to the UI for us to work with UI objects. - [UIThreadTestMethod] - public void SimpleUIAttributeExampleTest() - { - var component = new TokenizingTextBox(); - Assert.IsNotNull(component); - } - - // The UIThreadTestMethod can also easily grab a XAML Page for us by passing its type as a parameter. - // This lets us actually test a control as it would behave within an actual application. - // The page will already be loaded by the time your test is called. - [UIThreadTestMethod] - public void SimpleUIExamplePageTest(ExampleTokenizingTextBoxTestPage page) - { - // You can use the Toolkit Visual Tree helpers here to find the component by type or name: - var component = page.FindDescendant(); - - Assert.IsNotNull(component); - - var componentByName = page.FindDescendant("TokenizingTextBoxControl"); - - Assert.IsNotNull(componentByName); - } - - // You can still do async work with a UIThreadTestMethod as well. - [UIThreadTestMethod] - public async Task SimpleAsyncUIExamplePageTest(ExampleTokenizingTextBoxTestPage page) - { - // This helper can be used to wait for a rendering pass to complete. - // Note, this is already done by loading a Page with the [UIThreadTestMethod] helper. - await CompositionTargetHelper.ExecuteAfterCompositionRenderingAsync(() => { }); - - var component = page.FindDescendant(); - - Assert.IsNotNull(component); - } - - //// ----------------------------- ADVANCED TEST SCENARIOS ----------------------------- - - // If you need to use DataRow, you can use this pattern with the UI dispatch still. - // Otherwise, checkout the UIThreadTestMethod attribute above. - // See https://github.com/CommunityToolkit/Labs-Windows/issues/186 - [TestMethod] - public async Task ComplexAsyncUIExampleTest() - { - await EnqueueAsync(() => - { - var component = new TokenizingTextBox(); - Assert.IsNotNull(component); - }); - } - - // If you want to load other content not within a XAML page using the UIThreadTestMethod above. - // Then you can do that using the Load/UnloadTestContentAsync methods. - [TestMethod] - public async Task ComplexAsyncLoadUIExampleTest() - { - await EnqueueAsync(async () => - { - var component = new TokenizingTextBox(); - Assert.IsNotNull(component); - Assert.IsFalse(component.IsLoaded); - - await LoadTestContentAsync(component); - - Assert.IsTrue(component.IsLoaded); - - await UnloadTestContentAsync(component); - - Assert.IsFalse(component.IsLoaded); - }); - } - - // You can still use the UIThreadTestMethod to remove the extra layer for the dispatcher as well: - [UIThreadTestMethod] - public async Task ComplexAsyncLoadUIExampleWithoutDispatcherTest() - { - var component = new TokenizingTextBox(); - Assert.IsNotNull(component); - Assert.IsFalse(component.IsLoaded); - - await LoadTestContentAsync(component); - - Assert.IsTrue(component.IsLoaded); - - await UnloadTestContentAsync(component); - - Assert.IsFalse(component.IsLoaded); - } -} diff --git a/components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestPage.xaml b/components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestPage.xaml deleted file mode 100644 index 0093b9bb..00000000 --- a/components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestPage.xaml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - diff --git a/components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestPage.xaml.cs b/components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestPage.xaml.cs deleted file mode 100644 index 38663cec..00000000 --- a/components/TokenizingTextBox/tests/ExampleTokenizingTextBoxTestPage.xaml.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -namespace TokenizingTextBoxExperiment.Tests; - -/// -/// An empty page that can be used on its own or navigated to within a Frame. -/// -public sealed partial class ExampleTokenizingTextBoxTestPage : Page -{ - public ExampleTokenizingTextBoxTestPage() - { - this.InitializeComponent(); - } -} diff --git a/components/TokenizingTextBox/tests/Test_TokenizingTextBox_AutomationPeer.cs b/components/TokenizingTextBox/tests/Test_TokenizingTextBox_AutomationPeer.cs new file mode 100644 index 00000000..ae9069bf --- /dev/null +++ b/components/TokenizingTextBox/tests/Test_TokenizingTextBox_AutomationPeer.cs @@ -0,0 +1,117 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Tests; +using CommunityToolkit.WinUI.Automation.Peers; +using CommunityToolkit.WinUI.Controls; + +namespace TokenizingTextBoxExperiment.Tests; + +[TestClass] +[TestCategory("Test_TokenizingTextBox")] +public class Test_TokenizingTextBox_AutomationPeer : VisualUITestBase +{ + [TestMethod] + public async Task ShouldConfigureTokenizingTextBoxAutomationPeerAsync() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + const string expectedAutomationName = "MyAutomationName"; + const string expectedName = "MyName"; + const string expectedValue = "Wor"; + + var items = new ObservableCollection { new() { Title = "Hello" }, new() { Title = "World" } }; + + var tokenizingTextBox = new TokenizingTextBox { ItemsSource = items }; + + await LoadTestContentAsync(tokenizingTextBox); + + var tokenizingTextBoxAutomationPeer = + FrameworkElementAutomationPeer.CreatePeerForElement(tokenizingTextBox) as TokenizingTextBoxAutomationPeer; + + Assert.IsNotNull(tokenizingTextBoxAutomationPeer, "Verify that the AutomationPeer is TokenizingTextBoxAutomationPeer."); + + // Asserts the automation peer name based on the Automation Property Name value. + tokenizingTextBox.SetValue(AutomationProperties.NameProperty, expectedAutomationName); + Assert.IsTrue(tokenizingTextBoxAutomationPeer.GetName().Contains(expectedAutomationName), "Verify that the UIA name contains the given AutomationProperties.Name of the TokenizingTextBox."); + + // Asserts the automation peer name based on the element Name property. + tokenizingTextBox.Name = expectedName; + Assert.IsTrue(tokenizingTextBoxAutomationPeer.GetName().Contains(expectedName), "Verify that the UIA name contains the given Name of the TokenizingTextBox."); + + tokenizingTextBoxAutomationPeer.SetValue(expectedValue); + Assert.IsTrue(tokenizingTextBoxAutomationPeer.Value.Equals(expectedValue), "Verify that the Value contains the given Text of the TokenizingTextBox."); + }); + } + + [TestMethod] + public async Task ShouldReturnTokensForTokenizingTextBoxAutomationPeerAsync() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var items = new ObservableCollection + { + new() { Title = "Hello" }, new() { Title = "World" } + }; + + var tokenizingTextBox = new TokenizingTextBox { ItemsSource = items }; + + await LoadTestContentAsync(tokenizingTextBox); + + tokenizingTextBox + .SelectAllTokensAndText(); // Will be 3 items due to the `AndText` that will select an empty text item. + + var tokenizingTextBoxAutomationPeer = + FrameworkElementAutomationPeer.CreatePeerForElement(tokenizingTextBox) as + TokenizingTextBoxAutomationPeer; + + Assert.IsNotNull( + tokenizingTextBoxAutomationPeer, + "Verify that the AutomationPeer is TokenizingTextBoxAutomationPeer."); + + var selectedItems = tokenizingTextBoxAutomationPeer + .GetChildren() + .Cast() + .Select(peer => peer.Owner as TokenizingTextBoxItem) + .Select(item => item?.Content as TokenizingTextBoxTestItem) + .ToList(); + + Assert.AreEqual(3, selectedItems.Count); + Assert.AreEqual(items[0], selectedItems[0]); + Assert.AreEqual(items[1], selectedItems[1]); + Assert.IsNull(selectedItems[2]); // The 3rd item is the empty text item. + }); + } + + [TestMethod] + public async Task ShouldThrowElementNotEnabledExceptionIfValueSetWhenDisabled() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + const string expectedValue = "Wor"; + + var tokenizingTextBox = new TokenizingTextBox { IsEnabled = false }; + + await LoadTestContentAsync(tokenizingTextBox); + + var tokenizingTextBoxAutomationPeer = + FrameworkElementAutomationPeer.CreatePeerForElement(tokenizingTextBox) as TokenizingTextBoxAutomationPeer; + + Assert.ThrowsException(() => + { + tokenizingTextBoxAutomationPeer.SetValue(expectedValue); + }); + }); + } + + public class TokenizingTextBoxTestItem + { + public string Title { get; set; } + + public override string ToString() + { + return Title; + } + } +} diff --git a/components/TokenizingTextBox/tests/Test_TokenizingTextBox_General.cs b/components/TokenizingTextBox/tests/Test_TokenizingTextBox_General.cs new file mode 100644 index 00000000..724c9a13 --- /dev/null +++ b/components/TokenizingTextBox/tests/Test_TokenizingTextBox_General.cs @@ -0,0 +1,363 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Tests; +using CommunityToolkit.WinUI.Controls; + +namespace TokenizingTextBoxExperiment.Tests; + + [TestClass] +public class Test_TokenizingTextBox_General : VisualUITestBase +{ + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_ClearTokens() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Token default items failed"); + + // Add 4 items + tokenBox.AddTokenItem("TokenItem1"); + tokenBox.AddTokenItem("TokenItem2"); + tokenBox.AddTokenItem("TokenItem3"); + tokenBox.AddTokenItem("TokenItem4"); + + Assert.AreEqual(5, tokenBox.Items.Count, "Token Add count failed"); // 5th item is the textbox + + var count = 0; + + tokenBox.TokenItemRemoving += (sender, args) => { count++; }; + + // now test clear + await tokenBox.ClearAsync(); + + Assert.AreEqual(1, tokenBox.Items.Count, "Clear Failed to clear"); // Still expect textbox to remain + Assert.AreEqual(4, count, "Did not receive 4 removal events."); + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_ClearTokenCancel() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Token default items failed"); + + // test cancelled clear + tokenBox.AddTokenItem("TokenItem1"); + tokenBox.AddTokenItem("TokenItem2"); + tokenBox.AddTokenItem("TokenItem3"); + tokenBox.AddTokenItem("TokenItem4"); + + Assert.AreEqual(5, tokenBox.Items.Count, "Token Add count failed"); + + tokenBox.TokenItemRemoving += (sender, args) => { args.Cancel = true; }; + + await tokenBox.ClearAsync(); + + // Should have the same number of items left + Assert.AreEqual(5, tokenBox.Items.Count, "Cancelled Clear Failed "); + + // TODO: We should have test for individual removal as well. + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_MaximumTokens() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var maxTokens = 2; + + var treeRoot = XamlReader.Load( +$@" + + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + + // Items includes the text fields as well, so we can expect at least one item to exist initially, the input box. + // Use the starting count as an offset. + var startingItemsCount = tokenBox.Items.Count; + + // Add two items. + tokenBox.AddTokenItem("TokenItem1"); + tokenBox.AddTokenItem("TokenItem2"); + + // Make sure we have the appropriate amount of items and that they are in the appropriate order. + Assert.AreEqual(startingItemsCount + maxTokens, tokenBox.Items.Count, "Token Add failed"); + Assert.AreEqual("TokenItem1", tokenBox.Items[0]); + Assert.AreEqual("TokenItem2", tokenBox.Items[1]); + + // Attempt to add an additional item, beyond the maximum. + tokenBox.AddTokenItem("TokenItem3"); + + // Check that the number of items did not change, because the maximum number of items are already present. + Assert.AreEqual(startingItemsCount + maxTokens, tokenBox.Items.Count, "Token Add succeeded, where it should have failed."); + Assert.AreEqual("TokenItem1", tokenBox.Items[0]); + Assert.AreEqual("TokenItem2", tokenBox.Items[1]); + + // Reduce the maximum number of tokens. + tokenBox.MaximumTokens = 1; + + // The last token should be removed to account for the reduced maximum. + Assert.AreEqual(startingItemsCount + 1, tokenBox.Items.Count); + Assert.AreEqual("TokenItem1", tokenBox.Items[0]); + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_SetInitialText() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Token default items failed"); // AutoSuggestBox + + // Test initial value of property + Assert.AreEqual("Some Text", tokenBox.Text, "Token text not equal to starting value."); + + // Reach into AutoSuggestBox's text to check it was set properly + var autoSuggestBox = tokenBox.FindDescendant(); + + Assert.IsNotNull(autoSuggestBox, "Could not find inner autosuggestbox"); + Assert.AreEqual("Some Text", autoSuggestBox.Text, "Inner text not set based on initial value of TokenizingTextBox"); + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_ChangeText() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Token default items failed"); // AutoSuggestBox + + // Test initial value of property + Assert.AreEqual(string.Empty, tokenBox.Text, "Text should start as empty."); + + // Reach into AutoSuggestBox's text to check it was set properly + var autoSuggestBox = tokenBox.FindDescendant(); + + Assert.IsNotNull(autoSuggestBox, "Could not find inner autosuggestbox"); + Assert.AreEqual(string.Empty, autoSuggestBox.Text, "Inner text not set based on initial value of TokenizingTextBox"); + + // Change Text + tokenBox.Text = "New Text"; + + // Wait for update + await CompositionTargetHelper.ExecuteAfterCompositionRenderingAsync(() => { }); + + Assert.AreEqual("New Text", tokenBox.Text, "Text should be changed now."); + Assert.AreEqual("New Text", autoSuggestBox.Text, "Inner text not set based on value of TokenizingTextBox"); + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_ClearText() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Token default items failed"); // AutoSuggestBox + + // TODO: When in Labs, we should inject text via keyboard here vs. setting an initial value (more independent of SetInitialText test). + + // Test initial value of property + Assert.AreEqual("Some Text", tokenBox.Text, "Token text not equal to starting value."); + + // Reach into AutoSuggestBox's text to check it was set properly + var autoSuggestBox = tokenBox.FindDescendant(); + + Assert.IsNotNull(autoSuggestBox, "Could not find inner autosuggestbox"); + Assert.AreEqual("Some Text", autoSuggestBox.Text, "Inner text not set based on initial value of TokenizingTextBox"); + + await tokenBox.ClearAsync(); + + Assert.AreEqual(string.Empty, autoSuggestBox.Text, "Inner text was not cleared."); + Assert.AreEqual(string.Empty, tokenBox.Text, "TokenizingTextBox text was not cleared."); + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_SetInitialTextWithDelimiter() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Tokens not created"); // AutoSuggestBox + + Assert.AreEqual("Token 1, Token 2, Token 3", tokenBox.Text, "Token text not equal to starting value."); + + await Task.Delay(500); // TODO: Wait for a loaded event? + + Assert.AreEqual(1 + 2, tokenBox.Items.Count, "Tokens not created"); + + // Test initial value of property + Assert.AreEqual("Token 3", tokenBox.Text, "Token text should be last value now."); + + Assert.AreEqual("Token 1", tokenBox.Items[0]); + Assert.AreEqual("Token 2", tokenBox.Items[1]); + }); + } + + [TestCategory("Test_TokenizingTextBox_General")] + [TestMethod] + public async Task Test_SetInitialTextWithDelimiterAll() + { + await App.DispatcherQueue.EnqueueAsync(async () => + { + var treeRoot = XamlReader.Load( +@" + + + +") as FrameworkElement; + + Assert.IsNotNull(treeRoot, "Could not load XAML tree."); + + await LoadTestContentAsync(treeRoot); + + var tokenBox = treeRoot.FindChild("tokenboxname") as TokenizingTextBox; + + Assert.IsNotNull(tokenBox, "Could not find TokenizingTextBox in tree."); + Assert.AreEqual(1, tokenBox.Items.Count, "Tokens not created"); // AutoSuggestBox + + Assert.AreEqual("Token 1, Token 2, Token 3, ", tokenBox.Text, "Token text not equal to starting value."); + + await Task.Delay(500); // TODO: Wait for a loaded event? + + Assert.AreEqual(1 + 3, tokenBox.Items.Count, "Tokens not created"); + + // Test initial value of property + Assert.AreEqual(string.Empty, tokenBox.Text, "Token text should be blank now."); + + Assert.AreEqual("Token 1", tokenBox.Items[0]); + Assert.AreEqual("Token 2", tokenBox.Items[1]); + Assert.AreEqual("Token 3", tokenBox.Items[2]); + }); + } +} diff --git a/components/TokenizingTextBox/tests/TokenizingTextBox.Tests.projitems b/components/TokenizingTextBox/tests/TokenizingTextBox.Tests.projitems index b6cef034..eacf9ba8 100644 --- a/components/TokenizingTextBox/tests/TokenizingTextBox.Tests.projitems +++ b/components/TokenizingTextBox/tests/TokenizingTextBox.Tests.projitems @@ -9,15 +9,7 @@ TokenizingTextBoxExperiment.Tests - - - ExampleTokenizingTextBoxTestPage.xaml - - - - - Designer - MSBuild:Compile - + + \ No newline at end of file From c3e261380f2fc76644e665d9e9392694f582b787 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Tue, 6 Jun 2023 14:36:31 +0200 Subject: [PATCH 12/25] Adding SampleEmailType --- .../samples/SampleEmailType.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 components/TokenizingTextBox/samples/SampleEmailType.cs diff --git a/components/TokenizingTextBox/samples/SampleEmailType.cs b/components/TokenizingTextBox/samples/SampleEmailType.cs new file mode 100644 index 00000000..6719855f --- /dev/null +++ b/components/TokenizingTextBox/samples/SampleEmailType.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TokenizingTextBoxExperiment.Samples; + +/// +/// Sample of strongly-typed email address simulated data for . +/// +public class SampleEmailDataType +{ + /// + /// Gets the initials to Display + /// + public string Initials => string.Empty + FirstName[0] + FamilyName[0]; + + /// + /// Gets or sets the first name . + /// +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public string FirstName { get; set; } + + /// + /// Gets or sets the family name . + /// + public string FamilyName { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + + /// + /// Gets the display text. + /// + public string DisplayName => $"{FirstName} {FamilyName}"; + + /// + /// Gets the formatted email address + /// + public string EmailAddress => $"{DisplayName} <{FirstName}.{FamilyName}@contoso.com>"; + + public override string ToString() + { + return EmailAddress; + } +} From 80f35ee44b5d8e5c4e06dc766692418a5be9fe6c Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Tue, 6 Jun 2023 19:12:37 +0200 Subject: [PATCH 13/25] Remove unused files --- .../samples/TokenizingTextBoxBasicSample.xaml | 72 ----------- .../TokenizingTextBoxBasicSample.xaml.cs | 109 ----------------- .../samples/TokenizingTextBoxEmailSample.xaml | 72 ----------- .../TokenizingTextBoxEmailSample.xaml.cs | 112 ------------------ 4 files changed, 365 deletions(-) delete mode 100644 components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml delete mode 100644 components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml.cs delete mode 100644 components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml delete mode 100644 components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml.cs diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml b/components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml deleted file mode 100644 index 82ad7a42..00000000 --- a/components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml.cs b/components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml.cs deleted file mode 100644 index 81a4716a..00000000 --- a/components/TokenizingTextBox/samples/TokenizingTextBoxBasicSample.xaml.cs +++ /dev/null @@ -1,109 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using CommunityToolkit.WinUI.Controls; - -namespace TokenizingTextBoxExperiment.Samples; - -[ToolkitSample(id: nameof(TokenizingTextBoxBasicSample), "Basic sample", description: $"A sample for showing how to create and use a {nameof(TokenizingTextBox)}.")] -public sealed partial class TokenizingTextBoxBasicSample : Page -{ - public readonly List _samples = new List() - { - new SampleDataType() { Text = "Account", Icon = Symbol.Account }, - new SampleDataType() { Text = "Add friend", Icon = Symbol.AddFriend }, - new SampleDataType() { Text = "Attach", Icon = Symbol.Attach }, - new SampleDataType() { Text = "Attach camera", Icon = Symbol.AttachCamera }, - new SampleDataType() { Text = "Audio", Icon = Symbol.Audio }, - new SampleDataType() { Text = "Block contact", Icon = Symbol.BlockContact }, - new SampleDataType() { Text = "Calculator", Icon = Symbol.Calculator }, - new SampleDataType() { Text = "Calendar", Icon = Symbol.Calendar }, - new SampleDataType() { Text = "Camera", Icon = Symbol.Camera }, - new SampleDataType() { Text = "Contact", Icon = Symbol.Contact }, - new SampleDataType() { Text = "Favorite", Icon = Symbol.Favorite }, - new SampleDataType() { Text = "Link", Icon = Symbol.Link }, - new SampleDataType() { Text = "Mail", Icon = Symbol.Mail }, - new SampleDataType() { Text = "Map", Icon = Symbol.Map }, - new SampleDataType() { Text = "Phone", Icon = Symbol.Phone }, - new SampleDataType() { Text = "Pin", Icon = Symbol.Pin }, - new SampleDataType() { Text = "Rotate", Icon = Symbol.Rotate }, - new SampleDataType() { Text = "Rotate camera", Icon = Symbol.RotateCamera }, - new SampleDataType() { Text = "Send", Icon = Symbol.Send }, - new SampleDataType() { Text = "Tags", Icon = Symbol.Tag }, - new SampleDataType() { Text = "UnFavorite", Icon = Symbol.UnFavorite }, - new SampleDataType() { Text = "UnPin", Icon = Symbol.UnPin }, - new SampleDataType() { Text = "Zoom", Icon = Symbol.Zoom }, - new SampleDataType() { Text = "ZoomIn", Icon = Symbol.ZoomIn }, - new SampleDataType() { Text = "ZoomOut", Icon = Symbol.ZoomOut }, - }; - - public ObservableCollection SelectedTokens { get; set; } - - public TokenizingTextBoxBasicSample() - { - this.InitializeComponent(); - SelectedTokens = new(); - SelectedTokens.Add(_samples[0]); - SelectedTokens.Add(_samples[1]); - } - private void TokenItemAdded(TokenizingTextBox sender, object data) - { - // TODO: Add InApp Notification? - if (data is SampleDataType sample) - { - Debug.WriteLine("Added Token: " + sample.Text); - } - else - { - Debug.WriteLine("Added Token: " + data); - } - } - - private void TokenItemRemoved(TokenizingTextBox sender, TokenItemRemovingEventArgs args) - { - if (args.Item is SampleDataType sample) - { - Debug.WriteLine("Removed Token: " + sample.Text); - } - else - { - Debug.WriteLine("Removed Token: " + args.Item); - } - } - - private void TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) - { - if (args.CheckCurrent() && args.Reason == AutoSuggestionBoxTextChangeReason.UserInput) - { - // TO DO: Filter items - } - } - - private void TokenItemCreating(object sender, TokenItemAddingEventArgs e) - { - // Take the user's text and convert it to our data type (if we have a matching one). -#if !HAS_UNO - e.Item = _samples.FirstOrDefault((item) => item.Text!.Contains(e.TokenText, StringComparison.CurrentCultureIgnoreCase)); -#else - e.Item = _samples.FirstOrDefault((item) => item.Text!.Contains(e.TokenText)); -#endif - // Otherwise, create a new version of our data type - if (e.Item == null) - { - e.Item = new SampleDataType() - { - Text = e.TokenText, - Icon = Symbol.OutlineStar - }; - } - } - - private void TokenBox_ItemClick(object sender, ItemClickEventArgs e) - { - if (e.ClickedItem is SampleDataType selectedItem) - { - clickedItem.Text = selectedItem.Text!; - } - } -} diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml b/components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml deleted file mode 100644 index 0c409cb6..00000000 --- a/components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml.cs b/components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml.cs deleted file mode 100644 index 739262d9..00000000 --- a/components/TokenizingTextBox/samples/TokenizingTextBoxEmailSample.xaml.cs +++ /dev/null @@ -1,112 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using CommunityToolkit.WinUI.Controls; - -namespace TokenizingTextBoxExperiment.Samples; - -[ToolkitSample(id: nameof(TokenizingTextBoxEmailSample), "Email sample", description: $"A sample for showing how to create and use a {nameof(TokenizingTextBox)}.")] -public sealed partial class TokenizingTextBoxEmailSample : Page -{ - private readonly List _Samples = new List() - { - new SampleEmailDataType() { FirstName = "Marcus", FamilyName = "Perryman" }, - new SampleEmailDataType() { FirstName = "Michael", FamilyName = "Hawker" }, - new SampleEmailDataType() { FirstName = "Matt", FamilyName = "Lacey" }, - new SampleEmailDataType() { FirstName = "Alexandre", FamilyName = "Chohfi" }, - new SampleEmailDataType() { FirstName = "Filip", FamilyName = "Wallberg" }, - new SampleEmailDataType() { FirstName = "Shane", FamilyName = "Weaver" }, - new SampleEmailDataType() { FirstName = "Vincent", FamilyName = "Gromfeld" }, - new SampleEmailDataType() { FirstName = "Sergio", FamilyName = "Pedri" }, - new SampleEmailDataType() { FirstName = "Alex", FamilyName = "Wilber" }, - new SampleEmailDataType() { FirstName = "Allan", FamilyName = "Deyoung" }, - new SampleEmailDataType() { FirstName = "Adele", FamilyName = "Vance" }, - new SampleEmailDataType() { FirstName = "Grady", FamilyName = "Archie" }, - new SampleEmailDataType() { FirstName = "Megan", FamilyName = "Bowen" }, - new SampleEmailDataType() { FirstName = "Ben", FamilyName = "Walters" }, - new SampleEmailDataType() { FirstName = "Debra", FamilyName = "Berger" }, - new SampleEmailDataType() { FirstName = "Emily", FamilyName = "Braun" }, - new SampleEmailDataType() { FirstName = "Christine", FamilyName = "Cline" }, - new SampleEmailDataType() { FirstName = "Enrico", FamilyName = "Catteneo" }, - new SampleEmailDataType() { FirstName = "Davit", FamilyName = "Badalyan" }, - new SampleEmailDataType() { FirstName = "Diego", FamilyName = "Siciliani" }, - new SampleEmailDataType() { FirstName = "Raul", FamilyName = "Razo" }, - new SampleEmailDataType() { FirstName = "Miriam", FamilyName = "Graham" }, - new SampleEmailDataType() { FirstName = "Lynne", FamilyName = "Robbins" }, - new SampleEmailDataType() { FirstName = "Lydia", FamilyName = "Holloway" }, - new SampleEmailDataType() { FirstName = "Nestor", FamilyName = "Wilke" }, - new SampleEmailDataType() { FirstName = "Patti", FamilyName = "Fernandez" }, - new SampleEmailDataType() { FirstName = "Pradeep", FamilyName = "Gupta" }, - new SampleEmailDataType() { FirstName = "Joni", FamilyName = "Sherman" }, - new SampleEmailDataType() { FirstName = "Isaiah", FamilyName = "Langer" }, - new SampleEmailDataType() { FirstName = "Irvin", FamilyName = "Sayers" }, - }; - - public ObservableCollection SelectedTokens { get; set; } - - public TokenizingTextBoxEmailSample() - { - this.InitializeComponent(); - SelectedTokens = new(); - } - private void TokenItemAdded(TokenizingTextBox sender, object data) - { - // TODO: Add InApp Notification? - if (data is SampleDataType sample) - { - Debug.WriteLine("Added Token: " + sample.Text); - } - else - { - Debug.WriteLine("Added Token: " + data); - } - } - - private void TokenItemRemoved(TokenizingTextBox sender, TokenItemRemovingEventArgs args) - { - if (args.Item is SampleDataType sample) - { - Debug.WriteLine("Removed Token: " + sample.Text); - } - else - { - Debug.WriteLine("Removed Token: " + args.Item); - } - } - - private void TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) - { - if (args.CheckCurrent() && args.Reason == AutoSuggestionBoxTextChangeReason.UserInput) - { - // TO DO: Filter items - } - } - - private void TokenItemCreating(object sender, TokenItemAddingEventArgs e) - { - // Take the user's text and convert it to our data type (if we have a matching one). -#if !HAS_UNO - e.Item = _samples.FirstOrDefault((item) => item.Text!.Contains(e.TokenText, StringComparison.CurrentCultureIgnoreCase)); -#else - e.Item = _samples.FirstOrDefault((item) => item.Text!.Contains(e.TokenText)); -#endif - // Otherwise, create a new version of our data type - if (e.Item == null) - { - e.Item = new SampleDataType() - { - Text = e.TokenText, - Icon = Symbol.OutlineStar - }; - } - } - - private void TokenBox_ItemClick(object sender, ItemClickEventArgs e) - { - if (e.ClickedItem is SampleDataType selectedItem) - { - clickedItem.Text = selectedItem.Text!; - } - } -} From eb1adbe77ace11c62c2aa33951bdb923436c6df0 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Tue, 6 Jun 2023 19:31:10 +0200 Subject: [PATCH 14/25] Sample improvements --- .../samples/TokenizingTextBox.md | 2 +- .../samples/TokenizingTextBoxSample.xaml | 85 +++++++++++++ .../samples/TokenizingTextBoxSample.xaml.cs | 120 ++++++++++++++++++ ...it.WinUI.Controls.TokenizingTextBox.csproj | 2 + .../src/TokenizingTextBox.Selection.cs | 2 +- .../TokenizingTextBoxItem.AutoSuggestBox.cs | 10 +- 6 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml create mode 100644 components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml.cs diff --git a/components/TokenizingTextBox/samples/TokenizingTextBox.md b/components/TokenizingTextBox/samples/TokenizingTextBox.md index c6ac0d5a..0b476174 100644 --- a/components/TokenizingTextBox/samples/TokenizingTextBox.md +++ b/components/TokenizingTextBox/samples/TokenizingTextBox.md @@ -15,7 +15,7 @@ issue-id: 0 The [TokenizingTextBox](/dotnet/api/microsoft.toolkit.uwp.ui.controls.tokenizingtextbox) is an advanced [AutoSuggestBox](/uwp/api/Windows.UI.Xaml.Controls.AutoSuggestBox) which will display selected items as tokens within the textbox. A user can easily see the picked items or remove them easily. -> [!Sample TokenizingTextBoxBasicSample] +> [!Sample TokenizingTextBoxSample] ## Syntax diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml b/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml new file mode 100644 index 00000000..80d6f1ee --- /dev/null +++ b/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml.cs b/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml.cs new file mode 100644 index 00000000..95dabef0 --- /dev/null +++ b/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.WinUI.Controls; + +namespace TokenizingTextBoxExperiment.Samples; + +[ToolkitSample(id: nameof(TokenizingTextBoxSample), "Basic sample", description: $"A sample for showing how to create and use a {nameof(TokenizingTextBox)}.")] +public sealed partial class TokenizingTextBoxSample : Page +{ + public readonly List _samples = new List() + { + new SampleDataType() { Text = "Account", Icon = Symbol.Account }, + new SampleDataType() { Text = "Add friend", Icon = Symbol.AddFriend }, + new SampleDataType() { Text = "Attach", Icon = Symbol.Attach }, + new SampleDataType() { Text = "Attach camera", Icon = Symbol.AttachCamera }, + new SampleDataType() { Text = "Audio", Icon = Symbol.Audio }, + new SampleDataType() { Text = "Block contact", Icon = Symbol.BlockContact }, + new SampleDataType() { Text = "Calculator", Icon = Symbol.Calculator }, + new SampleDataType() { Text = "Calendar", Icon = Symbol.Calendar }, + new SampleDataType() { Text = "Camera", Icon = Symbol.Camera }, + new SampleDataType() { Text = "Contact", Icon = Symbol.Contact }, + new SampleDataType() { Text = "Favorite", Icon = Symbol.Favorite }, + new SampleDataType() { Text = "Link", Icon = Symbol.Link }, + new SampleDataType() { Text = "Mail", Icon = Symbol.Mail }, + new SampleDataType() { Text = "Map", Icon = Symbol.Map }, + new SampleDataType() { Text = "Phone", Icon = Symbol.Phone }, + new SampleDataType() { Text = "Pin", Icon = Symbol.Pin }, + new SampleDataType() { Text = "Rotate", Icon = Symbol.Rotate }, + new SampleDataType() { Text = "Rotate camera", Icon = Symbol.RotateCamera }, + new SampleDataType() { Text = "Send", Icon = Symbol.Send }, + new SampleDataType() { Text = "Tags", Icon = Symbol.Tag }, + new SampleDataType() { Text = "UnFavorite", Icon = Symbol.UnFavorite }, + new SampleDataType() { Text = "UnPin", Icon = Symbol.UnPin }, + new SampleDataType() { Text = "Zoom", Icon = Symbol.Zoom }, + new SampleDataType() { Text = "ZoomIn", Icon = Symbol.ZoomIn }, + new SampleDataType() { Text = "ZoomOut", Icon = Symbol.ZoomOut }, + }; + + public ObservableCollection SelectedTokens { get; set; } + + public TokenizingTextBoxSample() + { + this.InitializeComponent(); + SelectedTokens = new() + { + _samples[0], + _samples[1] + }; + + } + private void TokenItemAdded(TokenizingTextBox sender, object data) + { + // TODO: Add InApp Notification? + if (data is SampleDataType sample) + { + Debug.WriteLine("Added Token: " + sample.Text); + } + else + { + Debug.WriteLine("Added Token: " + data); + } + } + + private void TokenItemRemoved(TokenizingTextBox sender, TokenItemRemovingEventArgs args) + { + if (args.Item is SampleDataType sample) + { + Debug.WriteLine("Removed Token: " + sample.Text); + } + else + { + Debug.WriteLine("Removed Token: " + args.Item); + } + } + + private void TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + currentEdit.Text = TokenBox.Text; + SetSelectedTokenText(); + } + + private void SetSelectedTokenText() + { + selectedItemsString.Text = TokenBox.SelectedTokenText; + } + + private void TokenItemCreating(object sender, TokenItemAddingEventArgs e) + { + // Take the user's text and convert it to our data type (if we have a matching one). +#if !HAS_UNO + e.Item = _samples.FirstOrDefault((item) => item.Text!.Contains(e.TokenText, StringComparison.CurrentCultureIgnoreCase)); +#else + e.Item = _samples.FirstOrDefault((item) => item.Text!.Contains(e.TokenText)); +#endif + // Otherwise, create a new version of our data type + if (e.Item == null) + { + e.Item = new SampleDataType() + { + Text = e.TokenText, + Icon = Symbol.OutlineStar + }; + } + } + + private void TokenBox_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is SampleDataType selectedItem) + { + clickedItem.Text = selectedItem.Text!; + } + } + + private void TokenBox_Loaded(object sender, RoutedEventArgs e) + { + SetSelectedTokenText(); + } +} diff --git a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj index e56a12cd..ac93782b 100644 --- a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj +++ b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj @@ -11,6 +11,8 @@ + + diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs b/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs index d0023d1c..64981df4 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs @@ -372,7 +372,7 @@ private string PrepareSelectionForClipboard() if (item is ITokenStringContainer) { // grab any selected text - if (ContainerFromItem(item) is TokenizingTextBoxItem pretoken) + if (ContainerFromItem(item) is TokenizingTextBoxItem pretoken && pretoken._autoSuggestTextBox != null) { tokenString += pretoken._autoSuggestTextBox.Text.Substring( pretoken._autoSuggestTextBox.SelectionStart, diff --git a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs index 0d3b2842..305d91cc 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBoxItem.AutoSuggestBox.cs @@ -124,11 +124,11 @@ private void OnApplyTemplateAutoSuggestBox(AutoSuggestBox auto) RelativeSource = new RelativeSource() { Mode = RelativeSourceMode.TemplatedParent } }; + #if !HAS_UNO var iconSourceElement = new IconSourceElement(); - iconSourceElement.SetBinding(IconSourceElement.IconSourceProperty, iconBinding); - _autoSuggestBox.QueryIcon = iconSourceElement; + #endif } } } @@ -325,12 +325,14 @@ async void AutoSuggestTextBox_TextChangingAsync(TextBox o, TextBoxTextChangingEv private void AutoSuggestTextBox_SelectionChanging(TextBox sender, TextBoxSelectionChangingEventArgs args) { +#if !HAS_UNO _isSelectedFocusOnFirstCharacter = args.SelectionLength > 0 && args.SelectionStart == 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); +#endif } private void AutoSuggestTextBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) @@ -383,8 +385,6 @@ private void UpdateTokensCounter(TokenizingTextBoxItem ttbi) { if (_autoSuggestBox?.FindDescendant(PART_TokensCounter) is TextBlock maxTokensCounter) { - - void OnTokenCountChanged(TokenizingTextBox ttb, object? value = null) { if (ttb.ItemsSource is InterspersedObservableCollection itemsSource) @@ -432,5 +432,5 @@ internal void UpdateQueryIconVisibility() } } } - #endregion +#endregion } From 605d9f09624d430ff6ef4298835190288308862d Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Wed, 7 Jun 2023 14:23:25 +0200 Subject: [PATCH 15/25] Uno improvements --- .../src/TokenizingTextBox.cs | 8 +++-- .../src/TokenizingTextBox.xaml | 33 +++++++++++-------- .../TokenizingTextBoxItem.AutoSuggestBox.xaml | 27 ++++++++------- 3 files changed, 40 insertions(+), 28 deletions(-) diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.cs b/components/TokenizingTextBox/src/TokenizingTextBox.cs index 9dfa4758..cbc5b2f4 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBox.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBox.cs @@ -221,11 +221,13 @@ protected override void OnApplyTemplate() selectAllMenuItem.Click += (s, e) => this.SelectAllTokensAndText(); var menuFlyout = new MenuFlyout(); menuFlyout.Items.Add(selectAllMenuItem); + +#if !HAS_UNO if (IsXamlRootAvailable && XamlRoot != null) { menuFlyout.XamlRoot = XamlRoot; } - +#endif ContextFlyout = menuFlyout; } @@ -399,11 +401,13 @@ protected override void PrepareContainerForItemOverride(DependencyObject element removeMenuItem.Click += (s, e) => TokenizingTextBoxItem_ClearClicked(tokenitem, null); menuFlyout.Items.Add(removeMenuItem); + +#if !HAS_UNO if (IsXamlRootAvailable && XamlRoot != null) { menuFlyout.XamlRoot = XamlRoot; } - +#endif var selectAllMenuItem = new MenuFlyoutItem { // TO DO: Localize - "WCT_TokenizingTextBox_MenuFlyout_SelectAll".GetLocalized("Microsoft.Toolkit.Uwp.UI.Controls.Input/Resources") diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.xaml b/components/TokenizingTextBox/src/TokenizingTextBox.xaml index d6914ffa..54a3c70c 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBox.xaml +++ b/components/TokenizingTextBox/src/TokenizingTextBox.xaml @@ -1,7 +1,8 @@ - + xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:win="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> @@ -29,27 +30,31 @@ - + - + - + - + - + - + @@ -145,15 +150,15 @@ + xmlns:muxc="using:Microsoft.UI.Xaml.Controls" + xmlns:win="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> 10,3,6,6 @@ -77,7 +78,7 @@ - + - + @@ -330,13 +333,13 @@ Grid.Row="1" Margin="{TemplateBinding BorderThickness}" Padding="{TemplateBinding Padding}" - AutomationProperties.AccessibilityView="Raw" + win:AutomationProperties.AccessibilityView="Raw" + win:IsDeferredScrollingEnabled="{TemplateBinding ScrollViewer.IsDeferredScrollingEnabled}" + win:IsHorizontalRailEnabled="{TemplateBinding ScrollViewer.IsHorizontalRailEnabled}" + win:IsVerticalRailEnabled="{TemplateBinding ScrollViewer.IsVerticalRailEnabled}" HorizontalScrollBarVisibility="{TemplateBinding ScrollViewer.HorizontalScrollBarVisibility}" HorizontalScrollMode="{TemplateBinding ScrollViewer.HorizontalScrollMode}" - IsDeferredScrollingEnabled="{TemplateBinding ScrollViewer.IsDeferredScrollingEnabled}" - IsHorizontalRailEnabled="{TemplateBinding ScrollViewer.IsHorizontalRailEnabled}" IsTabStop="False" - IsVerticalRailEnabled="{TemplateBinding ScrollViewer.IsVerticalRailEnabled}" VerticalScrollBarVisibility="{TemplateBinding ScrollViewer.VerticalScrollBarVisibility}" VerticalScrollMode="{TemplateBinding ScrollViewer.VerticalScrollMode}" ZoomMode="Disabled" /> @@ -356,7 +359,7 @@ Height="28" Padding="{ThemeResource HelperButtonThemePadding}" VerticalAlignment="Stretch" - AutomationProperties.AccessibilityView="Raw" + win:AutomationProperties.AccessibilityView="Raw" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" FontSize="{TemplateBinding FontSize}" @@ -380,7 +383,7 @@ Margin="0,0,2,0" Padding="0" VerticalAlignment="Stretch" - AutomationProperties.AccessibilityView="Raw" + win:AutomationProperties.AccessibilityView="Raw" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" FontSize="{TemplateBinding FontSize}" @@ -449,9 +452,9 @@ Date: Wed, 7 Jun 2023 14:24:11 +0200 Subject: [PATCH 16/25] Remove local WrapPanel --- ...it.WinUI.Controls.TokenizingTextBox.csproj | 4 - .../src/WrapPanel/StretchChild.cs | 21 -- .../src/WrapPanel/WrapPanel.Data.cs | 92 ------ .../src/WrapPanel/WrapPanel.cs | 264 ------------------ 4 files changed, 381 deletions(-) delete mode 100644 components/TokenizingTextBox/src/WrapPanel/StretchChild.cs delete mode 100644 components/TokenizingTextBox/src/WrapPanel/WrapPanel.Data.cs delete mode 100644 components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs diff --git a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj index ac93782b..c5b295c9 100644 --- a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj +++ b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj @@ -15,10 +15,6 @@ - - - - diff --git a/components/TokenizingTextBox/src/WrapPanel/StretchChild.cs b/components/TokenizingTextBox/src/WrapPanel/StretchChild.cs deleted file mode 100644 index 754fa2ba..00000000 --- a/components/TokenizingTextBox/src/WrapPanel/StretchChild.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -namespace CommunityToolkit.WinUI.Controls; - -/// -/// Options for how to calculate the layout of items. -/// -public enum StretchChild -{ - /// - /// Don't apply any additional stretching logic - /// - None, - - /// - /// Make the last child stretch to fill the available space - /// - Last -} diff --git a/components/TokenizingTextBox/src/WrapPanel/WrapPanel.Data.cs b/components/TokenizingTextBox/src/WrapPanel/WrapPanel.Data.cs deleted file mode 100644 index 2008c701..00000000 --- a/components/TokenizingTextBox/src/WrapPanel/WrapPanel.Data.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -namespace CommunityToolkit.WinUI.Controls; - -/// -/// WrapPanel is a panel that position child control vertically or horizontally based on the orientation and when max width/ max height is received a new row(in case of horizontal) or column (in case of vertical) is created to fit new controls. -/// -public partial class WrapPanel -{ - [DebuggerDisplay("U = {U} V = {V}")] - private struct UvMeasure - { - internal static UvMeasure Zero => default; - - internal double U { get; set; } - - internal double V { get; set; } - - public UvMeasure(Orientation orientation, Size size) - : this(orientation, size.Width, size.Height) - { - } - - public UvMeasure(Orientation orientation, double width, double height) - { - if (orientation == Orientation.Horizontal) - { - U = width; - V = height; - } - else - { - U = height; - V = width; - } - } - - public UvMeasure Add(double u, double v) - => new UvMeasure { U = U + u, V = V + v }; - - public UvMeasure Add(UvMeasure measure) - => Add(measure.U, measure.V); - - public Size ToSize(Orientation orientation) - => orientation == Orientation.Horizontal ? new Size(U, V) : new Size(V, U); - } - - private struct UvRect - { - public UvMeasure Position { get; set; } - - public UvMeasure Size { get; set; } - - public Rect ToRect(Orientation orientation) => orientation switch - { - Orientation.Vertical => new Rect(Position.V, Position.U, Size.V, Size.U), - Orientation.Horizontal => new Rect(Position.U, Position.V, Size.U, Size.V), - _ => ThrowArgumentException() - }; - - private static Rect ThrowArgumentException() => throw new ArgumentException("The input orientation is not valid."); - } - - private struct Row - { - public Row(List childrenRects, UvMeasure size) - { - ChildrenRects = childrenRects; - Size = size; - } - - public List ChildrenRects { get; } - - public UvMeasure Size { get; set; } - - public UvRect Rect => ChildrenRects.Count > 0 ? - new UvRect { Position = ChildrenRects[0].Position, Size = Size } : - new UvRect { Position = UvMeasure.Zero, Size = Size }; - - public void Add(UvMeasure position, UvMeasure size) - { - ChildrenRects.Add(new UvRect { Position = position, Size = size }); - Size = new UvMeasure - { - U = position.U + size.U, - V = Math.Max(Size.V, size.V), - }; - } - } -} diff --git a/components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs b/components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs deleted file mode 100644 index 27035df6..00000000 --- a/components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs +++ /dev/null @@ -1,264 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -namespace CommunityToolkit.WinUI.Controls; - -/// -/// WrapPanel is a panel that position child control vertically or horizontally based on the orientation and when max width / max height is reached a new row (in case of horizontal) or column (in case of vertical) is created to fit new controls. -/// -public partial class WrapPanel : Panel -{ - /// - /// Gets or sets a uniform Horizontal distance (in pixels) between items when is set to Horizontal, - /// or between columns of items when is set to Vertical. - /// - public double HorizontalSpacing - { - get { return (double)GetValue(HorizontalSpacingProperty); } - set { SetValue(HorizontalSpacingProperty, value); } - } - - /// - /// Identifies the dependency property. - /// - public static readonly DependencyProperty HorizontalSpacingProperty = - DependencyProperty.Register( - nameof(HorizontalSpacing), - typeof(double), - typeof(WrapPanel), - new PropertyMetadata(0d, LayoutPropertyChanged)); - - /// - /// Gets or sets a uniform Vertical distance (in pixels) between items when is set to Vertical, - /// or between rows of items when is set to Horizontal. - /// - public double VerticalSpacing - { - get { return (double)GetValue(VerticalSpacingProperty); } - set { SetValue(VerticalSpacingProperty, value); } - } - - /// - /// Identifies the dependency property. - /// - public static readonly DependencyProperty VerticalSpacingProperty = - DependencyProperty.Register( - nameof(VerticalSpacing), - typeof(double), - typeof(WrapPanel), - new PropertyMetadata(0d, LayoutPropertyChanged)); - - /// - /// Gets or sets the orientation of the WrapPanel. - /// Horizontal means that child controls will be added horizontally until the width of the panel is reached, then a new row is added to add new child controls. - /// Vertical means that children will be added vertically until the height of the panel is reached, then a new column is added. - /// - public Orientation Orientation - { - get { return (Orientation)GetValue(OrientationProperty); } - set { SetValue(OrientationProperty, value); } - } - - /// - /// Identifies the dependency property. - /// - public static readonly DependencyProperty OrientationProperty = - DependencyProperty.Register( - nameof(Orientation), - typeof(Orientation), - typeof(WrapPanel), - new PropertyMetadata(Orientation.Horizontal, LayoutPropertyChanged)); - - /// - /// Gets or sets the distance between the border and its child object. - /// - /// - /// The dimensions of the space between the border and its child as a Thickness value. - /// Thickness is a structure that stores dimension values using pixel measures. - /// - public Thickness Padding - { - get { return (Thickness)GetValue(PaddingProperty); } - set { SetValue(PaddingProperty, value); } - } - - /// - /// Identifies the Padding dependency property. - /// - /// The identifier for the dependency property. - public static readonly DependencyProperty PaddingProperty = - DependencyProperty.Register( - nameof(Padding), - typeof(Thickness), - typeof(WrapPanel), - new PropertyMetadata(default(Thickness), LayoutPropertyChanged)); - - /// - /// Gets or sets a value indicating how to arrange child items - /// - public StretchChild StretchChild - { - get { return (StretchChild)GetValue(StretchChildProperty); } - set { SetValue(StretchChildProperty, value); } - } - - /// - /// Identifies the dependency property. - /// - /// The identifier for the dependency property. - public static readonly DependencyProperty StretchChildProperty = - DependencyProperty.Register( - nameof(StretchChild), - typeof(StretchChild), - typeof(WrapPanel), - new PropertyMetadata(StretchChild.None, LayoutPropertyChanged)); - - private static void LayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is WrapPanel wp) - { - wp.InvalidateMeasure(); - wp.InvalidateArrange(); - } - } - - private readonly List _rows = new List(); - - /// - protected override Size MeasureOverride(Size availableSize) - { - var childAvailableSize = new Size( - availableSize.Width - Padding.Left - Padding.Right, - availableSize.Height - Padding.Top - Padding.Bottom); - - if (double.IsInfinity(childAvailableSize.Width) || double.IsInfinity(childAvailableSize.Height)) - { - childAvailableSize.Width = 100; - childAvailableSize.Height = 100; - } - foreach (var child in Children) - { - child.Measure(availableSize); - } - - var requiredSize = UpdateRows(availableSize); - return requiredSize; - } - - /// - protected override Size ArrangeOverride(Size finalSize) - { - if ((Orientation == Orientation.Horizontal && finalSize.Width < DesiredSize.Width) || - (Orientation == Orientation.Vertical && finalSize.Height < DesiredSize.Height)) - { - // We haven't received our desired size. We need to refresh the rows. - UpdateRows(finalSize); - } - - if (_rows.Count > 0) - { - // Now that we have all the data, we do the actual arrange pass - var childIndex = 0; - foreach (var row in _rows) - { - foreach (var rect in row.ChildrenRects) - { - var child = Children[childIndex++]; - while (child.Visibility == Visibility.Collapsed) - { - // Collapsed children are not added into the rows, - // we skip them. - child = Children[childIndex++]; - } - - var arrangeRect = new UvRect - { - Position = rect.Position, - Size = new UvMeasure { U = rect.Size.U, V = row.Size.V }, - }; - - var finalRect = arrangeRect.ToRect(Orientation); - child.Arrange(finalRect); - } - } - } - - return finalSize; - } - - private Size UpdateRows(Size availableSize) - { - _rows.Clear(); - - var paddingStart = new UvMeasure(Orientation, Padding.Left, Padding.Top); - var paddingEnd = new UvMeasure(Orientation, Padding.Right, Padding.Bottom); - - if (Children.Count == 0) - { - var emptySize = paddingStart.Add(paddingEnd).ToSize(Orientation); - return emptySize; - } - - var parentMeasure = new UvMeasure(Orientation, availableSize.Width, availableSize.Height); - var spacingMeasure = new UvMeasure(Orientation, HorizontalSpacing, VerticalSpacing); - var position = new UvMeasure(Orientation, Padding.Left, Padding.Top); - - var currentRow = new Row(new List(), default); - var finalMeasure = new UvMeasure(Orientation, width: 0.0, height: 0.0); - void Arrange(UIElement child, bool isLast = false) - { - if (child.Visibility == Visibility.Collapsed) - { - return; // if an item is collapsed, avoid adding the spacing - } - - var desiredMeasure = new UvMeasure(Orientation, child.DesiredSize); - if ((desiredMeasure.U + position.U + paddingEnd.U) > parentMeasure.U) - { - // next row! - position.U = paddingStart.U; - position.V += currentRow.Size.V + spacingMeasure.V; - - _rows.Add(currentRow); - currentRow = new Row(new List(), default); - } - - // Stretch the last item to fill the available space - if (isLast) - { - desiredMeasure.U = parentMeasure.U - position.U; - } - - currentRow.Add(position, desiredMeasure); - - // adjust the location for the next items - position.U += desiredMeasure.U + spacingMeasure.U; - finalMeasure.U = Math.Max(finalMeasure.U, position.U); - } - - var lastIndex = Children.Count - 1; - for (var i = 0; i < lastIndex; i++) - { - Arrange(Children[i]); - } - - Arrange(Children[lastIndex], StretchChild == StretchChild.Last); - if (currentRow.ChildrenRects.Count > 0) - { - _rows.Add(currentRow); - } - - if (_rows.Count == 0) - { - var emptySize = paddingStart.Add(paddingEnd).ToSize(Orientation); - return emptySize; - } - - // Get max V here before computing final rect - var lastRowRect = _rows.Last().Rect; - finalMeasure.V = lastRowRect.Position.V + lastRowRect.Size.V; - var finalRect = finalMeasure.Add(paddingEnd).ToSize(Orientation); - return finalRect; - } -} From bf51fc6cadc8d7d3b35a38251d0e12500a02c522 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Wed, 7 Jun 2023 14:30:23 +0200 Subject: [PATCH 17/25] Revert "Remove local WrapPanel" This reverts commit f982bcaca5a6740a9faa753c6d632fb47e111c11. --- ...it.WinUI.Controls.TokenizingTextBox.csproj | 4 + .../src/WrapPanel/StretchChild.cs | 21 ++ .../src/WrapPanel/WrapPanel.Data.cs | 92 ++++++ .../src/WrapPanel/WrapPanel.cs | 264 ++++++++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 components/TokenizingTextBox/src/WrapPanel/StretchChild.cs create mode 100644 components/TokenizingTextBox/src/WrapPanel/WrapPanel.Data.cs create mode 100644 components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs diff --git a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj index c5b295c9..ac93782b 100644 --- a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj +++ b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj @@ -15,6 +15,10 @@ + + + + diff --git a/components/TokenizingTextBox/src/WrapPanel/StretchChild.cs b/components/TokenizingTextBox/src/WrapPanel/StretchChild.cs new file mode 100644 index 00000000..754fa2ba --- /dev/null +++ b/components/TokenizingTextBox/src/WrapPanel/StretchChild.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// Options for how to calculate the layout of items. +/// +public enum StretchChild +{ + /// + /// Don't apply any additional stretching logic + /// + None, + + /// + /// Make the last child stretch to fill the available space + /// + Last +} diff --git a/components/TokenizingTextBox/src/WrapPanel/WrapPanel.Data.cs b/components/TokenizingTextBox/src/WrapPanel/WrapPanel.Data.cs new file mode 100644 index 00000000..2008c701 --- /dev/null +++ b/components/TokenizingTextBox/src/WrapPanel/WrapPanel.Data.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// WrapPanel is a panel that position child control vertically or horizontally based on the orientation and when max width/ max height is received a new row(in case of horizontal) or column (in case of vertical) is created to fit new controls. +/// +public partial class WrapPanel +{ + [DebuggerDisplay("U = {U} V = {V}")] + private struct UvMeasure + { + internal static UvMeasure Zero => default; + + internal double U { get; set; } + + internal double V { get; set; } + + public UvMeasure(Orientation orientation, Size size) + : this(orientation, size.Width, size.Height) + { + } + + public UvMeasure(Orientation orientation, double width, double height) + { + if (orientation == Orientation.Horizontal) + { + U = width; + V = height; + } + else + { + U = height; + V = width; + } + } + + public UvMeasure Add(double u, double v) + => new UvMeasure { U = U + u, V = V + v }; + + public UvMeasure Add(UvMeasure measure) + => Add(measure.U, measure.V); + + public Size ToSize(Orientation orientation) + => orientation == Orientation.Horizontal ? new Size(U, V) : new Size(V, U); + } + + private struct UvRect + { + public UvMeasure Position { get; set; } + + public UvMeasure Size { get; set; } + + public Rect ToRect(Orientation orientation) => orientation switch + { + Orientation.Vertical => new Rect(Position.V, Position.U, Size.V, Size.U), + Orientation.Horizontal => new Rect(Position.U, Position.V, Size.U, Size.V), + _ => ThrowArgumentException() + }; + + private static Rect ThrowArgumentException() => throw new ArgumentException("The input orientation is not valid."); + } + + private struct Row + { + public Row(List childrenRects, UvMeasure size) + { + ChildrenRects = childrenRects; + Size = size; + } + + public List ChildrenRects { get; } + + public UvMeasure Size { get; set; } + + public UvRect Rect => ChildrenRects.Count > 0 ? + new UvRect { Position = ChildrenRects[0].Position, Size = Size } : + new UvRect { Position = UvMeasure.Zero, Size = Size }; + + public void Add(UvMeasure position, UvMeasure size) + { + ChildrenRects.Add(new UvRect { Position = position, Size = size }); + Size = new UvMeasure + { + U = position.U + size.U, + V = Math.Max(Size.V, size.V), + }; + } + } +} diff --git a/components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs b/components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs new file mode 100644 index 00000000..27035df6 --- /dev/null +++ b/components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs @@ -0,0 +1,264 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// WrapPanel is a panel that position child control vertically or horizontally based on the orientation and when max width / max height is reached a new row (in case of horizontal) or column (in case of vertical) is created to fit new controls. +/// +public partial class WrapPanel : Panel +{ + /// + /// Gets or sets a uniform Horizontal distance (in pixels) between items when is set to Horizontal, + /// or between columns of items when is set to Vertical. + /// + public double HorizontalSpacing + { + get { return (double)GetValue(HorizontalSpacingProperty); } + set { SetValue(HorizontalSpacingProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty HorizontalSpacingProperty = + DependencyProperty.Register( + nameof(HorizontalSpacing), + typeof(double), + typeof(WrapPanel), + new PropertyMetadata(0d, LayoutPropertyChanged)); + + /// + /// Gets or sets a uniform Vertical distance (in pixels) between items when is set to Vertical, + /// or between rows of items when is set to Horizontal. + /// + public double VerticalSpacing + { + get { return (double)GetValue(VerticalSpacingProperty); } + set { SetValue(VerticalSpacingProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty VerticalSpacingProperty = + DependencyProperty.Register( + nameof(VerticalSpacing), + typeof(double), + typeof(WrapPanel), + new PropertyMetadata(0d, LayoutPropertyChanged)); + + /// + /// Gets or sets the orientation of the WrapPanel. + /// Horizontal means that child controls will be added horizontally until the width of the panel is reached, then a new row is added to add new child controls. + /// Vertical means that children will be added vertically until the height of the panel is reached, then a new column is added. + /// + public Orientation Orientation + { + get { return (Orientation)GetValue(OrientationProperty); } + set { SetValue(OrientationProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty OrientationProperty = + DependencyProperty.Register( + nameof(Orientation), + typeof(Orientation), + typeof(WrapPanel), + new PropertyMetadata(Orientation.Horizontal, LayoutPropertyChanged)); + + /// + /// Gets or sets the distance between the border and its child object. + /// + /// + /// The dimensions of the space between the border and its child as a Thickness value. + /// Thickness is a structure that stores dimension values using pixel measures. + /// + public Thickness Padding + { + get { return (Thickness)GetValue(PaddingProperty); } + set { SetValue(PaddingProperty, value); } + } + + /// + /// Identifies the Padding dependency property. + /// + /// The identifier for the dependency property. + public static readonly DependencyProperty PaddingProperty = + DependencyProperty.Register( + nameof(Padding), + typeof(Thickness), + typeof(WrapPanel), + new PropertyMetadata(default(Thickness), LayoutPropertyChanged)); + + /// + /// Gets or sets a value indicating how to arrange child items + /// + public StretchChild StretchChild + { + get { return (StretchChild)GetValue(StretchChildProperty); } + set { SetValue(StretchChildProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + /// The identifier for the dependency property. + public static readonly DependencyProperty StretchChildProperty = + DependencyProperty.Register( + nameof(StretchChild), + typeof(StretchChild), + typeof(WrapPanel), + new PropertyMetadata(StretchChild.None, LayoutPropertyChanged)); + + private static void LayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is WrapPanel wp) + { + wp.InvalidateMeasure(); + wp.InvalidateArrange(); + } + } + + private readonly List _rows = new List(); + + /// + protected override Size MeasureOverride(Size availableSize) + { + var childAvailableSize = new Size( + availableSize.Width - Padding.Left - Padding.Right, + availableSize.Height - Padding.Top - Padding.Bottom); + + if (double.IsInfinity(childAvailableSize.Width) || double.IsInfinity(childAvailableSize.Height)) + { + childAvailableSize.Width = 100; + childAvailableSize.Height = 100; + } + foreach (var child in Children) + { + child.Measure(availableSize); + } + + var requiredSize = UpdateRows(availableSize); + return requiredSize; + } + + /// + protected override Size ArrangeOverride(Size finalSize) + { + if ((Orientation == Orientation.Horizontal && finalSize.Width < DesiredSize.Width) || + (Orientation == Orientation.Vertical && finalSize.Height < DesiredSize.Height)) + { + // We haven't received our desired size. We need to refresh the rows. + UpdateRows(finalSize); + } + + if (_rows.Count > 0) + { + // Now that we have all the data, we do the actual arrange pass + var childIndex = 0; + foreach (var row in _rows) + { + foreach (var rect in row.ChildrenRects) + { + var child = Children[childIndex++]; + while (child.Visibility == Visibility.Collapsed) + { + // Collapsed children are not added into the rows, + // we skip them. + child = Children[childIndex++]; + } + + var arrangeRect = new UvRect + { + Position = rect.Position, + Size = new UvMeasure { U = rect.Size.U, V = row.Size.V }, + }; + + var finalRect = arrangeRect.ToRect(Orientation); + child.Arrange(finalRect); + } + } + } + + return finalSize; + } + + private Size UpdateRows(Size availableSize) + { + _rows.Clear(); + + var paddingStart = new UvMeasure(Orientation, Padding.Left, Padding.Top); + var paddingEnd = new UvMeasure(Orientation, Padding.Right, Padding.Bottom); + + if (Children.Count == 0) + { + var emptySize = paddingStart.Add(paddingEnd).ToSize(Orientation); + return emptySize; + } + + var parentMeasure = new UvMeasure(Orientation, availableSize.Width, availableSize.Height); + var spacingMeasure = new UvMeasure(Orientation, HorizontalSpacing, VerticalSpacing); + var position = new UvMeasure(Orientation, Padding.Left, Padding.Top); + + var currentRow = new Row(new List(), default); + var finalMeasure = new UvMeasure(Orientation, width: 0.0, height: 0.0); + void Arrange(UIElement child, bool isLast = false) + { + if (child.Visibility == Visibility.Collapsed) + { + return; // if an item is collapsed, avoid adding the spacing + } + + var desiredMeasure = new UvMeasure(Orientation, child.DesiredSize); + if ((desiredMeasure.U + position.U + paddingEnd.U) > parentMeasure.U) + { + // next row! + position.U = paddingStart.U; + position.V += currentRow.Size.V + spacingMeasure.V; + + _rows.Add(currentRow); + currentRow = new Row(new List(), default); + } + + // Stretch the last item to fill the available space + if (isLast) + { + desiredMeasure.U = parentMeasure.U - position.U; + } + + currentRow.Add(position, desiredMeasure); + + // adjust the location for the next items + position.U += desiredMeasure.U + spacingMeasure.U; + finalMeasure.U = Math.Max(finalMeasure.U, position.U); + } + + var lastIndex = Children.Count - 1; + for (var i = 0; i < lastIndex; i++) + { + Arrange(Children[i]); + } + + Arrange(Children[lastIndex], StretchChild == StretchChild.Last); + if (currentRow.ChildrenRects.Count > 0) + { + _rows.Add(currentRow); + } + + if (_rows.Count == 0) + { + var emptySize = paddingStart.Add(paddingEnd).ToSize(Orientation); + return emptySize; + } + + // Get max V here before computing final rect + var lastRowRect = _rows.Last().Rect; + finalMeasure.V = lastRowRect.Position.V + lastRowRect.Size.V; + var finalRect = finalMeasure.Add(paddingEnd).ToSize(Orientation); + return finalRect; + } +} From 48e34dd05d54ba494b6239a341788e8d0d838b60 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Wed, 7 Jun 2023 14:57:10 +0200 Subject: [PATCH 18/25] Fix test --- .../TokenizingTextBox/src/TokenizingTextBox.Selection.cs | 5 ++++- .../tests/Test_TokenizingTextBox_AutomationPeer.cs | 7 +++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs b/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs index 64981df4..c3dddfd5 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs @@ -15,6 +15,8 @@ namespace CommunityToolkit.WinUI.Controls; /// /// Methods related to Selection of items in the . /// + +#pragma warning disable CS8602 public partial class TokenizingTextBox { private enum MoveDirection @@ -387,4 +389,5 @@ private string PrepareSelectionForClipboard() return tokenString; } -} +} +#pragma warning restore CS8602 diff --git a/components/TokenizingTextBox/tests/Test_TokenizingTextBox_AutomationPeer.cs b/components/TokenizingTextBox/tests/Test_TokenizingTextBox_AutomationPeer.cs index ae9069bf..5b90b3fd 100644 --- a/components/TokenizingTextBox/tests/Test_TokenizingTextBox_AutomationPeer.cs +++ b/components/TokenizingTextBox/tests/Test_TokenizingTextBox_AutomationPeer.cs @@ -61,7 +61,6 @@ await App.DispatcherQueue.EnqueueAsync(async () => tokenizingTextBox .SelectAllTokensAndText(); // Will be 3 items due to the `AndText` that will select an empty text item. - var tokenizingTextBoxAutomationPeer = FrameworkElementAutomationPeer.CreatePeerForElement(tokenizingTextBox) as TokenizingTextBoxAutomationPeer; @@ -100,18 +99,18 @@ await App.DispatcherQueue.EnqueueAsync(async () => Assert.ThrowsException(() => { - tokenizingTextBoxAutomationPeer.SetValue(expectedValue); + tokenizingTextBoxAutomationPeer!.SetValue(expectedValue); }); }); } public class TokenizingTextBoxTestItem { - public string Title { get; set; } + public string? Title { get; set; } public override string ToString() { - return Title; + return Title!; } } } From ecabf8e3a2ab3489c39acb5517cb1c9f3f0e1607 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Wed, 7 Jun 2023 14:58:01 +0200 Subject: [PATCH 19/25] Remove local WrapPanel --- ...it.WinUI.Controls.TokenizingTextBox.csproj | 7 +- .../src/WrapPanel/StretchChild.cs | 21 -- .../src/WrapPanel/WrapPanel.Data.cs | 92 ------ .../src/WrapPanel/WrapPanel.cs | 264 ------------------ 4 files changed, 1 insertion(+), 383 deletions(-) delete mode 100644 components/TokenizingTextBox/src/WrapPanel/StretchChild.cs delete mode 100644 components/TokenizingTextBox/src/WrapPanel/WrapPanel.Data.cs delete mode 100644 components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs diff --git a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj index ac93782b..9b028954 100644 --- a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj +++ b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj @@ -11,12 +11,7 @@ - - - - - - + diff --git a/components/TokenizingTextBox/src/WrapPanel/StretchChild.cs b/components/TokenizingTextBox/src/WrapPanel/StretchChild.cs deleted file mode 100644 index 754fa2ba..00000000 --- a/components/TokenizingTextBox/src/WrapPanel/StretchChild.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -namespace CommunityToolkit.WinUI.Controls; - -/// -/// Options for how to calculate the layout of items. -/// -public enum StretchChild -{ - /// - /// Don't apply any additional stretching logic - /// - None, - - /// - /// Make the last child stretch to fill the available space - /// - Last -} diff --git a/components/TokenizingTextBox/src/WrapPanel/WrapPanel.Data.cs b/components/TokenizingTextBox/src/WrapPanel/WrapPanel.Data.cs deleted file mode 100644 index 2008c701..00000000 --- a/components/TokenizingTextBox/src/WrapPanel/WrapPanel.Data.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -namespace CommunityToolkit.WinUI.Controls; - -/// -/// WrapPanel is a panel that position child control vertically or horizontally based on the orientation and when max width/ max height is received a new row(in case of horizontal) or column (in case of vertical) is created to fit new controls. -/// -public partial class WrapPanel -{ - [DebuggerDisplay("U = {U} V = {V}")] - private struct UvMeasure - { - internal static UvMeasure Zero => default; - - internal double U { get; set; } - - internal double V { get; set; } - - public UvMeasure(Orientation orientation, Size size) - : this(orientation, size.Width, size.Height) - { - } - - public UvMeasure(Orientation orientation, double width, double height) - { - if (orientation == Orientation.Horizontal) - { - U = width; - V = height; - } - else - { - U = height; - V = width; - } - } - - public UvMeasure Add(double u, double v) - => new UvMeasure { U = U + u, V = V + v }; - - public UvMeasure Add(UvMeasure measure) - => Add(measure.U, measure.V); - - public Size ToSize(Orientation orientation) - => orientation == Orientation.Horizontal ? new Size(U, V) : new Size(V, U); - } - - private struct UvRect - { - public UvMeasure Position { get; set; } - - public UvMeasure Size { get; set; } - - public Rect ToRect(Orientation orientation) => orientation switch - { - Orientation.Vertical => new Rect(Position.V, Position.U, Size.V, Size.U), - Orientation.Horizontal => new Rect(Position.U, Position.V, Size.U, Size.V), - _ => ThrowArgumentException() - }; - - private static Rect ThrowArgumentException() => throw new ArgumentException("The input orientation is not valid."); - } - - private struct Row - { - public Row(List childrenRects, UvMeasure size) - { - ChildrenRects = childrenRects; - Size = size; - } - - public List ChildrenRects { get; } - - public UvMeasure Size { get; set; } - - public UvRect Rect => ChildrenRects.Count > 0 ? - new UvRect { Position = ChildrenRects[0].Position, Size = Size } : - new UvRect { Position = UvMeasure.Zero, Size = Size }; - - public void Add(UvMeasure position, UvMeasure size) - { - ChildrenRects.Add(new UvRect { Position = position, Size = size }); - Size = new UvMeasure - { - U = position.U + size.U, - V = Math.Max(Size.V, size.V), - }; - } - } -} diff --git a/components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs b/components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs deleted file mode 100644 index 27035df6..00000000 --- a/components/TokenizingTextBox/src/WrapPanel/WrapPanel.cs +++ /dev/null @@ -1,264 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -namespace CommunityToolkit.WinUI.Controls; - -/// -/// WrapPanel is a panel that position child control vertically or horizontally based on the orientation and when max width / max height is reached a new row (in case of horizontal) or column (in case of vertical) is created to fit new controls. -/// -public partial class WrapPanel : Panel -{ - /// - /// Gets or sets a uniform Horizontal distance (in pixels) between items when is set to Horizontal, - /// or between columns of items when is set to Vertical. - /// - public double HorizontalSpacing - { - get { return (double)GetValue(HorizontalSpacingProperty); } - set { SetValue(HorizontalSpacingProperty, value); } - } - - /// - /// Identifies the dependency property. - /// - public static readonly DependencyProperty HorizontalSpacingProperty = - DependencyProperty.Register( - nameof(HorizontalSpacing), - typeof(double), - typeof(WrapPanel), - new PropertyMetadata(0d, LayoutPropertyChanged)); - - /// - /// Gets or sets a uniform Vertical distance (in pixels) between items when is set to Vertical, - /// or between rows of items when is set to Horizontal. - /// - public double VerticalSpacing - { - get { return (double)GetValue(VerticalSpacingProperty); } - set { SetValue(VerticalSpacingProperty, value); } - } - - /// - /// Identifies the dependency property. - /// - public static readonly DependencyProperty VerticalSpacingProperty = - DependencyProperty.Register( - nameof(VerticalSpacing), - typeof(double), - typeof(WrapPanel), - new PropertyMetadata(0d, LayoutPropertyChanged)); - - /// - /// Gets or sets the orientation of the WrapPanel. - /// Horizontal means that child controls will be added horizontally until the width of the panel is reached, then a new row is added to add new child controls. - /// Vertical means that children will be added vertically until the height of the panel is reached, then a new column is added. - /// - public Orientation Orientation - { - get { return (Orientation)GetValue(OrientationProperty); } - set { SetValue(OrientationProperty, value); } - } - - /// - /// Identifies the dependency property. - /// - public static readonly DependencyProperty OrientationProperty = - DependencyProperty.Register( - nameof(Orientation), - typeof(Orientation), - typeof(WrapPanel), - new PropertyMetadata(Orientation.Horizontal, LayoutPropertyChanged)); - - /// - /// Gets or sets the distance between the border and its child object. - /// - /// - /// The dimensions of the space between the border and its child as a Thickness value. - /// Thickness is a structure that stores dimension values using pixel measures. - /// - public Thickness Padding - { - get { return (Thickness)GetValue(PaddingProperty); } - set { SetValue(PaddingProperty, value); } - } - - /// - /// Identifies the Padding dependency property. - /// - /// The identifier for the dependency property. - public static readonly DependencyProperty PaddingProperty = - DependencyProperty.Register( - nameof(Padding), - typeof(Thickness), - typeof(WrapPanel), - new PropertyMetadata(default(Thickness), LayoutPropertyChanged)); - - /// - /// Gets or sets a value indicating how to arrange child items - /// - public StretchChild StretchChild - { - get { return (StretchChild)GetValue(StretchChildProperty); } - set { SetValue(StretchChildProperty, value); } - } - - /// - /// Identifies the dependency property. - /// - /// The identifier for the dependency property. - public static readonly DependencyProperty StretchChildProperty = - DependencyProperty.Register( - nameof(StretchChild), - typeof(StretchChild), - typeof(WrapPanel), - new PropertyMetadata(StretchChild.None, LayoutPropertyChanged)); - - private static void LayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is WrapPanel wp) - { - wp.InvalidateMeasure(); - wp.InvalidateArrange(); - } - } - - private readonly List _rows = new List(); - - /// - protected override Size MeasureOverride(Size availableSize) - { - var childAvailableSize = new Size( - availableSize.Width - Padding.Left - Padding.Right, - availableSize.Height - Padding.Top - Padding.Bottom); - - if (double.IsInfinity(childAvailableSize.Width) || double.IsInfinity(childAvailableSize.Height)) - { - childAvailableSize.Width = 100; - childAvailableSize.Height = 100; - } - foreach (var child in Children) - { - child.Measure(availableSize); - } - - var requiredSize = UpdateRows(availableSize); - return requiredSize; - } - - /// - protected override Size ArrangeOverride(Size finalSize) - { - if ((Orientation == Orientation.Horizontal && finalSize.Width < DesiredSize.Width) || - (Orientation == Orientation.Vertical && finalSize.Height < DesiredSize.Height)) - { - // We haven't received our desired size. We need to refresh the rows. - UpdateRows(finalSize); - } - - if (_rows.Count > 0) - { - // Now that we have all the data, we do the actual arrange pass - var childIndex = 0; - foreach (var row in _rows) - { - foreach (var rect in row.ChildrenRects) - { - var child = Children[childIndex++]; - while (child.Visibility == Visibility.Collapsed) - { - // Collapsed children are not added into the rows, - // we skip them. - child = Children[childIndex++]; - } - - var arrangeRect = new UvRect - { - Position = rect.Position, - Size = new UvMeasure { U = rect.Size.U, V = row.Size.V }, - }; - - var finalRect = arrangeRect.ToRect(Orientation); - child.Arrange(finalRect); - } - } - } - - return finalSize; - } - - private Size UpdateRows(Size availableSize) - { - _rows.Clear(); - - var paddingStart = new UvMeasure(Orientation, Padding.Left, Padding.Top); - var paddingEnd = new UvMeasure(Orientation, Padding.Right, Padding.Bottom); - - if (Children.Count == 0) - { - var emptySize = paddingStart.Add(paddingEnd).ToSize(Orientation); - return emptySize; - } - - var parentMeasure = new UvMeasure(Orientation, availableSize.Width, availableSize.Height); - var spacingMeasure = new UvMeasure(Orientation, HorizontalSpacing, VerticalSpacing); - var position = new UvMeasure(Orientation, Padding.Left, Padding.Top); - - var currentRow = new Row(new List(), default); - var finalMeasure = new UvMeasure(Orientation, width: 0.0, height: 0.0); - void Arrange(UIElement child, bool isLast = false) - { - if (child.Visibility == Visibility.Collapsed) - { - return; // if an item is collapsed, avoid adding the spacing - } - - var desiredMeasure = new UvMeasure(Orientation, child.DesiredSize); - if ((desiredMeasure.U + position.U + paddingEnd.U) > parentMeasure.U) - { - // next row! - position.U = paddingStart.U; - position.V += currentRow.Size.V + spacingMeasure.V; - - _rows.Add(currentRow); - currentRow = new Row(new List(), default); - } - - // Stretch the last item to fill the available space - if (isLast) - { - desiredMeasure.U = parentMeasure.U - position.U; - } - - currentRow.Add(position, desiredMeasure); - - // adjust the location for the next items - position.U += desiredMeasure.U + spacingMeasure.U; - finalMeasure.U = Math.Max(finalMeasure.U, position.U); - } - - var lastIndex = Children.Count - 1; - for (var i = 0; i < lastIndex; i++) - { - Arrange(Children[i]); - } - - Arrange(Children[lastIndex], StretchChild == StretchChild.Last); - if (currentRow.ChildrenRects.Count > 0) - { - _rows.Add(currentRow); - } - - if (_rows.Count == 0) - { - var emptySize = paddingStart.Add(paddingEnd).ToSize(Orientation); - return emptySize; - } - - // Get max V here before computing final rect - var lastRowRect = _rows.Last().Rect; - finalMeasure.V = lastRowRect.Position.V + lastRowRect.Size.V; - var finalRect = finalMeasure.Add(paddingEnd).ToSize(Orientation); - return finalRect; - } -} From d56e2cd52a9ff36570ae1d47baeeb5aa90e5e4a2 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Wed, 7 Jun 2023 16:13:53 +0200 Subject: [PATCH 20/25] Remove unused code from sample --- .../samples/SampleEmailType.cs | 49 ------------------- .../samples/TokenizingTextBoxSample.xaml | 4 +- .../samples/TokenizingTextBoxSample.xaml.cs | 24 --------- 3 files changed, 1 insertion(+), 76 deletions(-) delete mode 100644 components/TokenizingTextBox/samples/SampleEmailType.cs diff --git a/components/TokenizingTextBox/samples/SampleEmailType.cs b/components/TokenizingTextBox/samples/SampleEmailType.cs deleted file mode 100644 index 6719855f..00000000 --- a/components/TokenizingTextBox/samples/SampleEmailType.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace TokenizingTextBoxExperiment.Samples; - -/// -/// Sample of strongly-typed email address simulated data for . -/// -public class SampleEmailDataType -{ - /// - /// Gets the initials to Display - /// - public string Initials => string.Empty + FirstName[0] + FamilyName[0]; - - /// - /// Gets or sets the first name . - /// -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - public string FirstName { get; set; } - - /// - /// Gets or sets the family name . - /// - public string FamilyName { get; set; } -#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - - /// - /// Gets the display text. - /// - public string DisplayName => $"{FirstName} {FamilyName}"; - - /// - /// Gets the formatted email address - /// - public string EmailAddress => $"{DisplayName} <{FirstName}.{FamilyName}@contoso.com>"; - - public override string ToString() - { - return EmailAddress; - } -} diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml b/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml index 80d6f1ee..54081441 100644 --- a/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml +++ b/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml @@ -25,9 +25,7 @@ TextChanged="TextChanged" TextMemberPath="Text" TokenDelimiter="," - TokenItemAdded="TokenItemAdded" - TokenItemAdding="TokenItemCreating" - TokenItemRemoving="TokenItemRemoved"> + TokenItemAdding="TokenItemCreating"> diff --git a/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml.cs b/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml.cs index 95dabef0..738818c0 100644 --- a/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml.cs +++ b/components/TokenizingTextBox/samples/TokenizingTextBoxSample.xaml.cs @@ -50,30 +50,6 @@ public TokenizingTextBoxSample() }; } - private void TokenItemAdded(TokenizingTextBox sender, object data) - { - // TODO: Add InApp Notification? - if (data is SampleDataType sample) - { - Debug.WriteLine("Added Token: " + sample.Text); - } - else - { - Debug.WriteLine("Added Token: " + data); - } - } - - private void TokenItemRemoved(TokenizingTextBox sender, TokenItemRemovingEventArgs args) - { - if (args.Item is SampleDataType sample) - { - Debug.WriteLine("Removed Token: " + sample.Text); - } - else - { - Debug.WriteLine("Removed Token: " + args.Item); - } - } private void TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) { From 7937930d307180f663f1d2b53627c0a5a81dab1c Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Wed, 21 Jun 2023 18:18:44 +0200 Subject: [PATCH 21/25] Adding icons --- .../samples/Assets/TokenizingTextBox.png | Bin 0 -> 2198 bytes .../samples/TokenizingTextBox.md | 1 + 2 files changed, 1 insertion(+) create mode 100644 components/TokenizingTextBox/samples/Assets/TokenizingTextBox.png diff --git a/components/TokenizingTextBox/samples/Assets/TokenizingTextBox.png b/components/TokenizingTextBox/samples/Assets/TokenizingTextBox.png new file mode 100644 index 0000000000000000000000000000000000000000..91dd78f9e0a58478b50c4470fc7a8c9614841eb7 GIT binary patch literal 2198 zcmV;H2x<3;P);M1&0drDELIAGL9O(c600d`2O+f$vv5yPMm$48NF@eBB!rlFBqAXO9urK3cnwS&LBdEX%m_`0h=hnoJYpt9 zn3QS>1Me2OTy@=Y_WswhUjM)MIrsM5&bwD~&ffdq|G)nA{MPz6KuuTQ@QQ0{dO-SK2a26 zRR)deTBHIp$7{JKHtCRv+cA|XxzIA-C)-@$d}VgQWgfb*z!;dJgrBVgH%NYGMiXN> zAAm%BKRt5Z&aQmJ31NZnQ+?SDr<$wsYGPz}Kvv2uSFWa^ZQi+-dpCf4Ei3Kbin(e` zbH)UpVz|s#9Ht9h78@gz6T=WYg!3%r@C=!W=1#P*3m1T!F2V}T_{@{SPbg|$2qD5V zt*44|#Yie8P8-b)0_X;4ql7iQiQL@TDDuB$IC))FgH|PYEtNeZ-GleS&08g^ype4Q_q$hm3TQtc0_}GOX;HhM=81 z;;_E%NKwx?4A6!5031`y@+uoHk43n4>z6%Gp`_Qv1lZIJb&9k|uz94OTtOVn1#^P6 z>eYmDDMccu00)S8@8p|n8;NZ-Of6P%ggq88DaaamOVrjkZo}WvB9zzkC1s*cNZKz5 zf2rtcED@M6$-ROeX_c?X?By7b3{W&b+&B(NCvRYQ{PN@A;uGf(r<{(RkuMi(xc7ra z*t?i)L*=6kwr?50BUfyNOHZ4(k{MteE3Jg)hX84h!ta-AeEQ=>e(J;DY!YjJk%0*+ zkW11^vP+K9Ko(VPJkM0l^T@oFni`(F>UccuScFFIL!oH}?RxHJ#Fww#3YWZafDhJS z5Pj+b!z(vz!_D&pS>H^o$pO?>o=M)PZbEqU%5C`F@5}u5zJnrH6VXIw6!k9CSZ*q@ z&cG3KJ>^nkilN5mMOzV0oR?6qYsgm57Otw*N-hYe8_ycSGrLy|l&(By0~}M4tmopP ztjM%+rYVo#a{eZK>+@gDOX@HQODth2V--`R3$L%7TSt+#fj^gP(eaoGf%2y6h-__c z;|!XA4{2@$l-Jnxm>b6DwxKxrJWUG&QKA`Zpi4AVs2;1TO2Zy1O>%|*%Fqqh2b3l( zA&d9!{~NzNFyeDg*m$_TjdU}mgDYcr=bL5WF|wll@~4Y@*99B!xQ(L;;k(A@D%pL` zMtkX#pG21b46wv)26752nsX$I30#UtD??CNt=nV~w_;3&Tmo2geD~YG;QRabvxa6} zH;}LDk)}y@9goNG(bosyrd@mBhd;(>5hI3Psz-S9b6@cnKQ7rEUt61S`Mx{mJpH%$ z*D~yQbvL~7*?ujGZ22V$sOKnpK-2=xjTdnL&8~BC&XPZ|1`grpl{voW{Ux9`e`4(z z0=7BVrfhH?3apM65a#A(XyET~$a|NDaNV=tGK~LGAq?nb7xZEvo>0`YY|4F`6*sG2 zINT`G&HDxxf&?Y!k}HKY)R(zQT}QblMaLnjD$3DlqvvTQrNG*v|#KQx-h zG?b%rpRHWtyiOGa9?SvHNdn`1oT61EitgX<0S2n8wncy@ih0F19gIvAG^LX@yQY|x z>-JEVJ?FXOc4G&cXp<5iTsZ`}h$oAERXCJWuMr(thvI#mYFmk+N6dpkoI2Vblh8YJ z-B^XN5wT9?Nn)*vX08-japh1G-z~=jQ%K<4>Whx&`T4#DKAqQCzU}*YnV)=T4{Sea z0Uv&Ck8C&ePNJ#|VAcYZH9S{(K=31oYNg(xXqqt&XQi$vhLsN;6kb5N#CHa~LeYh)D$$P!CC%TUjWFYG}M`F+OQF zpy>H6&L8C~Rcz=&lYg;+2!qMfX;v%*0?(CL8+=mSr;cI?yB>>ta6>}E5=PdHN@!QM z5dHX2jw>Zs538KaT@rz}*k?OCOL-{zX_Y$e@7%jEEgbWNhYE;fA>y#uddbf|Fu}hV zD?mc|S=j*Z1j#{5^)nQAQ2B1Q=O{fS#(Kgn8(zwmoe-dFvFxQhp0cGp++3l!dj4h4 zm$1!zI$_)KCic!Vf) z6wjdwu`ZCQ?cCSknZkp49mTuy+~qkfFiV$#&%+xJybe9})KgDA_0&^OJ@wR6Pe%{^ Y52mi0@i_@%07*qoM6N<$f-Q6+UjP6A literal 0 HcmV?d00001 diff --git a/components/TokenizingTextBox/samples/TokenizingTextBox.md b/components/TokenizingTextBox/samples/TokenizingTextBox.md index 0b476174..0cfb952a 100644 --- a/components/TokenizingTextBox/samples/TokenizingTextBox.md +++ b/components/TokenizingTextBox/samples/TokenizingTextBox.md @@ -9,6 +9,7 @@ category: Controls subcategory: Input discussion-id: 0 issue-id: 0 +icon: Assets/TokenizingTextBox.png --- # TokenizingTextBox From 32451356cf63035104f304ce8a367508e06e67c0 Mon Sep 17 00:00:00 2001 From: Arlo Date: Wed, 21 Jun 2023 13:06:37 -0500 Subject: [PATCH 22/25] Update components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj --- .../CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj index 9b028954..292dfbb9 100644 --- a/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj +++ b/components/TokenizingTextBox/src/CommunityToolkit.WinUI.Controls.TokenizingTextBox.csproj @@ -16,4 +16,8 @@ + + + $(PackageIdPrefix).$(PackageIdVariant).Controls.$(ToolkitComponentName) + From 34e8ab60df1666caf6f355d3796fbfad41428e34 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Thu, 6 Jul 2023 15:03:50 +0200 Subject: [PATCH 23/25] Update components/TokenizingTextBox/src/InterspersedObservableCollection.cs Co-authored-by: Michael Hawker MSFT (XAML Llama) <24302614+michael-hawker@users.noreply.github.com> --- .../TokenizingTextBox/src/InterspersedObservableCollection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/TokenizingTextBox/src/InterspersedObservableCollection.cs b/components/TokenizingTextBox/src/InterspersedObservableCollection.cs index 93b2d368..74a3b81c 100644 --- a/components/TokenizingTextBox/src/InterspersedObservableCollection.cs +++ b/components/TokenizingTextBox/src/InterspersedObservableCollection.cs @@ -63,7 +63,7 @@ public InterspersedObservableCollection(object itemsSource) { var weakPropertyChangedListener = new WeakEventListener(this) { - OnEventAction = (instance, source, eventArgs) => instance.ItemsSource_CollectionChanged(source, eventArgs), + OnEventAction = static (instance, source, eventArgs) => instance.ItemsSource_CollectionChanged(source, eventArgs), OnDetachAction = (weakEventListener) => notifier.CollectionChanged -= weakEventListener.OnEvent // Use Local Reference Only }; notifier.CollectionChanged += weakPropertyChangedListener.OnEvent; From fdfcb9090d6753cb2089c3dbe58b77d2a418a3b7 Mon Sep 17 00:00:00 2001 From: Arlo Godfrey Date: Mon, 10 Jul 2023 18:49:21 -0500 Subject: [PATCH 24/25] Removed extraneous information from TokenizingTextBox sample docs --- .../samples/TokenizingTextBox.md | 52 ------------------- 1 file changed, 52 deletions(-) diff --git a/components/TokenizingTextBox/samples/TokenizingTextBox.md b/components/TokenizingTextBox/samples/TokenizingTextBox.md index 0cfb952a..65b9a34f 100644 --- a/components/TokenizingTextBox/samples/TokenizingTextBox.md +++ b/components/TokenizingTextBox/samples/TokenizingTextBox.md @@ -17,55 +17,3 @@ icon: Assets/TokenizingTextBox.png The [TokenizingTextBox](/dotnet/api/microsoft.toolkit.uwp.ui.controls.tokenizingtextbox) is an advanced [AutoSuggestBox](/uwp/api/Windows.UI.Xaml.Controls.AutoSuggestBox) which will display selected items as tokens within the textbox. A user can easily see the picked items or remove them easily. > [!Sample TokenizingTextBoxSample] - -## Syntax - -```xaml - -``` - -## Properties - -| Property | Type | Description | -| -- | -- | -- | -| AutoSuggestBoxStyle | Style | Inner AutoSuggestBox style | -| AutoSuggestBoxTextBoxStyle | Style | Inner TextBox style of the AutoSuggestBox | -| PlaceholderText | string | Placeholder text to display when there's no text in the textbox | -| QueryIcon | IconSource | -| QueryText | string | Gets or sets the text query of the AutoSuggestBox | -| SelectedItems | IList<object> | Collection of items selected by the user | -| SelectedTokenText | string | Complete set of text for any selection in the control | -| SuggestedItemsSource | object | List of suggested items | -| SuggestedItemTemplate | DataTemplate | Template for suggested items | -| SuggestedItemTemplateSelector | DataTemplateSelector | Template selector for suggested items | -| SuggestedItemContainerStyle | Style for suggested item's container | -| TabNavigateBackOnArrow | bool | Value indicating whether the control will move focus to the previous control when an arrow key is pressed and selection is at one of the limits in the control. | -| Text | string | Text of currently focused text box part | -| TextMemberPath | string | Path of property for item display | -| TokenDelimiter | string | Character delimiter for recognizing a token | -| TokenItemTemplate | DataTemplate | Template for a token item | -| TokenItemTemplateSelector | DataTemplateSelector | Template selector for token items | -| TokenItemStyle | Style | Style for a token item | -| TokenSpacing | double | Amount of spacing between tokens | - -## Methods - -| Methods | Return Type | Description | -| -- | -- | -- | -| AddTokenItem(data, bool) | void | Used in special cases where you want to add a token manually to the control | -| ClearAsync() | Task | Clears everything from the control, tokens and text. | -| GetUntokenizedText(string) | string | Returns the string representation of each token item, concatenated and delimited. | - -## Events - -| Events | Description | -| -- | -- | -| QuerySubmitted | Event raised when the user submits the text query. | -| SuggestionChosen | Event raised when a suggested item is chosen by the user. | -| TextChanged | Event raised when the text input value has changed. | -| TokenItemAdding | Event raised before a new token item has been added. Can be used to transform user text into an object. | -| TokenItemRemoving | Event raised before a token item is removed (cancelable). | -| TokenItemRemoved | Event raised after a token item has been removed. | From f60895c3eb40163f524512f224449f72bff241be Mon Sep 17 00:00:00 2001 From: Arlo Godfrey Date: Mon, 10 Jul 2023 19:00:49 -0500 Subject: [PATCH 25/25] Get dispatcher queue from constructor --- .../src/TokenizingTextBox.Selection.cs | 46 ++++++++----------- .../src/TokenizingTextBox.cs | 19 ++++++-- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs b/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs index c3dddfd5..0b20a6fe 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBox.Selection.cs @@ -147,39 +147,33 @@ private bool MoveFocusAndSelection(MoveDirection direction) internal void SelectAllTokensAndText() { -#if WINAPPSDK - _ = DispatcherQueue.EnqueueAsync( -#else - var dispatcherQueue = DispatcherQueue.GetForCurrentThread(); - _ = dispatcherQueue.EnqueueAsync( -#endif - () => - { - this.SelectAllSafe(); + _ = _dispatcherQueue.EnqueueAsync(() => + { + 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; + // 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 (var item in Items) + foreach (var item in Items) + { + if (item is ITokenStringContainer) { - if (item is ITokenStringContainer) + // grab any selected text + if (ContainerFromItem(item) is TokenizingTextBoxItem pretoken) { - // grab any selected text - if (ContainerFromItem(item) is TokenizingTextBoxItem pretoken) - { - pretoken._autoSuggestTextBox.SelectionStart = 0; - pretoken._autoSuggestTextBox.SelectionLength = pretoken._autoSuggestTextBox.Text.Length; - } + pretoken._autoSuggestTextBox.SelectionStart = 0; + pretoken._autoSuggestTextBox.SelectionLength = pretoken._autoSuggestTextBox.Text.Length; } } + } - if (ContainerFromIndex(Items.Count - 1) is TokenizingTextBoxItem container) - { - container.Focus(FocusState.Programmatic); - } - }, DispatcherQueuePriority.Normal); + if (ContainerFromIndex(Items.Count - 1) is TokenizingTextBoxItem container) + { + container.Focus(FocusState.Programmatic); + } + }, DispatcherQueuePriority.Normal); } internal void DeselectAllTokensAndText(TokenizingTextBoxItem? ignoreItem = null) diff --git a/components/TokenizingTextBox/src/TokenizingTextBox.cs b/components/TokenizingTextBox/src/TokenizingTextBox.cs index cbc5b2f4..d3972b4e 100644 --- a/components/TokenizingTextBox/src/TokenizingTextBox.cs +++ b/components/TokenizingTextBox/src/TokenizingTextBox.cs @@ -9,8 +9,10 @@ using Microsoft.UI.Input; using VirtualKey = Windows.System.VirtualKey; using DispatcherQueuePriority = Microsoft.UI.Dispatching.DispatcherQueuePriority; +using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; #else using DispatcherQueuePriority = Windows.System.DispatcherQueuePriority; +using DispatcherQueue = Windows.System.DispatcherQueue; using CommunityToolkit.WinUI.Deferred; #endif using Windows.System; @@ -43,16 +45,16 @@ public partial class TokenizingTextBox : ListViewBase /// #if WINAPPSDK - internal static bool IsShiftPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); + internal static bool IsShiftPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); #else internal static bool IsShiftPressed => CoreWindow.GetForCurrentThread()!.GetKeyState(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); #endif -/// -/// Gets a value indicating whether the control key is currently in a pressed state -/// + /// + /// Gets a value indicating whether the control key is currently in a pressed state + /// #if WINAPPSDK - internal bool IsControlPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + internal bool IsControlPressed => InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); #else internal bool IsControlPressed => CoreWindow.GetForCurrentThread()!.GetKeyState(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); #endif @@ -60,6 +62,7 @@ public partial class TokenizingTextBox : ListViewBase internal bool IsClearingForClick { get; set; } + private DispatcherQueue _dispatcherQueue; private InterspersedObservableCollection _innerItemsSource; private ITokenStringContainer _currentTextEdit; // Don't update this directly outside of initialization, use UpdateCurrentTextEdit Method - in future see https://github.com/dotnet/csharplang/issues/140#issuecomment-625012514 private ITokenStringContainer _lastTextEdit; @@ -86,6 +89,12 @@ public TokenizingTextBox() PreviewKeyUp += TokenizingTextBox_PreviewKeyUp; CharacterReceived += TokenizingTextBox_CharacterReceived; ItemClick += TokenizingTextBox_ItemClick; + +#if WINAPPSDK + _dispatcherQueue = DispatcherQueue; +#else + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); +#endif } private void ItemsSource_PropertyChanged(DependencyObject sender, DependencyProperty dp)