diff --git a/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj b/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj
index 9dddf4fb6b0..fca708e35d4 100644
--- a/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj
+++ b/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj
@@ -274,6 +274,7 @@
+
@@ -506,6 +507,10 @@
MetadataControlPage.xaml
+
+ RichSuggestBoxPage.xaml
+
+
TilesBrushPage.xaml
@@ -627,6 +632,7 @@
Designer
+
@@ -986,6 +992,14 @@
Designer
+
+ Designer
+ MSBuild:Compile
+
+
+ Designer
+ MSBuild:Compile
+
MSBuild:Compile
Designer
diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBox.png b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBox.png
new file mode 100644
index 00000000000..47e34d69c0c
Binary files /dev/null and b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBox.png differ
diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBoxCode.bind b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBoxCode.bind
new file mode 100644
index 00000000000..0c4e59518fa
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBoxCode.bind
@@ -0,0 +1,50 @@
+private void SuggestingBox_OnTokenPointerOver(RichSuggestBox sender, RichSuggestTokenPointerOverEventArgs args)
+{
+ var flyout = (Flyout)FlyoutBase.GetAttachedFlyout(sender);
+ var pointerPosition = args.CurrentPoint.Position;
+
+ if (flyout?.Content is ContentPresenter cp && sender.TextDocument.Selection.Type != SelectionType.Normal &&
+ (!flyout.IsOpen || cp.Content != args.Token.Item))
+ {
+ this._dispatcherQueue.TryEnqueue(() =>
+ {
+ cp.Content = args.Token.Item;
+ flyout.ShowAt(sender, new FlyoutShowOptions
+ {
+ Position = pointerPosition,
+ ExclusionRect = sender.GetRectFromRange(args.Range),
+ ShowMode = FlyoutShowMode.TransientWithDismissOnPointerMoveAway,
+ });
+ });
+ }
+}
+
+private void SuggestingBox_OnSuggestionChosen(RichSuggestBox sender, SuggestionChosenEventArgs args)
+{
+ if (args.Prefix == "#")
+ {
+ args.Format.BackgroundColor = Colors.DarkOrange;
+ args.Format.ForegroundColor = Colors.OrangeRed;
+ args.Format.Bold = FormatEffect.On;
+ args.Format.Italic = FormatEffect.On;
+ args.DisplayText = ((SampleDataType)args.SelectedItem).Text;
+ }
+ else
+ {
+ args.DisplayText = ((SampleEmailDataType)args.SelectedItem).DisplayName;
+ }
+}
+
+private void SuggestingBox_OnSuggestionRequested(RichSuggestBox sender, SuggestionRequestedEventArgs args)
+{
+ if (args.Prefix == "#")
+ {
+ sender.ItemsSource =
+ this._samples.Where(x => x.Text.Contains(args.QueryText, StringComparison.OrdinalIgnoreCase));
+ }
+ else
+ {
+ sender.ItemsSource =
+ this._emailSamples.Where(x => x.DisplayName.Contains(args.QueryText, StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBoxPage.xaml b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBoxPage.xaml
new file mode 100644
index 00000000000..a57d44b9576
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBoxPage.xaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBoxPage.xaml.cs b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBoxPage.xaml.cs
new file mode 100644
index 00000000000..20251c74c32
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBoxPage.xaml.cs
@@ -0,0 +1,191 @@
+// 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 Microsoft.Toolkit.Uwp.UI;
+using Microsoft.Toolkit.Uwp.UI.Controls;
+using Windows.System;
+using Windows.UI;
+using Windows.UI.Text;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Controls.Primitives;
+
+namespace Microsoft.Toolkit.Uwp.SampleApp.SamplePages
+{
+ ///
+ /// An empty page that can be used on its own or navigated to within a Frame.
+ ///
+ public sealed partial class RichSuggestBoxPage : Page, IXamlRenderListener
+ {
+ private readonly List _emailSamples = 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" },
+ new SampleEmailDataType() { FirstName = "Tung", FamilyName = "Huynh" },
+ };
+
+ private 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 },
+ };
+
+ private RichSuggestBox _rsb;
+ private RichSuggestBox _tsb;
+ private DispatcherQueue _dispatcherQueue;
+
+ public RichSuggestBoxPage()
+ {
+ this.InitializeComponent();
+ this._dispatcherQueue = DispatcherQueue.GetForCurrentThread();
+ Loaded += (sender, e) => { this.OnXamlRendered(this); };
+ }
+
+ public void OnXamlRendered(FrameworkElement control)
+ {
+ if (this._rsb != null)
+ {
+ this._rsb.SuggestionChosen -= this.SuggestingBox_OnSuggestionChosen;
+ this._rsb.SuggestionRequested -= this.SuggestingBox_OnSuggestionRequested;
+ }
+
+ if (this._tsb != null)
+ {
+ this._tsb.SuggestionChosen -= this.SuggestingBox_OnSuggestionChosen;
+ this._tsb.SuggestionRequested -= this.SuggestingBox_OnSuggestionRequested;
+ this._tsb.TokenPointerOver -= this.SuggestingBox_OnTokenPointerOver;
+ }
+
+ if (control.FindChild("SuggestingBox") is RichSuggestBox rsb)
+ {
+ this._rsb = rsb;
+ this._rsb.SuggestionChosen += this.SuggestingBox_OnSuggestionChosen;
+ this._rsb.SuggestionRequested += this.SuggestingBox_OnSuggestionRequested;
+ }
+
+ if (control.FindChild("PlainTextSuggestingBox") is RichSuggestBox tsb)
+ {
+ this._tsb = tsb;
+ this._tsb.SuggestionChosen += this.SuggestingBox_OnSuggestionChosen;
+ this._tsb.SuggestionRequested += this.SuggestingBox_OnSuggestionRequested;
+ this._tsb.TokenPointerOver += this.SuggestingBox_OnTokenPointerOver;
+ }
+
+ if (control.FindChild("TokenListView1") is ListView tls1)
+ {
+ tls1.ItemsSource = this._rsb?.Tokens;
+ }
+
+ if (control.FindChild("TokenListView2") is ListView tls2)
+ {
+ tls2.ItemsSource = this._tsb?.Tokens;
+ }
+ }
+
+ private void SuggestingBox_OnTokenPointerOver(RichSuggestBox sender, RichSuggestTokenPointerOverEventArgs args)
+ {
+ var flyout = (Flyout)FlyoutBase.GetAttachedFlyout(sender);
+ var pointerPosition = args.CurrentPoint.Position;
+
+ if (flyout?.Content is ContentPresenter cp && sender.TextDocument.Selection.Type != SelectionType.Normal &&
+ (!flyout.IsOpen || cp.Content != args.Token.Item))
+ {
+ this._dispatcherQueue.TryEnqueue(() =>
+ {
+ cp.Content = args.Token.Item;
+ flyout.ShowAt(sender, new FlyoutShowOptions
+ {
+ Position = pointerPosition,
+ ExclusionRect = sender.GetRectFromRange(args.Range),
+ ShowMode = FlyoutShowMode.TransientWithDismissOnPointerMoveAway,
+ });
+ });
+ }
+ }
+
+ private void SuggestingBox_OnSuggestionChosen(RichSuggestBox sender, SuggestionChosenEventArgs args)
+ {
+ if (args.Prefix == "#")
+ {
+ args.Format.BackgroundColor = Colors.DarkOrange;
+ args.Format.ForegroundColor = Colors.OrangeRed;
+ args.Format.Bold = FormatEffect.On;
+ args.Format.Italic = FormatEffect.On;
+ args.DisplayText = ((SampleDataType)args.SelectedItem).Text;
+ }
+ else
+ {
+ args.DisplayText = ((SampleEmailDataType)args.SelectedItem).DisplayName;
+ }
+ }
+
+ private void SuggestingBox_OnSuggestionRequested(RichSuggestBox sender, SuggestionRequestedEventArgs args)
+ {
+ if (args.Prefix == "#")
+ {
+ sender.ItemsSource =
+ this._samples.Where(x => x.Text.Contains(args.QueryText, StringComparison.OrdinalIgnoreCase));
+ }
+ else
+ {
+ sender.ItemsSource =
+ this._emailSamples.Where(x => x.DisplayName.Contains(args.QueryText, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBoxXaml.bind b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBoxXaml.bind
new file mode 100644
index 00000000000..e5325501dab
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/RichSuggestBoxXaml.bind
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Text:
+
+ Position:
+
+ Id:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/SuggestionTemplateSelector.cs b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/SuggestionTemplateSelector.cs
new file mode 100644
index 00000000000..863a9c62776
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/RichSuggestBox/SuggestionTemplateSelector.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.
+
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+
+namespace Microsoft.Toolkit.Uwp.SampleApp.SamplePages
+{
+ public class SuggestionTemplateSelector : DataTemplateSelector
+ {
+ public DataTemplate Person { get; set; }
+
+ public DataTemplate Data { get; set; }
+
+ protected override DataTemplate SelectTemplateCore(object item)
+ {
+ return item is SampleEmailDataType ? this.Person : this.Data;
+ }
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json
index 2447ae36db2..fe67e2f4940 100644
--- a/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json
+++ b/Microsoft.Toolkit.Uwp.SampleApp/SamplePages/samples.json
@@ -484,6 +484,17 @@
"XamlCodeFile": "/SamplePages/Primitives/ConstrainedBox.bind",
"Icon": "/SamplePages/Primitives/ConstrainedBox.png",
"DocumentationUrl": "https://raw.githubusercontent.com/MicrosoftDocs/WindowsCommunityToolkitDocs/master/docs/controls/ConstrainedBox.md"
+ },
+ {
+ "Name": "RichSuggestBox",
+ "Type": "RichSuggestBoxPage",
+ "Subcategory": "Input",
+ "About": "A text input control that makes suggestions and keeps track of data token items in a rich document.",
+ "CodeUrl": "https://github.com/CommunityToolkit/WindowsCommunityToolkit/tree/main/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox",
+ "CodeFile": "RichSuggestBoxCode.bind",
+ "XamlCodeFile": "RichSuggestBoxXaml.bind",
+ "Icon": "/SamplePages/RichSuggestBox/RichSuggestBox.png",
+ "DocumentationUrl": "https://raw.githubusercontent.com/MicrosoftDocs/WindowsCommunityToolkitDocs/master/docs/controls/RichSuggestBox.md"
}
]
},
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.Events.cs b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.Events.cs
new file mode 100644
index 00000000000..c57bfd7b680
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.Events.cs
@@ -0,0 +1,52 @@
+// 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 Windows.Foundation;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+
+namespace Microsoft.Toolkit.Uwp.UI.Controls
+{
+ ///
+ /// The RichSuggestBox control extends control that suggests and embeds custom data in a rich document.
+ ///
+ public partial class RichSuggestBox
+ {
+ ///
+ /// Event raised when the control needs to show suggestions.
+ ///
+ public event TypedEventHandler SuggestionRequested;
+
+ ///
+ /// Event raised when user click on a suggestion.
+ /// This event lets you customize the token appearance in the document.
+ ///
+ public event TypedEventHandler SuggestionChosen;
+
+ ///
+ /// Event raised when a token is fully highlighted.
+ ///
+ public event TypedEventHandler TokenSelected;
+
+ ///
+ /// Event raised when a pointer is hovering over a token.
+ ///
+ public event TypedEventHandler TokenPointerOver;
+
+ ///
+ /// Event raised when text is changed, either by user or by internal formatting.
+ ///
+ public event TypedEventHandler TextChanged;
+
+ ///
+ /// Event raised when the text selection has changed.
+ ///
+ public event TypedEventHandler SelectionChanged;
+
+ ///
+ /// Event raised when text is pasted into the control.
+ ///
+ public event TypedEventHandler Paste;
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.Helpers.cs b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.Helpers.cs
new file mode 100644
index 00000000000..0357e261b4b
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.Helpers.cs
@@ -0,0 +1,140 @@
+// 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.Linq;
+using Windows.Foundation;
+using Windows.Graphics.Display;
+using Windows.UI.Core;
+using Windows.UI.Text;
+using Windows.UI.ViewManagement;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+
+namespace Microsoft.Toolkit.Uwp.UI.Controls
+{
+ ///
+ /// The RichSuggestBox control extends control that suggests and embeds custom data in a rich document.
+ ///
+ public partial class RichSuggestBox
+ {
+ private static bool IsElementOnScreen(FrameworkElement element, double offsetX = 0, double offsetY = 0)
+ {
+ // DisplayInformation only works in UWP. No alternative to get DisplayInformation.ScreenHeightInRawPixels
+ // Or Window position in Window.Current.Bounds
+ // Tracking issues:
+ // https://github.com/microsoft/WindowsAppSDK/issues/114
+ // https://github.com/microsoft/microsoft-ui-xaml/issues/4228
+ // TODO: Remove when DisplayInformation.ScreenHeightInRawPixels alternative is available
+ if (CoreWindow.GetForCurrentThread() == null)
+ {
+ return true;
+ }
+
+ // Get bounds of element from root of tree
+ var elementBounds = element.CoordinatesFrom(null).ToRect(element.ActualWidth, element.ActualHeight);
+
+ // Apply offset
+ elementBounds.X += offsetX;
+ elementBounds.Y += offsetY;
+
+ // Get Window position
+ var windowBounds = Window.Current.Bounds;
+
+ // Offset Element within Window on Screen
+ elementBounds.X += windowBounds.X;
+ elementBounds.Y += windowBounds.Y;
+
+ // Get Screen DPI info
+ var displayInfo = DisplayInformation.GetForCurrentView();
+ var scaleFactor = displayInfo.RawPixelsPerViewPixel;
+ var displayHeight = displayInfo.ScreenHeightInRawPixels;
+
+ // Check if top/bottom are within confines of screen
+ return elementBounds.Top * scaleFactor >= 0 && elementBounds.Bottom * scaleFactor <= displayHeight;
+ }
+
+ private static bool IsElementInsideWindow(FrameworkElement element, double offsetX = 0, double offsetY = 0)
+ {
+ // Get bounds of element from root of tree
+ var elementBounds = element.CoordinatesFrom(null).ToRect(element.ActualWidth, element.ActualHeight);
+
+ // Apply offset
+ elementBounds.X += offsetX;
+ elementBounds.Y += offsetY;
+
+ // Get size of window itself
+ var windowBounds = ControlHelpers.IsXamlRootAvailable && element.XamlRoot != null
+ ? element.XamlRoot.Size.ToRect()
+ : ApplicationView.GetForCurrentView().VisibleBounds.ToSize().ToRect(); // Normalize
+
+ // Calculate if there's an intersection
+ elementBounds.Intersect(windowBounds);
+
+ // See if we are still fully visible within the Window
+ return elementBounds.Height >= element.ActualHeight;
+ }
+
+ private static string EnforcePrefixesRequirements(string value)
+ {
+ return string.IsNullOrEmpty(value) ? string.Empty : string.Concat(value.Where(char.IsPunctuation));
+ }
+
+ ///
+ /// Pad range with Zero-Width-Spaces.
+ ///
+ /// Range to pad.
+ /// Character format to apply to the padding.
+ private static void PadRange(ITextRange range, ITextCharacterFormat format)
+ {
+ var startPosition = range.StartPosition;
+ var endPosition = range.EndPosition + 1;
+ var clone = range.GetClone();
+ clone.Collapse(true);
+ clone.SetText(TextSetOptions.Unhide, "\u200B");
+ clone.CharacterFormat.SetClone(format);
+ clone.SetRange(endPosition, endPosition);
+ clone.SetText(TextSetOptions.Unhide, "\u200B");
+ clone.CharacterFormat.SetClone(format);
+ range.SetRange(startPosition, endPosition + 1);
+ }
+
+ private static void ForEachLinkInDocument(ITextDocument document, Action action)
+ {
+ var range = document.GetRange(0, 0);
+ range.SetIndex(TextRangeUnit.Character, -1, false);
+
+ // Handle link at the very end of the document where GetIndex fails to detect
+ range.Expand(TextRangeUnit.Link);
+ if (!string.IsNullOrEmpty(range.Link))
+ {
+ action?.Invoke(range);
+ }
+
+ var nextIndex = range.GetIndex(TextRangeUnit.Link);
+ while (nextIndex != 0 && nextIndex != 1)
+ {
+ range.Move(TextRangeUnit.Link, -1);
+
+ var linkRange = range.GetClone();
+ linkRange.Expand(TextRangeUnit.Link);
+
+ // Adjacent links have the same index. Manually check each link with Collapse and Expand.
+ var previousStart = linkRange.StartPosition;
+ var hasAdjacentToken = true;
+ while (hasAdjacentToken)
+ {
+ action?.Invoke(linkRange);
+
+ linkRange.Collapse(false);
+ linkRange.Expand(TextRangeUnit.Link);
+ hasAdjacentToken = !string.IsNullOrEmpty(linkRange.Link) && linkRange.StartPosition != previousStart;
+ previousStart = linkRange.StartPosition;
+ }
+
+ nextIndex = range.GetIndex(TextRangeUnit.Link);
+ }
+ }
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.Properties.cs b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.Properties.cs
new file mode 100644
index 00000000000..dbaf4ca58ee
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.Properties.cs
@@ -0,0 +1,372 @@
+// 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 Windows.UI.Text;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Media;
+
+namespace Microsoft.Toolkit.Uwp.UI.Controls
+{
+ ///
+ /// The RichSuggestBox control extends control that suggests and embeds custom data in a rich document.
+ ///
+ public partial class RichSuggestBox
+ {
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty PlaceholderTextProperty =
+ DependencyProperty.Register(
+ nameof(PlaceholderText),
+ typeof(string),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(string.Empty));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty RichEditBoxStyleProperty =
+ DependencyProperty.Register(
+ nameof(RichEditBoxStyle),
+ typeof(Style),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(null));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty HeaderProperty =
+ DependencyProperty.Register(
+ nameof(Header),
+ typeof(object),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(null, OnHeaderChanged));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty HeaderTemplateProperty =
+ DependencyProperty.Register(
+ nameof(HeaderTemplate),
+ typeof(DataTemplate),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(null));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty DescriptionProperty =
+ DependencyProperty.Register(
+ nameof(Description),
+ typeof(object),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(null, OnDescriptionChanged));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty PopupPlacementProperty =
+ DependencyProperty.Register(
+ nameof(PopupPlacement),
+ typeof(SuggestionPopupPlacementMode),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(SuggestionPopupPlacementMode.Floating, OnSuggestionPopupPlacementChanged));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty PopupCornerRadiusProperty =
+ DependencyProperty.Register(
+ nameof(PopupCornerRadius),
+ typeof(CornerRadius),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(default(CornerRadius)));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty PopupHeaderProperty =
+ DependencyProperty.Register(
+ nameof(PopupHeader),
+ typeof(object),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(null));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty PopupHeaderTemplateProperty =
+ DependencyProperty.Register(
+ nameof(PopupHeaderTemplate),
+ typeof(DataTemplate),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(null));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty PopupFooterProperty =
+ DependencyProperty.Register(
+ nameof(PopupFooter),
+ typeof(object),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(null));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty PopupFooterTemplateProperty =
+ DependencyProperty.Register(
+ nameof(PopupFooterTemplate),
+ typeof(DataTemplate),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(null));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty TokenBackgroundProperty =
+ DependencyProperty.Register(
+ nameof(TokenBackground),
+ typeof(SolidColorBrush),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(null));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty TokenForegroundProperty =
+ DependencyProperty.Register(
+ nameof(TokenForeground),
+ typeof(SolidColorBrush),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(null));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty PrefixesProperty =
+ DependencyProperty.Register(
+ nameof(Prefixes),
+ typeof(string),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(string.Empty, OnPrefixesChanged));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty ClipboardPasteFormatProperty =
+ DependencyProperty.Register(
+ nameof(ClipboardPasteFormat),
+ typeof(RichEditClipboardFormat),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(RichEditClipboardFormat.AllFormats));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty ClipboardCopyFormatProperty =
+ DependencyProperty.Register(
+ nameof(ClipboardCopyFormat),
+ typeof(RichEditClipboardFormat),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(RichEditClipboardFormat.AllFormats));
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty DisabledFormattingAcceleratorsProperty =
+ DependencyProperty.Register(
+ nameof(DisabledFormattingAccelerators),
+ typeof(DisabledFormattingAccelerators),
+ typeof(RichSuggestBox),
+ new PropertyMetadata(DisabledFormattingAccelerators.None));
+
+ ///
+ /// Gets or sets the text that is displayed in the control until the value is changed by a user action or some other operation.
+ ///
+ public string PlaceholderText
+ {
+ get => (string)GetValue(PlaceholderTextProperty);
+ set => SetValue(PlaceholderTextProperty, value);
+ }
+
+ ///
+ /// Gets or sets the style of the underlying .
+ ///
+ public Style RichEditBoxStyle
+ {
+ get => (Style)GetValue(RichEditBoxStyleProperty);
+ set => SetValue(RichEditBoxStyleProperty, value);
+ }
+
+ ///
+ /// Gets or sets the content for the control's header.
+ ///
+ ///
+ /// Suggestion popup relies on the actual size of the text control to calculate its placement on the screen.
+ /// It is recommended to set the header using this property instead of using .
+ ///
+ public object Header
+ {
+ get => GetValue(HeaderProperty);
+ set => SetValue(HeaderProperty, value);
+ }
+
+ ///
+ /// Gets or sets the used to display the content of the control's header.
+ ///
+ public DataTemplate HeaderTemplate
+ {
+ get => (DataTemplate)GetValue(HeaderTemplateProperty);
+ set => SetValue(HeaderTemplateProperty, value);
+ }
+
+ ///
+ /// Gets or sets content that is shown below the control. The content should provide guidance about the input expected by the control.
+ ///
+ ///
+ /// Suggestion popup relies on the actual size of the text control to calculate its placement on the screen.
+ /// It is recommended to set the description using this property instead of using .
+ ///
+ public object Description
+ {
+ get => GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ ///
+ /// Gets or sets suggestion popup placement to either Floating or Attached to the text box.
+ ///
+ public SuggestionPopupPlacementMode PopupPlacement
+ {
+ get => (SuggestionPopupPlacementMode)GetValue(PopupPlacementProperty);
+ set => SetValue(PopupPlacementProperty, value);
+ }
+
+ ///
+ /// Gets or sets the radius for the corners of the popup control's border.
+ ///
+ public CornerRadius PopupCornerRadius
+ {
+ get => (CornerRadius)GetValue(PopupCornerRadiusProperty);
+ set => SetValue(PopupCornerRadiusProperty, value);
+ }
+
+ ///
+ /// Gets or sets the content for the suggestion popup control's header.
+ ///
+ public object PopupHeader
+ {
+ get => GetValue(PopupHeaderProperty);
+ set => SetValue(PopupHeaderProperty, value);
+ }
+
+ ///
+ /// Gets or sets the used to display the content of the suggestion popup control's header.
+ ///
+ public DataTemplate PopupHeaderTemplate
+ {
+ get => (DataTemplate)GetValue(PopupHeaderTemplateProperty);
+ set => SetValue(PopupHeaderTemplateProperty, value);
+ }
+
+ ///
+ /// Gets or sets the content for the suggestion popup control's footer.
+ ///
+ public object PopupFooter
+ {
+ get => GetValue(PopupFooterProperty);
+ set => SetValue(PopupFooterProperty, value);
+ }
+
+ ///
+ /// Gets or sets the used to display the content of the suggestion popup control's footer.
+ ///
+ public DataTemplate PopupFooterTemplate
+ {
+ get => (DataTemplate)GetValue(PopupFooterTemplateProperty);
+ set => SetValue(PopupFooterTemplateProperty, value);
+ }
+
+ ///
+ /// Gets or sets the default brush used to color the suggestion token background.
+ ///
+ public SolidColorBrush TokenBackground
+ {
+ get => (SolidColorBrush)GetValue(TokenBackgroundProperty);
+ set => SetValue(TokenBackgroundProperty, value);
+ }
+
+ ///
+ /// Gets or sets the default brush used to color the suggestion token foreground.
+ ///
+ public SolidColorBrush TokenForeground
+ {
+ get => (SolidColorBrush)GetValue(TokenForegroundProperty);
+ set => SetValue(TokenForegroundProperty, value);
+ }
+
+ ///
+ /// Gets or sets prefix characters to start a query.
+ ///
+ ///
+ /// Prefix characters must be punctuations (must satisfy method).
+ ///
+ public string Prefixes
+ {
+ get => (string)GetValue(PrefixesProperty);
+ set => SetValue(PrefixesProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value that specifies whether pasted text preserves all formats, or as plain text only.
+ ///
+ public RichEditClipboardFormat ClipboardPasteFormat
+ {
+ get => (RichEditClipboardFormat)GetValue(ClipboardPasteFormatProperty);
+ set => SetValue(ClipboardPasteFormatProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value that specifies whether text is copied with all formats, or as plain text only.
+ ///
+ public RichEditClipboardFormat ClipboardCopyFormat
+ {
+ get => (RichEditClipboardFormat)GetValue(ClipboardCopyFormatProperty);
+ set => SetValue(ClipboardCopyFormatProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value that indicates which keyboard shortcuts for formatting are disabled.
+ ///
+ public DisabledFormattingAccelerators DisabledFormattingAccelerators
+ {
+ get => (DisabledFormattingAccelerators)GetValue(DisabledFormattingAcceleratorsProperty);
+ set => SetValue(DisabledFormattingAcceleratorsProperty, value);
+ }
+
+ ///
+ /// Gets an object that enables access to the text object model for the text contained in a .
+ ///
+ public RichEditTextDocument TextDocument => _richEditBox?.TextDocument;
+
+ ///
+ /// Gets the distance the content has been scrolled horizontally from the underlying .
+ ///
+ public double HorizontalOffset => this._scrollViewer?.HorizontalOffset ?? 0;
+
+ ///
+ /// Gets the distance the content has been scrolled vertically from the underlying .
+ ///
+ public double VerticalOffset => this._scrollViewer?.VerticalOffset ?? 0;
+
+ ///
+ /// Gets a collection of suggestion tokens that are present in the document.
+ ///
+ public ReadOnlyObservableCollection Tokens { get; }
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.cs b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.cs
new file mode 100644
index 00000000000..bb1e1ee00ec
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.cs
@@ -0,0 +1,993 @@
+// 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.Collections.ObjectModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Toolkit.Uwp.Deferred;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.Foundation;
+using Windows.Foundation.Metadata;
+using Windows.System;
+using Windows.UI.Input;
+using Windows.UI.Text;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Controls.Primitives;
+using Windows.UI.Xaml.Input;
+
+namespace Microsoft.Toolkit.Uwp.UI.Controls
+{
+ ///
+ /// The RichSuggestBox control extends control that suggests and embeds custom data in a rich document.
+ ///
+ [TemplatePart(Name = PartRichEditBox, Type = typeof(RichEditBox))]
+ [TemplatePart(Name = PartSuggestionsPopup, Type = typeof(Popup))]
+ [TemplatePart(Name = PartSuggestionsList, Type = typeof(ListViewBase))]
+ [TemplatePart(Name = PartSuggestionsContainer, Type = typeof(Border))]
+ [TemplatePart(Name = PartHeaderContentPresenter, Type = typeof(ContentPresenter))]
+ [TemplatePart(Name = PartDescriptionPresenter, Type = typeof(ContentPresenter))]
+ public partial class RichSuggestBox : ItemsControl
+ {
+ private const string PartRichEditBox = "RichEditBox";
+ private const string PartSuggestionsPopup = "SuggestionsPopup";
+ private const string PartSuggestionsList = "SuggestionsList";
+ private const string PartSuggestionsContainer = "SuggestionsContainer";
+ private const string PartHeaderContentPresenter = "HeaderContentPresenter";
+ private const string PartDescriptionPresenter = "DescriptionPresenter";
+
+ private readonly object _tokensLock;
+ private readonly Dictionary _tokens;
+ private readonly ObservableCollection _visibleTokens;
+
+ private Popup _suggestionPopup;
+ private RichEditBox _richEditBox;
+ private ScrollViewer _scrollViewer;
+ private ListViewBase _suggestionsList;
+ private Border _suggestionsContainer;
+
+ private int _suggestionChoice;
+ private bool _ignoreChange;
+ private bool _popupOpenDown;
+ private bool _textCompositionActive;
+ private RichSuggestQuery _currentQuery;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public RichSuggestBox()
+ {
+ _tokensLock = new object();
+ _tokens = new Dictionary();
+ _visibleTokens = new ObservableCollection();
+ Tokens = new ReadOnlyObservableCollection(_visibleTokens);
+
+ DefaultStyleKey = typeof(RichSuggestBox);
+
+ RegisterPropertyChangedCallback(CornerRadiusProperty, OnCornerRadiusChanged);
+ RegisterPropertyChangedCallback(PopupCornerRadiusProperty, OnCornerRadiusChanged);
+ LostFocus += OnLostFocus;
+ Loaded += OnLoaded;
+ }
+
+ ///
+ /// Clear unused tokens and undo/redo history.
+ ///
+ public void ClearUndoRedoSuggestionHistory()
+ {
+ TextDocument.ClearUndoRedoHistory();
+ lock (_tokensLock)
+ {
+ if (_tokens.Count == 0)
+ {
+ return;
+ }
+
+ var keysToDelete = _tokens.Where(pair => !pair.Value.Active).Select(pair => pair.Key).ToArray();
+ foreach (var key in keysToDelete)
+ {
+ _tokens.Remove(key);
+ }
+ }
+ }
+
+ ///
+ /// Clear the document and token list. This will also clear the undo/redo history.
+ ///
+ public void Clear()
+ {
+ lock (_tokensLock)
+ {
+ _tokens.Clear();
+ _visibleTokens.Clear();
+ TextDocument.Selection.Expand(TextRangeUnit.Story);
+ TextDocument.Selection.Delete(TextRangeUnit.Story, 0);
+ TextDocument.ClearUndoRedoHistory();
+ }
+ }
+
+ ///
+ /// Add tokens to be tracked against the document. Duplicate tokens will not be updated.
+ ///
+ /// The collection of tokens to be tracked.
+ public void AddTokens(IEnumerable tokens)
+ {
+ lock (_tokensLock)
+ {
+ foreach (var token in tokens)
+ {
+ _tokens.TryAdd($"\"{token.Id}\"", token);
+ }
+ }
+ }
+
+ ///
+ /// Populate the with an existing Rich Text Format (RTF) document and a collection of tokens.
+ ///
+ /// The Rich Text Format (RTF) text to be imported.
+ /// The collection of tokens embedded in the document.
+ public void Load(string rtf, IEnumerable tokens)
+ {
+ Clear();
+ AddTokens(tokens);
+ TextDocument.SetText(TextSetOptions.FormatRtf, rtf);
+ }
+
+ ///
+ /// Try getting the token associated with a text range.
+ ///
+ /// The range of the token to get.
+ /// When this method returns, contains the token associated with the specified range; otherwise, it is null.
+ /// true if there is a token associated with the text range; otherwise false.
+ public bool TryGetTokenFromRange(ITextRange range, out RichSuggestToken token)
+ {
+ token = null;
+ range = range.GetClone();
+ if (range != null && !string.IsNullOrEmpty(range.Link))
+ {
+ lock (_tokensLock)
+ {
+ return _tokens.TryGetValue(range.Link, out token);
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Retrieves the bounding rectangle that encompasses the text range
+ /// with position measured from the top left of the control.
+ ///
+ /// Text range to retrieve the bounding box from.
+ /// The bounding rectangle.
+ public Rect GetRectFromRange(ITextRange range)
+ {
+ var padding = _richEditBox.Padding;
+ range.GetRect(PointOptions.None, out var rect, out var hit);
+ rect.X += padding.Left - HorizontalOffset;
+ rect.Y += padding.Top - VerticalOffset;
+ var transform = _richEditBox.TransformToVisual(this);
+ return transform.TransformBounds(rect);
+ }
+
+ ///
+ protected override void OnApplyTemplate()
+ {
+ base.OnApplyTemplate();
+ PointerEventHandler pointerPressedHandler = RichEditBox_OnPointerPressed;
+ PointerEventHandler pointerMovedHandler = RichEditBox_OnPointerMoved;
+
+ _suggestionPopup = (Popup)GetTemplateChild(PartSuggestionsPopup);
+ _richEditBox = (RichEditBox)GetTemplateChild(PartRichEditBox);
+ _suggestionsList = (ListViewBase)GetTemplateChild(PartSuggestionsList);
+ _suggestionsContainer = (Border)GetTemplateChild(PartSuggestionsContainer);
+ ConditionallyLoadElement(Header, PartHeaderContentPresenter);
+ ConditionallyLoadElement(Description, PartDescriptionPresenter);
+
+ if (_richEditBox != null)
+ {
+ _richEditBox.SizeChanged -= RichEditBox_SizeChanged;
+ _richEditBox.TextChanging -= RichEditBox_TextChanging;
+ _richEditBox.TextChanged -= RichEditBox_TextChanged;
+ _richEditBox.TextCompositionStarted -= RichEditBox_TextCompositionStarted;
+ _richEditBox.TextCompositionChanged -= RichEditBox_TextCompositionChanged;
+ _richEditBox.TextCompositionEnded -= RichEditBox_TextCompositionEnded;
+ _richEditBox.SelectionChanging -= RichEditBox_SelectionChanging;
+ _richEditBox.SelectionChanged -= RichEditBox_SelectionChanged;
+ _richEditBox.Paste -= RichEditBox_Paste;
+ _richEditBox.PreviewKeyDown -= RichEditBox_PreviewKeyDown;
+ _richEditBox.RemoveHandler(PointerMovedEvent, pointerMovedHandler);
+ _richEditBox.RemoveHandler(PointerPressedEvent, pointerPressedHandler);
+ _richEditBox.ProcessKeyboardAccelerators -= RichEditBox_ProcessKeyboardAccelerators;
+
+ _richEditBox.SizeChanged += RichEditBox_SizeChanged;
+ _richEditBox.TextChanging += RichEditBox_TextChanging;
+ _richEditBox.TextChanged += RichEditBox_TextChanged;
+ _richEditBox.TextCompositionStarted += RichEditBox_TextCompositionStarted;
+ _richEditBox.TextCompositionChanged += RichEditBox_TextCompositionChanged;
+ _richEditBox.TextCompositionEnded += RichEditBox_TextCompositionEnded;
+ _richEditBox.SelectionChanging += RichEditBox_SelectionChanging;
+ _richEditBox.SelectionChanged += RichEditBox_SelectionChanged;
+ _richEditBox.Paste += RichEditBox_Paste;
+ _richEditBox.PreviewKeyDown += RichEditBox_PreviewKeyDown;
+ _richEditBox.AddHandler(PointerMovedEvent, pointerMovedHandler, true);
+ _richEditBox.AddHandler(PointerPressedEvent, pointerPressedHandler, true);
+ _richEditBox.ProcessKeyboardAccelerators += RichEditBox_ProcessKeyboardAccelerators;
+ }
+
+ if (_suggestionsList != null)
+ {
+ _suggestionsList.ItemClick -= SuggestionsList_ItemClick;
+ _suggestionsList.SizeChanged -= SuggestionsList_SizeChanged;
+ _suggestionsList.GotFocus -= SuggestionList_GotFocus;
+
+ _suggestionsList.ItemClick += SuggestionsList_ItemClick;
+ _suggestionsList.SizeChanged += SuggestionsList_SizeChanged;
+ _suggestionsList.GotFocus += SuggestionList_GotFocus;
+ }
+ }
+
+ private static void OnHeaderChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ var view = (RichSuggestBox)d;
+ view.ConditionallyLoadElement(e.NewValue, PartHeaderContentPresenter);
+ }
+
+ private static void OnDescriptionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ var view = (RichSuggestBox)d;
+ view.ConditionallyLoadElement(e.NewValue, PartDescriptionPresenter);
+ }
+
+ private static void OnSuggestionPopupPlacementChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ var view = (RichSuggestBox)d;
+ view.UpdatePopupWidth();
+ }
+
+ private static void OnPrefixesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ var view = (RichSuggestBox)d;
+
+ var newValue = (string)e.NewValue;
+ var prefixes = EnforcePrefixesRequirements(newValue);
+
+ if (newValue != prefixes)
+ {
+ view.SetValue(PrefixesProperty, prefixes);
+ }
+ }
+
+ private void OnCornerRadiusChanged(DependencyObject sender, DependencyProperty dp)
+ {
+ UpdateCornerRadii();
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ _scrollViewer = _richEditBox?.FindDescendant();
+ }
+
+ private void OnLostFocus(object sender, RoutedEventArgs e)
+ {
+ ShowSuggestionsPopup(false);
+ }
+
+ private void SuggestionsList_SizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ if (this._suggestionPopup.IsOpen)
+ {
+ this.UpdatePopupOffset();
+ }
+ }
+
+ private void SuggestionList_GotFocus(object sender, RoutedEventArgs e)
+ {
+ if (_richEditBox != null)
+ {
+ _richEditBox.Focus(FocusState.Programmatic);
+ }
+ }
+
+ private void RichEditBox_OnPointerMoved(object sender, PointerRoutedEventArgs e)
+ {
+ var pointer = e.GetCurrentPoint(this);
+ if (this.TokenPointerOver != null)
+ {
+ this.InvokeTokenPointerOver(pointer);
+ }
+ }
+
+ private void RichEditBox_SelectionChanging(RichEditBox sender, RichEditBoxSelectionChangingEventArgs args)
+ {
+ var selection = TextDocument.Selection;
+
+ if (selection.Type != SelectionType.InsertionPoint && selection.Type != SelectionType.Normal)
+ {
+ return;
+ }
+
+ var range = selection.GetClone();
+ range.Expand(TextRangeUnit.Link);
+ lock (_tokensLock)
+ {
+ if (!_tokens.ContainsKey(range.Link))
+ {
+ return;
+ }
+ }
+
+ ExpandSelectionOnPartialTokenSelect(selection, range);
+ }
+
+ private async void RichEditBox_SelectionChanged(object sender, RoutedEventArgs e)
+ {
+ SelectionChanged?.Invoke(this, e);
+
+ // During text composition changing (e.g. user typing with an IME),
+ // SelectionChanged event is fired multiple times with each keystroke.
+ // To reduce the number of suggestion requests, the request is made
+ // in TextCompositionChanged handler instead.
+ if (_textCompositionActive)
+ {
+ return;
+ }
+
+ await RequestSuggestionsAsync();
+ }
+
+ private void RichEditBox_OnPointerPressed(object sender, PointerRoutedEventArgs e)
+ {
+ ShowSuggestionsPopup(false);
+ }
+
+ private async void RichEditBox_ProcessKeyboardAccelerators(UIElement sender, ProcessKeyboardAcceleratorEventArgs args)
+ {
+ var itemsList = _suggestionsList.Items;
+ if (!_suggestionPopup.IsOpen || itemsList == null || itemsList.Count == 0)
+ {
+ return;
+ }
+
+ var key = args.Key;
+ switch (key)
+ {
+ case VirtualKey.Up when itemsList.Count == 1:
+ case VirtualKey.Down when itemsList.Count == 1:
+ args.Handled = true;
+ UpdateSuggestionsListSelectedItem(1);
+ break;
+
+ case VirtualKey.Up:
+ args.Handled = true;
+ _suggestionChoice = _suggestionChoice <= 0 ? itemsList.Count : _suggestionChoice - 1;
+ UpdateSuggestionsListSelectedItem(this._suggestionChoice);
+ break;
+
+ case VirtualKey.Down:
+ args.Handled = true;
+ _suggestionChoice = _suggestionChoice >= itemsList.Count ? 0 : _suggestionChoice + 1;
+ UpdateSuggestionsListSelectedItem(this._suggestionChoice);
+ break;
+
+ case VirtualKey.Enter when _suggestionsList.SelectedItem != null:
+ args.Handled = true;
+ await CommitSuggestionAsync(_suggestionsList.SelectedItem);
+ break;
+
+ case VirtualKey.Escape:
+ args.Handled = true;
+ ShowSuggestionsPopup(false);
+ break;
+ }
+ }
+
+ private async void RichEditBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
+ {
+ if (e.Key == VirtualKey.Tab && _suggestionPopup.IsOpen && _suggestionsList.SelectedItem != null)
+ {
+ e.Handled = true;
+ await CommitSuggestionAsync(_suggestionsList.SelectedItem);
+ }
+ }
+
+ private async void SuggestionsList_ItemClick(object sender, ItemClickEventArgs e)
+ {
+ var selectedItem = e.ClickedItem;
+ await CommitSuggestionAsync(selectedItem);
+ }
+
+ private void RichEditBox_TextChanging(RichEditBox sender, RichEditBoxTextChangingEventArgs args)
+ {
+ if (_ignoreChange || !args.IsContentChanging)
+ {
+ return;
+ }
+
+ _ignoreChange = true;
+ ValidateTokensInDocument();
+ TextDocument.EndUndoGroup();
+ TextDocument.BeginUndoGroup();
+ _ignoreChange = false;
+ }
+
+ private void RichEditBox_TextChanged(object sender, RoutedEventArgs e)
+ {
+ UpdateVisibleTokenList();
+ TextChanged?.Invoke(this, e);
+ }
+
+ private void RichEditBox_TextCompositionStarted(RichEditBox sender, TextCompositionStartedEventArgs args)
+ {
+ _textCompositionActive = true;
+ }
+
+ private async void RichEditBox_TextCompositionChanged(RichEditBox sender, TextCompositionChangedEventArgs args)
+ {
+ var range = TextDocument.GetRange(args.StartIndex == 0 ? 0 : args.StartIndex - 1, args.StartIndex + args.Length);
+ await RequestSuggestionsAsync(range);
+ }
+
+ private void RichEditBox_TextCompositionEnded(RichEditBox sender, TextCompositionEndedEventArgs args)
+ {
+ _textCompositionActive = false;
+ }
+
+ private void RichEditBox_SizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ this.UpdatePopupWidth();
+ this.UpdatePopupOffset();
+ }
+
+ private async void RichEditBox_Paste(object sender, TextControlPasteEventArgs e)
+ {
+ Paste?.Invoke(this, e);
+
+ if (e.Handled || TextDocument == null || ClipboardPasteFormat != RichEditClipboardFormat.PlainText)
+ {
+ return;
+ }
+
+ e.Handled = true;
+ var dataPackageView = Clipboard.GetContent();
+ if (dataPackageView.Contains(StandardDataFormats.Text))
+ {
+ var text = await dataPackageView.GetTextAsync();
+ TextDocument.Selection.SetText(TextSetOptions.Unhide, text);
+ TextDocument.Selection.Collapse(false);
+ }
+ }
+
+ private void ExpandSelectionOnPartialTokenSelect(ITextSelection selection, ITextRange tokenRange)
+ {
+ switch (selection.Type)
+ {
+ case SelectionType.InsertionPoint:
+ // Snap selection to token on click
+ if (tokenRange.StartPosition < selection.StartPosition && selection.EndPosition < tokenRange.EndPosition)
+ {
+ selection.Expand(TextRangeUnit.Link);
+ InvokeTokenSelected(selection);
+ }
+
+ break;
+
+ case SelectionType.Normal:
+ // We do not want user to partially select a token since pasting to a partial token can break
+ // the token tracking system, which can result in unwanted character formatting issues.
+ if ((tokenRange.StartPosition <= selection.StartPosition && selection.EndPosition < tokenRange.EndPosition) ||
+ (tokenRange.StartPosition < selection.StartPosition && selection.EndPosition <= tokenRange.EndPosition))
+ {
+ // TODO: Figure out how to expand selection without breaking selection flow (with Shift select or pointer sweep select)
+ selection.Expand(TextRangeUnit.Link);
+ InvokeTokenSelected(selection);
+ }
+
+ break;
+ }
+ }
+
+ private void InvokeTokenSelected(ITextSelection selection)
+ {
+ if (TokenSelected == null || !TryGetTokenFromRange(selection, out var token) || token.RangeEnd != selection.EndPosition)
+ {
+ return;
+ }
+
+ TokenSelected.Invoke(this, new RichSuggestTokenSelectedEventArgs
+ {
+ Token = token,
+ Range = selection.GetClone()
+ });
+ }
+
+ private void InvokeTokenPointerOver(PointerPoint pointer)
+ {
+ var pointerPosition = TransformToVisual(_richEditBox).TransformPoint(pointer.Position);
+ var padding = _richEditBox.Padding;
+ pointerPosition.X += HorizontalOffset - padding.Left;
+ pointerPosition.Y += VerticalOffset - padding.Top;
+ var range = TextDocument.GetRangeFromPoint(pointerPosition, PointOptions.ClientCoordinates);
+ var linkRange = range.GetClone();
+ range.Expand(TextRangeUnit.Character);
+ range.GetRect(PointOptions.None, out var hitTestRect, out _);
+ hitTestRect.X -= hitTestRect.Width;
+ hitTestRect.Width *= 2;
+ if (hitTestRect.Contains(pointerPosition) && linkRange.Expand(TextRangeUnit.Link) > 0 &&
+ TryGetTokenFromRange(linkRange, out var token))
+ {
+ this.TokenPointerOver.Invoke(this, new RichSuggestTokenPointerOverEventArgs
+ {
+ Token = token,
+ Range = linkRange,
+ CurrentPoint = pointer
+ });
+ }
+ }
+
+ private async Task RequestSuggestionsAsync(ITextRange range = null)
+ {
+ string prefix;
+ string query;
+ var currentQuery = _currentQuery;
+ var queryFound = range == null
+ ? TryExtractQueryFromSelection(out prefix, out query, out range)
+ : TryExtractQueryFromRange(range, out prefix, out query);
+
+ if (queryFound && prefix == currentQuery?.Prefix && query == currentQuery?.QueryText &&
+ range.EndPosition == currentQuery?.Range.EndPosition && _suggestionPopup.IsOpen)
+ {
+ return;
+ }
+
+ var previousTokenSource = currentQuery?.CancellationTokenSource;
+ if (!(previousTokenSource?.IsCancellationRequested ?? true))
+ {
+ previousTokenSource.Cancel();
+ }
+
+ if (queryFound)
+ {
+ using var tokenSource = new CancellationTokenSource();
+ _currentQuery = new RichSuggestQuery
+ {
+ Prefix = prefix,
+ QueryText = query,
+ Range = range,
+ CancellationTokenSource = tokenSource
+ };
+
+ var cancellationToken = tokenSource.Token;
+ var eventArgs = new SuggestionRequestedEventArgs { QueryText = query, Prefix = prefix };
+ if (SuggestionRequested != null)
+ {
+ try
+ {
+ await SuggestionRequested.InvokeAsync(this, eventArgs, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ }
+
+ if (!eventArgs.Cancel)
+ {
+ _suggestionChoice = 0;
+ ShowSuggestionsPopup(_suggestionsList?.Items?.Count > 0);
+ }
+
+ tokenSource.Cancel();
+ }
+ else
+ {
+ ShowSuggestionsPopup(false);
+ }
+ }
+
+ internal async Task CommitSuggestionAsync(object selectedItem)
+ {
+ var currentQuery = _currentQuery;
+ var range = currentQuery?.Range.GetClone();
+ var id = Guid.NewGuid();
+ var prefix = currentQuery?.Prefix;
+ var query = currentQuery?.QueryText;
+
+ // range has length of 0 at the end of the commit.
+ // Checking length == 0 to avoid committing twice.
+ if (prefix == null || query == null || range == null || range.Length == 0)
+ {
+ return;
+ }
+
+ var textBefore = range.Text;
+ var format = CreateTokenFormat(range);
+ var eventArgs = new SuggestionChosenEventArgs
+ {
+ Id = id,
+ Prefix = prefix,
+ QueryText = query,
+ SelectedItem = selectedItem,
+ DisplayText = query,
+ Format = format
+ };
+
+ if (SuggestionChosen != null)
+ {
+ await SuggestionChosen.InvokeAsync(this, eventArgs);
+ }
+
+ var text = eventArgs.DisplayText;
+
+ // Since this operation is async, the document may have changed at this point.
+ // Double check if the range still has the expected query.
+ if (string.IsNullOrEmpty(text) || textBefore != range.Text ||
+ !TryExtractQueryFromRange(range, out var testPrefix, out var testQuery) ||
+ testPrefix != prefix || testQuery != query)
+ {
+ return;
+ }
+
+ lock (_tokensLock)
+ {
+ var displayText = prefix + text;
+
+ _ignoreChange = true;
+ var committed = TryCommitSuggestionIntoDocument(range, displayText, id, eventArgs.Format ?? format);
+ TextDocument.EndUndoGroup();
+ TextDocument.BeginUndoGroup();
+ _ignoreChange = false;
+
+ if (committed)
+ {
+ var token = new RichSuggestToken(id, displayText) { Active = true, Item = selectedItem };
+ token.UpdateTextRange(range);
+ _tokens.TryAdd(range.Link, token);
+ }
+ }
+ }
+
+ private bool TryCommitSuggestionIntoDocument(ITextRange range, string displayText, Guid id, ITextCharacterFormat format, bool addTrailingSpace = true)
+ {
+ // We don't want to set text when the display text doesn't change since it may lead to unexpected caret move.
+ range.GetText(TextGetOptions.NoHidden, out var existingText);
+ if (existingText != displayText)
+ {
+ range.SetText(TextSetOptions.Unhide, displayText);
+ }
+
+ var formatBefore = range.CharacterFormat.GetClone();
+ range.CharacterFormat.SetClone(format);
+ PadRange(range, formatBefore);
+ range.Link = $"\"{id}\"";
+
+ // In some rare case, setting Link can fail. Only observed when interacting with Undo/Redo feature.
+ if (range.Link != $"\"{id}\"")
+ {
+ range.Delete(TextRangeUnit.Story, -1);
+ return false;
+ }
+
+ if (addTrailingSpace)
+ {
+ var clone = range.GetClone();
+ clone.Collapse(false);
+ clone.SetText(TextSetOptions.Unhide, " ");
+ clone.Collapse(false);
+ TextDocument.Selection.SetRange(clone.EndPosition, clone.EndPosition);
+ }
+
+ return true;
+ }
+
+ private void ValidateTokensInDocument()
+ {
+ lock (_tokensLock)
+ {
+ foreach (var (_, token) in _tokens)
+ {
+ token.Active = false;
+ }
+ }
+
+ ForEachLinkInDocument(TextDocument, ValidateTokenFromRange);
+ }
+
+ private void ValidateTokenFromRange(ITextRange range)
+ {
+ if (range.Length == 0 || !TryGetTokenFromRange(range, out var token))
+ {
+ return;
+ }
+
+ // Check for duplicate tokens. This can happen if the user copies and pastes the token multiple times.
+ if (token.Active && token.RangeStart != range.StartPosition && token.RangeEnd != range.EndPosition)
+ {
+ lock (_tokensLock)
+ {
+ var guid = Guid.NewGuid();
+ if (TryCommitSuggestionIntoDocument(range, token.DisplayText, guid, CreateTokenFormat(range), false))
+ {
+ token = new RichSuggestToken(guid, token.DisplayText) { Active = true, Item = token.Item };
+ token.UpdateTextRange(range);
+ _tokens.Add(range.Link, token);
+ }
+
+ return;
+ }
+ }
+
+ if (token.ToString() != range.Text)
+ {
+ range.Delete(TextRangeUnit.Story, 0);
+ token.Active = false;
+ return;
+ }
+
+ token.UpdateTextRange(range);
+ token.Active = true;
+ }
+
+ private void ConditionallyLoadElement(object property, string elementName)
+ {
+ if (property != null && GetTemplateChild(elementName) is UIElement presenter)
+ {
+ presenter.Visibility = Visibility.Visible;
+ }
+ }
+
+ private void UpdateSuggestionsListSelectedItem(int choice)
+ {
+ var itemsList = _suggestionsList.Items;
+ if (itemsList == null)
+ {
+ return;
+ }
+
+ _suggestionsList.SelectedItem = choice == 0 ? null : itemsList[choice - 1];
+ _suggestionsList.ScrollIntoView(_suggestionsList.SelectedItem);
+ }
+
+ private void ShowSuggestionsPopup(bool show)
+ {
+ if (_suggestionPopup == null)
+ {
+ return;
+ }
+
+ this._suggestionPopup.IsOpen = show;
+ if (!show)
+ {
+ this._suggestionChoice = 0;
+ this._suggestionPopup.VerticalOffset = 0;
+ this._suggestionPopup.HorizontalOffset = 0;
+ this._suggestionsList.SelectedItem = null;
+ this._suggestionsList.ScrollIntoView(this._suggestionsList.Items?.FirstOrDefault());
+ UpdateCornerRadii();
+ }
+ }
+
+ private void UpdatePopupWidth()
+ {
+ if (this._suggestionsContainer == null)
+ {
+ return;
+ }
+
+ if (this.PopupPlacement == SuggestionPopupPlacementMode.Attached)
+ {
+ this._suggestionsContainer.MaxWidth = double.PositiveInfinity;
+ this._suggestionsContainer.Width = this._richEditBox.ActualWidth;
+ }
+ else
+ {
+ this._suggestionsContainer.MaxWidth = this._richEditBox.ActualWidth;
+ this._suggestionsContainer.Width = double.NaN;
+ }
+ }
+
+ ///
+ /// Calculate whether to open the suggestion list up or down depends on how much screen space is available
+ ///
+ private void UpdatePopupOffset()
+ {
+ if (this._suggestionsContainer == null || this._suggestionPopup == null || this._richEditBox == null)
+ {
+ return;
+ }
+
+ this._richEditBox.TextDocument.Selection.GetRect(PointOptions.None, out var selectionRect, out _);
+ Thickness padding = this._richEditBox.Padding;
+ selectionRect.X -= HorizontalOffset;
+ selectionRect.Y -= VerticalOffset;
+
+ // Update horizontal offset
+ if (this.PopupPlacement == SuggestionPopupPlacementMode.Attached)
+ {
+ this._suggestionPopup.HorizontalOffset = 0;
+ }
+ else
+ {
+ double editBoxWidth = this._richEditBox.ActualWidth - padding.Left - padding.Right;
+ if (this._suggestionPopup.HorizontalOffset == 0 && editBoxWidth > 0)
+ {
+ var normalizedX = selectionRect.X / editBoxWidth;
+ this._suggestionPopup.HorizontalOffset =
+ (this._richEditBox.ActualWidth - this._suggestionsContainer.ActualWidth) * normalizedX;
+ }
+ }
+
+ // Update vertical offset
+ double downOffset = this._richEditBox.ActualHeight;
+ double upOffset = -this._suggestionsContainer.ActualHeight;
+ if (this.PopupPlacement == SuggestionPopupPlacementMode.Floating)
+ {
+ downOffset = selectionRect.Bottom + padding.Top + padding.Bottom;
+ upOffset += selectionRect.Top;
+ }
+
+ if (this._suggestionPopup.VerticalOffset == 0)
+ {
+ if (IsElementOnScreen(this._suggestionsContainer, offsetY: downOffset) &&
+ (IsElementInsideWindow(this._suggestionsContainer, offsetY: downOffset) ||
+ !IsElementInsideWindow(this._suggestionsContainer, offsetY: upOffset) ||
+ !IsElementOnScreen(this._suggestionsContainer, offsetY: upOffset)))
+ {
+ this._suggestionPopup.VerticalOffset = downOffset;
+ this._popupOpenDown = true;
+ }
+ else
+ {
+ this._suggestionPopup.VerticalOffset = upOffset;
+ this._popupOpenDown = false;
+ }
+
+ UpdateCornerRadii();
+ }
+ else
+ {
+ this._suggestionPopup.VerticalOffset = this._popupOpenDown ? downOffset : upOffset;
+ }
+ }
+
+ ///
+ /// Set corner radii so that inner corners, where suggestion list and text box connect, are square.
+ /// This only applies when is set to .
+ ///
+ /// https://docs.microsoft.com/en-us/windows/apps/design/style/rounded-corner#when-not-to-round
+ private void UpdateCornerRadii()
+ {
+ if (this._richEditBox == null || this._suggestionsContainer == null ||
+ !ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 7))
+ {
+ return;
+ }
+
+ this._richEditBox.CornerRadius = CornerRadius;
+ this._suggestionsContainer.CornerRadius = PopupCornerRadius;
+
+ if (this._suggestionPopup.IsOpen && PopupPlacement == SuggestionPopupPlacementMode.Attached)
+ {
+ if (this._popupOpenDown)
+ {
+ var cornerRadius = new CornerRadius(CornerRadius.TopLeft, CornerRadius.TopRight, 0, 0);
+ this._richEditBox.CornerRadius = cornerRadius;
+ var popupCornerRadius =
+ new CornerRadius(0, 0, PopupCornerRadius.BottomRight, PopupCornerRadius.BottomLeft);
+ this._suggestionsContainer.CornerRadius = popupCornerRadius;
+ }
+ else
+ {
+ var cornerRadius = new CornerRadius(0, 0, CornerRadius.BottomRight, CornerRadius.BottomLeft);
+ this._richEditBox.CornerRadius = cornerRadius;
+ var popupCornerRadius =
+ new CornerRadius(PopupCornerRadius.TopLeft, PopupCornerRadius.TopRight, 0, 0);
+ this._suggestionsContainer.CornerRadius = popupCornerRadius;
+ }
+ }
+ }
+
+ private bool TryExtractQueryFromSelection(out string prefix, out string query, out ITextRange range)
+ {
+ prefix = string.Empty;
+ query = string.Empty;
+ range = null;
+ if (TextDocument.Selection.Type != SelectionType.InsertionPoint)
+ {
+ return false;
+ }
+
+ // Check if selection is on existing link (suggestion)
+ var expandCount = TextDocument.Selection.GetClone().Expand(TextRangeUnit.Link);
+ if (expandCount != 0)
+ {
+ return false;
+ }
+
+ var selection = TextDocument.Selection.GetClone();
+ selection.MoveStart(TextRangeUnit.Word, -1);
+ if (selection.Length == 0)
+ {
+ return false;
+ }
+
+ range = selection;
+ if (TryExtractQueryFromRange(selection, out prefix, out query))
+ {
+ return true;
+ }
+
+ selection.MoveStart(TextRangeUnit.Word, -1);
+ if (TryExtractQueryFromRange(selection, out prefix, out query))
+ {
+ return true;
+ }
+
+ range = null;
+ return false;
+ }
+
+ private bool TryExtractQueryFromRange(ITextRange range, out string prefix, out string query)
+ {
+ prefix = string.Empty;
+ query = string.Empty;
+ range.GetText(TextGetOptions.NoHidden, out var possibleQuery);
+ if (possibleQuery.Length > 0 && Prefixes.Contains(possibleQuery[0]) &&
+ !possibleQuery.Any(char.IsWhiteSpace) && string.IsNullOrEmpty(range.Link))
+ {
+ if (possibleQuery.Length == 1)
+ {
+ prefix = possibleQuery;
+ return true;
+ }
+
+ prefix = possibleQuery[0].ToString();
+ query = possibleQuery.Substring(1);
+ return true;
+ }
+
+ return false;
+ }
+
+ private ITextCharacterFormat CreateTokenFormat(ITextRange range)
+ {
+ var format = range.CharacterFormat.GetClone();
+ if (this.TokenBackground != null)
+ {
+ format.BackgroundColor = this.TokenBackground.Color;
+ }
+
+ if (this.TokenForeground != null)
+ {
+ format.ForegroundColor = this.TokenForeground.Color;
+ }
+
+ return format;
+ }
+
+ private void UpdateVisibleTokenList()
+ {
+ lock (_tokensLock)
+ {
+ var toBeRemoved = _visibleTokens.Where(x => !x.Active || !_tokens.ContainsKey($"\"{x.Id}\"")).ToArray();
+
+ foreach (var elem in toBeRemoved)
+ {
+ _visibleTokens.Remove(elem);
+ }
+
+ var toBeAdded = _tokens.Where(pair => pair.Value.Active && !_visibleTokens.Contains(pair.Value))
+ .Select(pair => pair.Value).ToArray();
+
+ foreach (var elem in toBeAdded)
+ {
+ _visibleTokens.Add(elem);
+ }
+ }
+ }
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.xaml b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.xaml
new file mode 100644
index 00000000000..aeeb2e851b8
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestBox.xaml
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestQuery.cs b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestQuery.cs
new file mode 100644
index 00000000000..892fd2753ed
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestQuery.cs
@@ -0,0 +1,23 @@
+// 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.Threading;
+using Windows.UI.Text;
+
+namespace Microsoft.Toolkit.Uwp.UI.Controls
+{
+ ///
+ /// A structure for to keep track of the current query internally.
+ ///
+ internal class RichSuggestQuery
+ {
+ public string Prefix { get; set; }
+
+ public string QueryText { get; set; }
+
+ public ITextRange Range { get; set; }
+
+ public CancellationTokenSource CancellationTokenSource { get; set; }
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestToken.cs b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestToken.cs
new file mode 100644
index 00000000000..1cd40a22d4a
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestToken.cs
@@ -0,0 +1,95 @@
+// 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.ComponentModel;
+using Windows.UI.Text;
+
+namespace Microsoft.Toolkit.Uwp.UI.Controls
+{
+ ///
+ /// RichSuggestToken describes a suggestion token in the document.
+ ///
+ public class RichSuggestToken : INotifyPropertyChanged
+ {
+ ///
+ public event PropertyChangedEventHandler PropertyChanged;
+
+ ///
+ /// Gets the token ID.
+ ///
+ public Guid Id { get; }
+
+ ///
+ /// Gets the text displayed in the document.
+ ///
+ public string DisplayText { get; }
+
+ ///
+ /// Gets or sets the suggested item associated with this token.
+ ///
+ public object Item { get; set; }
+
+ ///
+ /// Gets the start position of the text range.
+ ///
+ public int RangeStart { get; private set; }
+
+ ///
+ /// Gets the end position of the text range.
+ ///
+ public int RangeEnd { get; private set; }
+
+ ///
+ /// Gets the start position of the token in number of characters.
+ ///
+ public int Position => _range?.GetIndex(TextRangeUnit.Character) - 1 ?? 0;
+
+ internal bool Active { get; set; }
+
+ private ITextRange _range;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Token ID
+ /// Text in the document
+ public RichSuggestToken(Guid id, string displayText)
+ {
+ Id = id;
+ DisplayText = displayText;
+ }
+
+ internal void UpdateTextRange(ITextRange range)
+ {
+ bool rangeStartChanged = RangeStart != range.StartPosition;
+ bool rangeEndChanged = RangeEnd != range.EndPosition;
+ bool positionChanged = _range == null || rangeStartChanged;
+ _range = range.GetClone();
+ RangeStart = _range.StartPosition;
+ RangeEnd = _range.EndPosition;
+
+ if (rangeStartChanged)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(RangeStart)));
+ }
+
+ if (rangeEndChanged)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(RangeEnd)));
+ }
+
+ if (positionChanged)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Position)));
+ }
+ }
+
+ ///
+ public override string ToString()
+ {
+ return $"HYPERLINK \"{Id}\"\u200B{DisplayText}\u200B";
+ }
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestTokenPointerOverEventArgs.cs b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestTokenPointerOverEventArgs.cs
new file mode 100644
index 00000000000..d784abe839c
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestTokenPointerOverEventArgs.cs
@@ -0,0 +1,31 @@
+// 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 Windows.UI.Input;
+using Windows.UI.Text;
+
+namespace Microsoft.Toolkit.Uwp.UI.Controls
+{
+ ///
+ /// Provides data for event.
+ ///
+ public class RichSuggestTokenPointerOverEventArgs : EventArgs
+ {
+ ///
+ /// Gets or sets the selected token.
+ ///
+ public RichSuggestToken Token { get; set; }
+
+ ///
+ /// Gets or sets the range associated with the token.
+ ///
+ public ITextRange Range { get; set; }
+
+ ///
+ /// Gets or sets a PointerPoint object relative to the control.
+ ///
+ public PointerPoint CurrentPoint { get; set; }
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestTokenSelectedEventArgs.cs b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestTokenSelectedEventArgs.cs
new file mode 100644
index 00000000000..9711eb617af
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/RichSuggestTokenSelectedEventArgs.cs
@@ -0,0 +1,25 @@
+// 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 Windows.UI.Text;
+
+namespace Microsoft.Toolkit.Uwp.UI.Controls
+{
+ ///
+ /// Provides data for event.
+ ///
+ public class RichSuggestTokenSelectedEventArgs : EventArgs
+ {
+ ///
+ /// Gets or sets the selected token.
+ ///
+ public RichSuggestToken Token { get; set; }
+
+ ///
+ /// Gets or sets the range associated with the token.
+ ///
+ public ITextRange Range { get; set; }
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/SuggestionChosenEventArgs.cs b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/SuggestionChosenEventArgs.cs
new file mode 100644
index 00000000000..b465e3ed5de
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/SuggestionChosenEventArgs.cs
@@ -0,0 +1,46 @@
+// 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.Toolkit.Deferred;
+using Windows.UI.Text;
+
+namespace Microsoft.Toolkit.Uwp.UI.Controls
+{
+ ///
+ /// Provides data for the event.
+ ///
+ public class SuggestionChosenEventArgs : DeferredEventArgs
+ {
+ ///
+ /// Gets the query used for this token.
+ ///
+ public string QueryText { get; internal set; }
+
+ ///
+ /// Gets the prefix character used for this token.
+ ///
+ public string Prefix { get; internal set; }
+
+ ///
+ /// Gets or sets the display text.
+ ///
+ public string DisplayText { get; set; }
+
+ ///
+ /// Gets the suggestion item associated with this token.
+ ///
+ public object SelectedItem { get; internal set; }
+
+ ///
+ /// Gets token ID.
+ ///
+ public Guid Id { get; internal set; }
+
+ ///
+ /// Gets or sets the object used to format the display text for this token.
+ ///
+ public ITextCharacterFormat Format { get; set; }
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/SuggestionPopupPlacementMode.cs b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/SuggestionPopupPlacementMode.cs
new file mode 100644
index 00000000000..b446b45b93d
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/SuggestionPopupPlacementMode.cs
@@ -0,0 +1,28 @@
+// 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 Windows.UI.Xaml.Controls;
+
+namespace Microsoft.Toolkit.Uwp.UI.Controls
+{
+ ///
+ /// Placement modes for the suggestion popup in .
+ ///
+ public enum SuggestionPopupPlacementMode
+ {
+ ///
+ /// Suggestion popup floats above or below the typing caret.
+ ///
+ Floating,
+
+ ///
+ /// Suggestion popup is attached to either the top edge or the bottom edge of the text box.
+ ///
+ ///
+ /// In this mode, popup width will be text box's width and the interior corners that connect the text box and the popup are square.
+ /// This is the same behavior as in .
+ ///
+ Attached
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/SuggestionRequestedEventArgs.cs b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/SuggestionRequestedEventArgs.cs
new file mode 100644
index 00000000000..21c8655bb0f
--- /dev/null
+++ b/Microsoft.Toolkit.Uwp.UI.Controls.Input/RichSuggestBox/SuggestionRequestedEventArgs.cs
@@ -0,0 +1,24 @@
+// 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.Toolkit.Deferred;
+
+namespace Microsoft.Toolkit.Uwp.UI.Controls
+{
+ ///
+ /// Provide data for event.
+ ///
+ public class SuggestionRequestedEventArgs : DeferredCancelEventArgs
+ {
+ ///
+ /// Gets or sets the prefix character used for the query.
+ ///
+ public string Prefix { get; set; }
+
+ ///
+ /// Gets or sets the query for suggestions.
+ ///
+ public string QueryText { get; set; }
+ }
+}
diff --git a/Microsoft.Toolkit.Uwp.UI.Controls.Input/Themes/Generic.xaml b/Microsoft.Toolkit.Uwp.UI.Controls.Input/Themes/Generic.xaml
index 85a16de6c5c..761bf93fb6c 100644
--- a/Microsoft.Toolkit.Uwp.UI.Controls.Input/Themes/Generic.xaml
+++ b/Microsoft.Toolkit.Uwp.UI.Controls.Input/Themes/Generic.xaml
@@ -8,5 +8,6 @@
+
\ No newline at end of file
diff --git a/Microsoft.Toolkit.Uwp/Extensions/RectExtensions.cs b/Microsoft.Toolkit.Uwp/Extensions/RectExtensions.cs
index 367bfa3e105..6e9a1e66ec5 100644
--- a/Microsoft.Toolkit.Uwp/Extensions/RectExtensions.cs
+++ b/Microsoft.Toolkit.Uwp/Extensions/RectExtensions.cs
@@ -4,7 +4,9 @@
using System.Diagnostics.Contracts;
using System.Runtime.CompilerServices;
+using Windows.Foundation;
using Rect = Windows.Foundation.Rect;
+using Size = Windows.Foundation.Size;
namespace Microsoft.Toolkit.Uwp
{
@@ -33,5 +35,17 @@ public static bool IntersectsWith(this Rect rect1, Rect rect2)
(rect1.Top <= rect2.Bottom) &&
(rect1.Bottom >= rect2.Top);
}
+
+ ///
+ /// Creates a new of the specified 's width and height.
+ ///
+ /// Rectangle to size.
+ /// Size of rectangle.
+ [Pure]
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Size ToSize(this Rect rect)
+ {
+ return new Size(rect.Width, rect.Height);
+ }
}
}
\ No newline at end of file
diff --git a/UITests/UITests.App/App.xaml b/UITests/UITests.App/App.xaml
index e923b2739ef..ba6c10cd397 100644
--- a/UITests/UITests.App/App.xaml
+++ b/UITests/UITests.App/App.xaml
@@ -1,4 +1,9 @@
+ xmlns:controls="using:Microsoft.UI.Xaml.Controls"
+ xmlns:local="using:UITests.App">
+
+
+
+
diff --git a/UITests/UITests.App/UITests.App.csproj b/UITests/UITests.App/UITests.App.csproj
index d0eb10b9c05..a502e95f063 100644
--- a/UITests/UITests.App/UITests.App.csproj
+++ b/UITests/UITests.App/UITests.App.csproj
@@ -1,4 +1,4 @@
-
+
Debug
@@ -168,6 +168,9 @@
6.2.12
+
+ 2.6.1
+
0.0.4
diff --git a/UITests/UITests.Tests.Shared/Controls/RichSuggestBoxTest.cs b/UITests/UITests.Tests.Shared/Controls/RichSuggestBoxTest.cs
new file mode 100644
index 00000000000..e29fe4f63a4
--- /dev/null
+++ b/UITests/UITests.Tests.Shared/Controls/RichSuggestBoxTest.cs
@@ -0,0 +1,77 @@
+// 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.Windows.Apps.Test.Foundation;
+using Microsoft.Windows.Apps.Test.Foundation.Controls;
+using Windows.UI.Xaml.Tests.MUXControls.InteractionTests.Common;
+using Windows.UI.Xaml.Tests.MUXControls.InteractionTests.Infra;
+using Microsoft.Windows.Apps.Test.Automation;
+
+#if USING_TAEF
+using WEX.Logging.Interop;
+using WEX.TestExecution;
+using WEX.TestExecution.Markup;
+#else
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+#endif
+
+namespace UITests.Tests
+{
+ [TestClass]
+ public class RichSuggestBoxTest : UITestBase
+ {
+ [ClassInitialize]
+ [TestProperty("RunAs", "User")]
+ [TestProperty("Classification", "ScenarioTestSuite")]
+ [TestProperty("Platform", "Any")]
+ public static void ClassInitialize(TestContext testContext)
+ {
+ TestEnvironment.Initialize(testContext, WinUICsUWPSampleApp);
+ }
+
+ [TestMethod]
+ [TestPage("RichSuggestBoxTestPage")]
+ public void RichSuggestBox_DefaultTest()
+ {
+ var richSuggestBox = FindElement.ByName("richSuggestBox");
+ var richEditBox = new TextBlock(FindElement.ByClassName("RichEditBox"));
+ var tokenCounter = new TextBlock(FindElement.ById("tokenCounter"));
+ var tokenListView = FindElement.ById("tokenListView");
+
+ Verify.AreEqual(string.Empty, richEditBox.GetText());
+
+ richEditBox.SendKeys("Hello@Test1");
+
+ var suggestListView = richSuggestBox.Descendants.Find(UICondition.CreateFromClassName("ListView"));
+ Verify.IsNotNull(suggestListView);
+ Verify.AreEqual(3, suggestListView.Children.Count);
+ InputHelper.LeftClick(suggestListView.Children[0]);
+
+ var tokenInfo1 = tokenListView.Children[0];
+ var text = "Hello\u200b@Test1Token1\u200b ";
+ var actualText = richEditBox.GetText(false);
+ Verify.AreEqual(text, actualText);
+ Verify.AreEqual("1", tokenCounter.GetText());
+ Verify.AreEqual("Token1", tokenInfo1.Children[0].GetText());
+ Verify.AreEqual("5", tokenInfo1.Children[1].GetText());
+
+ richEditBox.SendKeys("@Test2");
+ Verify.AreEqual(3, suggestListView.Children.Count);
+ InputHelper.LeftClick(suggestListView.Children[1]);
+
+ var tokenInfo2 = tokenListView.Children[1];
+ text = "Hello\u200b@Test1Token1\u200b \u200b@Test2Token2\u200b ";
+ actualText = richEditBox.GetText(false);
+ Verify.AreEqual(text, actualText);
+ Verify.AreEqual("2", tokenCounter.GetText());
+ Verify.AreEqual("Token2", tokenInfo2.Children[0].GetText());
+ Verify.AreEqual("68", tokenInfo2.Children[1].GetText());
+
+ KeyboardHelper.PressKey(Key.Home);
+ richEditBox.SendKeys(" ");
+ Verify.AreEqual("6", tokenInfo1.Children[1].GetText());
+ Verify.AreEqual("69", tokenInfo2.Children[1].GetText());
+ }
+ }
+}
\ No newline at end of file
diff --git a/UITests/UITests.Tests.Shared/Controls/RichSuggestBoxTestPage.xaml b/UITests/UITests.Tests.Shared/Controls/RichSuggestBoxTestPage.xaml
new file mode 100644
index 00000000000..72250ea9e16
--- /dev/null
+++ b/UITests/UITests.Tests.Shared/Controls/RichSuggestBoxTestPage.xaml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/UITests/UITests.Tests.Shared/Controls/RichSuggestBoxTestPage.xaml.cs b/UITests/UITests.Tests.Shared/Controls/RichSuggestBoxTestPage.xaml.cs
new file mode 100644
index 00000000000..cfe45370b64
--- /dev/null
+++ b/UITests/UITests.Tests.Shared/Controls/RichSuggestBoxTestPage.xaml.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 System.Collections.Generic;
+using Microsoft.Toolkit.Uwp.UI.Controls;
+using Windows.UI.Xaml.Controls;
+
+namespace UITests.App.Pages
+{
+ ///
+ /// An empty page that can be used on its own or navigated to within a Frame.
+ ///
+ public sealed partial class RichSuggestBoxTestPage : Page
+ {
+ private static readonly List _suggestions = new() { "Token1", "Token2", "Token3" };
+
+ public RichSuggestBoxTestPage()
+ {
+ this.InitializeComponent();
+ }
+
+ private void RichSuggestBox_OnSuggestionRequested(RichSuggestBox sender, SuggestionRequestedEventArgs args)
+ {
+ sender.ItemsSource = _suggestions;
+ }
+
+ private void RichSuggestBox_OnSuggestionChosen(RichSuggestBox sender, SuggestionChosenEventArgs args)
+ {
+ args.DisplayText = args.QueryText + (string)args.SelectedItem;
+ }
+ }
+}
\ No newline at end of file
diff --git a/UnitTests/UnitTests.UWP/UI/Controls/Test_RichSuggestBox.cs b/UnitTests/UnitTests.UWP/UI/Controls/Test_RichSuggestBox.cs
new file mode 100644
index 00000000000..ea6af4c911d
--- /dev/null
+++ b/UnitTests/UnitTests.UWP/UI/Controls/Test_RichSuggestBox.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.Linq;
+using System.Threading.Tasks;
+using Windows.UI.Text;
+using Microsoft.Toolkit.Uwp;
+using Microsoft.Toolkit.Uwp.UI.Controls;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace UnitTests.UWP.UI.Controls
+{
+ [TestClass]
+ public class Test_RichSuggestBox : VisualUITestBase
+ {
+ [TestCategory(nameof(RichSuggestBox))]
+ [TestMethod]
+ [DataRow("@Token1", "@Token2", "@Token3")]
+ [DataRow("@Token1", "@Token2", "#Token3")]
+ [DataRow("#Token1", "@Token2", "@Token3")]
+ public async Task Test_RichSuggestBox_AddTokens(string tokenText1, string tokenText2, string tokenText3)
+ {
+ await App.DispatcherQueue.EnqueueAsync(async () =>
+ {
+ var rsb = new RichSuggestBox() { Prefixes = "@#" };
+ await SetTestContentAsync(rsb);
+ var document = rsb.TextDocument;
+
+ // Adding token 1
+ await TestAddTokenAsync(rsb, tokenText1);
+
+ Assert.AreEqual(1, rsb.Tokens.Count, "Token count is not 1 after committing 1 token.");
+
+ var token1 = rsb.Tokens.Last();
+
+ AssertToken(rsb, token1);
+ var expectedStory = $"{token1} \r";
+ document.GetText(TextGetOptions.None, out var actualStory);
+ Assert.AreEqual(expectedStory, actualStory);
+
+ // Adding token 2 with space between previous token
+ await TestAddTokenAsync(rsb, tokenText2);
+
+ Assert.AreEqual(2, rsb.Tokens.Count, "Token count is not 2 after committing 2 token.");
+
+ var token2 = rsb.Tokens.Last();
+
+ AssertToken(rsb, token2);
+ expectedStory = $"{token1} {token2} \r";
+ document.GetText(TextGetOptions.None, out actualStory);
+ Assert.AreEqual(expectedStory, actualStory);
+
+ // Adding token 3 without space between previous token
+ rsb.TextDocument.Selection.Delete(TextRangeUnit.Character, -1);
+ await TestAddTokenAsync(rsb, tokenText3);
+
+ Assert.AreEqual(3, rsb.Tokens.Count, "Token count is not 3 after committing 3 token.");
+
+ var token3 = rsb.Tokens.Last();
+
+ AssertToken(rsb, token3);
+ expectedStory = $"{token1} {token2}{token3} \r";
+ document.GetText(TextGetOptions.None, out actualStory);
+ Assert.AreEqual(expectedStory, actualStory);
+
+ document.Selection.Delete(TextRangeUnit.Character, -1);
+ Assert.AreEqual(3, rsb.Tokens.Count, "Token at the end of the document is not recognized.");
+ });
+ }
+
+ [TestCategory(nameof(RichSuggestBox))]
+ [TestMethod]
+ public async Task Test_RichSuggestBox_CustomizeToken()
+ {
+ await App.DispatcherQueue.EnqueueAsync(async () =>
+ {
+ var rsb = new RichSuggestBox() { Prefixes = "@" };
+ await SetTestContentAsync(rsb);
+ var inputText = "@Placeholder";
+ var expectedText = "@Token";
+
+ rsb.SuggestionChosen += (rsb, e) =>
+ {
+ e.DisplayText = expectedText.Substring(1);
+ var format = e.Format;
+ format.BackgroundColor = Windows.UI.Colors.Beige;
+ format.ForegroundColor = Windows.UI.Colors.Azure;
+ format.Bold = FormatEffect.On;
+ format.Italic = FormatEffect.On;
+ format.Size = 9;
+ };
+
+ await AddTokenAsync(rsb, inputText);
+
+ Assert.AreEqual(1, rsb.Tokens.Count, "Token count is not 1 after committing 1 token.");
+
+ var defaultFormat = rsb.TextDocument.GetDefaultCharacterFormat();
+ var token = rsb.Tokens[0];
+ var range = rsb.TextDocument.GetRange(token.RangeStart, token.RangeEnd);
+ Assert.AreEqual(expectedText, token.DisplayText, "Unexpected token text.");
+ Assert.AreEqual(range.Text, token.ToString());
+
+ var prePad = range.GetClone();
+ prePad.SetRange(range.StartPosition, range.StartPosition + 1);
+ Assert.AreEqual(defaultFormat.BackgroundColor, prePad.CharacterFormat.BackgroundColor, "Unexpected background color for pre padding.");
+ Assert.AreEqual(defaultFormat.ForegroundColor, prePad.CharacterFormat.ForegroundColor, "Unexpected foreground color for pre padding.");
+
+ var postPad = range.GetClone();
+ postPad.SetRange(range.EndPosition - 1, range.EndPosition);
+ Assert.AreEqual(defaultFormat.BackgroundColor, postPad.CharacterFormat.BackgroundColor, "Unexpected background color for post padding.");
+ Assert.AreEqual(defaultFormat.ForegroundColor, postPad.CharacterFormat.ForegroundColor, "Unexpected foreground color for post padding.");
+
+ var hiddenText = $"HYPERLINK \"{token.Id}\"\u200B";
+ range.SetRange(range.StartPosition + hiddenText.Length, range.EndPosition - 1);
+ Assert.AreEqual(Windows.UI.Colors.Beige, range.CharacterFormat.BackgroundColor, "Unexpected token background color.");
+ Assert.AreEqual(Windows.UI.Colors.Azure, range.CharacterFormat.ForegroundColor, "Unexpected token foreground color.");
+ Assert.AreEqual(FormatEffect.On, range.CharacterFormat.Bold, "Token is expected to be bold.");
+ Assert.AreEqual(FormatEffect.On, range.CharacterFormat.Italic, "Token is expected to be italic.");
+ Assert.AreEqual(9, range.CharacterFormat.Size, "Unexpected token font size.");
+ });
+ }
+
+ [TestCategory(nameof(RichSuggestBox))]
+ [TestMethod]
+ [DataRow("@Token1", "@Token2")]
+ [DataRow("@Token1", "#Token2")]
+ [DataRow("#Token1", "@Token2")]
+ public async Task Test_RichSuggestBox_DeleteTokens(string token1, string token2)
+ {
+ await App.DispatcherQueue.EnqueueAsync(async () =>
+ {
+ var rsb = new RichSuggestBox() { Prefixes = "@#" };
+ await SetTestContentAsync(rsb);
+ var document = rsb.TextDocument;
+ var selection = document.Selection;
+
+ await AddTokenAsync(rsb, token1);
+ await AddTokenAsync(rsb, token2);
+
+ Assert.AreEqual(2, rsb.Tokens.Count, "Unexpected token count after adding.");
+
+ // Delete token as a whole
+ selection.Delete(TextRangeUnit.Character, -1);
+ selection.Delete(TextRangeUnit.Link, -1);
+ await Task.Delay(10);
+
+ Assert.AreEqual(1, rsb.Tokens.Count, "Unexpected token count after deleting token 2");
+
+ // Partially delete a token
+ selection.Delete(TextRangeUnit.Character, -2);
+ await Task.Delay(10);
+
+ Assert.AreEqual(0, rsb.Tokens.Count, "Unexpected token count after deleting token 1");
+ });
+ }
+
+ [TestCategory(nameof(RichSuggestBox))]
+ [TestMethod]
+ public async Task Test_RichSuggestBox_ReplaceToken()
+ {
+ await App.DispatcherQueue.EnqueueAsync(async () =>
+ {
+ var rsb = new RichSuggestBox() { Prefixes = "@" };
+ await SetTestContentAsync(rsb);
+ var document = rsb.TextDocument;
+ var selection = document.Selection;
+
+ await AddTokenAsync(rsb, "@Before");
+ var tokenBefore = rsb.Tokens[0];
+ AssertToken(rsb, tokenBefore);
+
+ selection.Delete(TextRangeUnit.Character, -2);
+ await Task.Delay(10);
+
+ await AddTokenAsync(rsb, "@After");
+ var tokenAfter = rsb.Tokens[0];
+ AssertToken(rsb, tokenAfter);
+
+ Assert.AreNotSame(tokenBefore, tokenAfter, "Token before and token after are the same.");
+ Assert.AreNotEqual(tokenBefore.Id, tokenAfter.Id, "Token ID before and token ID after are the same.");
+ });
+ }
+
+ [TestCategory(nameof(RichSuggestBox))]
+ [TestMethod]
+ public async Task Test_RichSuggestBox_FormatReset()
+ {
+ await App.DispatcherQueue.EnqueueAsync(async () =>
+ {
+ var rsb = new RichSuggestBox() { Prefixes = "@" };
+ rsb.TokenBackground = new Windows.UI.Xaml.Media.SolidColorBrush(Windows.UI.Colors.Azure);
+ await SetTestContentAsync(rsb);
+ var document = rsb.TextDocument;
+ var selection = document.Selection;
+ var defaultFormat = document.GetDefaultCharacterFormat();
+
+ await AddTokenAsync(rsb, "@Token1");
+ selection.Delete(TextRangeUnit.Character, -1);
+ var middlePosition = selection.StartPosition;
+ await AddTokenAsync(rsb, "@Token2");
+ selection.Delete(TextRangeUnit.Character, -1);
+
+ await Task.Delay(10);
+ selection.SetText(TextSetOptions.Unhide, "text");
+ Assert.AreEqual(defaultFormat.BackgroundColor, selection.CharacterFormat.BackgroundColor, "Raw text have background color after a token.");
+
+ selection.SetRange(middlePosition, middlePosition);
+ await Task.Delay(10);
+ selection.SetText(TextSetOptions.Unhide, "text");
+ Assert.AreEqual(defaultFormat.BackgroundColor, selection.CharacterFormat.BackgroundColor, "Raw text have background color when sandwiched between 2 tokens.");
+
+ selection.SetRange(0, 0);
+ await Task.Delay(10);
+ selection.SetText(TextSetOptions.Unhide, "text");
+ Assert.AreEqual(defaultFormat.BackgroundColor, selection.CharacterFormat.BackgroundColor, "Raw text have background color when insert at beginning of the document.");
+ });
+ }
+
+ [TestCategory(nameof(RichSuggestBox))]
+ [TestMethod]
+ public async Task Test_RichSuggestBox_Clear()
+ {
+ await App.DispatcherQueue.EnqueueAsync(async () =>
+ {
+ var rsb = new RichSuggestBox();
+ await SetTestContentAsync(rsb);
+
+ var document = rsb.TextDocument;
+ var selection = document.Selection;
+ selection.TypeText("before ");
+ await AddTokenAsync(rsb, "@Token");
+ selection.TypeText("after");
+ document.GetText(TextGetOptions.NoHidden, out var text);
+
+ Assert.AreEqual(1, rsb.Tokens.Count, "Unexpected tokens count before clear.");
+ Assert.IsTrue(document.CanUndo(), "Document cannot undo before clear.");
+ Assert.AreEqual("before \u200B@Token\u200B after", text);
+
+ rsb.Clear();
+ document.GetText(TextGetOptions.NoHidden, out text);
+
+ Assert.AreEqual(0, rsb.Tokens.Count, "Unexpected tokens count after clear.");
+ Assert.IsFalse(document.CanUndo(), "Document can undo after clear.");
+ Assert.AreEqual(string.Empty, text);
+ });
+ }
+
+ [TestCategory(nameof(RichSuggestBox))]
+ [TestMethod]
+ public async Task Test_RichSuggestBox_ClearUndoRedoHistory()
+ {
+ await App.DispatcherQueue.EnqueueAsync(async () =>
+ {
+ var rsb = new RichSuggestBox();
+ await SetTestContentAsync(rsb);
+
+ var document = rsb.TextDocument;
+ var selection = document.Selection;
+ selection.TypeText("before ");
+ await AddTokenAsync(rsb, "@Token");
+ selection.TypeText("after");
+ document.GetText(TextGetOptions.NoHidden, out var text);
+
+ Assert.AreEqual(1, rsb.Tokens.Count, "Unexpected tokens count before clear.");
+ Assert.IsTrue(document.CanUndo(), "Document cannot undo before clear.");
+ Assert.AreEqual("before \u200B@Token\u200B after", text);
+
+ rsb.ClearUndoRedoSuggestionHistory();
+ document.GetText(TextGetOptions.NoHidden, out text);
+
+ Assert.AreEqual(1, rsb.Tokens.Count, "Unexpected tokens count after clear.");
+ Assert.IsFalse(document.CanUndo(), "Document can undo after clear.");
+ Assert.AreEqual("before \u200B@Token\u200B after", text);
+ });
+ }
+
+ [TestCategory(nameof(RichSuggestBox))]
+ [TestMethod]
+ public async Task Test_RichSuggestBox_Load()
+ {
+ const string rtf = @"{\rtf1\fbidis\ansi\ansicpg1252\deff0\nouicompat\deflang1033{\fonttbl{\f0\fnil\fcharset0 Segoe UI;}{\f1\fnil Segoe UI;}}
+{\colortbl ;\red255\green255\blue255;\red0\green0\blue255;\red41\green150\blue204;}
+{\*\generator Riched20 10.0.19041}\viewkind4\uc1
+\pard\tx720\cf1\f0\fs21\lang4105 Hello {{\field{\*\fldinst{HYPERLINK ""c3b58ee9-df54-4686-b295-f203a5d8809a""}}{\fldrslt{\ul\cf2\u8203?\cf3\highlight1 @Michael Hawker\cf1\highlight0\u8203?}}}}\f1\fs21 \f0 from {{\field{\*\fldinst{HYPERLINK ""1c6a71c3-f81f-4a27-8f17-50d64acd5b61""}}{\fldrslt{\ul\cf2\u8203?\cf3\highlight1 @Tung Huynh\cf1\highlight0\u8203?}}}}\f1\fs21\par
+}
+";
+ var token1 = new RichSuggestToken(Guid.Parse("c3b58ee9-df54-4686-b295-f203a5d8809a"), "@Michael Hawker");
+ var token2 = new RichSuggestToken(Guid.Parse("1c6a71c3-f81f-4a27-8f17-50d64acd5b61"), "@Tung Huynh");
+
+ await App.DispatcherQueue.EnqueueAsync(async () =>
+ {
+ var rsb = new RichSuggestBox();
+ await SetTestContentAsync(rsb);
+
+ var document = rsb.TextDocument;
+ var selection = document.Selection;
+ selection.TypeText("before ");
+ await AddTokenAsync(rsb, "@Token");
+ selection.TypeText("after");
+
+ rsb.Load(rtf, new[] { token1, token2 });
+ await Task.Delay(10);
+ document.GetText(TextGetOptions.NoHidden, out var text);
+
+ Assert.AreEqual(2, rsb.Tokens.Count, "Unexpected tokens count after load.");
+ Assert.AreEqual("Hello \u200b@Michael Hawker\u200b from \u200b@Tung Huynh\u200b\r", text, "Unexpected document text.");
+ AssertToken(rsb, token1);
+ AssertToken(rsb, token2);
+ });
+ }
+
+ private static void AssertToken(RichSuggestBox rsb, RichSuggestToken token)
+ {
+ var document = rsb.TextDocument;
+ var tokenRange = document.GetRange(token.RangeStart, token.RangeEnd);
+ Assert.AreEqual(token.ToString(), tokenRange.Text);
+ Assert.AreEqual($"\"{token.Id}\"", tokenRange.Link, "Unexpected link value.");
+ Assert.AreEqual(LinkType.FriendlyLinkAddress, tokenRange.CharacterFormat.LinkType, "Unexpected link type.");
+ }
+
+ private static async Task TestAddTokenAsync(RichSuggestBox rsb, string tokenText)
+ {
+ bool suggestionsRequestedCalled = false;
+ bool suggestionChosenCalled = false;
+
+ void SuggestionsRequestedHandler(RichSuggestBox sender, SuggestionRequestedEventArgs args)
+ {
+ suggestionsRequestedCalled = true;
+ Assert.AreEqual(tokenText[0].ToString(), args.Prefix, $"Unexpected prefix in {nameof(RichSuggestBox.SuggestionRequested)}.");
+ Assert.AreEqual(tokenText.Substring(1), args.QueryText, $"Unexpected query in {nameof(RichSuggestBox.SuggestionRequested)}.");
+ }
+
+ void SuggestionChosenHandler(RichSuggestBox sender, SuggestionChosenEventArgs args)
+ {
+ suggestionChosenCalled = true;
+ Assert.AreEqual(tokenText[0].ToString(), args.Prefix, $"Unexpected prefix in {nameof(RichSuggestBox.SuggestionChosen)}.");
+ Assert.AreEqual(tokenText.Substring(1), args.QueryText, $"Unexpected query in {nameof(RichSuggestBox.SuggestionChosen)}.");
+ Assert.AreEqual(args.QueryText, args.DisplayText, $"Unexpected display text in {nameof(RichSuggestBox.SuggestionChosen)}.");
+ Assert.AreSame(tokenText, args.SelectedItem, $"Selected item has unknown object {args.SelectedItem} in {nameof(RichSuggestBox.SuggestionChosen)}.");
+ }
+
+ rsb.SuggestionRequested += SuggestionsRequestedHandler;
+ rsb.SuggestionChosen += SuggestionChosenHandler;
+
+ await AddTokenAsync(rsb, tokenText);
+
+ rsb.SuggestionRequested -= SuggestionsRequestedHandler;
+ rsb.SuggestionChosen -= SuggestionChosenHandler;
+
+ Assert.IsTrue(suggestionsRequestedCalled, $"{nameof(RichSuggestBox.SuggestionRequested)} was not invoked.");
+ Assert.IsTrue(suggestionChosenCalled, $"{nameof(RichSuggestBox.SuggestionChosen)} was not invoked.");
+ }
+
+ private static async Task AddTokenAsync(RichSuggestBox rsb, string tokenText)
+ {
+ var selection = rsb.TextDocument.Selection;
+ selection.TypeText(tokenText);
+ await Task.Delay(10); // Wait for SelectionChanged to be invoked
+ await rsb.CommitSuggestionAsync(tokenText);
+ await Task.Delay(10); // Wait for TextChanged to be invoked
+ }
+ }
+}
diff --git a/UnitTests/UnitTests.UWP/UnitTests.UWP.csproj b/UnitTests/UnitTests.UWP/UnitTests.UWP.csproj
index 734228e69b1..fcbc41cf708 100644
--- a/UnitTests/UnitTests.UWP/UnitTests.UWP.csproj
+++ b/UnitTests/UnitTests.UWP/UnitTests.UWP.csproj
@@ -132,10 +132,10 @@
2.6.1
- 2.1.2
+ 2.2.5
- 2.1.2
+ 2.2.5
@@ -234,6 +234,7 @@
+