diff --git a/immersion/src/command.rs b/immersion/src/command.rs index 07f3855..abe9628 100644 --- a/immersion/src/command.rs +++ b/immersion/src/command.rs @@ -37,9 +37,36 @@ pub struct Command { /// True for commands that only read or that a palette should de-emphasize; /// today it marks the ones undo should not record (pure navigation). pub navigational: bool, + /// Whether this could run against the workbench as it is — Blender's + /// `poll()`. A menu greys out what fails it, and `run` refuses it, so a + /// control that cannot do anything says so instead of erroring after the + /// click. + /// + /// The params may be `Null`: a surface deciding whether to *offer* a + /// command asks before it has any. Answer for the context in that case, + /// and use the params to be more exact when they are there. + pub poll: fn(&Workspaces, &Value) -> bool, pub run: fn(&mut Workspaces, &Value) -> Result<()>, } +/// A command that is always offered. Most are — splitting an area, renaming a +/// workspace: there is no state in which they make no sense. +pub fn always(_: &Workspaces, _: &Value) -> bool { + true +} + +/// Needs something to act across: a second area. Join, swap and the seam +/// between two areas all vanish from a menu when there is only one. +pub fn many_areas(ws: &Workspaces, _: &Value) -> bool { + ws.current().layout.root.leaves().len() > 1 +} + +/// Needs a second workspace. The whole `workspace.*` family except add, +/// rename and duplicate. +pub fn many_workspaces(ws: &Workspaces, _: &Value) -> bool { + ws.tabs.len() > 1 +} + /// The registry. Ordered so a palette lists commands predictably. #[derive(Clone, Default)] pub struct Commands(BTreeMap<&'static str, Command>); @@ -71,9 +98,29 @@ impl Commands { .0 .get(name) .ok_or_else(|| anyhow!("unknown command {name}"))?; + // Checked here rather than only in the chrome, because the chrome is + // not the only caller: an agent reaches the same registry, and a + // command that cannot apply should say so once, in one place, rather + // than failing differently depending on who asked. + if !(cmd.poll)(ws, params) { + return Err(anyhow!("{name} does not apply to the workbench as it is")); + } (cmd.run)(ws, params) } + /// Whether a command could run right now. `params` may be `Null` when the + /// question is "should this be offered at all". + pub fn can(&self, ws: &Workspaces, name: &str, params: &Value) -> bool { + self.0.get(name).is_some_and(|c| (c.poll)(ws, params)) + } + + /// The commands that apply to the workbench as it is. What a palette or a + /// menu should be built from — Blender lists operators the same way, and + /// it is why its menus shrink rather than filling with things that error. + pub fn available<'a>(&'a self, ws: &'a Workspaces) -> impl Iterator { + self.0.values().filter(move |c| (c.poll)(ws, &Value::Null)) + } + /// Whether running `name` should be recorded for undo. Unknown or /// navigational commands are not. pub fn records_undo(&self, name: &str) -> bool { @@ -143,6 +190,7 @@ const BUILTINS: &[Command] = &[ name: "split", description: "Split an area in two", navigational: false, + poll: always, run: |ws, p| { let id = u64_field(p, "id")?; let applied = ws @@ -160,6 +208,7 @@ const BUILTINS: &[Command] = &[ name: "join", description: "Close an area; its sibling takes the space", navigational: false, + poll: many_areas, run: |ws, p| { let id = u64_field(p, "id")?; let applied = ws.current_layout_mut().join(id); @@ -170,6 +219,7 @@ const BUILTINS: &[Command] = &[ name: "join_into", description: "Merge one area into a sibling", navigational: false, + poll: many_areas, run: |ws, p| { let (a, b) = (u64_field(p, "survivor")?, u64_field(p, "victim")?); let applied = ws.current_layout_mut().join_into(a, b); @@ -180,6 +230,7 @@ const BUILTINS: &[Command] = &[ name: "ratio", description: "Move a seam between two areas", navigational: false, + poll: many_areas, run: |ws, p| { let id = u64_field(p, "id")?; let applied = ws.current_layout_mut().set_seam( @@ -194,6 +245,7 @@ const BUILTINS: &[Command] = &[ name: "set_region_width", description: "Resize an area's toolbar or sidebar", navigational: true, + poll: always, run: |ws, p| { let id = u64_field(p, "id")?; let applied = ws.current_layout_mut().set_region_width( @@ -209,6 +261,7 @@ const BUILTINS: &[Command] = &[ description: "Show or hide an area's toolbar or sidebar", // A view toggle, persisted with the layout but not something you undo. navigational: true, + poll: always, run: |ws, p| { let id = u64_field(p, "id")?; let applied = ws @@ -221,6 +274,7 @@ const BUILTINS: &[Command] = &[ name: "duplicate_area", description: "Split an area and show the same editor in the new half", navigational: false, + poll: always, run: |ws, p| { let id = u64_field(p, "id")?; let l = ws.current_layout_mut(); @@ -246,6 +300,7 @@ const BUILTINS: &[Command] = &[ name: "swap", description: "Swap what two areas show", navigational: false, + poll: many_areas, run: |ws, p| { let (a, b) = (u64_field(p, "a")?, u64_field(p, "b")?); let applied = ws.current_layout_mut().swap_editors(a, b); @@ -256,6 +311,7 @@ const BUILTINS: &[Command] = &[ name: "set_editor", description: "Change what an area shows", navigational: false, + poll: always, run: |ws, p| { let id = u64_field(p, "id")?; let applied = ws @@ -268,6 +324,7 @@ const BUILTINS: &[Command] = &[ name: "set_target", description: "Point an area at something without changing its editor", navigational: false, + poll: always, run: |ws, p| { let id = u64_field(p, "id")?; // The empty string clears the target, so "show everything again" @@ -281,6 +338,7 @@ const BUILTINS: &[Command] = &[ name: "open_editor", description: "Point an area at a specific thing (editor + argument)", navigational: false, + poll: always, run: |ws, p| { let id = u64_field(p, "id")?; let applied = ws.current_layout_mut().set_editor_arg( @@ -295,6 +353,7 @@ const BUILTINS: &[Command] = &[ name: "workspace.switch", description: "Show a workspace by index", navigational: true, + poll: many_workspaces, run: |ws, p| { ws.switch(u64_field(p, "index")? as usize); Ok(()) @@ -304,6 +363,7 @@ const BUILTINS: &[Command] = &[ name: "workspace.cycle", description: "Show the next or previous workspace", navigational: true, + poll: many_workspaces, run: |ws, p| { ws.cycle(p.get("delta").and_then(Value::as_i64).unwrap_or(1) as i32); Ok(()) @@ -313,6 +373,7 @@ const BUILTINS: &[Command] = &[ name: "workspace.add", description: "Add a workspace from a layout", navigational: false, + poll: always, run: |ws, p| { let name = str_field(p, "name")?; let layout = p @@ -327,6 +388,7 @@ const BUILTINS: &[Command] = &[ name: "workspace.rename", description: "Rename a workspace", navigational: false, + poll: always, run: |ws, p| { ws.rename(u64_field(p, "index")? as usize, str_field(p, "name")?); Ok(()) @@ -336,6 +398,7 @@ const BUILTINS: &[Command] = &[ name: "workspace.duplicate", description: "Duplicate a workspace", navigational: false, + poll: always, run: |ws, _| { let cur = ws.current(); let name = format!("{} copy", cur.name); @@ -348,6 +411,7 @@ const BUILTINS: &[Command] = &[ name: "workspace.move", description: "Move a workspace tab to another position", navigational: false, + poll: many_workspaces, run: |ws, p| { let from = u64_field(p, "from")? as usize; let to = u64_field(p, "to")? as usize; @@ -359,6 +423,7 @@ const BUILTINS: &[Command] = &[ name: "workspace.close", description: "Close a workspace", navigational: false, + poll: many_workspaces, run: |ws, p| { ws.close(u64_field(p, "index")? as usize); Ok(()) @@ -471,6 +536,7 @@ mod tests { name: "open_run", description: "Open a run in a new area", navigational: false, + poll: always, run: open_run, }); let mut w = ws(); @@ -479,3 +545,101 @@ mod tests { assert_eq!(w.current().layout.root.leaves().len(), 2); } } + +#[cfg(test)] +mod poll_tests { + use super::*; + use crate::area::{Dir, Layout}; + + fn lone() -> Workspaces { + Workspaces::new("one", Layout::single("runs")) + } + + fn two_areas() -> Workspaces { + let mut w = lone(); + w.current_layout_mut().split(1, Dir::Row, 0.5); + w + } + + /// The row everybody meets: the last area has nothing to join into. It + /// has always been offered and has always failed on the click. + #[test] + fn what_needs_a_second_area_is_not_offered_with_one() { + let cmds = Commands::builtin(); + let one = lone(); + for name in ["join", "join_into", "ratio", "swap"] { + assert!(!cmds.can(&one, name, &Value::Null), "{name} was offered"); + } + // And splitting always is: there is no workbench where it makes no + // sense, which is exactly why it needs no poll of its own. + assert!(cmds.can(&one, "split", &Value::Null)); + + let two = two_areas(); + for name in ["join", "join_into", "ratio", "swap", "split"] { + assert!(cmds.can(&two, name, &Value::Null), "{name} went missing"); + } + } + + /// The whole workspace family, except the three that make sense alone. + #[test] + fn what_needs_a_second_workspace_is_not_offered_with_one() { + let cmds = Commands::builtin(); + let mut w = lone(); + for name in [ + "workspace.close", + "workspace.cycle", + "workspace.switch", + "workspace.move", + ] { + assert!(!cmds.can(&w, name, &Value::Null), "{name} was offered"); + } + for name in ["workspace.add", "workspace.rename", "workspace.duplicate"] { + assert!(cmds.can(&w, name, &Value::Null), "{name} went missing"); + } + w.add("second", Layout::single("runs")); + for name in [ + "workspace.close", + "workspace.cycle", + "workspace.switch", + "workspace.move", + ] { + assert!(cmds.can(&w, name, &Value::Null), "{name} went missing"); + } + } + + /// A poll the chrome respects and `run` does not is a poll an agent walks + /// straight through. It is checked in the one place both go through. + #[test] + fn run_refuses_what_poll_refuses() { + let cmds = Commands::builtin(); + let mut w = lone(); + let err = cmds + .run(&mut w, "join", &serde_json::json!({ "id": 1 })) + .expect_err("joining the only area is not a thing"); + assert!( + err.to_string().contains("does not apply"), + "the refusal should say why: {err}" + ); + // And the workbench is untouched — a refused command is not a + // half-applied one. + assert_eq!(w.current().layout.root.leaves().len(), 1); + } + + /// `available` is what a palette or a menu is built from, so it has to + /// shrink with the workbench rather than listing everything always. + #[test] + fn available_shrinks_with_the_workbench() { + let cmds = Commands::builtin(); + let one = lone(); + let mut many = two_areas(); + many.add("second", Layout::single("runs")); + let count = |w: &Workspaces| cmds.available(w).count(); + assert!( + count(&one) < count(&many), + "one area and one workspace should offer less: {} vs {}", + count(&one), + count(&many) + ); + assert!(!cmds.available(&one).any(|c| c.name == "join")); + } +} diff --git a/immersion/src/contextmenu.js b/immersion/src/contextmenu.js index eeb9181..8166fe4 100644 --- a/immersion/src/contextmenu.js +++ b/immersion/src/contextmenu.js @@ -77,7 +77,7 @@ continue; } const row = document.createElement("div"); - row.className = "im-ctx-item"; + row.className = it.disabled ? "im-ctx-item is-disabled" : "im-ctx-item"; if (it.icon) { const glyph = document.createElement("span"); glyph.className = "im-ctx-icon"; @@ -96,6 +96,8 @@ row.dataset.action = it.action ?? ""; row.dataset.params = JSON.stringify(it.params ?? null); row.addEventListener("click", () => { + if (it.disabled) + return; pick(row.dataset.action ?? "", row.dataset.params); close(); }); @@ -132,7 +134,7 @@ menu.style.left = Math.max(4, x) + "px"; menu.style.top = Math.max(4, y) + "px"; const el = menu; - const rows = () => Array.from(el.querySelectorAll(".im-ctx-item")); + const rows = () => Array.from(el.querySelectorAll(".im-ctx-item:not(.is-disabled)")); let sel = 0; const paint = () => rows().forEach((r, i) => r.classList.toggle("is-sel", i === sel)); paint(); diff --git a/immersion/src/contextmenu.rs b/immersion/src/contextmenu.rs index 1456c53..0ec4717 100644 --- a/immersion/src/contextmenu.rs +++ b/immersion/src/contextmenu.rs @@ -45,6 +45,10 @@ pub struct MenuItem { pub icon: Option, /// A divider rather than a row. Always emitted, so the type can promise it. pub sep: bool, + /// Shown, but not runnable — Blender greys an operator whose `poll` fails + /// rather than hiding it, because a row that disappears teaches nothing + /// and a row that is there but dim says "this exists, not now". + pub disabled: bool, } impl MenuItem { @@ -72,6 +76,14 @@ impl MenuItem { self } + /// Grey this row out unless `available`. Takes the answer rather than the + /// question, because only the host holds the registry the answer comes + /// from — `Commands::can`. + pub fn when(mut self, available: bool) -> Self { + self.disabled = !available; + self + } + /// A divider. pub fn sep() -> Self { MenuItem { @@ -206,24 +218,29 @@ pub fn view_menu_json(id: crate::AreaId, toolbar: bool, sidebar: bool, regions: /// The `data-im-menu` JSON for an area leaf — split either way, then close. /// Kept here so the area view and the menu never drift: both come from the id. -pub fn area_menu_json(id: crate::AreaId) -> String { +pub fn area_menu_json(id: crate::AreaId, can: impl Fn(&str) -> bool) -> String { menu_json(&[ MenuItem::new( "Split horizontal", "split", serde_json::json!({ "id": id, "dir": "row" }), - ), + ) + .when(can("split")), MenuItem::new( "Split vertical", "split", serde_json::json!({ "id": id, "dir": "col" }), - ), + ) + .when(can("split")), MenuItem::new( "Duplicate", "duplicate_area", serde_json::json!({ "id": id }), - ), + ) + .when(can("duplicate_area")), MenuItem::sep(), - MenuItem::new("Close area", "join", serde_json::json!({ "id": id })), + // The one everybody meets: the last area has nothing to join into, so + // this has always been a row that looked live and did nothing. + MenuItem::new("Close area", "join", serde_json::json!({ "id": id })).when(can("join")), ]) } diff --git a/immersion/src/immersion.css b/immersion/src/immersion.css index 358e4a9..e67c0a6 100644 --- a/immersion/src/immersion.css +++ b/immersion/src/immersion.css @@ -652,6 +652,10 @@ body { /* Icon, then label, then the chord pushed to the far right. space-between put the label there instead the moment rows grew a third child. */ .im-ctx-item { display: flex; align-items: baseline; gap: .5rem; } +/* A command that cannot run against the workbench as it is — Blender's poll, + showing. Dim and inert, with the pointer saying so. */ +.im-ctx-item.is-disabled { opacity: .38; cursor: default; } +.im-ctx-item.is-disabled:hover { background: transparent; } .im-ctx-chord { margin-left: auto; padding-left: 1.2rem; } .im-ctx-chord { color: var(--im-text-muted); font-family: var(--im-mono); font-size: .68rem; flex: none; diff --git a/immersion/src/lib.rs b/immersion/src/lib.rs index 89cefd2..ce69d81 100644 --- a/immersion/src/lib.rs +++ b/immersion/src/lib.rs @@ -37,7 +37,7 @@ mod workspace; pub use area::{Area, AreaId, Dir, Layout, MIN_RATIO, Region}; pub use client::{Chrome, ChromeProps}; -pub use command::{Command, Commands}; +pub use command::{Command, Commands, always, many_areas, many_workspaces}; pub use contextmenu::{ ContextMenu, ContextMenuProps, MenuItem, area_menu_json, editor_menu_json, menu_json, view_menu_json, diff --git a/immersion/src/ui.rs b/immersion/src/ui.rs index 06657ac..5e64243 100644 --- a/immersion/src/ui.rs +++ b/immersion/src/ui.rs @@ -152,6 +152,13 @@ pub struct AreasProps { /// properties region — from `(area id, editor)`. #[props(default)] pub render_sidebar: Option>, + /// Optional: "could this command run against the workbench as it is" — + /// Blender's `poll`, asked of the host because the host holds the + /// registry. Menu rows it answers `false` for are drawn grey and inert. + /// Without it every row is live, which is what these menus did before + /// there was a poll to ask. + #[props(default)] + pub can: Option>, /// Optional: the editor's own controls in its header, from /// `(area id, editor)` — Blender's header carries what the editor it /// holds needs and nothing else. Drawn after the target chip and before @@ -340,7 +347,16 @@ fn render_leaf( let editor_owned = editor.to_string(); let cmd = props.on_command; let body = props.render.call((id, editor_owned.clone(), arg.clone())); - let menu = crate::contextmenu::area_menu_json(id); + // The host holds the registry, so it answers whether a row can run. A + // host that does not offer an answer gets every row live, which is what + // this did before there was a poll to ask. + let can = |name: &str| { + props + .can + .map(|cb| cb.call(name.to_string())) + .unwrap_or(true) + }; + let menu = crate::contextmenu::area_menu_json(id, can); let header_items = props .render_header .map(|cb| cb.call((id, editor_owned.clone()))); diff --git a/immersion/ts/contextmenu.ts b/immersion/ts/contextmenu.ts index 5ad955b..b067bb4 100644 --- a/immersion/ts/contextmenu.ts +++ b/immersion/ts/contextmenu.ts @@ -81,7 +81,10 @@ if (once("__imCtxMenu")) { continue; } const row = document.createElement("div"); - row.className = "im-ctx-item"; + // A row whose command cannot run right now is dim and inert, not + // missing: a row that disappears teaches nothing, and one that is there + // but grey says "this exists, not now". + row.className = it.disabled ? "im-ctx-item is-disabled" : "im-ctx-item"; if (it.icon) { // The icon is the library's own sprite output — markup by // construction, never user text — so it is inserted as markup while @@ -103,6 +106,7 @@ if (once("__imCtxMenu")) { row.dataset.action = it.action ?? ""; row.dataset.params = JSON.stringify(it.params ?? null); row.addEventListener("click", () => { + if (it.disabled) return; pick(row.dataset.action ?? "", row.dataset.params); close(); }); @@ -139,7 +143,12 @@ if (once("__imCtxMenu")) { menu.style.top = Math.max(4, y) + "px"; const el = menu; - const rows = (): HTMLElement[] => Array.from(el.querySelectorAll(".im-ctx-item")); + // Disabled rows are excluded here rather than only from the click + // handler: this list is what the arrows walk, what Enter fires and what + // the letter accelerators match, so leaving them in would let the + // keyboard run a command the pointer refuses. + const rows = (): HTMLElement[] => + Array.from(el.querySelectorAll(".im-ctx-item:not(.is-disabled)")); let sel = 0; const paint = () => rows().forEach((r, i) => r.classList.toggle("is-sel", i === sel)); paint(); diff --git a/immersion/ts/generated/MenuItem.ts b/immersion/ts/generated/MenuItem.ts index 9dbce1f..af187c5 100644 --- a/immersion/ts/generated/MenuItem.ts +++ b/immersion/ts/generated/MenuItem.ts @@ -20,4 +20,10 @@ icon?: string, /** * A divider rather than a row. Always emitted, so the type can promise it. */ -sep: boolean, }; +sep: boolean, +/** + * Shown, but not runnable — Blender greys an operator whose `poll` fails + * rather than hiding it, because a row that disappears teaches nothing + * and a row that is there but dim says "this exists, not now". + */ +disabled: boolean, }; diff --git a/powderman/src/menus.rs b/powderman/src/menus.rs index 1853149..e038731 100644 --- a/powderman/src/menus.rs +++ b/powderman/src/menus.rs @@ -260,23 +260,33 @@ pub(crate) fn view_menu(settings: &Value) -> String { menu_json(&items) } -pub(crate) fn window_menu(active: usize, mac: bool) -> String { +pub(crate) fn window_menu(ws: &immersion::Workspaces, mac: bool) -> String { + // Blender's poll, showing: with one workspace there is nothing to close, + // cycle to or reorder, and those rows go grey rather than staying live + // and failing on the click. + let commands = crate::workflows::commands(); + let can = |name: &str| commands.can(ws, name, &Value::Null); + let active = ws.active; menu_json(&[ - MenuItem::new("Duplicate workspace", "workspace.duplicate", json!({})), + MenuItem::new("Duplicate workspace", "workspace.duplicate", json!({})) + .when(can("workspace.duplicate")), MenuItem::new( "Close workspace", "workspace.close", json!({ "index": active }), - ), + ) + .when(can("workspace.close")), MenuItem::sep(), MenuItem::new("Next workspace", "workspace.cycle", json!({ "delta": 1 })) - .with_chord(&pretty_chord("Alt+PageDown", mac)), + .with_chord(&pretty_chord("Alt+PageDown", mac)) + .when(can("workspace.cycle")), MenuItem::new( "Previous workspace", "workspace.cycle", json!({ "delta": -1 }), ) - .with_chord(&pretty_chord("Alt+PageUp", mac)), + .with_chord(&pretty_chord("Alt+PageUp", mac)) + .when(can("workspace.cycle")), MenuItem::sep(), MenuItem::new("Maximize area", "maximize", Value::Null) .with_chord(&pretty_chord("Mod+Shift+Space", mac)), diff --git a/powderman/src/ui.rs b/powderman/src/ui.rs index 02d551a..b0b157e 100644 --- a/powderman/src/ui.rs +++ b/powderman/src/ui.rs @@ -673,6 +673,12 @@ pub fn App() -> Element { // Whatever this editor wants in its own header — the machine area's // window, the browser's way back up. `None` leaves the header exactly as // it was. + // The registry's answer to "could this run right now", for the chrome that + // draws rows. Blender greys an operator whose poll fails; this is what + // lets the area menu do the same. + let can_run = use_callback(move |name: String| { + crate::workflows::commands().can(&ws.read(), &name, &serde_json::Value::Null) + }); let render_header = use_callback(move |(id, editor): (AreaId, String)| -> Element { let own = crate::editors::header(&draw_for(id, editor.clone())); // The pin belongs to every editor that points at something, not to @@ -838,7 +844,7 @@ pub fn App() -> Element { button { class: "im-menubtn", "data-im-menu-click": "{undo_history_menu(&crate::daemon::undo_history())}", "Undo History" } button { class: "im-menubtn", "data-im-menu-click": "{repeat_history_menu(&crate::daemon::command_log())}", "Repeat History" } button { class: "im-menubtn", "data-im-menu-click": "{view_menu(&settings())}", "View" } - button { class: "im-menubtn", "data-im-menu-click": "{window_menu(ws.read().active, mac())}", "Window" } + button { class: "im-menubtn", "data-im-menu-click": "{window_menu(&ws.read(), mac())}", "Window" } button { class: "im-menubtn", "data-im-menu-click": "{help_menu(mac())}", "Help" } } // Everything that is not a menu sits to the right, the way @@ -990,6 +996,7 @@ pub fn App() -> Element { layout: ws.read().current().layout.clone(), kinds: crate::editors::kinds(), render, + can: Some(can_run), render_header: Some(render_header), render_toolbar: Some(render_toolbar), render_sidebar: Some(render_sidebar), @@ -1278,7 +1285,10 @@ mod parity_tests { for p in palette_items(&ws) { actions.push(("palette".into(), p.action)); } - for a in menu_actions(&window_menu(0, false)) { + for a in menu_actions(&window_menu( + &immersion::Workspaces::new("t", Layout::single("runs")), + false, + )) { actions.push(("window menu".into(), a)); } for a in menu_actions(&file_menu()) { @@ -1322,7 +1332,7 @@ mod parity_tests { for a in menu_actions(&immersion::view_menu_json(1, true, true, true)) { actions.push(("view menu".into(), a)); } - for a in menu_actions(&immersion::area_menu_json(1)) { + for a in menu_actions(&immersion::area_menu_json(1, |_| true)) { actions.push(("area menu".into(), a)); } diff --git a/powderman/src/workflows.rs b/powderman/src/workflows.rs index 17fa69c..1c7c9d3 100644 --- a/powderman/src/workflows.rs +++ b/powderman/src/workflows.rs @@ -418,6 +418,9 @@ pub fn commands() -> immersion::Commands { name: "open_run", description: "Open a run in a new area beside the list", navigational: false, + // Always: any area can be split to hold a run, and whether the run + // exists is the run function's business. + poll: immersion::always, run: open_run, }) .with(immersion::Command { @@ -427,12 +430,20 @@ pub fn commands() -> immersion::Commands { // on the undo stack per click would bury the splits and joins // that are. navigational: true, + // Always, deliberately. Selecting a file in a workspace with no + // viewer open writes nothing, and a click that reports an error + // for doing something reasonable is worse than one that quietly + // does nothing — see the test that pins this. + poll: immersion::always, run: select, }) .with(immersion::Command { name: "set_pinned", description: "Freeze an area on what it is showing, or let it follow the selection", navigational: false, + // Always: poll answers about the workbench, not about params. A + // bad id is the run function's error, with its own message. + poll: immersion::always, run: set_pinned, }) }