首页
/ CmdPal 参数页(Parameters Page)扩展 SDK 设计规范与实现指南

CmdPal 参数页(Parameters Page)扩展 SDK 设计规范与实现指南

2026-09-06 18:56:15作者:冯梦姬Eddie

IParametersPage 是 Microsoft PowerToys 中 Command Palette(CmdPal)模块扩展 SDK 的一部分,它让命令在真正执行前可以要求用户补充参数(如字符串、枚举列表、文件等),从而把"一条带表单的查询"渲染进主搜索框。本文以规范草稿 ParametersPage-draft-D.md 为骨架,逐条拆解参数页的接口模型、三类参数运行单元的渲染与交互规则,并结合仓库中已落地的 IDL 接口、Toolkit 基类、UI ViewModel 与示例页源码,说明这套设计从"草稿 Addenda"演进到真实 API 的实现全貌。

文档背景:Addenda II-D 在 SDK 规范中的位置

该文档是 CmdPal 扩展 SDK 早期规范(initial-sdk-spec)下的一组"增补条款(Addenda)"草稿中的第四篇,由 Mike Griese 起草(创建于 2025-09-04,最后更新于 2025-09-08),标题为 Addenda II-D: Parameters page。它归属于 Addenda II: Commands with Parameters 这一主题分支,用于回答一个问题:

当一条命令(如"在某文件夹中创建一条笔记")除了查询词本身还需要额外输入时,扩展作者应当如何在 CmdPal 的 UI 里收集这些输入?

文档给出的答案是:引入一种新的页面类型 IParametersPage。与普通列表页不同,参数页把"输入表单"直接内嵌到 CmdPal 顶部搜索框中——用户在同一个输入框里依次填充所有参数,填齐后 CmdPal 才展示唯一的执行命令项。

与它并排的还有 RichSearchBox-draft-A.mdPrefixSearch-draft-B.mdPlainRichSearch-draft-C.md 等其它增补草稿,它们共同勾勒了搜索框演进的路线图。需要说明的是:本文讨论的"草稿"并非空中楼阁——下文中你会看到,IParameterRun 接口的 UUID(a2590cc9-510c-4af7-b562-a6b56fe37f55)、属性签名与整套 Toolkit 基类,已经逐字落地到了当前仓库的真实代码中。

核心接口模型:从 IParameterRun 到 IParametersPage

文档给出了参数页的完整接口树。首先是一个空的标记接口作为所有"参数运行单元(run)"的公共基座:

[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; };
};

把这段草稿与仓库中正式的接口定义 Microsoft.CommandPalette.Extensions.idl 对照可以发现:除了补齐 [contract(...)] 版本特性外,IParameterRun 的空接口、ILabelRun.Text 只读、IStringParameterRun.Text 读写、ICommandParameterRun.GetSelectValueCommand(UInt64) 等签名几乎逐字一致——草稿所描述的接口后来确实进入了扩展契约。

接口树的关键语义可以归纳为:

接口 父接口 承担角色 关键成员
IParameterRun INotifyPropChanged 所有 run 的公共基类(标记接口)
ILabelRun IParameterRun 纯文本标签,无交互 Text(只读)
IParameterValueRun IParameterRun 可接收值的参数基类 PlaceholderTextNeedsValue
IStringParameterRun IParameterValueRun 自由文本输入 Text(读写)
ICommandParameterRun IParameterValueRun 通过命令选择值(可调用命令 / 列表) DisplayTextGetSelectValueCommand()Icon
IParametersPage IPage 参数页整体 Parameters(run 数组)、Command(列表项)

其中两个设计点值得展开:

  • NeedsValue 是驱动整个页面状态机的开关。它被注释为"TODO! name is weird",暗示这是一个语义上略微别扭、但承担核心职责的属性。后面会看到,CmdPal 的 UI 层正是以"是否还有参数 NeedsValue == true"来决定要不要显示最终执行命令。
  • IParametersPage 同时是一个 IPage 又是一个 ICommand。因为 IPage requires ICommand(见 idl 第 257 行),参数页本身可以像普通命令一样被检索、导航,而其返回的 Parameters 数组与 Command 项则定义了页面的"表单 + 提交按钮"。

