Skip to the content.

Commands and command surfaces

UI/UX design for the command system: ToolBar, Menu (hamburger fly-out), and Ribbon (core + contextual tabs). Commands stay pure ICommands on the VM; all visual UX (icons, text, gesture display, chrome states) lives on the control side. Surface controls are templated ItemsControls over collections of invokers; the same ICommand appears in multiple surfaces without redeclaration on the command layer.

This document captures the design at sketch-level: control inventory, DPs, UX rules, and mockups. The infrastructure (5.9.1–5.9.5) has shipped — ICommandSource contract, CommandTarget, RoutedCommand + CommandBindings, InputBindings + KeyBinding / MouseBinding, the four named-command libraries (ApplicationCommands / EditingCommands / NavigationCommands / MediaCommands), and CommandManager.RequerySuggested all live now (see completed-backlog.md §5.9). What’s still pending is the surface controls (5.11) — ToolBar / Menu / Ribbon plus the commands demo that exercises them.

Related: behaviors.md, marquee-selection.md, items-and-scrolling.md.

1. Layered architecture

┌────────────────────────────────────────────────────────────────────┐
│  Surface controls (this exercise)                                  │
│     ToolBar / Menu / Ribbon and their item subclasses.             │
│     Own ALL visual UX: icons, text, gesture display, chrome states.│
├────────────────────────────────────────────────────────────────────┤
│  ICommandSource mixin (backlog 5.9.1)                              │
│     Command + CommandParameter + CommandTarget on every invoker.   │
│     CanExecuteChanged → auto-disable visual state.                 │
├────────────────────────────────────────────────────────────────────┤
│  InputBindings (backlog 5.9.2)                                     │
│     KeyBinding[Key, Modifiers, Command] declared per Visual,       │
│     dispatched from the focus root via PreviewKeyDown.             │
├────────────────────────────────────────────────────────────────────┤
│  ICommand (shipping)                                               │
│     Execute / CanExecute / CanExecuteChanged. RelayCommand.        │
└────────────────────────────────────────────────────────────────────┘

Why visual UX lives on the control, not the command

The natural pull toward WPF-style RoutedUICommand (carrying Text / Icon / KeyGesture on the command itself) was deliberately rejected. The trade-off:

Approach Reuse Per-surface tailoring Coupling
Metadata on command High — declare once, every surface reads the same Hard — same label everywhere Command leaks presentation concerns
Metadata on control Lower — each invoker declares its own Easy — Ribbon can say “Delete shape” where Menu just says “Delete” Clean separation; commands are pure logic

The framework’s MVVM-first stance puts commands on the VM; the control layer is where presentation already lives, so that’s where Text / Icon / display gesture belong. Reuse of the same visual config across surfaces happens through Style and ResourceDictionary keyed resources — the standard mechanisms — not through a special command-metadata layer.

2. ICommand (shipping)

The existing primitive in runtime/command.ts.

interface ICommand
{
    Execute(parameter?: unknown): void;
    CanExecute(parameter?: unknown): boolean;
    AddCanExecuteChanged(listener: () => void): void;
    RemoveCanExecuteChanged(listener: () => void): void;
}

RelayCommand is the standard concrete: takes an execute callback, an optional canExecute predicate, and a RaiseCanExecuteChanged() the VM calls when its world changes. Surface controls subscribe to CanExecuteChanged to auto-disable.

3. ICommandSource (shipping)

A contract every command invoker implements. Three DPs:

DP Type Default Meaning
Command ICommand \| undefined undefined The command to invoke. undefined = the source is non-interactive (or driven by other means like a click handler). Accepts both RelayCommand and RoutedCommand.
CommandParameter unknown undefined Passed as the argument to Execute / CanExecute.
CommandTarget Visual \| undefined undefined RoutedCommand-only override of the routing dispatch target. Plain ICommand ignores this DP. When unset, RoutedCommand dispatches from the source Visual itself.

Button implements ICommandSource today through CommandSourceHelper (src/runtime/command-source.ts). Each command-source control registers its own Command / CommandParameter / CommandTarget DPs and forwards OnPropertyChanged writes to a CommandSourceHelper instance that handles the cached _canExecute boolean, the CanExecuteChanged subscription, and the RoutedCommand-vs-plain-ICommand dispatch branch. Future ICommandSource controls (MenuItem, ToolBarButton, RibbonButton, Hyperlink, ToggleButton) follow the same pattern.

Auto-disable contract: when Command !== undefined and Command.CanExecute(CommandParameter) === false, the source visually appears disabled (low alpha, no hover/press states, click ignored). Driven by CanExecuteChanged — every invoker subscribes when Command is set, unsubscribes when it clears.

4. InputBindings (shipping)

