Skip to main content

brep_app/workbench/
commands.rs

1//! Renderer-neutral command snapshots. Availability and dispatch belong to their owners.
2use super::{ButtonState, WorkbenchButton};
3use brep_render::engine_state::EngineState;
4
5pub const RIBBON_TABS: [&str; 3] = ["Home", "View", "Help"];
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum CommandSize {
9    Large,
10    Compact,
11}
12
13/// Built-ins borrow static strings; runtime adapters borrow their owned metadata.
14#[derive(Clone, Copy, Debug)]
15pub struct CommandPresentation<'a> {
16    pub id: &'a str,
17    pub glyph: &'a str,
18    pub ribbon_path: &'a str,
19    pub size: CommandSize,
20}
21impl<'a> CommandPresentation<'a> {
22    pub fn label(&self) -> &'a str {
23        self.ribbon_path.rsplit('/').next().unwrap_or("")
24    }
25}
26
27pub fn ribbon_segments(path: &str) -> Result<[&str; 3], String> {
28    let parts: Vec<_> = path.split('/').collect();
29    if parts.len() != 3 || parts.iter().any(|p| p.trim().is_empty() || p.trim() != *p) {
30        return Err(format!("invalid ribbon path: {path:?}"));
31    }
32    if !RIBBON_TABS.contains(&parts[0]) {
33        return Err(format!("unknown ribbon tab: {}", parts[0]));
34    }
35    Ok([parts[0], parts[1], parts[2]])
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub enum CommandTarget {
40    Shell(&'static str),
41    Workbench(&'static str),
42    Plugin(String),
43    Feature(String),
44    Constraint(String),
45    Annotation(String),
46}
47
48#[derive(Clone, Debug)]
49pub struct OfferedCommand {
50    pub id: String,
51    pub glyph: String,
52    pub ribbon_path: String,
53    pub size: CommandSize,
54    pub target: CommandTarget,
55    pub pressed: bool,
56    pub toggle: bool,
57    pub disabled_reason: Option<String>,
58    pub detail: String,
59    pub menu: Vec<OfferedCommand>,
60}
61impl OfferedCommand {
62    pub fn presentation(&self) -> CommandPresentation<'_> {
63        CommandPresentation {
64            id: &self.id,
65            glyph: &self.glyph,
66            ribbon_path: &self.ribbon_path,
67            size: self.size,
68        }
69    }
70    pub fn segments(&self) -> [&str; 3] {
71        ribbon_segments(self.presentation().ribbon_path).expect("validated command path")
72    }
73    pub fn label(&self) -> &str {
74        self.presentation().label()
75    }
76    pub fn is_home(&self) -> bool {
77        self.segments()[0] == "Home"
78    }
79    /// A creation command: Classic draws these on its creation strip, and
80    /// everything else (File, Undo, actions, tools) on its primary row.
81    pub fn is_creation(&self) -> bool {
82        matches!(
83            self.target,
84            CommandTarget::Feature(_) | CommandTarget::Constraint(_) | CommandTarget::Annotation(_)
85        )
86    }
87}
88
89pub fn validate_commands(commands: &[OfferedCommand]) -> Result<(), String> {
90    fn visit(
91        commands: &[OfferedCommand],
92        ids: &mut std::collections::HashSet<String>,
93    ) -> Result<(), String> {
94        for command in commands {
95            ribbon_segments(&command.ribbon_path)?;
96            if command.id.is_empty() || !ids.insert(command.id.clone()) {
97                return Err(format!("duplicate or empty command id: {}", command.id));
98            }
99            visit(&command.menu, ids)?;
100        }
101        Ok(())
102    }
103    visit(commands, &mut Default::default())
104}
105
106fn static_command(p: CommandPresentation<'static>, target: CommandTarget) -> OfferedCommand {
107    OfferedCommand {
108        id: p.id.into(),
109        glyph: p.glyph.into(),
110        ribbon_path: p.ribbon_path.into(),
111        size: p.size,
112        target,
113        pressed: false,
114        toggle: false,
115        disabled_reason: None,
116        detail: p.label().into(),
117        menu: vec![],
118    }
119}
120
121/// File commands share metadata; Properties and Settings are offered only in File.
122pub const FILE_COMMANDS: &[CommandPresentation<'static>] = &[
123    CommandPresentation {
124        id: "file:new",
125        glyph: "\u{E010}",
126        ribbon_path: "Home/File/New",
127        size: CommandSize::Large,
128    },
129    CommandPresentation {
130        id: "file:open",
131        glyph: "\u{E011}",
132        ribbon_path: "Home/File/Open",
133        size: CommandSize::Large,
134    },
135    CommandPresentation {
136        id: "file:save",
137        glyph: "\u{E012}",
138        ribbon_path: "Home/File/Save",
139        size: CommandSize::Large,
140    },
141    CommandPresentation {
142        id: "file:saveas",
143        glyph: "\u{E013}",
144        ribbon_path: "Home/File/Save As",
145        size: CommandSize::Compact,
146    },
147    CommandPresentation {
148        id: "file:import",
149        glyph: "\u{E014}",
150        ribbon_path: "Home/File/Import",
151        size: CommandSize::Compact,
152    },
153    CommandPresentation {
154        id: "file:export",
155        glyph: "\u{E015}",
156        ribbon_path: "Home/File/Export",
157        size: CommandSize::Compact,
158    },
159    CommandPresentation {
160        id: "properties",
161        glyph: "\u{E066}",
162        ribbon_path: "Home/File/Document Properties",
163        size: CommandSize::Compact,
164    },
165    CommandPresentation {
166        id: "settings",
167        glyph: "\u{2699}",
168        ribbon_path: "Home/File/Settings",
169        size: CommandSize::Compact,
170    },
171    // Plugin management lives under Settings in the File menu, in both styles.
172    CommandPresentation {
173        id: "workbench:manage:plugins",
174        glyph: "\u{2699}",
175        ribbon_path: "Home/File/Plugins",
176        size: CommandSize::Compact,
177    },
178    CommandPresentation {
179        id: "workbench:manage:javascript",
180        glyph: "\u{270E}",
181        ribbon_path: "Home/File/JavaScript",
182        size: CommandSize::Compact,
183    },
184];
185
186/// Flat New rows in DocumentClass::ALL order.
187pub const NEW_DOCUMENT_COMMANDS: &[CommandPresentation<'static>] = &[
188    CommandPresentation {
189        id: "file:newclass:normal",
190        glyph: "\u{E010}",
191        ribbon_path: "Home/File/New part",
192        size: CommandSize::Compact,
193    },
194    CommandPresentation {
195        id: "file:newclass:family",
196        glyph: "\u{E010}",
197        ribbon_path: "Home/File/New family",
198        size: CommandSize::Compact,
199    },
200    CommandPresentation {
201        id: "file:newclass:template",
202        glyph: "\u{E010}",
203        ribbon_path: "Home/File/New template",
204        size: CommandSize::Compact,
205    },
206];
207
208pub fn file_presentation(id: &str) -> CommandPresentation<'static> {
209    *FILE_COMMANDS
210        .iter()
211        .find(|c| c.id == id)
212        .expect("File command is declared")
213}
214
215pub const SHELL_COMMANDS: &[CommandPresentation<'static>] = &[
216    CommandPresentation {
217        id: "undo",
218        glyph: "\u{E016}",
219        ribbon_path: "Home/Undo/Undo",
220        size: CommandSize::Compact,
221    },
222    CommandPresentation {
223        id: "redo",
224        glyph: "\u{E017}",
225        ribbon_path: "Home/Undo/Redo",
226        size: CommandSize::Compact,
227    },
228    CommandPresentation {
229        id: "wireframe",
230        glyph: "\u{1F578}",
231        ribbon_path: "View/Display/Wireframe",
232        size: CommandSize::Compact,
233    },
234    CommandPresentation {
235        id: "projection",
236        glyph: "\u{E018}",
237        ribbon_path: "View/Display/Projection",
238        size: CommandSize::Compact,
239    },
240    CommandPresentation {
241        id: "show:faces",
242        glyph: "\u{E028}",
243        ribbon_path: "View/Display/Show faces",
244        size: CommandSize::Compact,
245    },
246    CommandPresentation {
247        id: "show:edges",
248        glyph: "\u{E029}",
249        ribbon_path: "View/Display/Show edges",
250        size: CommandSize::Compact,
251    },
252    CommandPresentation {
253        id: "show:vertices",
254        glyph: "\u{E02A}",
255        ribbon_path: "View/Display/Show vertices",
256        size: CommandSize::Compact,
257    },
258    CommandPresentation {
259        id: "fit",
260        glyph: "\u{26F6}",
261        ribbon_path: "View/Navigation/Zoom to fit",
262        size: CommandSize::Large,
263    },
264    CommandPresentation {
265        id: "help",
266        glyph: "\u{2753}",
267        ribbon_path: "Help/Support/Help",
268        size: CommandSize::Large,
269    },
270    CommandPresentation {
271        id: "info",
272        glyph: "\u{2139}",
273        ribbon_path: "Help/Support/Info",
274        size: CommandSize::Compact,
275    },
276    CommandPresentation {
277        id: "bug",
278        glyph: "\u{1F41E}",
279        ribbon_path: "Help/Support/Submit Bug",
280        size: CommandSize::Compact,
281    },
282];
283
284pub(crate) fn adapt_button(button: &WorkbenchButton, state: &ButtonState) -> OfferedCommand {
285    let mut c = static_command(button.presentation(), CommandTarget::Workbench(button.id));
286    c.pressed = button.is_pressed(state);
287    c.toggle = button.pressed.is_some();
288    c.disabled_reason = button.disabled_reason(state).map(str::to_owned);
289    c.detail = button.detail(state);
290    c.menu = button
291        .menu
292        .iter()
293        .filter(|b| b.offered(state))
294        .map(|b| adapt_button(b, state))
295        .collect();
296    c
297}
298
299/// Canonical feature command text for text-and-icon surfaces such as palettes.
300pub fn feature_command_label(feature: &serde_json::Value) -> String {
301    let ty = feature["type"].as_str().unwrap_or("");
302    let label = feature["ribbonPath"]
303        .as_str()
304        .and_then(|p| p.rsplit('/').next())
305        .or(feature["longName"].as_str())
306        .or(feature["label"].as_str())
307        .unwrap_or(ty);
308    let glyph = brep_render::features::feature_icon(ty)
309        .map(|g| g.to_string())
310        .or_else(|| feature["icon"].as_str().map(str::to_owned));
311    glyph.map_or_else(|| label.to_owned(), |g| format!("{g} {label}"))
312}
313
314/// The same Home descriptors feed the Classic strip and Ribbon. Order is catalogue order.
315pub fn offered_home_commands(engine: &EngineState) -> Vec<OfferedCommand> {
316    if !engine.settings.show_workbench_toolbar || engine.sketch_mode() || engine.ref_select_active()
317    {
318        return vec![];
319    }
320    let active = &engine.settings.workbench;
321    let dynamic = super::owned_workbenches(engine);
322    let active_owned = dynamic.iter().find(|w| w.id == *active);
323    let mut commands = vec![];
324    let catalogue = engine.feature_catalogue();
325    for f in catalogue["features"].as_array().into_iter().flatten() {
326        let Some(ty) = f["type"].as_str() else {
327            continue;
328        };
329        if !active_owned.map_or_else(
330            || super::includes_feature(active, ty),
331            |w| w.feature_types.iter().any(|f| f == ty),
332        ) {
333            continue;
334        }
335        // Older runtime plugins are adapted at this boundary; built-ins declare paths in their schemas.
336        let label = f["label"].as_str().or(f["longName"].as_str()).unwrap_or(ty);
337        let path = f["ribbonPath"]
338            .as_str()
339            .map(str::to_owned)
340            .unwrap_or_else(|| format!("Home/Extensions/{}", label.replace('/', " ")));
341        commands.push(OfferedCommand {
342            id: format!("feature:{ty}"),
343            glyph: brep_render::features::feature_icon(ty)
344                .map(|glyph| glyph.to_string())
345                .or_else(|| f["icon"].as_str().map(str::to_owned))
346                .unwrap_or_else(|| ty.into()),
347            ribbon_path: path,
348            size: if f["commandSize"] == "Compact" {
349                CommandSize::Compact
350            } else {
351                CommandSize::Large
352            },
353            target: CommandTarget::Feature(ty.into()),
354            pressed: false,
355            toggle: false,
356            disabled_reason: None,
357            detail: format!("Add {label}"),
358            menu: vec![],
359        });
360    }
361    let state = ButtonState::of(engine);
362    if super::panel_visible(active, super::assembly::CONSTRAINTS_PANEL_ID, &state) {
363        for d in brep_render::brep_kernel::CONSTRAINT_TYPES {
364            commands.push(creation(
365                format!("constraint:{}", d.type_id),
366                d.icon,
367                format!("Home/Constraints/{}", d.label),
368                CommandTarget::Constraint(d.type_id.into()),
369            ));
370        }
371    }
372    if super::panel_visible(active, super::pmi::PANEL_ID, &state) {
373        for d in brep_render::brep_kernel::PMI_TYPES {
374            commands.push(creation(
375                format!("annotation:{}", d.type_id),
376                d.icon,
377                format!("Home/Annotations/{}", d.label),
378                CommandTarget::Annotation(d.type_id.into()),
379            ));
380        }
381    }
382    commands
383}
384fn creation(id: String, glyph: &str, path: String, target: CommandTarget) -> OfferedCommand {
385    let label = path.rsplit('/').next().unwrap_or("");
386    let detail = match &target {
387        CommandTarget::Constraint(_) => format!("Add {label} constraint from the selection"),
388        CommandTarget::Annotation(_) => format!(
389            "Add a {} to the active view, from the selection",
390            label.to_lowercase()
391        ),
392        _ => String::new(),
393    };
394    OfferedCommand {
395        id,
396        glyph: glyph.into(),
397        ribbon_path: path,
398        size: CommandSize::Compact,
399        target,
400        pressed: false,
401        toggle: false,
402        disabled_reason: None,
403        detail,
404        menu: vec![],
405    }
406}
407
408pub fn offered_commands(state: &ButtonState, info_open: bool) -> Vec<OfferedCommand> {
409    #[cfg(debug_assertions)]
410    {
411        static VALIDATED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
412        VALIDATED.get_or_init(|| {
413            validate_builtin_presentations().expect("invalid built-in command registry")
414        });
415    }
416    let engine = state.engine;
417    let mut commands: Vec<_> = FILE_COMMANDS
418        .iter()
419        .filter(|p| p.id.starts_with("file:"))
420        .chain(SHELL_COMMANDS)
421        .map(|p| {
422            let mut c = static_command(*p, CommandTarget::Shell(p.id));
423            c.toggle = matches!(
424                p.id,
425                "wireframe" | "projection" | "show:faces" | "show:edges" | "show:vertices" | "info"
426            );
427            c.pressed = match p.id {
428                "wireframe" => engine.settings.wireframe,
429                "projection" => matches!(
430                    engine.camera.projection,
431                    brep_render::view::Projection::Perspective { .. }
432                ),
433                "show:faces" => engine.settings.show_faces,
434                "show:edges" => engine.settings.show_edges,
435                "show:vertices" => engine.settings.show_vertices,
436                "info" => info_open,
437                _ => false,
438            };
439            let unavailable = match p.id {
440                "undo" => {
441                    !(if engine.sketch_mode() {
442                        engine.sketch_can_undo()
443                    } else {
444                        engine.can_undo()
445                    })
446                }
447                "redo" => {
448                    !(if engine.sketch_mode() {
449                        engine.sketch_can_redo()
450                    } else {
451                        engine.can_redo()
452                    })
453                }
454                _ => false,
455            };
456            if unavailable {
457                c.disabled_reason = Some("No history step available".into());
458            }
459            match p.id {
460                "undo" if engine.sketch_mode() => c.detail = "Undo sketch edit (Ctrl+Z)".into(),
461                "redo" if engine.sketch_mode() => c.detail = "Redo sketch edit (Ctrl+Y)".into(),
462                "fit" if engine.sheet_open().is_some() => {
463                    c.detail = "Zoom to fit (the open sheet's paper)".into()
464                }
465                "info" => c.detail = "Info (licences and diagnostics)".into(),
466                _ => {}
467            }
468            if p.id == "projection" {
469                c.detail = if c.pressed {
470                    "Perspective projection"
471                } else {
472                    "Orthographic projection"
473                }
474                .into();
475            }
476            c
477        })
478        .collect();
479    commands.extend(
480        super::offered_buttons(&engine.settings.workbench, state)
481            .into_iter()
482            .map(|b| adapt_button(b, state)),
483    );
484    for a in super::plugin_actions(engine, &engine.settings.workbench) {
485        let Some(id) = a["id"].as_str() else { continue };
486        let label = a["label"].as_str().unwrap_or(id);
487        commands.push(OfferedCommand {
488            id: id.into(),
489            glyph: a["glyph"].as_str().unwrap_or("\u{2699}").into(),
490            ribbon_path: a["ribbonPath"]
491                .as_str()
492                .map(str::to_owned)
493                .unwrap_or_else(|| format!("Home/Extensions/{}", label.replace('/', " "))),
494            size: if a["commandSize"] == "Large" {
495                CommandSize::Large
496            } else {
497                CommandSize::Compact
498            },
499            target: CommandTarget::Plugin(id.into()),
500            pressed: false,
501            toggle: false,
502            disabled_reason: None,
503            detail: label.into(),
504            menu: vec![],
505        });
506    }
507    commands.extend(offered_home_commands(engine));
508    debug_assert!(
509        validate_commands(&commands).is_ok(),
510        "invalid command registry: {:?}",
511        validate_commands(&commands)
512    );
513    commands
514}
515
516/// Groups are derived in first-declaration order; commands retain source order within them.
517pub fn command_groups<'a>(
518    commands: &'a [OfferedCommand],
519    tab: &str,
520) -> Vec<(&'a str, Vec<&'a OfferedCommand>)> {
521    let mut groups: Vec<(&str, Vec<&OfferedCommand>)> = vec![];
522    for c in commands.iter().filter(|c| c.segments()[0] == tab) {
523        let group = c.segments()[1];
524        if let Some((_, members)) = groups.iter_mut().find(|(name, _)| *name == group) {
525            members.push(c);
526        } else {
527            groups.push((group, vec![c]));
528        }
529    }
530    groups
531}
532
533/// Validate all declarations, including commands not currently offered.
534pub fn validate_builtin_presentations() -> Result<(), String> {
535    let mut seen = std::collections::HashSet::new();
536    let catalogue = brep_render::features::feature_catalogue();
537    for p in FILE_COMMANDS
538        .iter()
539        .chain(NEW_DOCUMENT_COMMANDS)
540        .chain(SHELL_COMMANDS)
541        .copied()
542        .chain(super::every_button_in(super::WORKBENCHES).map(|b| b.presentation()))
543    {
544        ribbon_segments(p.ribbon_path)?;
545        if !seen.insert(p.id.to_owned()) {
546            return Err(format!("duplicate command id: {}", p.id));
547        }
548    }
549    for feature in catalogue["features"].as_array().into_iter().flatten() {
550        let path = feature["ribbonPath"]
551            .as_str()
552            .ok_or_else(|| format!("feature has no ribbonPath: {}", feature["type"]))?;
553        if ribbon_segments(path)?[0] != "Home" {
554            return Err(format!("feature outside Home: {path}"));
555        }
556        if !matches!(feature["commandSize"].as_str(), Some("Large" | "Compact")) {
557            return Err(format!("feature has no commandSize: {path}"));
558        }
559        let ty = feature["type"].as_str().ok_or("feature has no type")?;
560        if !seen.insert(format!("feature:{ty}")) {
561            return Err(format!("duplicate feature type: {ty}"));
562        }
563    }
564    Ok(())
565}
566