打开参数页后发生了什么

文档对参数页的打开行为给出了明确约定:

当我们打开一个 IParametersPage 时,会在搜索框中渲染 Parameters 中的每一项,并把焦点移动到第一个不是 ILabelRunIParameterRun 上。

也就是说,标签 run(ILabelRun)只负责把 "Create a note"、"in" 这类提示性文字渲染在搜索框中,但它们不参与焦点顺序。用户的实际输入旅程永远从第一个需要值的参数开始。每一种具体交互形态由 IParameterRun 的具体类型决定。

文档将"可接收值"的参数归纳为三类输入:字符串(strings)、可调用命令(invokable commands)、列表(lists)。其中字符串是一个特例——它不需要任何命令来赋值;而列表与可调用命令的取舍则取决于参数对象的 SelectValueCommand 具体是什么类型(详见下文 4.3、4.4 节)。列表与可调用命令是通过 SelectValueCommand 的类型来区分的。

完成的判定:什么时候"提交"命令现身

一个贯穿始终的规则是:

当所有参数的 NeedsValue 都为 false 时,我们将只向用户展示一个条目——即 Command 条目。

这与真实 UI 层的 ParametersPageViewModel.ShowCommand 逻辑完全对应(见 ParametersViewModels.cs):

public bool ShowCommand =>
    IsInitialized &&
    IsLoading == false &&
    !NeedsAnyValues()
    ;

NeedsAnyValues() 遍历所有 ParameterValueRunViewModel,只要还有一个参数 NeedsValue == true,执行按钮就保持隐藏。这种"值未齐不出现提交动作"的机制,天然防止了用户半途执行命令。

字符串参数(String parameters):搜索框即文本框

字符串参数是最简单的输入类型。文档规定:

它们被渲染为搜索框内的一个文本框。用户可以直接在其中输入。当用户按 Enter 或 Tab 时,焦点移动到下一个参数。

一个完整的 StringParameterRun 示例(摘自文档的 Create-note 例子)如下:

private readonly StringParameterRun _titleParameter = new StringParameterRun()
{
    PlaceholderText = "Note title"
};

在真实 Toolkit 实现 StringParameterRun.cs 中,字符串参数的 NeedsValue 直接由文本内容推导:

public virtual string Text { get => _text; set { if (SetProperty(ref _text, value)) { OnPropertyChanged(nameof(NeedsValue)); } } }

public override bool NeedsValue => string.IsNullOrEmpty(Text);

也就是说,一个没有输入任何字符的字符串参数会被视为"仍需要值"。基类同时提供了便捷构造函数 StringParameterRun(string placeholderText)ClearValue()(将 Text 重置为空串并通知 NeedsValue 变化)。一个值得留意的实现细节是 Toolkit 对 Value 强类型约束的校验(见 StringParameterRun.cs):如果外部向一个字符串参数的 Value 写入非字符串对象,会抛出 ArgumentException 并通过 ExtensionHost.LogMessage 记录一条错误日志。

草稿在此处留下的开放问题是 "我们是否需要一种验证字符串输入的方式?"TODO! do we need a way to validate string inputs?)——即对输入做格式/正则校验的能力当时尚未纳入规范。

命令参数 I:可调用命令(Invokable Commands)——按钮式取值

当参数的 SelectValueCommand 是一个 IInvokableCommand 时,它被归类为"可调用命令"型参数。此时渲染形态是一个按钮

这类参数被渲染为搜索框内的一个按钮。按钮文本是 DisplayText(若已设置),否则是 PlaceholderText。若用户点击按钮,我们调用 SelectValueCommand(并忽略其 CommandResult)。

