Skip to content

Command Surfaces

Mike Strobel edited this page Aug 4, 2026 · 2 revisions

Command surfaces

Cursorial.UI.Bars is the command-surface suite: a Toolbar, a Ribbon, and floating/drop-down menus are three surfaces over one shared set of command-driven bar controls. You define each command once as a BarCommand, bind it to whichever surfaces you want, and every surface fills in its own label, icon, and gesture hint from the command — "define once, surface anywhere". A toolbar button, a ribbon toggle, and a mini-toolbar entry that all invoke Save are the same command with no per-surface duplication.

The controls are ordinary Cursorial.UI controls: the bar buttons derive from ButtonBase, the surfaces from ItemsControl/TabControl, so the same styling, data binding, and input and focus machinery applies. Looks come from a self-registering theme in a box-drawing idiom that tracks the active palette and dark/light flips.

Namespace note. Every type here lives in Cursorial.UI.Bars. The suite registers its own control themes at module load (no consumer dictionary merge needed). To address the bars from XAML, register the assembly once — XamlSchemaContext.Default.RegisterAssembly(typeof(Cursorial.UI.Bars.Toolbar)) — after which the controls are available under the default https://cursorial.dev/ui xmlns, unprefixed like every other Cursorial control.


The define-once model: BarCommand

BarCommand is the model every surface binds to — a UIObject (not a control) that pairs an ICommand with the display metadata a command surface needs. Its headline properties are Command (ICommand? — the bindable inner behavior, typically a view-model command), Text (the label), Icon (object? — an Icon, an IconCarrier, or a glyph string), InputGestureText (the shortcut hint, display-only), IsCheckable, and Description (rich hover-help body — see SuperTips). Delegate constructors wrap a plain action in the inner command as sugar; an optional previewExecute/cancelPreviewExecute pair on the parameterized constructor makes that inner command previewable (an IPreviewableCommand), which a value drop-down uses to dry-run a value live — see Value commands and live preview.

BarCommand forwards every ICommand member to its inner Command; while that is null, CanExecute answers false, so bound surfaces grey out until a command binds. A bar control auto-fills any unset Content/Icon/ InputGestureText from a bound BarCommand (via SetCurrentValue, so an explicit author or style value still wins) — one declaration drives the face on every surface that hosts it.

var save = new BarCommand(() => document.Save())
{
    Text = "_Save",
    Icon = "💾",
    InputGestureText = "Ctrl+S",
    Description = "Save the current document to disk.",
};

// The same command on three surfaces — each fills its own label / icon / gesture:
var toolbarButton = new BarButton { Command = save };
var ribbonButton  = new BarButton { Command = save };
var miniButton    = new BarButton { Command = save };

