iced_command_palette_spec - mark-ik/graphshell GitHub Wiki
Date: 2026-04-29 (revised)
Status: Canonical / Active — third concrete S2 deliverable for the iced jump-ship plan
Scope: The two iced-side command-dispatch surfaces — Command Palette
(Modal overlay with fuzzy-search filter over a contextual command list,
Zed/VSCode-shaped) and Context Menu (right-click on any interactable
target, flat list of available commands). Both source actions from the same
ActionRegistry and route dispatch through HostIntent::Action with the
uphill rule. The previous draft of this spec mandated a two-tier
category/option contract across three modes (Search / Context / Radial);
that model is retired per the 2026-04-29 simplification — see §1.1.
Code-sample mode: Illustrative signatures. Concrete S3/S4 code lives in the implementation, not this spec.
Related:
-
../aspect_command/command_surface_interaction_spec.md— canonical command-surface contract (revised 2026-04-29 to drop two-tier + Radial) -
../aspect_command/ASPECT_COMMAND.md— Command aspect authority -
iced_composition_skeleton_spec.md— Application skeleton (§1.5), CommandBar slot (§7.2), context palette (§7.3), uphill rule routing (§8) -
iced_omnibar_spec.md— sibling spec; shares focus-dance contract -
2026-04-28_iced_jump_ship_plan.md§4.10 — coherence guarantees for command palette and context menu - SHELL.md §6 — Shell ↔ Navigator chrome relationship
-
../subsystem_ux_semantics/2026-04-05_command_surface_observability_and_at_plan.md— provenance / AT validation
Two surfaces handle command discovery and dispatch:
| Surface | Trigger | Shape | Source |
|---|---|---|---|
| Command Palette |
Ctrl+Shift+P (canonical, Zed/VSCode-shaped), F2 (alternate), CommandBar trigger button, programmatic |
gs::Modal overlay with text_input filter + flat ranked list of currently-available commands |
ActionRegistry::rank_for_query(query, view_model) |
| Context Menu | Right-click on an interactable target |
gs::ContextMenu with a flat list of commands available on that target |
ActionRegistry::available_for(target, view_model) |
Widget-source note (added 2026-04-30): gs::Modal, gs::ContextMenu,
gs::TileTabs / gs::TileTab, and any other gs::* widget references
in this spec are hand-rolled Graphshell widgets in
crates/graphshell-iced-widgets/. Per the 2026-04-30 decision to drop
the iced_aw dependency, we own the small set of widgets we actually
use (TileTabs / ContextMenu / Modal) rather than depending on the
alpha-stage external crate. The widgets are ordinary
iced::widget::Widget<Message, Theme> impls — no special trait or
framework. Approximate scope: ~200-400 LOC total across the four
widgets. Naming note: TileTabs — not bare Tabs — because each
entry is the tile's tab (a handle), not the tile itself; using
Tabs would re-introduce the egui_tiles-shaped conflation between
"the page" and "the handle that selects it".
Both surfaces:
- read action data exclusively from
ActionRegistry(atomic registry per TERMINOLOGY.md §Registry Architecture); - dispatch via a single
HostIntent::Actionper selection; - gate by selection-set availability per command_surface_interaction_spec.md §4.1;
- show disabled actions with explicit reasons;
- gate destructive actions through
ConfirmDialog(per §5); - route uphill per the iced jump-ship plan §4.9.
The previous draft mandated a two-tier category/option model across three modes (Search Palette / Context Palette / Radial Palette) with cross-mode equivalence rules from command_surface_interaction_spec.md §3.3. That model is retired:
- Two-tier rendering (Tier 1 horizontal category strip + Tier 2 vertical option list) → flat ranked list in both Command Palette and Context Menu. The search filter is the discovery mechanism; category browsing is dropped.
- Search Palette Mode + Context Palette Mode distinction → folded into a single Command Palette; "search" is the palette's input affordance, not a separate mode.
-
Radial Palette Mode → retired indefinitely. Was originally gamepad-
oriented per iced jump-ship plan §11 G2;
if gamepad input lands later as part of the input-subsystem rework, a
radial surface can be reintroduced as a third command-dispatch route
with its own design pass. The geometry research in
../aspect_command/radial_menu_geometry_and_overflow_spec.mdis preserved for that future work. - Cross-mode equivalence rule (Tier 1 strip = Tier 1 ring) → moot; there is no Tier 1.
This is a real change to the canonical aspect_command spec; the canonical spec was updated in the same 2026-04-29 commit. See its §3 and §4 for the revised canonical interaction model.
Trigger sources, all converging on Message::PaletteOpen { origin }:
-
Ctrl+Shift+P— global keyboard shortcut, captured by the iced application's keyboard subscription. Canonical (Zed/VSCode-shaped). (Note:Ctrl+Pis reserved for the Node Finder per the 2026-04-29 omnibar- split simplification, matching Zed's separation of file finder vs command palette.) -
F2— alternate shortcut for parity with prior canonical binding (see command_surface_interaction_spec.md §4.2). -
CommandBar trigger button click — emits
Message::PaletteOpenwithPaletteOrigin::TriggerButton. -
Context Menu → Search fallback — a "Search commands…" footer
entry in any Context Menu opens the palette pre-scoped to that
target. Origin is
PaletteOrigin::ContextFallback. - Programmatic — actions that open the palette as part of their effect (rare).
Origin is recorded for diagnostics provenance per
subsystem_ux_semantics/2026-04-05_command_surface_observability_and_at_plan.md.
fn command_palette_overlay(state: &State) -> Option<Element<'_, Message, GraphshellTheme>> {
state.command_palette.is_open.then(|| {
gs::modal(
container(
column![
text_input(
&state.command_palette.query,
Message::PaletteQuery,
)
.on_submit(Message::PaletteSubmitFocused)
.id(text_input::Id::new("command_palette_input"))
.placeholder("Type a command or search…"),
horizontal_rule(),
palette_results_list(
&state.command_palette.ranked_actions,
state.command_palette.focused_index,
),
palette_footer(state), // disabled-reason explanation, hint text
]
.spacing(8)
.padding(12)
)
.style(palette_container_style)
.max_width(640)
.max_height(480)
)
.on_blur(Message::PaletteCloseAndRestoreFocus)
.into()
})
}
fn palette_results_list<'a>(
ranked: &'a [RankedAction],
focused: Option<usize>,
) -> Element<'a, Message, GraphshellTheme> {
scrollable(
column(ranked.iter().enumerate().map(|(i, action)| {
action_row(action, focused == Some(i))
.on_press(Message::PaletteActionSelected(action.id))
}))
.spacing(2)
)
.into()
}// Illustrative.
pub struct CommandPaletteState {
pub is_open: bool,
pub origin: PaletteOrigin,
pub query: String, // empty = "all available, default order"
pub scope: PaletteScope, // CurrentTarget | ActivePane | ActiveGraph | Workbench
pub ranked_actions: Vec<RankedAction>,
pub focused_index: Option<usize>, // keyboard-focused row
pub focus_token: Option<widget::Id>, // saved iced widget focus id at open time
pub pending_confirmation: Option<PendingConfirmation>, // see §5
pub current_request: Option<RankRequestId>,
}
pub struct RankedAction {
pub id: ActionId,
pub label: String, // verb-target wording, canonical
pub description: Option<String>, // secondary text
pub category_badge: Option<String>, // inline category indicator (small)
pub keybinding: Option<String>, // right-aligned shortcut display
pub is_available: bool,
pub disabled_reason: Option<String>,
}
pub enum PaletteOrigin {
KeyboardShortcut,
TriggerButton,
ContextFallback { target: ContextualTarget },
ProgrammaticByAction(ActionId),
}Empty query: ranked_actions shows all actions available in the current
context, ordered by:
- Pinned actions (user-customizable, persisted in
WorkbenchProfile) - Recently used actions (per
ActionRegistry's recency tracking) - Default canonical order from
ActionRegistry
Non-empty query: ranked_actions is the result of fuzzy-match scoring
against (label, description, category) tokens. Ranking algorithm is
runtime-side (not iced-side); the palette consumes
ActionRegistryViewModel::rank_for_query(query, scope, view_model)
asynchronously.
pub trait ActionRegistryViewModel {
/// Default available action list (no query). Pinned-first, recency,
/// then canonical order. Sync — fast enough to compute per frame.
fn available_for_scope(&self, scope: PaletteScope) -> Vec<RankedAction>;
/// Fuzzy-match ranked list for a query. Async — may compute on a
/// background task for large action sets.
fn rank_for_query(
&self,
query: String,
scope: PaletteScope,
) -> impl Future<Output = (RankRequestId, Vec<RankedAction>)>;
/// Selection-set availability gate.
fn is_available(&self, action_id: ActionId, target: &SelectionSet) -> bool;
fn disabled_reason(&self, action_id: ActionId, target: &SelectionSet) -> Option<String>;
}Each row in the palette shows:
-
Action label (verb-target wording per
command_surface_interaction_spec.md §3.4) —
the canonical text from
ActionRegistry, never reformatted. - Optional secondary text — short description, ≤ 80 chars.
- Optional category badge — small inline chip showing source category for breadth visibility (e.g., "Graph", "Workbench", "View"). Category is informational only; rows are never grouped by it.
- Right-aligned keybinding — current shortcut for the action, if any.
-
Disabled state — disabled rows render with reduced opacity and
no
on_pressbinding;disabled_reasonshows in the footer when the disabled row is focused.
pub enum Message {
// Open / close
PaletteOpen { origin: PaletteOrigin },
PaletteClose,
PaletteCloseAndRestoreFocus,
// Input
PaletteQuery(String), // text_input on_input
// Navigation
PaletteFocusNext, // ArrowDown / Tab
PaletteFocusPrev, // ArrowUp / Shift+Tab
PaletteFocusedRowChanged(usize), // mouse hover updates focus
// Submit
PaletteSubmitFocused, // Enter on focused row
PaletteActionSelected(ActionId), // click on a row
// Async
PaletteRankResultsReady {
request_id: RankRequestId,
results: Vec<RankedAction>,
},
// Confirmation (destructive actions, see §5)
PaletteConfirmDispatch,
PaletteConfirmCancel,
}Sketches of the load-bearing arms:
fn update(&mut self, msg: Message) -> Task<Message> {
match msg {
Message::PaletteOpen { origin } => {
// Per the 2026-04-29 omnibar-split simplification, the palette
// stores its own focus_token (no shared CommandBarFocusTarget).
self.command_palette = CommandPaletteState::open_for(
origin,
self.current_focused_widget_id(),
self.runtime.actions().available_for_scope(PaletteScope::default()),
);
return widget::focus(text_input::Id::new("command_palette_input"));
}
Message::PaletteQuery(query) => {
self.command_palette.query = query.clone();
if query.is_empty() {
// Restore default available list
self.command_palette.ranked_actions =
self.runtime.actions().available_for_scope(self.command_palette.scope);
self.command_palette.focused_index = None;
Task::none()
} else {
// Spawn fuzzy rank; result returns via PaletteRankResultsReady
let req = self.runtime.actions().next_rank_request_id();
self.command_palette.current_request = Some(req);
Task::perform(
self.runtime.actions().rank_for_query(query, self.command_palette.scope),
move |(rid, results)| Message::PaletteRankResultsReady {
request_id: rid,
results,
},
)
}
}
Message::PaletteRankResultsReady { request_id, results } => {
// Drop stale results
if Some(request_id) != self.command_palette.current_request {
return Task::none();
}
self.command_palette.ranked_actions = results;
self.command_palette.focused_index = (!results.is_empty()).then_some(0);
Task::none()
}
Message::PaletteActionSelected(action_id) => {
let target = self.view_model.current_selection_set();
if !self.runtime.actions().is_available(action_id, &target) {
self.command_palette.show_disabled_explanation_for(action_id);
return Task::none();
}
// Destructive actions go through ConfirmDialog (§5)
if self.runtime.actions().requires_confirmation_dialog(action_id, &target) {
self.command_palette.pending_confirmation = Some(
PendingConfirmation::for_action(action_id, target)
);
return Task::none();
}
// Otherwise dispatch immediately
self.runtime.emit(HostIntent::Action(ActionInvocation {
action_id,
target,
origin: ActionOrigin::CommandPalette(self.command_palette.origin),
}));
return Task::done(Message::PaletteCloseAndRestoreFocus);
}
Message::PaletteCloseAndRestoreFocus => {
let restore_target = self.command_palette.focus_token.clone();
self.command_palette.close();
return restore_focus_to(restore_target);
}
// ... other arms ...
}
}- The palette opens as a
Modaloverlay; pointer/keyboard input goes to the palette, the omnibar'sviewcontinues running beneath but is not focused. -
command_palette.focus_tokenis recorded atPaletteOpentime. - On dismiss (Escape, click outside, action selected), focus returns
to
focus_tokenviawidget::focus()Operation.
The omnibar and the palette never simultaneously hold input focus.
Every palette-rendered list is a derivation from
ActionRegistryViewModel against the current frame's view-model. The
palette never owns action data. No palette state aliases action truth.
The ActionRegistry itself is canonical (atomic registry, see
TERMINOLOGY.md §Registry Architecture). The palette is a renderer
over its projected views.
Right-click on any interactable target opens a context menu scoped to that target. Per the composition skeleton spec §7.3, the target catalog is:
- Tile (in tile pane tab)
- Canvas node / canvas edge
- Frame border / Split handle
- Navigator row (Tree Spine / Activity Log)
- Swatch (Navigator Swatches bucket or expanded preview)
- Empty FrameSplitTree (canvas base layer)
fn target_with_context_menu<'a>(
target_widget: Element<'a, Message, GraphshellTheme>,
target_id: ContextualTarget,
available: &'a [RankedAction],
) -> Element<'a, Message, GraphshellTheme> {
gs::ContextMenu::new(target_widget, move || {
column(
available.iter().map(|action| {
context_menu_item(action)
.on_press(Message::ContextMenuActionSelected {
target: target_id.clone(),
action_id: action.id,
})
})
.chain(std::iter::once(
context_menu_separator()
))
.chain(std::iter::once(
context_menu_search_fallback()
.on_press(Message::PaletteOpen {
origin: PaletteOrigin::ContextFallback { target: target_id.clone() }
})
))
).into()
})
.into()
}The context menu is a flat list of available actions, plus a "Search commands…" footer entry that opens the Command Palette pre-scoped to the target. No category tier; the context already implies the relevant category.
ActionRegistry::available_for(target, view_model) returns the flat
list of actions that validly apply to the right-click target. Standard
selection-set availability rules
(per command_surface_interaction_spec.md §4.1)
apply.
Same uphill route as the Command Palette:
Message::ContextMenuActionSelected { target, action_id } => {
let selection = SelectionSet::from(target);
if self.runtime.actions().requires_confirmation_dialog(action_id, &selection) {
self.pending_confirmation = Some(PendingConfirmation::for_action(action_id, selection));
return Task::none();
}
self.runtime.emit(HostIntent::Action(ActionInvocation {
action_id,
target: selection,
origin: ActionOrigin::ContextMenu(target.kind()),
}));
Task::none()
}The context menu dismisses on action selection, click outside, or
Escape — gs::ContextMenu handles dismissal automatically.
The "Search commands…" footer entry in any context menu emits
Message::PaletteOpen { origin: ContextFallback { target } }. The
palette opens with scope = PaletteScope::CurrentTarget and the same
SelectionSet derived from the right-click target. This gives the
user keyboard-driven escape into the full command set when the
context-menu list isn't enough.
Reverse switch (Palette → Context Menu) is not supported — once the palette is open, the user dismisses it explicitly to return to a context-menu flow.
Per command_surface_interaction_spec.md §3.4:
Command labels must follow explicit
Verb + Target (+ Destination/Scope when needed)grammar.
iced rendering passes the canonical label through verbatim. Both the
palette and the context menu render RankedAction.label unchanged.
If a future iced styling pass adds inline icons or color cues, those sit alongside the canonical label, not as substitutes.
Destructive actions (Tombstone, Remove edge, Discard frame snapshot, etc.) carry a confirmation step. Iced realization:
- Action's
requires_confirmation_dialogflag (fromActionRegistry) gates aConfirmDialogmodal that intercepts the dispatch path. - The dialog shows: action name, target description (which nodes / edges / frames), and "Confirm" / "Cancel" buttons.
- Confirm dispatches the
HostIntentand closes both palette/menu and dialog; Cancel closes the dialog and restores palette/menu focus. - Keyboard: Enter confirms, Escape cancels.
fn confirm_dialog(state: &State) -> Option<Element<Message>> {
state.command_palette.pending_confirmation.as_ref().map(|p| {
gs::modal(
container(column![
text(p.action_name.clone()).size(18),
text(p.target_description.clone()),
vertical_space(),
row![
button("Cancel").on_press(Message::PaletteConfirmCancel),
horizontal_space(),
button("Confirm").on_press(Message::PaletteConfirmDispatch),
]
])
).into()
})
}The same pending_confirmation field handles both palette-initiated
and context-menu-initiated destructive actions; only one
ConfirmDialog is ever active at a time.
Single-step destructive actions (e.g., explicit Tombstone-with-acknowledgment)
skip the confirmation dialog if ActionRegistry::requires_confirmation_dialog
returns false; is_destructive is a description, not the gate.
Per iced jump-ship plan §4.10:
Command palette: Selecting an action emits a single
HostIntent; the action only takes effect once the receiving authority confirms viaIntentApplied. Confirmation appears in the Activity Log; unconfirmed actions never silently apply.Context palette: Right-click never mutates graph truth on its own. Each action in the menu emits an explicit intent; destructive actions carry confirmation; non-destructive actions take effect immediately and appear in the Activity Log.
This spec preserves both:
-
PaletteActionSelectedandContextMenuActionSelectedarms always emit oneHostIntent::Action(...); neither surface mutates graph / workbench / shell state directly. - After dispatch, the palette/menu closes — neither shows "success"; the Activity Log is the canonical confirmation surface.
- Failed actions surface via toast (per the iced jump-ship plan §12.2) and a row in the Activity Log; surfaces do not silently swallow failures.
- Disabled actions never dispatch (§2.5); the disabled-reason text is shown instead.
- Destructive actions route through
ConfirmDialog(§5).
Per command_surface_interaction_spec.md §4.6:
- Keyboard equivalents for both surfaces (Tab / Arrow keys navigate; Enter dispatches; Escape dismisses).
-
AccessKit roles:
- Command Palette Modal →
dialog - Palette
text_input→searchbox - Palette result list →
listbox, rows →option - Context Menu →
menu, items →menuitem
- Command Palette Modal →
- Live region on the palette result list announces selection changes during keyboard navigation.
-
Focus appearance meets WCAG 2.2 AA SC 2.4.11 via
iced::Theme. - Target size for action rows ≥ 32×32 dp (SC 2.5.8).
-
Disabled-reason text reaches AT users via a
descriptionattribute on the disabled row (not just a tooltip).
These targets land at Stage E (per the iced jump-ship plan §12.3) and
gate via the UxProbeSet AT-validation contract.
Both surfaces participate in the command-surface provenance contract per command_surface_observability_and_at_plan.md:
-
Message::PaletteOpen→command-surface.paletteopenedevent with origin / scope / focused-target snapshot. -
Message::PaletteActionSelected(orContextMenuActionSelected) →command-surface.palette(or.context-menu)dispatchedevent with action ID, target selection, origin, and resolution result (resolved/blocked/fallback/no-target). - Disabled-action attempts emit a
blockedreceipt with thedisabled_reason. - Palette dismiss emits a
dismissedreceipt; if the focus restore failed (stale return target), an explicitfallbackreceipt records what happened.
The trace shape is owned by subsystem_ux_semantics; the palette and
context menu use the existing landed trace path.
The following models from prior drafts are retired and should not be re-added without an explicit design pass:
- Search Palette Mode + Context Palette Mode distinction: collapsed into a single Command Palette. The "search input" is the palette's affordance; there is no Context Palette Mode separately invoked.
- Two-tier (Tier 1 categories + Tier 2 options): replaced by flat ranked list. Categories appear only as inline badges on action rows for breadth visibility; rows are not grouped by category.
- Cross-mode equivalence rule (Tier 1 strip = Tier 1 ring): retired alongside Tier 1.
-
Radial Palette Mode: deferred indefinitely. Was originally
gamepad-oriented per iced jump-ship plan §11 G2.
If gamepad input lands later, radial can be reintroduced as a third
surface; the geometry research in
../aspect_command/radial_menu_geometry_and_overflow_spec.mdis preserved for that future pass.
These were specified in the canonical command_surface_interaction_spec.md prior to 2026-04-29; the canonical spec was revised in the same commit that landed this version of the iced spec.
- Pinned actions UI: pinning surface (probably right-click on an action row in the palette → "Pin to top"). Not specified here.
-
Inline command syntax (e.g.,
>action-name args): potential affordance for power users in the palette. Tracked iniced_omnibar_spec.md§12. -
Action ranking algorithm: out of scope; lives in
ActionRegistryvia runtime view-model. -
Action history / recently-used persistence: runtime / settings
concern, not iced rendering. Read from
WorkbenchProfile. -
Keyboard shortcut customization: routed through settings; the
palette displays the current keybinding next to each action via
ActionRegistry's shortcut metadata. - Visual polish: row styling, category-badge colors, modal enter/exit animation. Stage F polish, not skeleton concern.
The iced command surfaces are two: a Command Palette
(Modal + text_input + flat ranked list, Zed/VSCode-shaped) and a
Context Menu (gs::ContextMenu with a flat list). Both source
actions from ActionRegistry; both dispatch one HostIntent::Action
per selection through the uphill rule. Search-as-filter is the palette's
discovery mechanism, not a separate mode. Two-tier rendering, Search /
Context mode distinction, and Radial Palette Mode are retired per the
2026-04-29 simplification. AccessKit roles and keyboard equivalents
land at Stage E. Destructive actions go through ConfirmDialog.
The canonical UX (action source, verb-target wording, accessibility targets, acceptance criteria) lives in command_surface_interaction_spec.md (also revised 2026-04-29); this spec is the iced renderer.
This closes the third concrete S2 sub-deliverable in its simplified form. Together with the composition skeleton, omnibar, browser amenities, and coherence guarantees, the iced command-surface story is anchored for S4 implementation.