文档明确指出这一形态适用于 文件选择器、日期选择器、颜色选择器 等一切"需要自定义 UI 来挑选值"的场景。典型交互流程如下:

  1. 按钮点击(或在按钮聚焦时按下 Enter)→ 触发 SelectValueCommand,CmdPal 忽略其返回值(CommandResult 在此无关紧要)。
  2. 扩展完成取值后,应把 NeedsValue 置为 false,并可选地更新 DisplayTextIcon 来反映所选项。
  3. 当焦点位于按钮上时按下 Tab,焦点前进到下一个参数。
  4. 若按钮处于聚焦状态时 NeedsValue 被改为 false,CmdPal 会自动把焦点移动到下一个参数。

这里的关键点是 GetSelectValueCommand(UInt64 hostHwnd) 的入参。因为 WinUI 3 桌面应用中文件选择器等原生对话框必须用宿主窗口句柄初始化,CmdPal 将主窗口的 hwnd 传给扩展,扩展才能正确弹出模态选择器。文档中的文件选择器示例(FilePickerCommand)清楚地展示了这一点:

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
    var file = await picker.PickSingleFileAsync();
    FileSelected?.Invoke(this, file);
}

注意 Invoke() 返回 CommandResult.KeepOpen()——即命令被调用后参数页保持打开,等用户在原生选择器里完成选择后,通过 FileSelected 事件把值回传给 CommandParameterRun。这印证了文档"点击按钮调用命令时忽略 CommandResult"的约定:此时按钮命令的返回值不用于导航,只用于驱动异步取值。

对应地,真实实现中凡是实现了 IRequiresHostHwnd 的命令,Toolkit 会在 CommandParameterRun.GetSelectValueCommand 内注入宿主窗口句柄(见 CommandParameterRun.cs)。

命令参数 II:列表命令(List Commands)——搜索框 + 下拉列表

当参数的 SelectValueCommand 是一个 IListPage 时(静态列表与动态列表行为一致),它被归类为"列表命令"型参数。这是最复杂的交互形态,也是参数页真正发挥威力的地方:

这类参数被渲染为搜索框内的一个文本框。当用户聚焦该文本框时,我们会在 CmdPal 的主体区域展示 IListPage 中的条目。用户随后可以输入文字来过滤列表。此过滤行为与 CmdPal 中任何其它列表页一致——CmdPal 会过滤静态列表,或者把查询传给动态列表。

这条规则揭示了参数页与普通列表页的深层关系:一个参数可以"借用"一个列表页作为其取值来源,用户在当前参数上的打字输入会被同时用作列表过滤关键字。同时,列表中的条目应为带 IInvokableCommandIListItem;文档特别警告:

这些列表中的条目都应该是带 IInvokableCommandIListItem 对象。把 IPage 塞进这些条目会让用户导航离开参数页,这很可能是意料之外的。

在参数页语境下,列表项命令绝不能返回"导航"语义——例如在"选择文件夹"时,如果点某一项就把用户带到别处,参数页交互就断掉了。列表型参数的正确闭环如下:

  1. 用户聚焦该参数文本框 → CmdPal 主体显示列表项,用户打字过滤。
  2. 用户从列表选中一项 → 扩展应通过向 CommandRun 冒泡事件来处理该命令,同时设置 ValueDisplayTextIcon,并把 NeedsValue 置为 false
  3. 文本框聚焦时按 Enter → 触发列表中被选中项的命令。
  4. 按 Tab → 焦点前进到下一个参数。
  5. 聚焦期间若 NeedsValue 变为 false → CmdPal 自动把焦点移动到下一个参数。

完整示例:把规范落到代码

