Windows Terminal 设置模型 Actions 重构:统一 Command 与键位绑定表示(Actions Addendum)
Windows Terminal 设置模型 Actions 重构统一 Command 与键位绑定表示Actions Addendum【免费下载链接】terminalThe new Windows Terminal and the original Windows console host, all in the same place!项目地址: https://gitcode.com/GitHub_Trending/term/terminal本文为 Windows Terminal 仓库中#885 - Terminal Settings Model系列规范中的Actions Addendum动作补充规范详解它提出了一套重构方案将原先分离存储的键位绑定keybindings与命令面板命令commands统一到同一个设置模型表示中以支持 Settings UI 对二者的序列化、反序列化与修改。读完本文你将理解该规范三步走的解决方案Command 升级、ActionMap 查询、Settings UI 绑定、defaults.json / settings.json 分层加载时的冲突消解策略以及该设计在仓库当前源码src/cascadia/TerminalSettingsModel中的落地形态与演进方向。1. 背景设置模型中动作的两种旧表示在撰写本规范时2021 年issue #885 系列Windows Terminal 的 JSON 配置里一个可绑定的动作是以组合条目的形式出现在actions数组中的例如{ icon: path/to/icon.png, name: Copy the selected text, command: copy, keys: ctrlc }在这个 JSON 示例中copy 动作同时满足绑定到ctrlc键位在命令面板中以 Copy the selected text 的显示名和 path/to/icon.png 图标呈现。但当时的设置模型settings model内部却是两套独立结构键位绑定KeyMapping中的一条KeyChord→ActionAndArgs映射命令一个携带 icon、name 和ActionAndArgs的Command对象。这种割裂引入了规范原文指出的两类问题序列化Serialization无法判断某条命令command与某个键位绑定是否指向同一个动作因此不知道该不该往 JSON 里写 name也无法判断 name 是自动生成的还是用户显式设置的导致 JSON 被大量本可自动生成的名字膨胀。重复处理Handling Duplicates同一动作可以被绑定到多个 KeyChord。命令面板之所以能合并它们只是因为它们同名——而本质上只是同一个动作被以不同方式引用。主规范 Terminal Settings Model 已经完成了把AppKeyBindings拆分为TerminalApp侧的分发职责与TerminalSettingsModel侧的KeyMapping(de)序列化与导航这一步本补充规范则进一步处理动作本身在模型中的统一表示。2. 解决方案 Step 1统一动作Consolidating actions规范的核心提案是把键位绑定动作与命令面板动作合并进同一个类——即升级Command使其同时携带 KeyChord 信息。规范给出的目标形态为runtimeclass Command { // The path to the icon (or icon itself, if its an emoji) String IconPath; // The associated name. If none is defined, one is auto-generated. String Name; // The key binding that can be used to invoke this action. // NOTE: Were actually holding the KeyChord instead of just the text. // KeyChordText just serializes the relevant keychord Microsoft.Terminal.Control.KeyChord Keys; String KeyChordText; // The action itself. ActionAndArgs ActionAndArgs; // NOTE: nested and iterable command logic will still be here, // But they are omitted to make this section seem cleaner. // Future Considerations: // - [#6899]: Action IDs -- add an identifier here }围绕这个升级规范列出了必要的配套修改Command::LayerJson必须合并原来KeyMapping::LayerJson与Command::LayerJson的逻辑Key Chord 数据内部用一个vectorKeyChord _keyMappings记录与该动作关联的全部键位RegisterKey/EraseKey更新该列表并保证最新注册的键位位于列表末尾Keys()返回列表最后一项即最新绑定的 KeyChordKeyChordText()直接暴露序列化文本给命令面板因为它依赖Keys所以能自动随其变化可观察属性Observable propertiesCommand今天有可观察属性但重构后不再需要——因为Command在应用运行期间不会被修改嵌套与可迭代命令HasNestedCommands、NestedCommands{ get; }、IterateOn继续保留在其需要被 Settings UI 自定义之前不暴露 setter命令展开command expansion继续在此暴露以降低成本额外引入IsNestedCommand用于记录嵌套命令被解绑这一情形即{ commands: null, name: foo }。规范总结这一步只是把Command类提升为包含其 KeyChord 的类实现成本相对较小对依赖Command的组件影响有限。但键位绑定一侧会受较大影响因为它们原本表示为KeyChord到ActionAndArgs的 map——这直接引出 Step 2。源码印证Command 的现状仓库中 Command.h 与 Command.idl 体现了该规范的落地并可见后续演进Command现持有Namestd::optionalCommandNameOrResource区分本地化 name 与语言中性名、IDGenerateID()/IDWasGenerated()——正是规范未来考虑中 Action ID 的雏形见 Action IDs 规范、IconIMediaResource、ActionAndArgs以及嵌套命令的HasNestedCommands()/NestedCommands()/IsNestedCommand()JSON 侧的键常量集中在 Command.hname、id、icon、command、iterateOn、commands、keys、description与 JSON 组合条目一一对应FromJson/LayerJson/ToJson/LogSettingChanges承担了规范 Step 1 中合并后的反序列化职责OriginTagWINRT_PROPERTY(OriginTag, Origin)则对应来源追踪见第 7 节。从当前 IDL 投影看可以推断实现者最终把键位状态集中到了ActionMap统一管理Command投影中没有Keys属性而非规范 Step 1 草图中挂在Command上——这是规范意图与实际落地之间的一处演进下文 ActionMap 部分会看到其合理性。3. 解决方案 Step 2查询动作Querying actions规范指出键位绑定与命令的反序列化本质上都是把一个个动作存进 mapKeyMapping实际上是一个带若干附加功能的IMapKeyChord, ActionAndArgs内部就是一个std::mapKeyChord, ActionAndArgsCommand::LayerJson在反序列化时遍历每个动作填充一个IMapString, Command。因此按 map 存储动作是合理的。在 Step 1 之后动作的存储与暴露形态如下runtimeclass ActionMap { ActionAndArgs GetActionByKeyChord(KeyChord keys); KeyChord GetKeyBindingForAction(ShortcutAction action); KeyChord GetKeyBindingForAction(ShortcutAction action, IActionArgs actionArgs); IMapViewString, Command NameMap { get; }; // Future Considerations: // - [#6899]: Action IDs -- GetActionByID() }关键设计点规范原文逐条对应查询未命中时 getter 返回 null可迭代命令iterable commands的展开发生在 TerminalApp 侧所以模型只暴露NameMap展开仍由 TerminalApp 照旧完成内部存储为两张表std::mapKeyChord, InternalActionID _KeyMap; std::mapInternalActionID, Command _ActionMap;InternalActionID是ActionAndArgs的哈希两个具有相同ShortcutAction和IActionArgs的ActionAndArgs会产出相同哈希值从而让不同 JSON 条目指向同一动作在数据层可判定——这正是解决 1 节中序列化时不知道是否同动作问题的核心GetActionByKeyChord先经_KeyMap找到InternalActionID再经_ActionMap找到对应CommandGetKeyBindingForAction则用给定参数构造ActionAndArgs后哈希直接在_ActionMap中查InternalActionIDNameMap的生成需保证_ActionMap中每个有名字的动作都进入结果遍历_ActionMap即可嵌套命令因无法哈希需单独加入AddAction(Command cmd)负责在命令注册时更新内部状态若命令有效则检查并解决冲突否则按unbound动作正常更新内部状态。规范特别强调不能区别对待 unbound 动作——因为显式解绑一个 KeyChord必须是一种被明确记录的状态。源码印证ActionMap 的内部结构当前仓库 ActionMap.h 与该设计高度一致并做了工程化扩展第 33 行using InternalActionID size_t;与规范的哈希 ID 一致第 140–141 行的两个本层状态表_KeyMapKeyChord → 动作 ID与_ActionMap动作 ID → Command注释明确These maps are the ones that we deserialize into when parsing the user json and vice-versa第 143–157 行的多级缓存_CumulativeKeyToActionMapCache/_CumulativeIDToActionMapCache/_CumulativeActionToKeyMapCache跨层合并视图孩子层覆盖父层与_ResolvedKeyToActionMapCache、_AllCommandsCache供 Settings UI 直接消费的键位 → 命令解析视图另有_NestedCommands无法哈希的嵌套命令按 name 存与_IterableCommands哈希的实现在 ActionMap.cpp先用IActionArgs::Hash()播种til::hasher无参数时回退到该动作默认参数的缓存哈希宏ALL_SHORTCUT_ACTIONS_WITH_ARGS展开最后写入ShortcutAction并 finalize——即规范所述同 ShortcutAction 同 IActionArgs → 同哈希。4. 解决方案 Step 3Settings UI 的需求完成前两步后新的动作表示与旧能力持平。要把它绑定到 Settings UI规范列出四项工作暴露映射可考虑新增ActionMap::KeyBindings与ActionMap::Commands把完整动作列表传给 Settings UI从而顺带改善 UI 对动作的呈现拷贝设置模型Settings UI 通过绑定 XAML 控件到设置模型的一份拷贝来工作。拷贝ActionMap很简单拷贝内部状态并调用Command::Copy确保没有指向原 WinRT 对象的引用残留由于使用InternalActionID无需担心同一ActionMap内出现多个Command引用修改Command修改必须由ActionMap负责以保证其内部状态始终正确Command在投影类型中只暴露 getter 不暴露 setter以此强制约束键位变化时同时更新_KeyMap与Command本身删除键位绑定时向该 KeyChord 加入一个 unbound 动作这与今天维护 color scheme 的方式类似若 name/key-chord 被设置成已占用的值需要把变化传播到ActionMap其余部分如同 JSON 侧一样遵循用户最后设置的 name/key-chord 生效见 6.2 节为键位页引入KeyBindingViewModel作为 Settings UI 与设置模型之间的中介负责向 UI 控件暴露相关信息、把 UI 控件交互转换为对设置模型的正确 API 调用序列化Command::ToJson()与ActionMap::ToJson()承担主要工作——遍历_ActionMap并对每个动作调用Command::ToJsonunbound 动作的输出见 6.3 节。源码印证IActionMapView 只读投影规范 6.2 节提出的IActionMapView只暴露查询、ActionMap才暴露修改的访问隔离已按原意落地。ActionMap.idl 中interface IActionMapView注释即This interface ensures that no changes are made to ActionMap只含查询与只读视图GetActionByKeyChord、GetActionByID、GetKeyBindingForAction、AllKeyBindingsForAction、IsKeyChordExplicitlyUnbound以及NameMap、KeyBindings、GlobalHotkeys、AllCommands、AvailableActions、ExpandedCommands等视图——TerminalApp含命令面板、快捷键分发只持有这个只读面可变 APIAddAction、RebindKeys、DeleteKeyBinding、DeleteUserCommand、AddKeyBinding、RegisterKeyBinding、AddSendInputAction只存在于runtimeclass ActionMap : IActionMapView上供 TerminalSettingsEditor 使用。从当前投影与规范草图的差异如RebindKeys代替SetKeyChord、UpdateCommandID的加入看可以推断 Settings UI 迭代过程中按需调整了修改面 API但查询/修改分离的架构约束保持不变。5. 潜在问题一动作的分层Layering Actions问题写盘时需要知道每个动作来自哪一层以最小化序列化数量只写与 defaults 不同的部分。两阶段加载流程规范原文加载 defaults.json对 JSON 中每个动作构造Command相当于今天的Command::LayerJson加入ActionMap相应更新内部状态若新加入的 KeyChord 与既有冲突则把_KeyMap重定向到新加入的Command并更新被冲突方。加载 settings.json为ActionMap创建 childparent 的用途是当当前ActionMap查不到Command时继续沿继承链查找parent 应视为不可变actions 数组照常加载进 child同第 1 步。ActionMap的 parent 机制让它能够理解某 Command 从哪一层来从而在写盘时只序列化必要子集而不是整份动作清单。查询时若当前层_ActionMap找不到匹配的InternalActionIDActionMap的查询会递归到 parent。NameMap因按需生成需要用一个std::setInternalActionID保证每条Command只被加入一次。生成顺序从所有 parent 处取一份累积的Command列表遍历列表把新Command更新进NameMap用std::set跟踪嵌套命令存入独立 map它们没有InternalActionID。填充过程中冲突必须立即解决新Command的 name 与某嵌套命令冲突时新者胜从嵌套 map 移除后者反之新嵌套命令与既有标准Command冲突则可忽略因为NameMap生成时会处理生成NameMap时必须先加入所有标准Command且从最顶层 parent 开始、沿继承树向下更新——冲突一律偏向当前层child确保当前层优先标准Command全部入NameMap后再注册嵌套命令嵌套命令除 name 外没有标识不能用哈希判定但由于填充时内部嵌套 map 已在做去重生成 name map 时可假定这些嵌套命令整体优先直接加入并在冲突中胜出。这套规则与主规范中的对象模型继承CreateChild()_parents coalesce 回退一脉相承当前仓库中该机制由 IInheritable.h 的模板IInheritableT统一提供——CreateChild()、AddLeastImportantParent/AddMostImportantParent、虚函数_FinalizeInheritance()而ActionMap正是实现了_FinalizeInheritance()见下的继承对象之一。源码印证_FinalizeInheritance 与内箱动作去重ActionMap.cpp 中的_FinalizeInheritance()是该钩子的具体应用也是规范来源感知思想的一个实例化子层建立后它从 parent 中收集Origin OriginTag::InBox的动作然后在用户层_ActionMap中删除那些ID 为自动生成、无 name 无 icon、且哈希与某个内箱inbox动作相同的命令并把这些命令的 KeyChord 重定向到内箱动作的 ID 上。这恰好实现了 1 节序列化目标——用户没有实际定制的动作不写盘其键位直接复用 defaults 层条目——避免了 JSON 被自动生成的副本膨胀。6. 潜在问题二/三/四修改、解绑与合并视图6.1 Modifying Actions修改动作命令可被修改的方式有改变/移除 KeyChord、改变 name、改变 icon、改变 action。规范要求这些修改必须经由ActionMap而非Command完成以维持两侧状态一致Command投影面只暴露 getter 以强制执行。为此给ActionMap增加规范草图runtimeclass ActionMap { void SetKeyChord(Command cmd, KeyChord keys); void SetName(Command cmd, String name); void SetIcon(Command cmd, String iconPath); void SetAction(Command cmd, ShortcutAction action, IActionArgs actionArgs); }各函数语义SetKeyChord更新_KeyMap与目标Command若新 KeyChord 已被占用还要更新被冲突的Command并移除其键位SetName更新_ActionMap中的Command并重新生成NameMapSetIcon只改Command本身不暴露也行但暴露可使 API 一致SetAction更新Command的ActionAndArgs若当前 name 是自动生成的需同步更新 name_ActionMap要用新动作的新InternalActionID重建条目——这是重量级操作所有已暴露视图都需重新生成。跨层修改copy-up流程若Command不在当前层而只存在于 parent 层则先检查是否存在——用哈希InternalActionID查当前层若没有调用_GetActionByID(InternalActionID)助手递归向上查找用Command::Copy复制它把副本存入当前层ActionMap::AddAction(duplicate)对副本执行修改。这保证修改在序列化后依然持久写进 settings.json 而不是 defaults.json。访问隔离的落地方式规范原文TerminalApp 没有任何理由调用这些 setter为此引入IActionMapView只暴露查询而可变ActionMap只暴露给 TerminalSettingsEditor——如第 4 节源码印证所示这一隔离在当前 ActionMap.idl 中已存在。6.2 Unbinding actions解绑动作规范明确移除 name暂不在范围内那属于 Command Palette 定制场景无 Settings UI 用例。当前范围内唯一的解绑类型是释放一个 KeyChord使其按下时不执行任何动作。做法是向ActionMapAddAction一个特殊CommandActionAndArgsShortcutAction Invalid且IActionArgs nullptrKeys即要释放的 KeyChord。显式存储unbound动作等于显式声明这个 KeyChord 必须被放行pass through且对应条目必须从命令面板中移除。AddAction会自动处理内部状态与冲突Command的更新。这使 JSON 可以输出{ command: unbound, keys: ctrlc }源码中可见这一定义的源头Command.idl 的ShortcutAction枚举第一项即为Invalid 0, // treat Invalid as unbound actions查询侧的 ActionMap.h 也暴露了IsKeyChordExplicitlyUnbound(KeyChord)供 UI 判定该键位是被显式解绑的。6.3 Consolidated Actions合并视图场景考虑如下三层条目——当前层{ command: unbound, keys: ctrlc }解绑 ctrlc某 parent 层{ command: copy, keys: ctrlc }某 parent 层{ command: copy, keys: ctrlshiftc }。_ActionMap不含任何关于 parent 层的信息只含当前层引入的动作。于是解绑 ctrlc确实与_ActionMap相关但GetKeyChordForAction会因此复杂化——不能只查内部_ActionMap因为条目中的主键位可能是错的_ActionMap只知道本层绑过什么。解法引入_ConsolidatedActions。它形似_ActionMap但把跨当前层与所有 parent 层的Command数据合并进单条条目。在上述场景中_ActionMap会说 copy 没有任何键位甚至_ActionMap里根本没有 copy因为它不是本层引入的而_ConsolidatedActions持有copy 绑定ctrlshiftcGetKeyChordForAction应返回该值。维护与查询规则规范原文任何新加入 ActionMap 的动作都必须同时更新_ConsolidatedActions冲突的传播尤为关键按 ID 查询 ActionMap 时固定按以下顺序查找_ConsolidatedActions_ActionMap对每个 parent 重复上述两步。这样查询返回的不是某一层构造的Command而是汇总了整条继承链上全部数据的完整视图。当前仓库 ActionMap.h 的_CumulativeIDToActionMapCache/_CumulativeKeyToActionMapCache/_CumulativeActionToKeyMapCache三张缓存可以推断正是对跨层合并 双向 O(1) 查询的工程化实现注释同样写明child layers overriding parent layers。7. 未来考虑Future considerations规范末尾指出在该重构之上以下扩展都相对直接Action IDsissue #6899随着动作在 Windows Terminal 内变得更普及下拉菜单、jumplist 集成等一套正式的 ID 体系能让用户在整个应用内引用同一动作。方案是在既有内部 ID 体系上新增一个像其他 map 一样维护的std::mapstring, InternalActionID _ExternalIDMap并给Action增加一个String ID属性。仓库中已有对应规范文档 Action IDs当前 Command.idl 已带String ID { get; }与GenerateID()可见该方向已在推进。来源追踪issue #8100记录一个设置来自哪里对模型与 UI 都极有价值。例如 profile 设置今天已有OverrideSourcegetter说明该值来自哪个Profilebase 层、profile generator 等。动作可采用类似机制记录该动作最后一次是在 defaults.json 还是 settings.json 中被修改。规范同时注明没有迹象表明需要动作级继承如继承 name/key-chord因此记录最后修改来源已足够。当前源码中Command的OriginOriginTag如InBox与_FinalizeInheritance中的来源判定见 5 节即为该方向的落地形态。8. 规范落地的验证入口单元测试规范中冲突消解、unbound 语义、NameMap 生成、序列化往返等行为仓库均提供了对应测试工程 UnitTests_SettingsModelKeyBindingsTests.cpp键位绑定注册、重绑、解绑行为且 ActionMap.h 通过friend class将其声明为友元便于白盒断言内部_KeyMap/_ActionMapCommandTests.cppCommand的 JSON 往返与 name/icon/nested 语义DeserializationTests.cpp 与 SerializationTests.cppLayerJson/ToJson的分层加载与写盘输出正是第 5、6 节规则的回归验证点。ActionMap的测试钩子还保留了规范的作者署名线索ActionMap.h 头部注释 Carlos Zamora - September 2020与规范头部的 author 一致可作为规范 → 实现的对应证据。9. 小结这套设计解决了什么回到 1 节提出的两类问题规范给出的答案可以归纳为三条主线统一表示以Command携带 name/icon/ID/ActionAndArgs为单一事实来源ActionMap以哈希 IDInternalActionID KeyChord 双表管理绑定与引用使同一动作的多个键位与命令面板条目天然合并——序列化时能判断 name 该不该写、是否自动生成继承感知parent/child 的ActionMap分层 _ConsolidatedActions合并视图 跨层查询顺序合并视图 → 本层 → 递归 parent让 settings.json 只写与 defaults 有差异的部分且任何查询都得到整条继承链的完整视图修改安全Command只读、修改一律走ActionMap含 copy-up 跨层复制、冲突传播、unbound 显式记录并通过IActionMapView/ActionMap的接口切分把 TerminalApp 与 Settings Editor 的职责物理隔离。对维护或扩展该仓库的开发者而言理解本文后可以直接从 ActionMap.h、ActionMap.cpp、Command.h 与 IInheritable.h 入手在规范语境下读懂 Windows Terminal 动作子系统的现状与演进方向Action IDs、来源追踪。【免费下载链接】terminalThe new Windows Terminal and the original Windows console host, all in the same place!项目地址: https://gitcode.com/GitHub_Trending/term/terminal创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考