mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-02-23 19:49:43 +01:00
Resurrect the suggestion page
This commit is contained in:
@@ -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<string, object> 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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<MyTokenType> _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<string, object> 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<string, object> 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<string, object> _properties;
|
||||
|
||||
public SearchUpdateArgs(string newSearchText, IDictionary<string, object>? properties)
|
||||
{
|
||||
NewSearchText = newSearchText;
|
||||
_properties = properties ?? new Dictionary<string, object>();
|
||||
}
|
||||
|
||||
public IDictionary<string, object> GetProperties() => _properties;
|
||||
}
|
||||
|
||||
internal sealed partial class PeopleSearchPage : DynamicListPage
|
||||
{
|
||||
internal event TypedEventHandler<object, MyTokenType>? SuggestionPicked;
|
||||
|
||||
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
public override IListItem[] GetItems()
|
||||
{
|
||||
var items = new List<IListItem>();
|
||||
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<object, MyTokenType>? SuggestionPicked;
|
||||
|
||||
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
public override IListItem[] GetItems()
|
||||
{
|
||||
var items = new List<IListItem>();
|
||||
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<object, MyTokenType>? _pickedHandler;
|
||||
|
||||
public PickSuggestionCommand(MyTokenType suggestion, TypedEventHandler<object, MyTokenType>? 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
|
||||
@@ -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())
|
||||
// {
|
||||
|
||||
Reference in New Issue
Block a user