文档给出的压轴示例是一条带两个参数的完整命令——Create a note ${title} in ${folder}title 是字符串输入,folder 是一个静态文件夹列表。扩展作者需要定义一个 IParametersPage,其中依次排列四个 run:

  • 一个 ILabelRun,文本为 "Create a note"
  • 一个 IStringParameterRun,用于接收 title
  • 一个 ILabelRun,文本为 "in"
  • 一个 ICommandParameterRun,用于接收 folder;其 Command 是一个 IListPage,列表项为可选文件夹

文档给出的完整代码是对理解"四个 run 如何协同 + 事件如何回流"的最佳教材,原样摘录如下:

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<IParameterRun> _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<IParameterRun>
        {
            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;

        };
    };
}

这个 CreateNoteParametersPage 至少示范了三条关键工程惯例:

  1. 参数与命令通过强类型引用互相绑定CreateNoteCommand 内部用 TitleParameter / FolderParameter 属性引用页面中的 run 实例(internal ... { get; init; } // set by the parameters page),命令执行时直接读取参数当前值,无需自行维护副本。
  2. 参数页不直接碰业务:选中文件夹的 FolderSelected 事件处理器只负责"镜像"取值结果到 _folderParameterValueIconDisplayText),列表的选择与过滤完全由 CmdPal 托管。
  3. Command 是一个普通的 ListItem:页面只负责在恰当时机把它交给 CmdPal,由 UI 层决定何时展示。

接着是消费这些参数的命令实现。注意 Invoke() 内部对字符串与列表参数采取不同的校验/处理策略:

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();
    }
}

这里有一个值得注意的细节:即便所有参数页机制都正常工作,Invoke() 内仍保留了二次防御——标题为空时弹出错误 Toast 并 KeepOpen(),让用户留在页面上补全。这暗示 NeedsValue 只是 UI 层的"放行闸门",业务校验仍由命令负责

最后是作为取值来源的文件夹列表页,以及它如何通过事件把选中值"传回"参数页:

public sealed partial class SelectFolderPage : ListPage
{
    public event EventHandler<Folder>? 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<Folder> _folderSelected;
        private readonly Folder _folder;

        public IIconInfo Icon => _folder.Icon;
        public string Title => _folder.Name;

        public SelectFolderCommand(Folder folder, EventHandler<Folder> folderSelected)
        {
            _folder = folder;
            _folderSelected = folderSelected;
        }

        public override ICommandResult Invoke()
        {
            _folderSelected?.Invoke(this, _folder);
            return CommandResult.KeepOpen();
        }
    }
}

这段代码再次印证了 4.4 节的规则:列表项命令 SelectFolderCommand.Invoke() 返回 CommandResult.KeepOpen(),而不是任何会导致导航的结果——它仅仅把选中的文件夹通过 FolderSelected 事件冒泡出去,让参数页构造函数中注册的处理器去回填参数。

示例同时给出了一个可复用的文件选择参数子类 FilePickerParameterRun,把"文件对话框 + 参数回填"封装成 20 余行的成品,你可以在自己的扩展里直接仿写:

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;
    }
    // ... FilePickerCommand 实现见上文"可调用命令"一节
}

再往下,文档提供了一套类型安全的静态列表工具类,它们把"泛型值集合 → 列表页 → 值回传事件"这条流水线通用化,值得完整阅读(见 ParametersPage-draft-D.md):