A raw ICommand still works on any bar control; the control then needs its own display properties set. Call RaiseCanExecuteChanged() to re-query every bound surface (the inner command's own raises are forwarded automatically, and Execute follows a run with a courtesy re-raise).

The shared bar controls

All of these are the same controls a Toolbar, a Ribbon, and a MiniToolbar host — build them once, host them anywhere.

  • BarButton — a command button (ButtonBase). Adds Icon (object?) and InputGestureText beside the inherited Content label and Click/Command/IsPressed. Auto-fills all three from a bound BarCommand.
  • BarToggleButton — a checkable button (ToggleButton): checked = accent whole-cell fill, :checked. When its CommandParameter is an ICheckableCommandParameter the checked state is command-owned (the command's Execute mutates the parameter and the button re-reads it, and it no longer self-toggles on click), so one command drives the checked state on every surface without an IsChecked binding. Hand several toggles one command with a ValueCommandParameter<T> each and they become a radio group with no extra wiring (see Value commands and live preview).
  • BarSplitButton — two hit zones: the label runs the primary action (like a BarButton), and a tinted ▾ zone opens a dropdown Popup over DropDownContent.
  • BarPopupButton — a whole-control opener (a bare ▾ caret): a click / Space / Enter / Down toggles the dropdown over DropDownContent; it has no separate primary action.
  • BarComboBox — a compact bar-face ComboBox ([value ▾]): single selection, drop-down list, type-ahead, editable mode. Give it a Command plus a ValueCommandParameter<T> and the drop-down becomes a value picker — a commit gesture writes the chosen value and executes; a dismissal restores the pre-open selection. If the command is additionally an IPreviewableCommand, moving the highlight dry-runs the value live — see Value commands and live preview.
  • BarGallery — a BarComboBox whose drop-down lays its choices out as a grid of preview cells (a WrapPanel items host) rather than a vertical list; the same value-command and live-preview support.
  • BarSeparator — a thin rule between clusters (an upright │ on a horizontal bar; the toolbar flips it to a horizontal ─ in the overflow popup).
  • BarLabel — a non-interactive caption (ContentControl) titling a cluster; not focusable, and its access-key (_) forwards focus into the cluster to its right.

The drop-openers (BarSplitButton/BarPopupButton, over the shared BarDropDownButton base) carry a DropDownPlacement (PlacementMode) that picks their navigation model — Bottom opens parked on the face (a second Down enters), side placements open and enter in one press — and FocusContentOnOpen opts a bottom opener into enter-on-open. IsDropDownOpen is two-way with the popup.

var bold = new BarToggleButton { Command = boldCommand, CommandParameter = new CheckableCommandParameter() };

var paste = new BarSplitButton { Command = pasteCommand, DropDownContent = pasteSpecialMenu };
var align = new BarPopupButton { Content = "Align", DropDownContent = alignMenu };

var zoom = new BarComboBox
{
    ItemsSource = new[] { 50, 75, 100, 150, 200 },
    Command = zoomCommand,
    CommandParameter = new ValueCommandParameter<int>(100),
};

Value commands and live preview

A value drop-down talks to a shared command through a value parameter — ValueCommandParameter<T> (in Cursorial.UI, so several controls can bind one command, each with its own parameter instance). Give a BarComboBox or BarGallery a Command plus a ValueCommandParameter<T> and its drop-down becomes a picker: a commit gesture (Enter / Space / item click) writes the chosen item into the parameter's Value and executes the command once, while a dismissal (Escape / light-dismiss / focus-out / toggle-close) restores the pre-open selection — so an unchosen value never sticks to the model or the face. The value type T is the app's to choose, so no default parameter is auto-provisioned; supply it explicitly. (This value channel works with any ICommand.)

Live preview. When the command is also an IPreviewableCommand (in Cursorial.UI) — a BarCommand built with the previewExecute/cancelPreviewExecute delegate pair — moving the highlight dry-runs the value: the control writes the highlighted item into the parameter's PreviewValue and calls Preview (a dry-run Execute that produces the outcome but commits nothing). The close is atomic — it always calls CancelPreview first to unwind the dry-run byte-exact, and only a real commit gesture then lets Execute do the thing for real on clean state, so an ordinary Execute needs zero preview awareness. CancelPreview is a cleanup verb structurally outside the CanExecute gate, so a command that greys itself mid-session still has its dry-run unwound and its commit refused. A command that is not previewable never receives a preview verb — nothing tentative is ever shown, and a dry-run can never be mis-executed as the real thing.

var applyFont = new BarCommand(
    execute:              p => model.Font = ((ValueCommandParameter<string>)p!).Value,
    previewExecute:       p => model.PreviewFont(((ValueCommandParameter<string>)p!).PreviewValue!),
    cancelPreviewExecute: _ => model.EndFontPreview());

var fontPicker = new BarGallery
{
    ItemsSource = installedFonts,
    Command = applyFont,
    CommandParameter = new ValueCommandParameter<string>(model.Font),
};

Radio sets. ValueCommandParameter<T> derives from CheckableCommandParameter, so a set of BarToggleButtons that share one command — each with its own parameter instance — becomes a radio group: on every re-query the command's CanExecute compares each parameter's Value to the model's current value and writes the matching one's checked state; Execute applies the clicked control's Value. Call the command's RaiseCanExecuteChanged() when the model moves and the whole set re-syncs. A CheckableCommandParameter.Override(value) / Release() pair is the context-gate that greys-and-locks a bound control's checked state while a value is inapplicable (pair Override with a false CanExecute to grey and lock; Release gives the control back its own preference).

Toolbar

Toolbar (an ItemsControl) is a single horizontal row of bar controls with discrete overflow: when the row is too narrow the trailing items fold into a » chevron Popup. The fold re-parents the same live control instances between the row and the popup band as the bar resizes — no rebuild. Add controls to Items (or bind ItemsSource); read HasOverflow/OverflowCount and toggle IsOverflowOpen. The per-item policy is the attached OverflowMode (ToolbarOverflowMode): AsNeeded (the default — folds from the trailing edge), Always (always in the popup), or Never (pinned to the row). The toolbar is a single Tab stop with arrow navigation within, and returns focus to where it came from on Escape or after an invoke.

var toolbar = new Toolbar();
toolbar.Items.Add(new BarButton { Command = cut });
toolbar.Items.Add(new BarButton { Command = copy });
toolbar.Items.Add(new BarSeparator());
toolbar.Items.Add(new BarToggleButton { Command = bold });

Toolbar.SetOverflowMode(cutButton, ToolbarOverflowMode.Never); // a signature control never folds away

MiniToolbar is the right-click floating strip — a horizontal bar of the same controls shown at the pointer. Attach one to any element with the Bar attached property; a right-click over that element opens it (and it wins the right-click over a co-present ContextMenu).

var mini = new MiniToolbar();
mini.Items.Add(new BarButton { Command = bold });
mini.Items.Add(new BarButton { Command = italic });
MiniToolbar.SetBar(editorSurface, mini);   // right-click over `editorSurface` floats the strip

Ribbon

Ribbon (a TabControl) is the full command surface: a tab strip over the selected tab's band of groups. Its items are **RibbonTab**s, each hosting **RibbonGroup**s in its Groups collection (the XAML content). A group is a HeaderedItemsControl whose Header is the bottom group name and whose items are the same bar controls — used directly, no wrapper. Set HasDialogLauncher for the ⋰ corner launcher (raises DialogLauncherRequested).

var ribbon = new Ribbon();
var home = new RibbonTab { Header = "_Home" };
var clipboard = new RibbonGroup { Header = "Clipboard", HasDialogLauncher = true };

var paste = new BarSplitButton { Command = pasteCommand, DropDownContent = pasteMenu };
Ribbon.SetButtonSize(paste, RibbonButtonSize.Large);   // the group's hero button

clipboard.Items.Add(paste);
home.Groups.Add(clipboard);
ribbon.Items.Add(home);

Button sizes. The inherited attached Ribbon.ButtonSize (RibbonButtonSize.Medium/Large/Small) picks a control's face — set it per-control or as a group default. Large is the tall glyph-over-label hero; Medium is the single-row toolbar face; Small is the densest inline cell.

Density collapse. As the band narrows, each group demotes through three discrete tiers (RibbonGroupDensity): Normal → Compact (every control drops to its small inline face) → Collapsed (the whole group becomes a [name ▾] dropdown whose controls move into a flyout). The band owns the width budget; cap how far a group may demote with the attached Ribbon.MinDensity (Normal pins a signature group full-size; Compact forbids the dropdown).

Control-group stacking. RibbonControlGroup packs a handful of related small/medium controls into the band's two rows beside a Large hero, so a tall band never wastes its second row. Its ItemSize (default RibbonButtonSize.Medium) stamps the children's face; RowBehavior (Auto/SingleRow/TwoRow) chooses the row count (Auto stacks only when the band is authored two rows tall); and the attached RibbonControlGroup.RowBreak pins the split point when the width heuristic guesses wrong.

var small = new RibbonControlGroup { ItemSize = RibbonButtonSize.Small };
small.Items.Add(new BarButton { Command = cut });
small.Items.Add(new BarButton { Command = copy });
RibbonControlGroup.SetRowBreak(pasteToggle, true);   // this child starts the second row
clipboard.Items.Add(small);

Layout mode. Ribbon.LayoutMode (RibbonLayoutMode) is the user-directed density axis, distinct from the width-driven fold: Classic (full presentation), Simplified (one labeled row — Large faces demote, group footers collapse), or Compact (one icon-only row). Authored faces are never touched, so flipping back to Classic restores them exactly.

Contextual tabs. Set RibbonTab.IsContextual for a conditional, purple-tinted tab (a "Table Tools"-style tab); bind its Visibility so it appears only when relevant. Hiding the selected contextual tab redirects selection so the band never blanks.

Backstage. Set RibbonTab.IsFileTab for the accent File tab — a click raises Ribbon.BackstageRequested instead of selecting a band. Backstage (a TabControl) is the File view: a vertical rail of BackstageItem destinations over a swapping detail pane, with a DisplayMode of FullScreen (a maximized surface) or Menu (a File-anchored popup). The app opens the Backstage in response to the event and closes it on BackRequested (the ◂ back or Escape).

ribbon.Items.Add(new RibbonTab { Header = "_File", IsFileTab = true });
ribbon.BackstageRequested += (_, _) =>
{
    var backstage = new Backstage { DisplayMode = BackstageDisplayMode.Menu };
    backstage.Items.Add(new BackstageItem { Header = "_New",  Content = newPane });
    backstage.Items.Add(new BackstageItem { Header = "_Open", Content = openPane });
    backstage.BackRequested += (_, _) => CloseBackstage();
    ShowBackstage(backstage);
};

Quick Access Toolbar. Ribbon.QuickAccessCommands is the ordered set of BarCommands on the QAT — the same commands the groups bind. QuickAccessCandidates is the superset the customize ▾ checklist offers. QuickAccessPlacement moves the QAT above the strip (default) or below the body.

ribbon.QuickAccessCommands.Add(save);
ribbon.QuickAccessCommands.Add(undo);
ribbon.QuickAccessCandidates.Add(redo);   // offered in the customize ▾, not yet pinned

Minimizable band. Ribbon.IsMinimized collapses the body to a tabs-only strip (Ctrl+F1, a double-click on a tab, or the pin chevron toggle it; clicking a content tab while minimized floats the band temporarily). Vertical navigation crosses the strip ↔ body boundary, and the whole ribbon is one returning focus scope, so Escape returns to wherever focus was before the ribbon was entered.

KeyTips

KeyTips are the Alt-overlay accelerator badges (the ribbon's multi-level drill-in: Alt → tab → group → control). Arm them once per application with EnableKeyTips(); they are gated on the same negotiated keyboard capability as the core AccessKeyManager. Each control's badge letter is auto-derived from its access-key / command text, or pinned explicitly with the attached KeyTip.Key; suppress a badge with KeyTip.SetAutoAssign(control, false).

app.EnableKeyTips();                 // arm the Alt-overlay badges
KeyTip.SetKey(pasteButton, "V");     // pin an explicit badge instead of auto-deriving

SuperTips

SuperTip is a rich, titled hover tip (Title + InputGestureText shortcut + KeyTip hop sequence + Description body + optional Footer). It is ordinary ToolTipService content, so it shows through the same hover controller as a plain tooltip — no parallel machinery. A BarCommand with a Description auto-provisions one on every bound bar control, so the rich help is identical wherever the command appears; attach one explicitly with SuperTip.FromCommand.

ToolTipService.SetTip(button, SuperTip.FromCommand(save));

Theming

Every bar control's look is a code-first control theme in CursorialBarsTheme, keyed by control Type and reusing the core ThemeKeys palette spine — so the bars track the active palette and dark/light flips with the rest of the framework. BarsThemeModule registers the whole dictionary into the framework's assembly ThemeContributions tier at module load (a chain hop between the app theme and the built-in backstop), so the suite renders self-contained and each template's {DynamicResource} reference resolves. Control themes resolve by exact key; a subclass (e.g. a custom Ribbon) opts into the base theme by overriding Control.ControlThemeKey to return the base type. See Styling and themes for the resolution chain and how to override a control theme wholesale.

See also

  • Controls — the base control catalog the bar controls extend (ButtonBase, ComboBox, TabControl, Popup, Icon).
  • Input and focus — routed commands, access keys, focus scopes, and returning focus.
  • Styling and themes — selectors, pseudo-classes, control themes, and theme contributions.
  • Data binding — wiring a BarCommand.Command to a view-model ICommand.
  • XAML — authoring surfaces declaratively (and registering the bars assembly).

Clone this wiki locally