From 3f8bd568694cd0a19cfdb946b2bab44f5adf193f Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Tue, 18 Aug 2026 07:57:17 +0000 Subject: [PATCH] commands: a command says whether it applies, and menus grey what does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blender's UI is operators, properties, poll and context. We had the first two and neither of the last two: every surface offered every command unconditionally, so "Close area" sat live in the menu of the only area and failed on the click, and the whole workspace family stayed live with one workspace. `Command` grows a `poll`. Most are `always` — there is no workbench where splitting an area makes no sense, which is exactly why it needs no poll of its own. The ones that do: join, join_into, ratio and swap want a second area; close, cycle, switch and move want a second workspace. `run` checks it too, not just the chrome. 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. It refuses before touching anything, so a refused command is never a half-applied one. Menu rows grey rather than vanish. A row that disappears teaches nothing; a row that is there and dim says "this exists, not now". The shim excludes disabled rows from the list the arrows walk, which is the same list Enter fires and the letter accelerators match — leaving them in would have let the keyboard run what the pointer refuses. The library asks the host, through a new `can` callback, because the host holds the registry. A host that offers no answer gets every row live, which is what these menus did before there was a poll to ask. Not done here, and worth saying: the command palette is still a hardcoded list of five entries plus workspace switches — it never enumerated the registry at all. Enumerating it needs commands to declare their parameters, since most cannot run without an area id or a direction, and that is its own piece of work rather than a line in this one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XjCx5QszLXroGYh1YBxybw --- immersion/src/command.rs | 164 +++++++++++++++++++++++++++++ immersion/src/contextmenu.js | 6 +- immersion/src/contextmenu.rs | 27 ++++- immersion/src/immersion.css | 4 + immersion/src/lib.rs | 2 +- immersion/src/ui.rs | 18 +++- immersion/ts/contextmenu.ts | 13 ++- immersion/ts/generated/MenuItem.ts | 8 +- powderman/src/menus.rs | 20 +++- powderman/src/ui.rs | 16 ++- powderman/src/workflows.rs | 11 ++ 11 files changed, 269 insertions(+), 20 deletions(-) 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, }) }