diff --git a/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj b/Microsoft.Toolkit.Uwp.SampleApp/Microsoft.Toolkit.Uwp.SampleApp.csproj index 44027e28bc7..d6b044e7526 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 6e9841a5a36..50b8eb710dc 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 769f3737d85..20c6e4582ea 100644 --- a/UnitTests/UnitTests.UWP/UnitTests.UWP.csproj +++ b/UnitTests/UnitTests.UWP/UnitTests.UWP.csproj @@ -132,10 +132,10 @@ 2.6.2 - 2.1.2 + 2.2.5 - 2.1.2 + 2.2.5 @@ -238,6 +238,7 @@ +