Per-Visual collection of gesture-to-command mappings:

Window x:name="root" {
    InputBindings {
        KeyBinding   [Key=S, Modifiers=Control, Command=$SaveCmd]
        KeyBinding   [Key=Delete,                Command=$DeleteCmd]
        MouseBinding [Gesture=MiddleClick,       Command=$PanCmd]
    }
    /* content */
}

Dispatch model: the routed-event walker’s KeyDown bubble pass calls into _tryFireInputBindingsForVisual at each ancestor on the route up from the focused element. Per-instance bindings (Visual.InputBindings) are consulted first, then per-class bindings registered via CommandManager.RegisterClassInputBinding. The innermost matching binding wins because the bubble walker stops on args.Handled = true. When the bound command is a RoutedCommand, dispatch flows through CommandManager.Execute against CommandTarget ?? host; for plain ICommand the binding calls Execute(CommandParameter) directly. KeyBinding fires on KeyDown only (matches WPF — KeyUp doesn’t trigger commands).

For surface controls: a MenuItem’s displayed InputGestureText is a STRING, not a wire. It’s the menu’s UI hint. The actual shortcut firing is the matching KeyBinding declared elsewhere in the tree. Both reference the same command, so the menu’s hint and the keyboard shortcut stay consistent. This is the same separation WPF maintains.

5. ToolBar

Flat strip of icon/text buttons. Mixed default: most buttons are icon-only with tooltip; high-value verbs opt into text via ShowText=true.

┌─────────────────────────────────────────────────────────────────────────────┐
│ [📂 Open] [💾 Save] │ [✂] [📋] [📄] │ [⊘ Delete] [⎘ Duplicate] │ [↶] [↷] │ ⋯ │
└─────────────────────────────────────────────────────────────────────────────┘
   ShowText=true        ShowText=false  ShowText=true (important)    ShowText=false   overflow

Controls

UX rules

6. Menu — hamburger fly-out

The Menu control opens from a MenuButton (hamburger). Top-level items render in a vertical popup; submenus fly out to the right.

[☰] Diagrammer
 ↓
┌──────────────────┐                ┌────────────────────────────┐
│ 📁 File       ▸  │ → hover Edit → │ ✂  Cut         Ctrl+X      │
│ ✏  Edit       ▸  │                │ 📋 Copy        Ctrl+C      │
│ 👁 View       ▸  │                │ 📄 Paste       Ctrl+V      │
│ ?  Help       ▸  │                │ ────────────────────────── │
└──────────────────┘                │ ⊘  Delete      Del         │
                                    │ ⎘  Duplicate   Ctrl+D      │
                                    │ ────────────────────────── │
                                    │    Select All  Ctrl+A      │
                                    └────────────────────────────┘

Controls

Template parts inside MenuItem

The row layout is a 4-column grid:

[icon  16px][header                 *  ][gesture 80px][chevron 16px]

Columns auto-hide when empty (no icon column when no item in the menu has an Icon; no gesture column when no item declares InputGestureText). This keeps a “no-icon” submenu from wasting 16px.

UX rules

7. Ribbon — core + contextual tabs

Tabbed grouped chrome. Two tab kinds:

No selection — only stable tabs:
┌────────────────────────────────────────────────────────────────────────┐
│  Home   Insert   View                                                   │
├────────────────────────────────────────────────────────────────────────┤
│  ┌────────┐ ┌──────────────┐ ┌────────────────────────┐                │
│  │   📂   │ │ [✂][📋][📄]  │ │ [Background ▼]         │                │
│  │  Open  │ │  Clipboard   │ │ [Theme       ▼]        │                │
│  │        │ │              │ │  Appearance            │                │
│  └────────┘ └──────────────┘ └────────────────────────┘                │
└────────────────────────────────────────────────────────────────────────┘

With selection — contextual "Format" tab badged in colour:
┌────────────────────────────────────────────────────────────────────────┐
│  Home   Insert   View   │ ██ Format ██                                  │
├────────────────────────────────────────────────────────────────────────┤
│  (Format tab body)                                                      │
│  ┌────────────────┐ ┌──────────────────┐ ┌────────────────────────────┐│
│  │ [⊘ Delete]     │ │ [Bring Front]    │ │ [Align L][C][R]            ││
│  │ [⎘ Duplicate]  │ │ [Send  Back]     │ │ [Align T][M][B]            ││
│  │     Edit       │ │   Z-order        │ │   Alignment              ↘ ││
│  └────────────────┘ └──────────────────┘ └────────────────────────────┘│
└────────────────────────────────────────────────────────────────────────┘

Controls

UX rules

Out of scope for v1

8. Cross-surface concerns

Tooltip integration

