From 84af471cb6ca3f89e10058cae76046e97ca374d4 Mon Sep 17 00:00:00 2001 From: Mike Griese Date: Fri, 21 Nov 2025 06:38:08 -0600 Subject: [PATCH] Resurrect the suggestion page --- .../ListViewModel.cs | 18 + .../Controls/SearchBar.xaml.cs | 44 +++ .../doc/initial-sdk-spec/initial-sdk-spec.md | 39 ++ .../Pages/SampleSuggestionsPage.cs | 361 ++++++++++++++++++ .../SamplePagesExtension/SamplesListPage.cs | 8 +- 5 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleSuggestionsPage.cs diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs index f9d58d0e68..9c4a57e911 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs @@ -66,6 +66,8 @@ public partial class ListViewModel : PageViewModel, IDisposable public bool IsMainPage { get; init; } + public bool IsTokenSearch { get; private set; } + private bool _isDynamic; private Task? _initializeItemsTask; @@ -604,6 +606,11 @@ public partial class ListViewModel : PageViewModel, IDisposable Filters?.InitializeProperties(); UpdateProperty(nameof(Filters)); + if (model is IExtendedAttributesProvider haveProperties) + { + LoadExtendedAttributes(haveProperties.GetProperties().AsReadOnly()); + } + FetchItems(); model.ItemsChanged += Model_ItemsChanged; } @@ -626,6 +633,17 @@ public partial class ListViewModel : PageViewModel, IDisposable return null; } + private void LoadExtendedAttributes(IReadOnlyDictionary properties) + { + // Check if this is a token page + if (properties.TryGetValue("TokenSearch", out var isTokenSearchObj) && + isTokenSearchObj is bool isTokenSearch) + { + IsTokenSearch = isTokenSearch; + UpdateProperty(nameof(IsTokenSearch)); + } + } + public void LoadMoreIfNeeded() { var model = _model.Unsafe; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs index f6cee9f002..e2f43ff5f3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -54,6 +54,8 @@ public sealed partial class SearchBar : UserControl, // 0.6+ suggestions private string? _textToSuggest; + private bool _tokenSearchEnabled; + public PageViewModel? CurrentPageViewModel { get => (PageViewModel?)GetValue(CurrentPageViewModelProperty); @@ -86,6 +88,11 @@ public sealed partial class SearchBar : UserControl, @this.FilterBox.Select(@this.FilterBox.Text.Length, 0); page.PropertyChanged += @this.Page_PropertyChanged; + + if (page is ListViewModel listViewModel) + { + @this._tokenSearchEnabled = listViewModel.IsTokenSearch; + } } @this?.PropertyChanged?.Invoke(@this, new(nameof(PageType))); @@ -201,6 +208,43 @@ public sealed partial class SearchBar : UserControl, { // Mark backspace as held to handle continuous deletion _isBackspaceHeld = true; + + // Try to handle token deletion + if (_tokenSearchEnabled && + FilterBox.SelectionLength == 0) + { + // Tokens are delimeted by zero-width space characters + // (ZWSP, U+200B). + // + // What we're gonna do here is check if the character we're + // about to backspace is the _second_ ZWSP in a pair + var lastCaretPosition = FilterBox.SelectionStart; + var text = FilterBox.Text; + + // Is the character before the caret a zwsp? + if (lastCaretPosition > 0 && + text[lastCaretPosition - 1] == '\u200B') + { + // make sure that this is a pair. So, we'd need to see an odd number of zwsp's before this one. + var zwspCount = 0; + var previousZwspIndex = -1; + for (var i = 0; i < lastCaretPosition - 1; i++) + { + if (text[i] == '\u200B') + { + zwspCount++; + previousZwspIndex = i; + } + } + + if (zwspCount % 2 == 1 && previousZwspIndex != -1) + { + // We have a pair! Select the whole token for deletion + FilterBox.Select(previousZwspIndex, lastCaretPosition - previousZwspIndex); + e.Handled = true; + } + } + } } } else if (e.Key == VirtualKey.Up) diff --git a/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md b/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md index c9ab37b6d5..183b6cf451 100644 --- a/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md +++ b/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md @@ -75,6 +75,8 @@ functionality. - [Advanced scenarios](#advanced-scenarios) - [Status messages](#status-messages) - [Rendering of ICommandItems in Lists and Menus](#rendering-of-icommanditems-in-lists-and-menus) + - [Addenda I: API additions (ICommandProvider2)](#addenda-i-api-additions-icommandprovider2) + - [Addenda II: Rich Search](#addenda-ii-rich-search) - [Class diagram](#class-diagram) - [Future considerations](#future-considerations) - [Arbitrary parameters and arguments](#arbitrary-parameters-and-arguments) @@ -2066,7 +2068,44 @@ parts **C** and **D**. not necessarily a full rich search box, we'll provide a parameter page experience. +```csharp +[uuid("a2590cc9-510c-4af7-b562-a6b56fe37f55")] +interface IParameterRun requires INotifyPropChanged +{ +}; +interface ILabelRun requires IParameterRun +{ + String Text{ get; }; +}; + +interface IParameterValueRun requires IParameterRun +{ + String PlaceholderText{ get; }; + Boolean NeedsValue{ get; }; // TODO! name is weird +}; + +interface IStringParameterRun requires IParameterValueRun +{ + String Text{ get; set; }; + + // TODO! do we need a way to validate string inputs? +}; + +interface ICommandParameterRun requires IParameterValueRun +{ + String DisplayText{ get; }; + ICommand GetSelectValueCommand(UInt64 hostHwnd); + IIconInfo Icon{ get; }; // ? maybe + +}; + +interface IParametersPage requires IPage +{ + IParameterRun[] Parameters{ get; }; + IListItem Command{ get; }; +}; +``` ## Class diagram diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleSuggestionsPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleSuggestionsPage.cs new file mode 100644 index 0000000000..4a4f5a3d4e --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleSuggestionsPage.cs @@ -0,0 +1,361 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation 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 Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; +using Windows.Foundation.Collections; + +namespace SamplePagesExtension.Pages; + +#nullable enable + +#pragma warning disable SA1402 // File may only contain a single type +internal sealed partial class SampleSuggestionsPage : DynamicListPage, IExtendedAttributesProvider +{ + private PeopleSearchPage _peopleSearchPage = new(); + private CommandsListPage _commandsListPage = new(); + private DynamicListPage? _suggestionPage; + private List _pickedTokens = new(); + private int _lastPrefixPosition = -1; + private ListItem _queryItem; + + // private int _lastCaretPosition = 0; + private string _searchText = string.Empty; + + public override string SearchText + { + get => _searchText; + set + { + var oldSearch = _searchText; + if (value != oldSearch) + { + _searchText = value; + UpdateSearch(oldSearch, new SearchUpdateArgs(value, null)); + } + } + } + + internal SampleSuggestionsPage() + { + _peopleSearchPage.SuggestionPicked += OnSuggestionPicked; + _commandsListPage.SuggestionPicked += OnSuggestionPicked; + Name = "Open"; + Title = "Sample prefixed search"; + PlaceholderText = "Type a query, and use '@' to add a person"; + Icon = new("\uE779"); + + _queryItem = new ListItem(new NoOpCommand()) + { + Title = string.Empty, + Icon = new IconInfo("\uE8F2"), // ChatBubbles + }; + } + + public override IListItem[] GetItems() + { + return _suggestionPage?.GetItems() ?? + (string.IsNullOrEmpty(this.SearchText) ? + [] : + [_queryItem]); + } + + public void UpdateSearch(string oldSearchText, ISearchUpdateArgs args) + { + // if (args.GetProperties() is IDictionary props) + // { + // if (props.TryGetValue("CaretPosition", out var caretPosObj) && caretPosObj is int caretPos) + // { + // _lastCaretPosition = caretPos; + // } + // } + var newSearchText = args.NewSearchText; + UpdateListItem(newSearchText); + if (string.IsNullOrEmpty(newSearchText) != string.IsNullOrEmpty(oldSearchText)) + { + RaiseItemsChanged(); + } + + if (newSearchText.Length < oldSearchText.Length) + { + HandleDeletion(oldSearchText, newSearchText); + return; + } + + this.SearchText = newSearchText; + + // We're not doing caret tracking in this sample. + // Just assume caret is at end of text. + var lastCaretPosition = newSearchText.Length; + + if (_suggestionPage == null) + { + var lastChar = newSearchText.Length > 0 && lastCaretPosition > 0 ? + newSearchText[lastCaretPosition - 1] : + '\0'; + + if (lastChar == '@') + { + // User typed '@', switch to people suggestion page + _lastPrefixPosition = lastCaretPosition - 1; + UpdateSuggestionPage(_peopleSearchPage); + } + else if (lastChar == '/') + { + // User typed '/', switch to commands suggestion page + _lastPrefixPosition = lastCaretPosition - 1; + UpdateSuggestionPage(_commandsListPage); + } + } + else if (_suggestionPage != null) + { + // figure out what part of the text applies to the current suggestion page + var startOfSubSearch = _lastPrefixPosition + 1; + var subString = _searchText.Substring(startOfSubSearch, lastCaretPosition - startOfSubSearch); + _suggestionPage.SearchText = subString; + + // When the suggestion page updates its items, it should raise ItemsChanged event, which we will bubble through + } + } + + private void OnSuggestionPicked(object sender, MyTokenType suggestion) + { + _pickedTokens.Add(suggestion); + UpdateSuggestionPage(null); // Clear suggestion page + + var displayText = suggestion.DisplayName; + var tokenText = $"\u200B{displayText}\u200B "; // Add ZWSP before and after token, and a trailing space + + // remove the prefix character and any partial text after it + if (_lastPrefixPosition >= 0 && _lastPrefixPosition < _searchText.Length) + { + _searchText = _searchText.Remove(_lastPrefixPosition); + } + + // this.SearchText = this.SearchText.Insert(_lastCaretPosition, tokenText); + this.SearchText = _searchText + tokenText; + OnPropertyChanged(nameof(SearchText)); + } + + private void UpdateSuggestionPage(DynamicListPage? page) + { + if (_suggestionPage != null) + { + _suggestionPage.ItemsChanged -= OnSuggestedItemsChanged; + } + + _suggestionPage = page; + if (_suggestionPage != null) + { + _suggestionPage.SearchText = string.Empty; // reset search text + _suggestionPage.ItemsChanged += OnSuggestedItemsChanged; + } + + RaiseItemsChanged(); + } + + private void OnSuggestedItemsChanged(object sender, IItemsChangedEventArgs e) + { + RaiseItemsChanged(); + } + + private void HandleDeletion(string oldSearch, string newSearch) + { + var lastCaretPosition = newSearch.Length; + + if (_suggestionPage != null) + { + if (lastCaretPosition <= _lastPrefixPosition) + { + // User deleted back over the prefix character, so close the suggestion page + UpdateSuggestionPage(null); + _lastPrefixPosition = -1; + return; + } + + // figure out what part of the text applies to the current suggestion page + var startOfSubSearch = _lastPrefixPosition + 1; + if (lastCaretPosition <= _lastPrefixPosition) + { + // User deleted back over the prefix character, so close the suggestion page + UpdateSuggestionPage(null); + _lastPrefixPosition = -1; + } + else + { + var subString = newSearch.Substring(startOfSubSearch, lastCaretPosition - startOfSubSearch); + _suggestionPage.SearchText = subString; + } + } + } + + private void UpdateListItem(string newSearchText) + { + // Iterate over the search text. + // Find all the strings that are surrounded by ZWSP characters. + // Use those strings to find all the matching picked tokens. + var index = 0; + var tokenSpans = new List<(int Start, int End, MyTokenType? Token)>(); + while (index < newSearchText.Length) + { + var startIndex = newSearchText.IndexOf('\u200B', index); + if (startIndex < 0) + { + break; + } + + var endIndex = newSearchText.IndexOf('\u200B', startIndex + 1); + if (endIndex < 0) + { + break; + } + + var tokenText = newSearchText.Substring(startIndex + 1, endIndex - startIndex - 1); + var token = _pickedTokens.Find(t => t.DisplayName == tokenText); + tokenSpans.Add((startIndex, endIndex, token)); + + index = endIndex + 1; + } + + // for each span, construct a string like $"[{start}, {end}): {token.DisplayName} {token.Id}\n" + var displayText = string.Empty; + foreach (var (start, end, token) in tokenSpans) + { + if (token != null) + { + displayText += $"[{start}, {end}): {token.DisplayName} {token.Id}\n"; + } + } + + _queryItem.Title = newSearchText; + _queryItem.Subtitle = string.IsNullOrEmpty(displayText) ? "no tokens" : displayText; + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + // from DynamicListPage, not used + } + + public IDictionary GetProperties() + { + return new ValueSet() + { + { "TokenSearch", true }, + }; + } +} + +internal interface ISearchUpdateArgs +{ + string NewSearchText { get; } +} + +internal sealed partial class SearchUpdateArgs : ISearchUpdateArgs, IExtendedAttributesProvider +{ + public string NewSearchText { get; } + + private IDictionary _properties; + + public SearchUpdateArgs(string newSearchText, IDictionary? properties) + { + NewSearchText = newSearchText; + _properties = properties ?? new Dictionary(); + } + + public IDictionary GetProperties() => _properties; +} + +internal sealed partial class PeopleSearchPage : DynamicListPage +{ + internal event TypedEventHandler? SuggestionPicked; + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + // do nothing + } + + public override IListItem[] GetItems() + { + var items = new List(); + for (var i = 1; i <= 5; i++) + { + var name = $"Person {i}"; + var suggestion = new MyTokenType + { + DisplayName = name, + Id = Guid.NewGuid().ToString(), + Value = name, + }; + items.Add(new ListItem(new PickSuggestionCommand(suggestion, SuggestionPicked)) + { + Title = name, + Subtitle = $"Email: person{i}@example.com", + }); + } + + return items.ToArray(); + } +} + +internal sealed partial class CommandsListPage : DynamicListPage +{ + internal event TypedEventHandler? SuggestionPicked; + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + // do nothing + } + + public override IListItem[] GetItems() + { + var items = new List(); + items.Add(new ListItem(new PickSuggestionCommand(new() { DisplayName = "Chat", Id = "chat" }, SuggestionPicked)) + { + Title = "/chat", + Subtitle = $"send a message", + }); + items.Add(new ListItem(new PickSuggestionCommand(new() { DisplayName = "Status", Id = "status" }, SuggestionPicked)) + { + Title = "/status", + Subtitle = $"set your status", + }); + + return items.ToArray(); + } +} + +internal sealed partial class MyTokenType +{ + public required string DisplayName { get; set; } + + public string Id { get; set; } = string.Empty; + + public object? Value { get; set; } +} + +internal sealed partial class PickSuggestionCommand : InvokableCommand +{ + internal MyTokenType Suggestion { get; private set; } + + private TypedEventHandler? _pickedHandler; + + public PickSuggestionCommand(MyTokenType suggestion, TypedEventHandler? pickedHandler) + { + Suggestion = suggestion; + _pickedHandler = pickedHandler; + Name = $"Select"; + } + + public override CommandResult Invoke() + { + _pickedHandler?.Invoke(this, Suggestion); + return CommandResult.KeepOpen(); + } +} + +#pragma warning restore SA1402 // File may only contain a single type +#nullable disable diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs index 9d9eef6f80..b0f10790f4 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation +// Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -54,6 +54,11 @@ public partial class SamplesListPage : ListPage Title = "Slow loading list page", Subtitle = "A demo of a list page that takes a while to load", }, + new ListItem(new SampleSuggestionsPage()) + { + Title = "Sample Prefix Suggestions", + Subtitle = "A demo of using 'nested' pages to provide 'suggestions' as the user types", + }, // Content pages new ListItem(new SampleContentPage()) @@ -122,6 +127,7 @@ public partial class SamplesListPage : ListPage Title = "Mixed parameter types (file first)", Subtitle = "A demo of a command that takes multiple types of parameters", }, + // List parameters aren't yet supported // new ListItem(new CreateNoteParametersPage()) // {