public sealed partial class SelectParameterCommand<T> : InvokableCommand
{
    public event TypedEventHandler<object, T>? 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<T> : ListPage
{
    public event TypedEventHandler<object, T>? ValueSelected;
    private bool _isInitialized = false;
    private readonly IEnumerable<T> _values;
    private readonly List<IListItem> _items = new List<IListItem>();
    private Func<T, ListItem, ListItem> _customizeListItemsCallback;

    // ctor takes an IEnumerable<T> values, and a function to customize the ListItem's depending on the value
    public StaticParameterList(IEnumerable<T> values, Func<T, ListItem> customizeListItem)
    {
        _values = values;
        _customizeListItemsCallback = (value, listItem) => { customizeListItem(value); return listItem; };
    }

    public StaticParameterList(IEnumerable<T> values, Func<T, ListItem, ListItem> customizeListItem)
    {
        _values = values;
        _customizeListItemsCallback = customizeListItem;
    }

    public override IListItem[] GetItems()
    {
        if (!_isInitialized)
        {
            Initialize(_values, _customizeListItemsCallback);
            _isInitialized = true;
        }
        return _items.ToArray();
    }

    private void Initialize(IEnumerable<T> values, Func<T, ListItem, ListItem> customizeListItem)
    {
        foreach (var value in values)
        {
            var command = new SelectParameterCommand<T>(value);
            command.ValueSelected += (s, v) => ValueSelected?.Invoke(this, v);
            var listItem = new ListItem(command);
            var item = customizeListItem(value, listItem);
            _items.Add(item);
        }
    }
}

这份工具代码的用途是:扩展作者只需要 new StaticParameterList<Folder>(folders, f => new ListItem(...)),即可得到一个会自动为每个值生成"选中命令"并广播 ValueSelected 事件的静态列表页——参数页业务代码从此不必手写逐条的 SelectFolderCommand

从草稿到 API:仓库中的真实实现与它们的行为细节

IDL 层:契约正式落地

参数页全套接口已在扩展契约 Microsoft.CommandPalette.Extensions.idl 中以 ExtensionsContract v1 版本正式发布。草稿中的开放问题(如 ICommandParameterRun.Icon 后的 // ? maybe)在落地时被保留了下来,接口形态与草稿基本一一对应,唯一明显的补充是每个接口都被标注了 [contract(...)] 以便做 ABI 版本演进管理。

Toolkit 层:给扩展作者的开箱基类

Microsoft.CommandPalette.Extensions.Toolkit 命名空间为每个接口提供了可继承的偏类型实现,文件集中在 Parameters 目录下:

  • ParametersPage.cs:抽象基类,要求派生类只实现 CommandParameters 两个成员,其余页面行为(标题、加载状态等)继承自 Page
  • ParameterValueRun.cs:所有可取值参数的抽象基类,定义 PlaceholderText,并把 Required 布尔值作为 NeedsValue 的兜底来源:
public virtual bool NeedsValue => _required;

private bool _required = true;

public virtual bool Required
{
    get => _required;
    set { _required = value; OnPropertyChanged(nameof(NeedsValue)); }
}

该基类注释里写明了这一"多态覆盖"约定:默认情况下参数在 Required 为真时始终"需要值";派生类型(如 StringParameterRun)通过重写 NeedsValue getter 引入自身状态(文本是否为空、值是否为 null)。它还要求派生类实现抽象的 Value { get; set; }ClearValue()

  • StringParameterRun.csLabelRun.cs:如前述,字符串参数以 string.IsNullOrEmpty(Text) 计算 NeedsValue;标签只需一个 LabelRun(string text) 构造函数即可创建。
  • CommandParameterRun.cs:以 Value == null 计算 NeedsValue,负责在 GetSelectValueCommand 中按需把 hostHwnd 注入到实现了 IRequiresHostHwnd 的命令,并在 Value 变化时同步触发 NeedsValue 的通知。

一个容易踩坑的语义是:Toolkit 中 CommandParameterRun.Value 的 setter 同时会触发 NeedsValueOnPropertyChanged(见 CommandParameterRun.cs),而 StringParameterRun 则在 Text 变化时做同样的事——这正是 4.2/4.3/4.4 中"值一旦就绪、CmdPal 自动推进焦点"能实现的底层原因(属性通知沿 PropChanged → ViewModel 一路传导)。

UI 层:ParametersPageViewModel 如何驱动交互

参数页的交互编排集中在 ParametersViewModels.cs。这个文件回答了"接口定义好之后,CmdPal 主程序如何把草稿描述的交互落实":

  • 按类型分发L651-L677):FetchItems()IParametersPage.Parameters 数组中的每个对象做模式匹配,ILabelRunLabelRunViewModelIStringParameterRunStringParameterRunViewModelICommandParameterRunCommandParameterRunViewModel,无法识别的类型记录错误日志。这正是草稿中"交互取决于 IParameterRun 的类型"的实现处。
  • 按钮 vs 文本框的切换L356-L371):CommandParameterRunViewModel_listViewModel != null 判定自己是否为"列表参数"(对应草稿中 SelectValueCommandIListPage 还是 IInvokableCommand)。对于列表参数,它暴露 ShowTextBox => NeedsValue || IsEditing——参数还没值或用户正在重新挑选时显示文本框,否则以 ButtonLabel(即 DisplayText)渲染为按钮。BeginEditing() / CancelEditing() 对应"用户已持有值但想重新选择"的场景。
  • host 窗口句柄注入L433-L448):初始化时通过 WeakReferenceMessenger 发送 GetHwndMessage 取回 CmdPal 主窗口句柄,再调用 commandRun.GetSelectValueCommand((ulong)msg.Hwnd);若返回的是 IListPage 则包装成 ListViewModel(复用普通列表页的过滤/渲染管线,正是草稿中"与任何其它列表页一致"的实现),若是 IInvokableCommand 则包装成 CommandViewModel
  • 焦点推进L850-L882):FocusNextParameter(lastParam) 在剩余参数里寻找下一个仍 NeedsValue 的项并通过 FocusParamMessage 让 UI 聚焦;找不到时回落到第一个未填值参数。列表参数的值一旦确认(扩展方回调触发 ValueChanged),页面便调用 SetActiveListParameter(null) 收起列表、FocusNextParameter 推进焦点(L796-L822)。
  • 命令显现时机UpdateCommand() 只在 ShowCommand(即 !NeedsAnyValues())为真时通过 UpdateCommandBarMessageCommand 发布给 UI;用户可通过 TrySubmit() 触发最终执行(L841-L848)。