Every ICommandSource produces an auto-tooltip composed from:

Consumers can override by setting Tooltip explicitly.

CanExecute → disabled chrome

When Command !== undefined, the source subscribes to Command.CanExecuteChanged. The result of Command.CanExecute(CommandParameter) drives an IsEffectivelyEnabled internal flag. The flag participates in a Style trigger that swaps to disabled visuals (50% alpha, no hover state, click ignored). Same pattern across all four invoker controls.

Keyboard activation paths

Action Toolbar Menu Ribbon
Tab focus Yes (focusable button) Once popup is open Yes
Activation key Enter / Space Enter Enter / Space
Accelerator — Underlined letter in Header (Alt+letter) KeyTips (Alt → letter overlay) — TBD
Shortcut InputBinding on focus root InputBinding (display via InputGestureText) InputBinding

KeyTips (the Alt-then-letter overlay Office uses to navigate the ribbon) are deferred — not in the v1 ribbon scope. Regular Tab focus into the ribbon and arrow-key navigation between tabs/groups/buttons covers the keyboard story for now.

Resource sharing of visual metadata

Same icon used by toolbar / menu / ribbon: declare once as a ResourceDictionary resource (DeleteIcon), reference from each invoker. Same Text: declare as a static-resource string. The command itself stays referenced by identity from the VM.

ResourceDictionary x:key="commandResources" {
    SolidColorBrush x:key="deleteIconColor" [Color=#e11d48]
    DataTemplate    x:key="deleteIcon"      { /* Path / icon */ }
    String          x:key="deleteText"      [Value="Delete"]
}

/* invokers */
ToolBarButton [Icon=@deleteIcon, Text=@deleteText, Command=$DeleteCmd]
MenuItem      [Icon=@deleteIcon, Header=@deleteText, Command=$DeleteCmd,
               InputGestureText="Del"]
RibbonButton  [LargeIcon=@deleteIcon, Text=@deleteText, Command=$DeleteCmd]

9. Demo plan — commands

Dedicated demo exercising the three surfaces over a shared command set. Reuses the existing Diagram model from the diagram demo so the visual context is familiar.

Layout

┌───────────────────────────────────────────────────────────────────────────┐
│  [☰] Diagrammer                                    [⃝ Classic  ⦿ Ribbon] │
├───────────────────────────────────────────────────────────────────────────┤
│  ━━ MODE: CLASSIC ━━                                                       │
│  [📂 Open][💾 Save] │ [✂][📋][📄] │ [⊘ Del][⎘ Dup] │ [↶][↷]              │
├───────────────────────────────────────────────────────────────────────────┤
│            [ Diagram canvas — NodeVMs; drag, click, marquee, etc. ]       │
└───────────────────────────────────────────────────────────────────────────┘

Toggle to RIBBON →
┌───────────────────────────────────────────────────────────────────────────┐
│  [☰] Diagrammer                                    [⃝ Classic  ⦿ Ribbon] │
├───────────────────────────────────────────────────────────────────────────┤
│  ━━ MODE: RIBBON ━━                                                        │
│  Home   Insert   View   │ ██ Format ██  (only when HasSelection)           │
│  [ribbon body for current tab]                                            │
├───────────────────────────────────────────────────────────────────────────┤
│            [ Diagram canvas — unchanged ]                                  │
└───────────────────────────────────────────────────────────────────────────┘

Commands

All pure ICommands on the VM. No visual metadata on the command.

Group Command Bound in surfaces InputBinding
File OpenCmd Menu › File, Toolbar Ctrl+O
File SaveCmd Menu › File, Toolbar Ctrl+S
File ClearAllCmd Menu › File —
Edit CutCmd Menu › Edit, Toolbar, Ribbon › Home Ctrl+X
Edit CopyCmd Menu › Edit, Toolbar, Ribbon › Home Ctrl+C
Edit PasteCmd Menu › Edit, Toolbar, Ribbon › Home Ctrl+V
Edit DeleteCmd Menu › Edit, Toolbar, Ribbon › Format (contextual) Del
Edit DuplicateCmd Menu › Edit, Toolbar, Ribbon › Format Ctrl+D
Edit SelectAllCmd Menu › Edit Ctrl+A
Edit UndoCmd Menu › Edit, Toolbar Ctrl+Z
Edit RedoCmd Menu › Edit, Toolbar Ctrl+Y
Arrange (contextual) BringFrontCmd / SendBackCmd Ribbon › Format —
Arrange (contextual) AlignLeftCmd / AlignCenterHCmd / AlignRightCmd / AlignTopCmd / AlignMiddleVCmd / AlignBottomCmd Ribbon › Format —

CanExecute drivers (computed from VM state)

Proof points

10. Open items / future work