diff --git a/src/modules/cmdpal/doc/initial-sdk-spec/ParametersPage-draft-D.md b/src/modules/cmdpal/doc/initial-sdk-spec/ParametersPage-draft-D.md new file mode 100644 index 0000000000..379b2ac50b --- /dev/null +++ b/src/modules/cmdpal/doc/initial-sdk-spec/ParametersPage-draft-D.md @@ -0,0 +1,517 @@ +--- +author: Mike Griese +created on: 2025-09-04 +last updated: 2025-09-08 +issue id: n/a +--- + +## Addenda II-D: Parameters page + + + + + + +```c# +interface IParameterRun requires INotifyPropChanged +{ + +}; + +interface ILabelRun requires IParameterRun +{ + String Text { get; }; +}; + +interface IParameterRun requires IParameterRun +{ + String PlaceholderText { get; }; + Boolean NeedsValue { get; } // TODO! name is weird +}; + +interface IStringParameterRun requires IParameterRun +{ + String Text { get; set; }; + + // TODO! do we need a way to validate string inputs? +}; + +interface ICommandParameterRun requires IParameterRun +{ + String DisplayText { get; }; + ICommand GetSelectValueCommand(UInt64 hostHwnd); + IIconInfo Icon { get; }; // ? maybe + +}; + +interface IParametersPage requires IPage +{ + IParameterRun[] Parameters { get; }; + IListItem Command { get; }; +}; +``` + +When we open a `IParametersPage`, we will render the `Parameters` in the search +box. We'll move focus to the first `IParameterRun` that is not a `ILabelRun`. +What those interactions looks like depends on the type of `IParameterRun`. + +There are three basic types of inputs: strings, invokable commands, and lists. +Strings are a special case that doesn't require a command to set the value. +Lists and invokable commands are picked based on the type of the +`SelectValueCommand`. Each of these are detailed below. + +When all the parameters have `NeedsValue` set to `false`, we will display a +single item to the user - the `Command` item. + +### String parameters + +These are rendered as a text box within the search box. The user can type into +it. Focus is moved to the next parameter when the user presses Enter or tab. + +### Command parameters - Invokable Commands + +These are used when the `SelectValueCommand` is an `IInvokableCommand`. + +These are rendered as a button within the search box. The button text is +`DisplayText` if it is set, otherwise it is `PlaceholderText`. If the user +clicks the button, we invoke the `SelectValueCommand` (and ignore the `CommandResult`). + +This is good for file pickers, date pickers, color pickers, etc. Anything that +requires a custom UI to pick a value. + +When the extension has picked a value, it should set the `NeedsValue` to false. +The extension can also set the `DisplayText` and `Icon` to reflect the chosen value. + +When the user presses enter with the button focused, we will also invoke the +`SelectValueCommand`. + +When the user presses tab, we will move focus to the next parameter. + +If the `NeedsValue` property is changed to `false` while it's focused, we will +move focus to the next parameter. + +### Command parameters - List Commands + +These are used when the `SelectValueCommand` is an `IListPage` - both static and +dynamic lists work similarly. + +These are rendered as a text box within the search box. When the user focuses +the text box, we will display the items from the `IListPage` in the body of +CmdPal. The user can then type to filter the list. This filtering will work the +same way as any other list page in CmdPal - CmdPal will filter static lists, or +pass the query to a dynamic list. + +The items in this list should all be `IListItem` objects with +`IInvokableCommands`. Putting a `IPage` into one of these items will cause the +user to navigate away from the parameters page, which would probably be +unexpected. + +When the user picks an item from the list, the extension should handle that +command by bubbling an event up to the `CommandRun`, and setting the `Value`, +`DisplayText`, and `Icon` properties, and setting `NeedsValue` to false. + +When the user presses enter with the text box focused, we will invoke the +command of the selected item in the list. + +When the user presses tab, we will move focus to the next parameter. + +If the `NeedsValue` property is changed to `false` while it's focused, we will +move focus to the next parameter. + +### Example + +Lets say you had a command like "Create a note \${title} in \${folder}". +`title` is a string input, and `folder` is a static list of folders. + +The extension author can then define a `IParametersPage` with four runs in it: +* A `ILabelRun` for "Create a note" +* A `IStringParameterRun` for the `title` +* A `ILabelRun` for "in" +* A `ICommandParameterRun` for the `folder`. The `Command` will be a `IListPage`, where the items are possible folders + + +In this example, the user can pick the "create note" command, then type the title, hit enter/tab, and then pick a folder from the list, then hit enter to run the command. + +```cs +public interface IRequiresHostHwnd +{ + void SetHostHwnd(UInt64 hostHwnd); +} + +public sealed partial class CommandParameterRun : BaseObservable, ICommandParameterRun +{ + public virtual string DisplayText { get; set; } // basic projected properties here, same as throughout the toolkit + public virtual string PlaceholderText { get; set; } // basic projected properties here, same as throughout the toolkit + public virtual ICommand Command { get; set; } // basic projected properties here, same as throughout the toolkit + public virtual IIconInfo Icon { get; set; } // basic projected properties here, same as throughout the toolkit + public virtual bool NeedsValue => Value == null; // Toolkit helper: does this parameter need a value? + + public virtual ICommand GetSelectValueCommand(UInt64 hostHwnd) + { + if (Command is IRequiresHostHwnd requiresHwnd) + { + requiresHwnd.SetHostHwnd(hostHwnd); + } + return Command; + } + + public object? Value { get; set; } // Toolkit helper: a value for the parameter +} + +public sealed partial class CreateNoteParametersPage : ParametersPage +{ + private readonly SelectFolderPage _selectFolderPage = new SelectFolderPage(); + + private readonly StringParameterRun _titleParameter = new StringParameterRun() + { + PlaceholderText = "Note title" + }; + private readonly ICommandParameterRun _folderParameter = new CommandParameterRun() + { + PlaceholderText = "Select folder", + Command = _selectFolderPage + }; + + private readonly List _parameters; + + private readonly CreateNoteCommand _command = new() { TitleParameter = _titleParameter, FolderParameter = _folderParameter }; + private readonly ListItem _item = new(_command); + + public IParameterRun[] Parameters => _parameters.ToArray(); + public IListItem Command => _item; + + public CreateNoteParametersPage() + { + _parameters = new List + { + new LabelRun("Create a note"), + _titleParameter, + new LabelRun("in"), + _folderParameter + }; + + _selectFolderPage.FolderSelected += (s, folder) => + { + _folderParameter.Value = folder; + _folderParameter.Icon = folder.Icon; + _folderParameter.DisplayText = folder.Name; + + }; + }; +} + +public sealed partial class CreateNoteCommand : BaseObservable, IInvokableCommand +{ + internal IStringParameterRun TitleParameter { get; init; } // set by the parameters page + internal ICommandParameterRun FolderParameter { get; init; } // set by the parameters page + + public IIconInfo Icon => new IconInfo("NoteAdd"); + + public override ICommandResult Invoke() + { + var title = TitleParameter.Text; + if (string.IsNullOrWhiteSpace(title)) + { + var t = new ToastStatusMessage(new StatusMessage(){ Title = "Title is required", State = MessageState.Error }); + t.Show(); + return CommandResult.KeepOpen(); + } + var folder = FolderParameter.Value; + if (folder is not Folder) + { + // This is okay, we'll create the note in the default folder + } + + // Create the note in the specified folder + NoteService.CreateNoteInFolder(title, folder); // whatever your backend is + + return CommandResult.Dismiss(); + } +} + +public sealed partial class SelectFolderPage : ListPage +{ + public event EventHandler? FolderSelected; + + public SelectFolderPage() + { + // Populate the list with folders + var folders = FolderService.GetFolders(); // whatever your backend is + Items = folders.Select(f => new ListItem(new SelectFolderCommand(f), f.Name, f.Icon)).ToArray(); + } + + private sealed partial class SelectFolderCommand : BaseObservable, IInvokableCommand + { + private readonly EventHandler _folderSelected; + private readonly Folder _folder; + + public IIconInfo Icon => _folder.Icon; + public string Title => _folder.Name; + + public SelectFolderCommand(Folder folder, EventHandler folderSelected) + { + _folder = folder; + _folderSelected = folderSelected; + } + + public override ICommandResult Invoke() + { + _folderSelected?.Invoke(this, _folder); + return CommandResult.KeepOpen(); + } + } +} + +public sealed partial class FilePickerParameterRun : CommandParameterRun +{ + public StorageFile? File { get; private set;} + public FilePickerParameterRun() + { + var command = new FilePickerCommand(); + command.FileSelected += (file) => + { + File = file; + if (file != null) + { + Value = file; + DisplayText = file.Name; + // Icon = new IconInfo("File"); + } + else + { + Value = null; + DisplayText = null; + // Icon = new IconInfo("File"); + } + }; + PlaceholderText = "Select a file"; + Icon = new IconInfo("File"); + Command = command; + } + + private sealed partial class FilePickerCommand : InvokableCommand, IRequiresHostHwnd + { + public IIconInfo Icon => new IconInfo("File"); + public string Name => "Pick a file"; + + public event EventHandler? FileSelected; + + private uint _hostHwnd; + + public void SetHostHwnd(uint hostHwnd) + { + _hostHwnd = hostHwnd; + } + + public override ICommandResult Invoke() + { + PickFileAsync(); + return CommandResult.KeepOpen(); + } + + private async void PickFileAsync() + { + var picker = new Windows.Storage.Pickers.FileOpenPicker(); + // You need to initialize the picker with a window handle in WinUI 3 desktop apps + // See https://learn.microsoft.com/en-us/windows/apps/design/controls/file-open-picker + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow); + WinRT.Interop.InitializeWithWindow.Initialize(picker, _hostHwnd); + + var file = await picker.PickSingleFileAsync(); + FileSelected?.Invoke(this, file); + } + } +} + +public sealed partial class SelectParameterCommand : InvokableCommand +{ + public event TypedEventHandler? ValueSelected; + private T _value; + public T Value { get => _value; protected set { _value = value; } } + public SelectParameterCommand(T value) + { + _value = value; + } + + public override ICommandResult Invoke() + { + ValueSelected?.Invoke(this, _value); + return CommandResult.KeepOpen(); + } +} +public sealed partial class StaticParameterList : ListPage +{ + public event TypedEventHandler? ValueSelected; + private bool _isInitialized = false; + private readonly IEnumerable _values; + private readonly List _items = new List(); + private Func _customizeListItemsCallback; + + // ctor takes an IEnumerable values, and a function to customize the ListItem's depending on the value + public StaticParameterList(IEnumerable values, Func customizeListItem) + { + _values = values; + _customizeListItemsCallback = (value, listItem) => { customizeListItem(value); return listItem; }; + } + } + public StaticParameterList(IEnumerable values, Func customizeListItem) + { + _values = values; + _customizeListItemsCallback = customizeListItem; + } + public override IListItem[] GetItems() + { + if (!_isInitialized) + { + Initialize(_values, _customizeListItemsCallback); + _isInitialized = true; + } + return _items.ToArray(); + } + private void Initialize(IEnumerable values, Func customizeListItem) + { + foreach (var value in values) + { + var command = new SelectParameterCommand(value); + command.ValueSelected += (s, v) => ValueSelected?.Invoke(this, v); + var listItem = new ListItem(command); + var item = customizeListItem(value, listItem); + _items.Add(item); + } + } +} +``` + + + + +-------------------------------------------------------- + +## original draft starts here + + +### Arbitrary parameters and arguments + +Something we'll want to consider soon is how to allow for arbitrary parameters +to be passed to commands. This allows for commands to require additional info from +the user _before_ they are run. In its simplest form, this is a lightweight way +to have an action accept form data inline with the query. But this also allows +for highly complex action chaining. + +I had originally started to spec this out as: + +```cs +enum ParameterType +{ + Text, + File, + Files, + Enum, + Entity +}; + +interface ICommandParameter +{ + ParameterType Type { get; }; + String Name { get; }; + Boolean Required{ get; }; + // TODO! values for enums? + // TODO! dynamic values for enums? like GetValues(string query) + // TODO! files might want to restrict types? but now we're a file picker and need that whole API + // TODO! parameters with more than one value? Like, + // SendMessage(People[] to, String message) +}; + +interface ICommandArgument +{ + String Name { get; }; + Object Value { get; }; +}; + +interface IInvokableCommandWithParameters requires ICommand { + ICommandParameter[] Parameters { get; }; + ICommandResult InvokeWithArgs(Object sender, ICommandArgument[] args); +}; + +``` + +TODO! Mike: +We should add like, a `CustomPicker` parameter type, which would allow +extensions to define their own custom pickers for parameters. Then when we go to fill the argument, we'd call something like `ShowPickerAsync(ICommandParameter param)` and let them fill in the value. We don't care what the value is. + +So it'd be more like + +```c# +enum ParameterType +{ + Text, + // File, + // Files, + Enum, + Custom +}; + +// interface IArgumentEnumValue requires INotifyPropChanged +// { +// String Name { get; }; +// IIconInfo Icon { get; }; +// } +interface ICommandArgument requires INotifyPropChanged +{ + ParameterType Type { get; }; + String Name { get; }; + Boolean Required{ get; }; + + Object Value { get; set; }; + String DisplayName { get; }; + IIconInfo Icon { get; }; + + void ShowPicker(UInt64 hostHwnd); + // todo + // IArgumentEnumValue[] GetValues(); +}; + +interface IInvokableCommandWithParameters requires ICommand { + ICommandArgument[] Parameters { get; }; + ICommandResult InvokeWithArgs(Object sender, ICommandArgument[] args); +}; +``` + + +And `CommandParameters` would be a set of `{ type, name, required }` structs, +which would specify the parameters that the action needs. Simple types would be +`string`, `file`, `file[]`, `enum` (with possible values), etc. + +But that may not be complex enough. We recently learned about Action Framework +and some of their plans there - that may be a good fit for this. My raw notes +follow - these are not part of the current SDK spec. + +> [!NOTE] +> +> A thought: what if a action returns a `CommandResult.Entity`, then that takes +> devpal back home, but leaves the entity in the query box. This would allow for +> a Quicksilver-like "thing, do" flow. That command would prepopulate the +> parameters. So we would then filter top-level commands based on things that can +> accept the entity in the search box. +> +> For example: The user uses the "Search for file" list page. They find the file +> they're looking for. That file's ListItem has a context item "With +> {filename}..." that then returns a `CommandResult.Entity` with the file entity. +> The user is taken back to the main page, and a file picker badge (with that +> filename) is at the top of the search box. In that state, the only commands +> now shown are ones that can accept a File entity. This could be things like +> the "Remove background" action (from REDACTED), the "Open with" action, the +> "Send to Teams chat" (which would then ask for another entity). If they did +> the "Remove Background" one, that could then return _another_ entity. +> +> We'd need to solve for the REDACTED case specifically, cause I bet they want to +> stay in the REDACTED action page, rather than the main one. +> +> We'd also probably want the REDACTED one to be able to accept arbitrary +> entities... like, they probably want a `+` button that lets you add... any +> kind of entity to their page, rather than explicitly ask for a list of args. + +However, we do not have enough visibility on how action framework actually +works, consumer-wise, to be able to specify more. As absolutely fun as chaining +actions together sounds, I've decided to leave this out of the official v1 spec. +We can ship a viable v0.1 of DevPal without it, and add it in post. \ No newline at end of file diff --git a/src/modules/cmdpal/doc/initial-sdk-spec/RichSearchBox-draft-A.md b/src/modules/cmdpal/doc/initial-sdk-spec/RichSearchBox-draft-A.md index f7d17c11e6..618a15c38d 100644 --- a/src/modules/cmdpal/doc/initial-sdk-spec/RichSearchBox-draft-A.md +++ b/src/modules/cmdpal/doc/initial-sdk-spec/RichSearchBox-draft-A.md @@ -432,3 +432,96 @@ sequenceDiagram %% H->>BST: Focus previous BasicStringToken %% end ``` + +### Commands with parameters + +We've also long experimented with the idea of commands having parameters that +can be filled in by the user. These would be commands that take a couple +lightweight inputs, so that the use can input them more natively than an +adaptive card. + +Previous drafts included a new type of `ICommand` ala +`IInvokableWithParameters`. However, these ran into edge cases: +* Where are the parameters displayed? In the search box? On the item? +* What happens if a context menu command needs parameters? +* Does the _page_ have parameters? + +none of which were trivially solvable by having the parameters on the command. + +Instead, we can leverage the concept of a "rich search" experience to provide +that lightweight parameter input method. + +We'll add a new type of page, called a `RichSearchPage`. It's a list page, but +with a rich search box at the top. + +```c# +interface IRichSearchPage requires IListPage { + IRichSearch Search { get; }; +}; +``` + +This lets the user activate the command that needs parameters, and go straight +into the rich input page. That page will act like it's _just_ the command the +user "invoked", and will let us display additional inputs to the user. + +#### Parameters Example + +Now, lets say you had a command like "Create a note \${title} in \${folder}". +`title` is a string input, and `folder` is a static list of folders. + +The extension author can then define a `RichSearchPage` with a `IRichSearch` +that has four tokens in it: +* A `ILabelToken` for "Create a note" +* A `IStringInputToken` for the `title` +* A `ILabelToken` for "in" +* A `IStaticListToken` for the `folder`, where the items are possible folders + +Then, when the user hits , we gather up all the tokens, and we can +reference them in the command. As an example, here's the `CreateNoteCommand`, +which implements the `IRichSearchPage` interface: + +The list page can always change it's results based on the user's input. In our +case, we'll listen for the value of the last token to be set. When it is, we can +then display the final list item with our fully formed command for the user to +invoke. + +```csharp +class CreateNoteCommand : IRichSearchPage { + public string Name => "Create a note"; + public string Id => "create_note"; + public IIconInfo Icon => null; + + public IRichSearch Search { get; } + private StringInputToken _titleToken; + private NotesFolderToken _folderToken; + + private IListItem? _createNoteItem; + + public CreateNoteCommand() { + Search = new RichSearch(); + + _titleToken = new StringInputToken("title", "Title of the note"); + _folderToken = new NotesFolderToken("folder", "Select a folder"); + + Search.SearchTokens = [ + new LabelToken("Create a note"), + _titleToken, + new LabelToken("in"), + _folderToken + ]; + + _folderToken.OnSelectedItemChanged += (sender, e) => { + // Update the command with the selected folder + UpdateCommand(); + }; + } + + private void UpdateCommand() { + _createNoteItem = new ListItem() { + Title = _titleToken.Value, + Subtitle = _folderToken.SelectedItem?.Title, + Command = new CreateNoteCommand(title: _titleToken.Value, folder: _folderToken.SelectedItem.Value) + }; + } +} +``` 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 ce2721441c..9fc3f2e1e7 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 @@ -2053,99 +2053,7 @@ This addenda is broken into multiple draft specs currently. These represent diff * [Rich Search Box](./RichSearchBox-draft-A.md) * [Prefix Search](./PrefixSearch-draft-B.md) * [ZWSP tokens](./PlainRichSearch-draft-C.md) - -### Commands with parameters - -We've also long experimented with the idea of commands having parameters that -can be filled in by the user. These would be commands that take a couple -lightweight inputs, so that the use can input them more natively than an -adaptive card. - -Previous drafts included a new type of `ICommand` ala -`IInvokableWithParameters`. However, these ran into edge cases: -* Where are the parameters displayed? In the search box? On the item? -* What happens if a context menu command needs parameters? -* Does the _page_ have parameters? - -none of which were trivially solvable by having the parameters on the command. - -Instead, we can leverage the concept of a "rich search" experience to provide -that lightweight parameter input method. - -We'll add a new type of page, called a `RichSearchPage`. It's a list page, but -with a rich search box at the top. - -```c# -interface IRichSearchPage requires IListPage { - IRichSearch Search { get; }; -}; -``` - -This lets the user activate the command that needs parameters, and go straight -into the rich input page. That page will act like it's _just_ the command the -user "invoked", and will let us display additional inputs to the user. - -#### Parameters Example - -Now, lets say you had a command like "Create a note \${title} in \${folder}". -`title` is a string input, and `folder` is a static list of folders. - -The extension author can then define a `RichSearchPage` with a `IRichSearch` -that has four tokens in it: -* A `ILabelToken` for "Create a note" -* A `IStringInputToken` for the `title` -* A `ILabelToken` for "in" -* A `IStaticListToken` for the `folder`, where the items are possible folders - -Then, when the user hits , we gather up all the tokens, and we can -reference them in the command. As an example, here's the `CreateNoteCommand`, -which implements the `IRichSearchPage` interface: - -The list page can always change it's results based on the user's input. In our -case, we'll listen for the value of the last token to be set. When it is, we can -then display the final list item with our fully formed command for the user to -invoke. - -```csharp -class CreateNoteCommand : IRichSearchPage { - public string Name => "Create a note"; - public string Id => "create_note"; - public IIconInfo Icon => null; - - public IRichSearch Search { get; } - private StringInputToken _titleToken; - private NotesFolderToken _folderToken; - - private IListItem? _createNoteItem; - - public CreateNoteCommand() { - Search = new RichSearch(); - - _titleToken = new StringInputToken("title", "Title of the note"); - _folderToken = new NotesFolderToken("folder", "Select a folder"); - - Search.SearchTokens = [ - new LabelToken("Create a note"), - _titleToken, - new LabelToken("in"), - _folderToken - ]; - - _folderToken.OnSelectedItemChanged += (sender, e) => { - // Update the command with the selected folder - UpdateCommand(); - }; - } - - private void UpdateCommand() { - _createNoteItem = new ListItem() { - Title = _titleToken.Value, - Subtitle = _folderToken.SelectedItem?.Title, - Command = new CreateNoteCommand(title: _titleToken.Value, folder: _folderToken.SelectedItem.Value) - }; - } -} -``` +* [Parameter Page](./ParameterPage-draft-D.md) ### Miscellaneous notes