这些机制在 ParametersPageViewModelTests.cs 中配套了单元测试,扩展作者若想核对诸如"填齐参数后 ShowCommand 才为真""值变化后焦点是否正确前移"等行为,可以从测试用例入手反向确认预期。

仓库内的真实示例页

规范不只停留在文档层,仓库的示例扩展提供了可直接运行的三组参数页(SamplePagesExtension/Pages/ParameterSamples.cs):

  • SimpleParameterTest:最简字符串参数页——一个 LabelRun("Enter a value:") 加一个 StringParameterRunPlaceholderText = "Type something"),提交后弹出 Toast 并调用 _stringParameter.ClearValue() 复位。
  • ButtonParameterTest:按钮式文件选择——一个 FilePickerParameterRun 加前后两个标签,提交时从 _fileParameter.ValueStorageFile 并打开(对应 4.3 节"文件选择器"用例)。
  • MixedParamTestPage:混排示例,同一页面同时承载字符串参数与文件选择参数,并且通过构造函数参数 stringFirst 控制字符串参数与文件参数谁先出现——可用于验证焦点推进对参数顺序的敏感性。

将这三页与草稿中的 CreateNoteParametersPage 对照,可以看到从"规范示例"到"示例扩展"的演化:页面都遵循"持有参数对象 → 提交命令读取 Value/Text → Toast 反馈 → ClearValue() 复位"的统一模式。

早期的另一条路线:任意参数与动作链探索

文档中 ## original draft starts here 之后的段落,是参数页定稿之前的一段更早思路记录(Mike 标注了"以下是原始笔记,不属于当前 SDK 规范")。这段内容对理解"为什么最终选择 run 流式模型"很有价值,也代表了被搁置的另一套候选设计。

第一版设想面向"任意参数直接挂在命令上":

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 里提出增加 CustomPicker 类型,让扩展自带选择器 UI:

我们应该添加类似 CustomPicker 的参数类型,允许扩展为参数定义自己的选择器。当需要填充参数值时,我们调用类似 ShowPickerAsync(ICommandParameter param) 的东西,让扩展去填值,我们不关心值是什么。

于是第二版草稿演化成"参数即对象"(参数自己携带值、展示名、图标与取值的 ShowPicker 方法):

enum ParameterType
{
    Text,
    // File,
    // Files,
    Enum,
    Custom
};

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);
};

interface IInvokableCommandWithParameters requires ICommand {
    ICommandArgument[] Parameters { get; };
    ICommandResult InvokeWithArgs(Object sender, ICommandArgument[] args);
};

值得注意的是 enum 的可选值列表(GetValues())与文件多选、批量类型参数(如 SendMessage(People[] to, String message))等场景,在两版草稿中始终被标记为 TODO 或注释掉——它们都涉及更重的 UI 与数据约定,作者明确选择不在 v1 硬啃。

作者还记录了一个更大胆的"实体链"设想(注记块中被称为 Quicksilver 式 "thing, do" 流程):让命令返回 CommandResult.Entity,把实体留在查询框顶部作为"徽章",随后只展示能接受该实体的命令,从而支持 文件 → 移除背景发送到 Teams 聊天 这类连续动作。但这个设想因为两个现实问题被推迟:一是对第三方 Action Framework 的消费侧行为缺乏可见度,无法精确建模;二是像"移除背景"这类页面需要更灵活的"任意实体 +"按钮而非死板的参数表。

作者最终的判断被忠实记录在文档末尾,这也是理解参数页 v1 边界的关键决策:

把动作链起来听起来很有意思,但我决定把它排除在官方 v1 规范之外。没有它我们也能交付一个可用的 DevPal v0.1,之后再加。

也就是说,当前文档正文的 IParametersPage 模型是"能立刻落地"的收敛方案,而"任意参数/动作链"则留作 v1 之后的演进方向。

扩展作者实践要点小结

  1. 选择正确的参数类型:纯文本用 StringParameterRun;需要系统级自定义选择器(文件、日期、颜色)用 IInvokableCommandCommandParameterRun,并实现 IRequiresHostHwnd 以接收 hostHwnd;需要"从集合中挑一个"用 IListPage 型参数,页面主体会自动变成可过滤列表。
  2. 牢记 NeedsValue 的驱动语义:所有参数 NeedsValue 都为假时 Command 才出现。StringParameterRun 以文本非空判定、CommandParameterRunValue != null 判定(见 StringParameterRun.csCommandParameterRun.cs),务必在取值完成后让二者进入"满足"状态,否则提交按钮永不出现。
  3. 列表项命令只能 KeepOpen 并事件回传:不要在参数列表项中返回任何会触发页面导航的命令结果;正确姿势是通过 FolderSelected / ValueSelected 类事件把选中值写回参数对象的 Value / DisplayText / Icon
  4. 命令内仍需业务校验NeedsValue 只约束 UI 门禁;像 CreateNoteCommand 那样在 Invoke() 里对空标题做 Toast 兜底校验并返回 KeepOpen(),是防御推荐写法。
  5. 复用 Toolkit 与示例:可直接继承 ParametersPage.cs、使用 ParameterSamples.cs 中的三页样例起步;页面 UI 层行为细节可对照 ParametersViewModels.csParametersPageViewModelTests.cs 验证。

参数页的设计把"命令执行前的数据收集"压缩进了搜索框本身——字符串、按钮、列表三类 run 以最小的认知负担覆盖了绝大多数取数场景,而其与列表页深度复用的架构,也让它在实现上保持了克制的复杂度。若你想追踪这套设计的后续演化(例如动作链与 CustomPicker 是否被重新提起),可继续阅读主规范 initial-sdk-spec.md 中 Addenda II 及其余增补条款。

登录后查看全文
热门项目推荐
相关项目推荐