Toolbars for every capability, VectorCraft icons, coming soon for planned ones (#20)

* feat(engine): capability catalogue — every toolbar entry, ready or coming soon, and docs/features.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* feat: 98 toolbar icons drawn with VectorCraft from recipes (cargo xtask icons), attributed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* feat(ui): toolbar of tabs and panels from the catalogue, VectorCraft icons recoloured per theme, coming-soon entries greyed with their milestone; ui.toolbar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* refactor: ready entries are their command; one tooltip; tabs and panels as catalogue runs; icon cache per theme and size; ui.toolbar read-only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: roadmap notes the toolbar and icons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* feat(ui): deck entries run deck.write with their format, ready when the writer is in FORMATS; file and edit entries; compact and collapsed panels instead of scrolling

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: features page after the Nastran writer (#24) made its deck entry ready

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: features page after the LS-DYNA writer (#25) made its deck entry ready

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* feat: solve.static, solve.frequency and solve.probe on the toolbar (Solve and Results tabs)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 17:49:31 -04:00
1 parent 40f98f0af9
commit a292212b67
320 files changed
+2990 -88

No files matched your search

+450
View File
@@ -0,0 +1,450 @@
//! The capability catalogue: everything ModelCraft does or will do, in one table.
//!
//! Each [`Capability`] is a toolbar entry: its tab and panel, its label and one-line tip, and
//! either the command of the same id it runs ([`Status::Ready`]) or the milestone that brings it
//! ([`Status::Planned`], shown greyed as "Coming soon"). Planned entries are not commands: a
//! command always runs. The icon of an entry is `assets/icons/<id>.svg`.
//!
//! Every registered command is claimed here, as an entry's command or as one of its related
//! commands ([`Capability::more`], the entry's drop-down); the tests check it. A new command
//! claims its place by turning its planned entry into a ready one (or by adding an entry with
//! an icon), see `docs/features.md`.
use std::fmt::Write;
use serde::{Deserialize, Serialize};
/// The toolbar tabs, in the order of an analyst's workflow.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Tab {
#[default]
Geometry,
Mesh,
Properties,
Assembly,
Steps,
Loads,
Interactions,
Solve,
Results,
Tools,
}
impl Tab {
pub const ALL: [Tab; 10] =
[Tab::Geometry, Tab::Mesh, Tab::Properties, Tab::Assembly, Tab::Steps, Tab::Loads, Tab::Interactions, Tab::Solve, Tab::Results, Tab::Tools];
pub fn label(self) -> &'static str {
match self {
Tab::Geometry => "Geometry",
Tab::Mesh => "Mesh",
Tab::Properties => "Properties",
Tab::Assembly => "Assembly",
Tab::Steps => "Steps",
Tab::Loads => "Loads & BCs",
Tab::Interactions => "Interactions",
Tab::Solve => "Solve",
Tab::Results => "Results",
Tab::Tools => "Tools",
}
}
}
/// Whether an entry runs today.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
/// Runs the command whose id is the entry's.
Ready,
/// Coming in this roadmap milestone (`M2`…).
Planned { milestone: &'static str },
/// Runs `deck.write` with this `format` once `modelcraft_decks::FORMATS` has a writer for it;
/// until then coming in `milestone`.
Deck { format: &'static str, milestone: &'static str },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Capability {
/// Our own dotted id; also the icon's file name.
pub id: &'static str,
pub label: &'static str,
pub tab: Tab,
pub panel: &'static str,
/// One line: what it does.
pub tip: &'static str,
pub status: Status,
/// Related commands in the entry's drop-down (edit, delete…).
pub more: &'static [&'static str],
}
impl Capability {
/// The command it runs; `None` while it is coming soon.
pub fn command(&self) -> Option<&'static str> {
match self.status {
Status::Ready => Some(self.id),
Status::Planned { .. } => None,
Status::Deck { format, .. } => modelcraft_decks::format(format).map(|_| crate::cmd::deck::WRITE.id),
}
}
/// The parameters its command starts with (an object; empty for most entries).
pub fn preset(&self) -> serde_json::Value {
match self.status {
Status::Deck { format, .. } => serde_json::json!({ "format": format }),
Status::Ready | Status::Planned { .. } => serde_json::json!({}),
}
}
/// The milestone that brings it, while it is coming soon.
pub fn milestone(&self) -> Option<&'static str> {
match self.status {
Status::Planned { milestone } => Some(milestone),
Status::Deck { milestone, .. } if self.command().is_none() => Some(milestone),
Status::Ready | Status::Deck { .. } => None,
}
}
/// The hover text: label, tip and command, or when it comes.
pub fn tooltip(&self) -> String {
match (self.command(), self.milestone()) {
(Some(command), _) => format!("{}\n{}\n{command}", self.label, self.tip),
(None, m) => format!("{} — coming soon ({})\n{}", self.label, m.unwrap_or("later"), self.tip),
}
}
}
const fn ready(id: &'static str, label: &'static str, tab: Tab, panel: &'static str, tip: &'static str) -> Capability {
Capability { id, label, tab, panel, tip, status: Status::Ready, more: &[] }
}
const fn ready_more(
id: &'static str,
label: &'static str,
tab: Tab,
panel: &'static str,
tip: &'static str,
more: &'static [&'static str],
) -> Capability {
Capability { more, ..ready(id, label, tab, panel, tip) }
}
const fn soon(id: &'static str, label: &'static str, tab: Tab, panel: &'static str, milestone: &'static str, tip: &'static str) -> Capability {
Capability { id, label, tab, panel, tip, status: Status::Planned { milestone }, more: &[] }
}
const fn deck(id: &'static str, label: &'static str, format: &'static str, tip: &'static str) -> Capability {
Capability { id, label, tab: Tab::Solve, panel: "Decks", tip, status: Status::Deck { format, milestone: "M3" }, more: &[] }
}
use Tab::*;
/// Every capability in toolbar order: grouped by tab (in [`Tab::ALL`] order), then by panel.
pub static CATALOGUE: &[Capability] = &[
// ---- Geometry ----
ready("geometry.open", "Open Design", Geometry, "Import", "Open a SolidCraft design: its bodies with persistent face names"),
ready("geometry.import.mesh", "Tagged Mesh", Geometry, "Import", "Read a face-tagged surface mesh (3MF)"),
ready("geometry.bodies", "Bodies", Geometry, "Inspect", "List the open design's bodies, volumes and named faces"),
ready("geometry.tessellate", "Tessellate", Geometry, "Inspect", "Tessellate a body into a face-tagged boundary surface"),
ready(
"mesh.surface.check",
"Check Surface",
Geometry,
"Inspect",
"Weld, closed, manifold, orientation and self-intersection checks; feature edges",
),
soon("geometry.partition", "Partition", Geometry, "Prepare", "M5", "Split bodies and faces as SolidCraft features"),
soon("geometry.datum", "Datum Plane", Geometry, "Prepare", "M5", "Construction planes for partitions"),
soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"),
// ---- Mesh ----
soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"),
soon("mesh.size.seed", "Seed Edges", Mesh, "Size", "M2", "Element counts, sizes and bias per edge and face"),
soon("mesh.size.adapt", "Curvature", Mesh, "Size", "M2", "Refine by curvature and proximity"),
soon("mesh.tet.generate", "Tet Mesh", Mesh, "Tet", "M2", "Delaunay tetrahedral mesh of a body, boundary recovered and refined"),
soon("mesh.order.quadratic", "Quadratic", Mesh, "Tet", "M2", "Second-order elements with mid-side nodes on the geometry"),
ready("mesh.delaunay.points", "Delaunay Points", Mesh, "Tet", "Delaunay tetrahedralization of a point list (debugging aid)"),
soon("mesh.quad.faces", "Quad Faces", Mesh, "Structured Hex", "M5", "All-quad meshes of faces on shared edge nodes"),
soon("mesh.hex.mapped", "Mapped Hex", Mesh, "Structured Hex", "M5", "Mapped hex mesh of six-sided regions"),
soon("mesh.hex.sweep", "Sweep Hex", Mesh, "Structured Hex", "M5", "Hex mesh swept along extrusions and revolutions"),
soon("mesh.hex.decompose", "Decompose", Mesh, "Structured Hex", "M5", "Find sweepable and mappable regions and propose the splits"),
soon("mesh.transition", "Transition", Mesh, "Structured Hex", "M5", "Conforming tet–hex interfaces with pyramids"),
soon("mesh.hex.generate", "All-Hex", Mesh, "Automatic Hex", "M6", "Octree dual all-hex mesh of any part"),
soon("mesh.hex.dominant", "Hex-Dominant", Mesh, "Automatic Hex", "M6", "Hex-dominant mesh by recombining tets"),
soon("mesh.hex.polycube", "Polycube", Mesh, "Automatic Hex", "M6", "Polycube hex mesh of blocky parts"),
soon("mesh.smooth", "Smooth", Mesh, "Improve", "M6", "Smooth node positions, keeping every element valid"),
soon("mesh.untangle", "Untangle", Mesh, "Improve", "M6", "Repair inverted elements"),
soon("mesh.layers", "Layers", Mesh, "Improve", "M6", "Boundary padding and layers through thin walls"),
ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]),
ready_more("mesh.element.add", "Element", Mesh, "Edit", "Add an element on existing nodes", &["mesh.element.remove"]),
soon("mesh.node.merge", "Merge Nodes", Mesh, "Edit", "M6", "Merge coincident nodes"),
soon("mesh.region.remesh", "Remesh Region", Mesh, "Edit", "M6", "Remesh a region in place; split and collapse elements"),
soon("mesh.import.orphan", "Orphan Mesh", Mesh, "Edit", "M6", "Import a mesh without geometry"),
ready("mesh.quality", "Quality", Mesh, "Verify", "Quality metrics and the certified validity check, failing elements listed"),
soon("mesh.verify", "Verify Set", Mesh, "Verify", "M2", "Quality and validity per set"),
ready_more("mesh.tag", "Tag Geometry", Mesh, "Verify", "Tag elements, faces, edges and nodes with the geometry they mesh", &["mesh.tag.clear"]),
// ---- Properties ----
ready_more(
"model.material.create",
"Material",
Properties,
"Materials",
"Elastic (isotropic, orthotropic) material with density",
&["model.material.edit", "model.material.delete"],
),
soon("model.material.plasticity", "Plasticity", Properties, "Materials", "M8", "Plastic hardening data"),
soon("model.material.hyperelastic", "Hyperelastic", Properties, "Materials", "M8", "Hyperelastic material models"),
soon("model.material.damage", "Damage", Properties, "Materials", "M8", "Damage initiation and evolution"),
soon("model.material.thermal", "Thermal", Properties, "Materials", "M8", "Conductivity, specific heat and expansion"),
soon(
"model.material.eos",
"Equation of State",
Properties,
"Materials",
"M11",
"Polynomial, ideal gas, Mie–Grüneisen, JWL and tabulated equations of state",
),
ready_more(
"model.section.create",
"Section",
Properties,
"Sections",
"Solid, shell, beam or rod section assigned to a set, with its element formulation",
&["model.section.edit", "model.section.delete"],
),
soon("model.section.composite", "Composite", Properties, "Sections", "M8", "Composite layups and continuum shells"),
soon("model.orientation", "Orientation", Properties, "Sections", "M8", "Material orientations"),
ready_more(
"model.set.create",
"Set",
Properties,
"Sets",
"A set of nodes, elements and faces, or of named geometry that survives remeshing",
&["model.set.edit", "model.set.delete", "model.set.resolve"],
),
ready("model.units", "Units", Properties, "Model", "The model's unit system (never converted)"),
// ---- Assembly ----
soon("assembly.instance", "Instances", Assembly, "Instances", "M7", "Part instances placed from SolidCraft occurrences"),
// ---- Steps ----
ready_more(
"model.step.create",
"Step",
Steps,
"Steps",
"A linear static or frequency step with its output",
&["model.step.edit", "model.step.delete"],
),
ready("model.step.activate", "Activate", Steps, "Steps", "Choose which loads and boundary conditions act in a step"),
ready("model.activity", "Step Table", Steps, "Steps", "The step by load and boundary condition table"),
soon("model.amplitude", "Amplitude", Steps, "Procedures", "M7", "Time and frequency amplitudes"),
soon("model.step.nonlinear", "Nonlinear", Steps, "Procedures", "M7", "Nonlinear static steps"),
soon("model.step.explicit", "Explicit", Steps, "Procedures", "M8", "Explicit dynamic steps"),
soon("model.step.heat", "Heat Transfer", Steps, "Procedures", "M8", "Heat transfer steps"),
// ---- Loads & BCs ----
ready_more(
"model.boundary_condition.create",
"Boundary Condition",
Loads,
"Boundary Conditions",
"Fixed, displacement or symmetry condition on a set or reference point",
&["model.boundary_condition.edit", "model.boundary_condition.delete"],
),
ready_more(
"model.load.create",
"Load",
Loads,
"Loads",
"Force, moment, pressure, traction, gravity or body force",
&["model.load.edit", "model.load.delete"],
),
soon("flow.domain", "Fluid Domain", Loads, "Flow", "M12", "The fluid grid voxelised from the face-tagged surface"),
soon("flow.boundary", "Inlet & Outlet", Loads, "Flow", "M12", "Velocity inlets, pressure outlets and walls"),
// ---- Interactions ----
ready_more(
"model.reference_point.create",
"Reference Point",
Interactions,
"Constraints",
"A reference point for couplings, loads and boundary conditions",
&["model.reference_point.edit", "model.reference_point.delete"],
),
ready_more(
"model.coupling.create",
"Coupling",
Interactions,
"Constraints",
"Kinematic or distributing coupling of a set to a reference point",
&["model.coupling.edit", "model.coupling.delete"],
),
soon("interaction.tie", "Tie", Interactions, "Constraints", "M7", "Tie non-matching meshes together"),
soon("interaction.contact.general", "General Contact", Interactions, "Contact", "M7", "Contact between everything that touches, with friction"),
soon("interaction.contact.pair", "Contact Pair", Interactions, "Contact", "M7", "Contact between two surfaces"),
soon("interaction.contact.find", "Find Contacts", Interactions, "Contact", "M7", "Detect contact pairs automatically"),
soon("interaction.rigid_wall", "Rigid Wall", Interactions, "Contact", "M10", "Rigid walls for explicit dynamics"),
soon("interaction.bolt", "Bolt Load", Interactions, "Connectors", "M7", "Bolt pretension"),
soon("interaction.spring", "Spring", Interactions, "Connectors", "M7", "Springs and dashpots"),
soon("interaction.mass", "Point Mass", Interactions, "Connectors", "M7", "Point masses and inertia"),
soon("interaction.connector", "Connector", Interactions, "Connectors", "M7", "Connector elements between points"),
soon("interaction.fsi", "FSI Interface", Interactions, "Coupled", "M13", "The fluid–structure interface"),
// ---- Solve ----
ready("model.check", "Check Model", Solve, "Check", "Name every missing or bad definition before writing or solving"),
deck("deck.write.abaqus", "Abaqus Input", "abaqus", "Write an Abaqus input file (.inp)"),
deck("deck.write.nastran", "Nastran Deck", "nastran", "Write Nastran bulk data (SOL 101, 103)"),
deck("deck.write.lsdyna", "LS-DYNA Deck", "lsdyna", "Write an LS-DYNA keyword file"),
soon("deck.read", "Read Deck", Solve, "Decks", "M9", "Read a deck, keeping keywords ModelCraft does not know"),
ready("solve.static", "Static", Solve, "Implicit", "Linear static solve of a step with ModelCraft's own solver"),
ready("solve.frequency", "Frequency", Solve, "Implicit", "Natural frequencies and mode shapes of a frequency step"),
soon("solve.explicit", "Explicit", Solve, "Explicit", "M10", "Explicit dynamics: central differences, contact, erosion"),
soon("solve.blast", "Blast & Impact", Solve, "Explicit", "M14", "EOS-driven fluids with fluid–structure contact (ALE or SPH)"),
soon("solve.flow", "Lattice Boltzmann", Solve, "Fluids", "M12", "Lattice Boltzmann flow on the CPU or the GPU"),
soon("solve.fsi", "Fluid–Structure", Solve, "Fluids", "M13", "Coupled fluid and structure solve"),
// ---- Results ----
soon("results.deformed", "Deformed", Results, "Display", "M4", "The deformed shape"),
soon("results.contour", "Contours", Results, "Display", "M4", "Contour plots of stress, strain and displacement"),
soon("results.modes", "Mode Shapes", Results, "Display", "M4", "Mode shapes of a frequency step"),
soon("results.animate", "Animate", Results, "Display", "M9", "Animate modes and time histories"),
ready("solve.probe", "Probe", Results, "Query", "Displacement, reaction and stress at nodes, or a mode shape"),
soon("results.reactions", "Reactions", Results, "Query", "M4", "Reaction force and moment sums"),
soon("results.free_body", "Free Body", Results, "Query", "M9", "Free-body forces across a cut"),
soon("results.xy", "XY Plot", Results, "Plots", "M9", "Plot a quantity against time or another"),
soon("results.path", "Path Plot", Results, "Plots", "M9", "Plot a quantity along a path"),
soon("results.cut", "Cut", Results, "Plots", "M9", "Section cuts through the model"),
soon("results.display_group", "Display Group", Results, "Plots", "M9", "Show and hide parts of the model"),
soon("results.streamlines", "Streamlines", Results, "Plots", "M12", "Streamlines and flow fields"),
soon("results.read", "Read Results", Results, "Files", "M9", "Read results in public formats"),
soon("results.convergence", "Convergence", Results, "Files", "M9", "Mesh convergence studies"),
// ---- Tools ----
ready("model.info", "Model Info", Tools, "Model", "Node and element counts and the model summary"),
ready("file.open", "Open Model", Tools, "File", "Open a .modelcraft model"),
ready("file.save", "Save", Tools, "File", "Save the model to its .modelcraft file"),
ready("file.save_as", "Save As", Tools, "File", "Save the model to a new .modelcraft file"),
ready_more(
"file.recover",
"Recover",
Tools,
"File",
"Open a model autosaved by an app that crashed or closed with unsaved changes",
&["file.recovery.list", "file.recovery.discard"],
),
ready("edit.undo", "Undo", Tools, "History", "Undo the last command that changed the model"),
ready("edit.redo", "Redo", Tools, "History", "Redo the command undone last"),
];
/// The catalogue entry with this id.
pub fn find(id: &str) -> Option<&'static Capability> {
CATALOGUE.iter().find(|c| c.id == id)
}
/// The entries of a tab (one run of the catalogue).
pub fn tab_entries(tab: Tab) -> &'static [Capability] {
let start = CATALOGUE.iter().position(|c| c.tab == tab).unwrap_or(CATALOGUE.len());
let rest = CATALOGUE.get(start..).unwrap_or_default();
rest.get(..rest.iter().take_while(|c| c.tab == tab).count()).unwrap_or_default()
}
/// The panels of a tab in order, each the run of its entries (never empty).
pub fn panels(tab: Tab) -> impl Iterator<Item = &'static [Capability]> {
tab_entries(tab).chunk_by(|a, b| a.panel == b.panel)
}
/// `docs/features.md`: every capability by tab and panel, ready or coming soon.
pub fn features_page() -> String {
let ready = CATALOGUE.iter().filter(|c| c.command().is_some()).count();
let mut md = String::from(
"# Features\n\n\
Generated from the capability catalogue (`crates/engine/src/catalogue.rs`) by\n\
`cargo run -p modelcraft-cli -- features > docs/features.md`; a test fails when it is out of date.\n\
Every entry is a toolbar button. **Ready** entries run their command; **coming soon** entries are\n\
shown greyed with the roadmap milestone that brings them (ROADMAP.md).\n\n",
);
let _ = writeln!(md, "{ready} ready, {} coming soon.", CATALOGUE.len() - ready);
for tab in Tab::ALL {
let _ = write!(md, "\n## {}\n\n| Panel | Capability | Status | Command |\n|---|---|---|---|\n", tab.label());
for c in tab_entries(tab) {
let status = c.milestone().map_or_else(|| "ready".to_string(), |m| format!("coming soon ({m})"));
let preset = c.preset();
let main =
c.command().map(|id| if preset.as_object().is_some_and(|o| !o.is_empty()) { format!("`{id} {preset}`") } else { format!("`{id}`") });
let commands: Vec<String> = main.into_iter().chain(c.more.iter().map(|id| format!("`{id}`"))).collect();
let _ = writeln!(md, "| {} | {} — {} | {status} | {} |", c.panel, c.label, c.tip, commands.join(", "));
}
}
md
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{commands, find_command};
#[test]
fn every_command_is_claimed_and_every_ready_entry_runs_a_command() {
let mut claimed: Vec<&str> = Vec::new();
for c in CATALOGUE {
if let Some(command) = c.command() {
assert!(find_command(command).is_some(), "{}: `{command}` is not a registered command", c.id);
claimed.push(command);
} else {
assert!(c.more.is_empty(), "{}: a planned entry has no commands", c.id);
}
for m in c.more {
assert!(find_command(m).is_some(), "{}: `{m}` is not a registered command", c.id);
claimed.push(m);
}
}
for spec in commands() {
let n = claimed.iter().filter(|c| **c == spec.id).count();
assert!(n >= 1, "command `{}` has no catalogue entry (a ready entry or an entry's `more`)", spec.id);
}
}
/// Deck entries follow the writers: one entry per format, ready exactly when its writer is in
/// `modelcraft_decks::FORMATS`, running `deck.write` with its format.
#[test]
fn deck_entries_follow_the_writers() {
let decks: Vec<(&Capability, &str)> =
CATALOGUE.iter().filter_map(|c| if let Status::Deck { format, .. } = c.status { Some((c, format)) } else { None }).collect();
for f in modelcraft_decks::FORMATS {
assert!(decks.iter().any(|(_, name)| *name == f.name), "deck format `{}` has no catalogue entry", f.name);
}
for (c, format) in decks {
let written = modelcraft_decks::format(format).is_some();
assert_eq!(c.command(), written.then_some("deck.write"), "{}", c.id);
assert_eq!(c.milestone().is_none(), written, "{}", c.id);
assert_eq!(c.preset(), serde_json::json!({"format": format}));
}
assert_eq!(find("deck.write.abaqus").and_then(Capability::command), Some("deck.write"));
}
#[test]
fn entries_are_well_formed() {
let mut ids: Vec<&str> = CATALOGUE.iter().map(|c| c.id).collect();
for c in CATALOGUE {
assert!(c.id.split('.').count() >= 2 && c.id.chars().all(|ch| ch.is_ascii_lowercase() || ch == '.' || ch == '_'), "{}", c.id);
assert!(!c.label.is_empty() && !c.panel.is_empty() && !c.tip.is_empty(), "{}", c.id);
if let Some(m) = c.milestone() {
assert!(m.strip_prefix('M').is_some_and(|n| n.parse::<u32>().is_ok_and(|n| (1..=14).contains(&n))), "{}: {m}", c.id);
}
}
ids.sort_unstable();
let n = ids.len();
ids.dedup();
assert_eq!(ids.len(), n, "catalogue ids are unique");
// Tabs are runs in Tab::ALL order, and each panel is one run within its tab.
let total: usize = Tab::ALL.iter().map(|t| tab_entries(*t).len()).sum();
assert_eq!(total, CATALOGUE.len(), "every tab's entries are together, in Tab::ALL order");
let order: Vec<Tab> =
CATALOGUE.iter().map(|c| c.tab).collect::<Vec<_>>().chunk_by(|a, b| a == b).filter_map(|r| r.first().copied()).collect();
assert_eq!(order, Tab::ALL);
for tab in Tab::ALL {
let names: Vec<&str> = panels(tab).filter_map(|p| p.first().map(|c| c.panel)).collect();
assert!(!names.is_empty(), "{tab:?} has no entries");
assert!(names.iter().enumerate().all(|(i, n)| !names[..i].contains(n)), "{tab:?}: a panel's entries are together: {names:?}");
}
}
#[test]
fn features_page_is_current() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/features.md");
let committed = std::fs::read_to_string(&path).unwrap_or_default().replace("\r\n", "\n");
assert!(committed == features_page(), "docs/features.md is out of date: run `cargo run -p modelcraft-cli -- features > docs/features.md`");
}
}
+1
View File
@@ -13,6 +13,7 @@
#![forbid(unsafe_code)]
pub mod autosave;
pub mod catalogue;
pub mod cmd;
pub mod history;
pub mod sample;
+1
View File
@@ -17,3 +17,4 @@ egui-wgpu = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
resvg = { workspace = true }
+23
View File
@@ -0,0 +1,23 @@
//! Embeds the toolbar icons: `assets/icons/*.svg` become `ICONS`, a sorted `(id, svg)` table
//! (`src/icons.rs`).
use std::fmt::Write;
use std::path::PathBuf;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
let dir = manifest.join("../../assets/icons");
println!("cargo:rerun-if-changed={}", dir.display());
let mut ids: Vec<String> =
std::fs::read_dir(&dir)?.flatten().filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".svg")).map(str::to_string)).collect();
ids.sort();
let mut out = String::from("/// Every toolbar icon by catalogue id, sorted by id.\npub static ICONS: &[(&str, &str)] = &[\n");
for id in &ids {
let path = dir.join(format!("{id}.svg"));
println!("cargo:rerun-if-changed={}", path.display());
let _ = writeln!(out, " ({id:?}, include_str!({:?})),", path.display().to_string());
}
out.push_str("];\n");
std::fs::write(PathBuf::from(std::env::var("OUT_DIR")?).join("icons.rs"), out)?;
Ok(())
}
+19
View File
@@ -114,6 +114,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
"model": {"nodes": app.session.model.nodes().len(), "elements": app.session.model.elements().len()},
"version": crate::VERSION,
})),
"ui.toolbar" => ok(crate::toolbar::describe(app)),
"ui.set" => {
let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({}));
if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) {
@@ -367,6 +368,8 @@ mod tests {
json!({"width": 1e12, "height": -5, "path": ""}),
json!({"command": 5, "params": "x", "commands": {"a": 1}}),
json!({"key": "NoSuchKey", "text": 7, "view": null, "points": [[1], "x", null]}),
json!({"tab": "nope", "command": "mesh.tet.generate"}),
json!({"tab": 3}),
];
for method in [
"engine.execute",
@@ -384,6 +387,7 @@ mod tests {
"ui.worldToScreen",
"ui.resize",
"ui.render",
"ui.toolbar",
] {
for p in &hostile {
let _ = call(&mut app, method, p.clone());
@@ -392,6 +396,21 @@ mod tests {
assert!(app.synthetic.len() < 1000);
}
#[test]
fn the_toolbar_lists_every_entry_and_switches_tabs() {
let mut app = ModelApp::new(Session::default());
assert_eq!(call(&mut app, "ui.set", json!({"tab": "mesh"}))["ok"], true);
let r = call(&mut app, "ui.toolbar", json!({}));
assert_eq!(r["result"]["tab"], "mesh", "{r}");
let entries = r["result"]["entries"].as_array().unwrap();
assert_eq!(entries.len(), modelcraft_engine::catalogue::CATALOGUE.len());
let tet = entries.iter().find(|e| e["id"] == "mesh.tet.generate").unwrap();
assert_eq!((tet["coming_soon"].as_str(), tet["command"].is_null()), (Some("M2"), true));
assert_eq!(call(&mut app, "ui.set", json!({"tab": "nope"}))["ok"], false);
let r = call(&mut app, "ui.start", json!({"command": "mesh.tet.generate"}));
assert_eq!((r["ok"].as_bool(), r["error"].as_str()), (Some(false), Some("Tet Mesh is coming soon (M2)")));
}
#[test]
fn screenshots_are_saved_as_png() {
let image = egui::ColorImage::filled([4, 3], egui::Color32::RED);
+132
View File
@@ -0,0 +1,132 @@
//! Toolbar icons: original artwork drawn with VectorCraft (`assets/icons`, `cargo xtask icons`),
//! embedded at build time and rasterised with resvg.
//!
//! Icons are drawn in four palette roles, each in one fixed colour (the dark theme's). Before
//! rasterising, every role colour is replaced by the theme token it stands for, so the same SVG
//! serves the dark and light themes and the greyed "coming soon" state. Textures are cached per
//! icon, theme, state and pixel size.
use std::collections::HashMap;
use egui::{Color32, TextureHandle};
use resvg::{tiny_skia, usvg};
use crate::theme::Tokens;
include!(concat!(env!("OUT_DIR"), "/icons.rs"));
/// The palette roles' colours in the SVG files. Keep in step with `PALETTE` in
/// `xtask/src/icons.rs`.
pub const PALETTE: [&str; 4] = ["#dde2ea", "#4f9ce8", "#2d4e70", "#f0b44a"];
/// Largest icon side rasterised (pixels).
const MAX_PX: u32 = 256;
/// The icon's SVG text.
pub fn svg(id: &str) -> Option<&'static str> {
ICONS.binary_search_by(|(k, _)| (*k).cmp(id)).ok().and_then(|i| ICONS.get(i)).map(|(_, s)| *s)
}
/// The colours the palette roles (line, accent, soft, warm) take in a theme, greyed when the
/// entry is not available yet.
pub fn colours(t: &Tokens, enabled: bool) -> [Color32; 4] {
if enabled { [t.text, t.accent, t.accent_soft, t.warm] } else { [t.text_dim, t.text_dim, t.panel_header, t.text_dim] }
}
/// The SVG with each palette colour replaced by its role's colour.
pub fn recolour(svg: &str, colours: [Color32; 4]) -> String {
let mut out = svg.to_string();
for (from, to) in PALETTE.iter().zip(colours) {
let hex = format!("#{:02x}{:02x}{:02x}", to.r(), to.g(), to.b());
out = out.replace(&format!("\"{from}\""), &format!("\"{hex}\""));
}
out
}
/// Rasterise an SVG to a `px` × `px` image.
pub fn rasterise(svg: &str, px: u32) -> Result<egui::ColorImage, String> {
let px = px.clamp(1, MAX_PX);
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).map_err(|e| e.to_string())?;
let mut pixmap = tiny_skia::Pixmap::new(px, px).ok_or("empty icon")?;
let size = tree.size();
let scale = px as f32 / size.width().max(size.height()).max(f32::EPSILON);
resvg::render(&tree, tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut());
Ok(egui::ColorImage::from_rgba_premultiplied([px as usize, px as usize], pixmap.data()))
}
/// Icon textures by (id, enabled) for one theme and pixel size; `None` when the icon is missing
/// or does not rasterise, so it is not tried again every frame. A change of theme or size
/// (zoom) drops the old textures.
#[derive(Default)]
pub struct IconCache {
theme_px: Option<(bool, u32)>,
textures: HashMap<(&'static str, bool), Option<TextureHandle>>,
}
impl IconCache {
pub fn get(&mut self, ctx: &egui::Context, id: &'static str, dark: bool, enabled: bool, px: u32) -> Option<TextureHandle> {
if self.theme_px != Some((dark, px)) {
self.textures.clear();
self.theme_px = Some((dark, px));
}
self.textures
.entry((id, enabled))
.or_insert_with(|| {
let svg = svg(id)?;
let image = rasterise(&recolour(svg, colours(&Tokens::get(dark), enabled)), px).map_err(|e| log::warn!("icon {id}: {e}")).ok()?;
Some(ctx.load_texture(format!("icon:{id}:{dark}:{enabled}:{px}"), image, egui::TextureOptions::LINEAR))
})
.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use modelcraft_engine::catalogue::CATALOGUE;
#[test]
fn every_catalogue_entry_has_an_icon_and_every_icon_an_entry() {
for c in CATALOGUE {
assert!(svg(c.id).is_some(), "no icon assets/icons/{}.svg", c.id);
}
for (id, _) in ICONS {
assert!(CATALOGUE.iter().any(|c| c.id == *id), "assets/icons/{id}.svg belongs to no catalogue entry");
}
assert!(ICONS.windows(2).all(|w| w[0].0 < w[1].0), "sorted for the binary search");
}
/// Icons use only the palette, so recolouring reaches every colour, and they rasterise.
#[test]
fn icons_use_only_palette_colours_and_rasterise() {
for (id, text) in ICONS {
for part in text.split('"').skip(1).step_by(2) {
if part.starts_with('#') {
assert!(PALETTE.contains(&part), "{id}: colour {part} is not in the palette");
}
}
let image = rasterise(text, 24).unwrap();
assert!(image.pixels.iter().any(|p| p.a() > 0), "{id} draws nothing");
}
}
/// The SVG colours are the dark theme's own, so the dark theme draws icons as authored.
#[test]
fn the_palette_is_the_dark_themes_colours() {
let hex: Vec<String> = colours(&Tokens::dark(), true).iter().map(|c| format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())).collect();
assert_eq!(hex, PALETTE);
}
#[test]
fn recolouring_follows_the_theme_and_greys_planned_entries() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0 L24 24" stroke="#4f9ce8" fill="#2d4e70"/></svg>"##;
let light = Tokens::light();
let out = recolour(svg, colours(&light, true));
assert!(out.contains("stroke=\"#1f6fc4\"") && out.contains("fill=\"#c6dcf4\""), "{out}");
let grey = recolour(svg, colours(&light, false));
let dim = light.text_dim;
assert!(grey.contains(&format!("stroke=\"#{:02x}{:02x}{:02x}\"", dim.r(), dim.g(), dim.b())), "{grey}");
assert!(rasterise("not svg", 24).is_err());
assert_eq!(rasterise(svg, 100_000).unwrap().size, [MAX_PX as usize; 2]);
}
}
+56 -5
View File
@@ -13,14 +13,17 @@
pub mod control;
pub mod gpu;
pub mod icons;
pub mod panels;
pub mod scene;
pub mod theme;
pub mod toolbar;
pub mod viewport;
use std::sync::mpsc::{Receiver, Sender};
use modelcraft_engine::Session;
use modelcraft_engine::catalogue::{self, Tab};
use modelcraft_render::{Camera, DisplayMesh, Scene, StandardView};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -41,11 +44,21 @@ pub struct UiState {
pub dark: bool,
pub show_tree: bool,
pub show_commands: bool,
/// The toolbar tab shown.
pub tab: Tab,
}
impl Default for UiState {
fn default() -> Self {
UiState { style: Style::ShadedEdges, show_nodes: false, perspective: false, dark: true, show_tree: true, show_commands: true }
UiState {
style: Style::ShadedEdges,
show_nodes: false,
perspective: false,
dark: true,
show_tree: true,
show_commands: true,
tab: Tab::default(),
}
}
}
@@ -102,6 +115,9 @@ pub struct ModelApp {
pub status: Option<Status>,
pub dialog: Option<ParamsDialog>,
pub viewport: Viewport,
pub icons: icons::IconCache,
/// The toolbar's buttons drawn last frame, by catalogue id (for the control channel).
pub toolbar_rects: Vec<(&'static str, egui::Rect, toolbar::Fit)>,
/// The GPU adapter, for the status bar and diagnostics.
pub gpu_info: Option<String>,
/// Bumped whenever the model may have changed.
@@ -131,6 +147,8 @@ impl ModelApp {
status: None,
dialog: None,
viewport: Viewport::default(),
icons: icons::IconCache::default(),
toolbar_rects: Vec::new(),
gpu_info: None,
revision: 0,
display: (None, DisplayMesh::default()),
@@ -200,15 +218,29 @@ impl ModelApp {
r
}
/// What clicking a command in the Commands panel does: a command without parameters runs,
/// one with parameters opens its form. Programmatic calls use [`ModelApp::run`] instead.
/// What clicking a toolbar entry or a command in the Commands panel does: a command without
/// parameters runs, one with parameters opens its form. `id` is a command or a catalogue
/// entry; an entry that is coming soon does nothing and says when it comes. Programmatic calls
/// use [`ModelApp::run`] instead.
pub fn start(&mut self, id: &str) -> Result<(), String> {
let spec = modelcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?;
let (command, preset) = match catalogue::find(id) {
Some(c) => match c.command() {
Some(command) => (command, c.preset()),
None => {
let e = format!("{} is coming soon ({})", c.label, c.milestone().unwrap_or("later"));
self.set_status(e.clone(), false);
return Err(e);
}
},
None => (id, json!({})),
};
let spec = modelcraft_engine::find_command(command).ok_or_else(|| format!("unknown command `{command}`"))?;
if spec.params.trim() == "{}" {
self.dialog = None;
self.run(spec.id, json!({})).map(|_| ())
} else {
self.dialog = Some(ParamsDialog { command: spec.id, text: "{}".into(), error: None });
// The form starts with the entry's preset; the rest (a path…) is filled in there.
self.dialog = Some(ParamsDialog { command: spec.id, text: preset.to_string(), error: None });
Ok(())
}
}
@@ -360,6 +392,7 @@ impl ModelApp {
pub fn ui(&mut self, ui: &mut egui::Ui) {
self.show_gpu_errors();
panels::menu_bar(self, ui);
toolbar::toolbar(self, ui);
panels::status_bar(self, ui);
if self.ui.show_tree {
panels::model_tree(self, ui);
@@ -480,6 +513,24 @@ mod tests {
assert!(app.start("no.such").is_err());
}
#[test]
fn toolbar_entries_start_their_command_and_planned_ones_do_nothing() {
let mut app = ModelApp::new(Session::default());
app.start("model.material.create").unwrap();
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.material.create"));
app.dialog = None;
let journal = app.session.journal.len();
let err = app.start("mesh.tet.generate").unwrap_err();
assert_eq!(err, "Tet Mesh is coming soon (M2)");
assert!(app.dialog.is_none());
assert_eq!(app.session.journal.len(), journal);
assert_eq!(app.status.as_ref().map(|s| s.error), Some(false));
// A deck entry opens deck.write's form with its format filled in.
app.start("deck.write.abaqus").unwrap();
let d = app.dialog.as_ref().unwrap();
assert_eq!((d.command, d.text.as_str()), ("deck.write", r#"{"format":"abaqus"}"#));
}
#[test]
fn the_sample_fills_the_view_and_renders() {
let mut app = ModelApp::new(Session::default());
+4
View File
@@ -22,6 +22,8 @@ pub struct Tokens {
pub hover: Color32,
pub error: Color32,
pub ok: Color32,
/// Loads and heat in icons.
pub warm: Color32,
// ---- viewport ----
/// Background gradient, top and bottom.
pub viewport_top: Color32,
@@ -62,6 +64,7 @@ impl Tokens {
hover: Color32::from_rgb(0x3c, 0x43, 0x4f),
error: Color32::from_rgb(0xf0, 0x6c, 0x64),
ok: Color32::from_rgb(0x8c, 0xc8, 0x8c),
warm: Color32::from_rgb(0xf0, 0xb4, 0x4a),
viewport_top: Color32::from_rgb(0x3a, 0x41, 0x4d),
viewport_bottom: Color32::from_rgb(0x1e, 0x22, 0x28),
overlay: Color32::from_rgba_unmultiplied_const(0x1c, 0x20, 0x26, 0xd0),
@@ -93,6 +96,7 @@ impl Tokens {
hover: Color32::from_rgb(0xd8, 0xde, 0xe6),
error: Color32::from_rgb(0xc0, 0x30, 0x28),
ok: Color32::from_rgb(0x2c, 0x7a, 0x3a),
warm: Color32::from_rgb(0xc0, 0x7a, 0x14),
viewport_top: Color32::from_rgb(0xf0, 0xf3, 0xf7),
viewport_bottom: Color32::from_rgb(0xc8, 0xd0, 0xdc),
overlay: Color32::from_rgba_unmultiplied_const(0xea, 0xee, 0xf3, 0xe0),
+283
View File
@@ -0,0 +1,283 @@
//! The toolbar: one tab per stage of the analyst's workflow, each a row of panels of icon buttons,
//! all from the capability catalogue (`modelcraft_engine::catalogue`).
//!
//! When the window is too narrow for labelled buttons, every panel goes icon-only (the label is
//! in the tooltip), then panels collapse from the right into one drop-down button each.
//!
//! A ready entry starts its command (the same as the Commands panel: a command without
//! parameters runs, one with parameters opens its form); its related commands (edit, delete…)
//! are in the drop-down beside it. A planned entry is drawn greyed with its milestone and does
//! nothing; its tooltip says when it comes. Structure follows SolidCraft's
//! `crates/ui-egui/src/toolbar.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
use std::sync::Arc;
use egui::{Align2, FontId, Galley, Rect, RichText, Sense, Stroke, vec2};
use modelcraft_engine::catalogue::{self, Capability, Tab};
use serde::Serialize;
use crate::ModelApp;
use crate::theme::Tokens;
/// Icon side (points).
const ICON: f32 = 24.0;
/// Button height with its label, and the width the label wraps at (points).
const BUTTON_H: f32 = 60.0;
const LABEL_W: f32 = 64.0;
/// A button without its label (compact panels).
const COMPACT: f32 = ICON + 12.0;
/// The drop-down strip beside a button with related commands.
const ARROW_W: f32 = 12.0;
/// A collapsed panel: one button that opens the list of its entries.
const COLLAPSED_W: f32 = COMPACT + ARROW_W;
/// Room a panel separator takes.
const PANEL_GAP: f32 = 13.0;
/// Room between buttons of a panel.
const BUTTON_GAP: f32 = 2.0;
/// How a panel is drawn on a toolbar that may be too narrow for every labelled button.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Fit {
/// Icon and label.
Full,
/// Icon only; the label is in the tooltip.
Compact,
/// One button that lists the panel's entries.
Collapsed,
}
/// Fit panels into `room`: every panel in full when they all fit; otherwise every panel compact,
/// then panels wider than a collapsed one collapse from the right until the row fits. Nothing is
/// dropped.
pub fn fit(full: &[f32], compact: &[f32], room: f32) -> Vec<Fit> {
let total = |widths: &mut dyn Iterator<Item = f32>| -> f32 { widths.map(|w| w + PANEL_GAP).sum() };
if total(&mut full.iter().copied()) <= room {
return vec![Fit::Full; full.len()];
}
let mut fits = vec![Fit::Compact; compact.len()];
for i in (0..fits.len()).rev() {
let width = total(&mut compact.iter().zip(&fits).map(|(w, f)| if *f == Fit::Collapsed { COLLAPSED_W } else { *w }));
if width <= room {
break;
}
if let (Some(f), Some(w)) = (fits.get_mut(i), compact.get(i))
&& *w > COLLAPSED_W
{
*f = Fit::Collapsed;
}
}
fits
}
/// An entry's label laid out under its icon (at most two lines).
fn label(ui: &egui::Ui, t: &Tokens, c: &Capability) -> Arc<Galley> {
let colour = if c.command().is_some() { t.text } else { t.text_dim };
let mut job = egui::text::LayoutJob::simple(c.label.to_string(), FontId::proportional(11.0), colour, LABEL_W);
job.wrap.max_rows = 2;
job.halign = egui::Align::Center;
ui.painter().layout_job(job)
}
fn arrow_w(c: &Capability) -> f32 {
if c.command().is_some() && !c.more.is_empty() { ARROW_W } else { 0.0 }
}
fn panel_width(widths: impl Iterator<Item = f32>) -> f32 {
let (sum, n) = widths.fold((0.0, 0usize), |(s, n), w| (s + w, n + 1));
sum + BUTTON_GAP * n.saturating_sub(1) as f32
}
pub fn toolbar(app: &mut ModelApp, ui: &mut egui::Ui) {
let t = Tokens::get(app.ui.dark);
let mut action = None;
egui::Panel::top("mc_toolbar").frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 4))).show(ui, |ui| {
ui.horizontal(|ui| {
for tab in Tab::ALL {
let text = RichText::new(tab.label()).color(if app.ui.tab == tab { t.accent } else { t.text });
if ui.selectable_label(app.ui.tab == tab, text).clicked() {
app.ui.tab = tab;
}
}
});
ui.add_space(2.0);
app.toolbar_rects.clear();
let panels: Vec<&'static [Capability]> = catalogue::panels(app.ui.tab).collect();
let labels: Vec<Vec<Arc<Galley>>> = panels.iter().map(|p| p.iter().map(|c| label(ui, &t, c)).collect()).collect();
let full: Vec<f32> = panels
.iter()
.zip(&labels)
.map(|(p, l)| panel_width(p.iter().zip(l).map(|(c, g)| g.size().x.max(ICON + 12.0) + 8.0 + arrow_w(c))))
.collect();
let compact: Vec<f32> = panels.iter().map(|p| panel_width(p.iter().map(|c| COMPACT + arrow_w(c)))).collect();
let fits = fit(&full, &compact, ui.available_width());
ui.horizontal(|ui| {
for (i, ((entries, galleys), fit)) in panels.iter().zip(labels).zip(&fits).enumerate() {
let Some(panel) = entries.first().map(|c| c.panel) else { continue };
if i > 0 {
ui.separator();
}
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = BUTTON_GAP;
let clicked = match fit {
Fit::Collapsed => collapsed(app, ui, &t, panel, entries),
Fit::Full | Fit::Compact => {
let mut clicked = None;
for (c, galley) in entries.iter().zip(galleys) {
let galley = (*fit == Fit::Full).then_some(galley);
clicked = button(app, ui, &t, c, galley).or(clicked);
}
clicked
}
};
action = clicked.or(action);
});
let width = ui.min_rect().width();
ui.allocate_ui(vec2(width, 14.0), |ui| {
ui.centered_and_justified(|ui| ui.add(egui::Label::new(RichText::new(panel).small().color(t.text_dim)).truncate()));
});
});
}
});
});
if let Some(id) = action {
// Errors already show in the status bar.
let _ = app.start(id);
}
}
fn icon(app: &mut ModelApp, ui: &egui::Ui, t: &Tokens, c: &'static Capability, rect: Rect) {
let px = (ICON * ui.ctx().pixels_per_point()).round() as u32;
let p = ui.painter();
match app.icons.get(ui.ctx(), c.id, app.ui.dark, c.command().is_some(), px) {
Some(tex) => p.image(tex.id(), rect, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE),
None => p.rect_stroke(rect.shrink(3.0), 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside),
};
}
/// One entry's button, with its label when `label` is given; returns the id to start when it
/// (or its drop-down) was clicked.
fn button(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, c: &'static Capability, label: Option<Arc<Galley>>) -> Option<&'static str> {
let enabled = c.command().is_some();
let size = match &label {
Some(g) => vec2(g.size().x.max(ICON + 12.0) + 8.0, BUTTON_H),
None => vec2(COMPACT, COMPACT),
};
let (rect, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() });
app.toolbar_rects.push((c.id, rect, if label.is_some() { Fit::Full } else { Fit::Compact }));
if resp.hovered() && enabled {
ui.painter().rect_filled(rect, 4.0, t.hover);
}
let icon_rect = Rect::from_center_size(egui::pos2(rect.center().x, rect.top() + 6.0 + ICON / 2.0), vec2(ICON, ICON));
icon(app, ui, t, c, icon_rect);
let p = ui.painter();
if let Some(g) = label {
p.galley(egui::pos2(rect.center().x, icon_rect.bottom() + 4.0), g, t.text);
}
if let Some(m) = c.milestone() {
let text = p.layout_no_wrap(m.to_string(), FontId::proportional(9.0), t.text);
let badge = Rect::from_min_size(egui::pos2(rect.right() - text.size().x - 4.0, rect.top()), text.size() + vec2(4.0, 0.0));
p.rect_filled(badge, 3.0, t.panel_header);
p.rect_stroke(badge, 3.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
p.galley(badge.min + vec2(2.0, 0.0), text, t.text);
}
let resp = resp.on_hover_ui(|ui| {
ui.label(c.tooltip());
});
let mut action = resp.clicked().then_some(c.id);
if enabled && !c.more.is_empty() {
let (arrow, arrow_resp) = ui.allocate_exact_size(vec2(ARROW_W, size.y), Sense::click());
if arrow_resp.hovered() {
ui.painter().rect_filled(arrow, 4.0, t.hover);
}
ui.painter().text(arrow.center(), Align2::CENTER_CENTER, "▾", FontId::proportional(11.0), t.text_dim);
let arrow_resp = arrow_resp.on_hover_text(format!("More {} commands", c.label));
egui::Popup::menu(&arrow_resp).show(|ui| {
for id in c.more {
if ui.button(crate::command_label(id)).clicked() {
action = Some(*id);
}
}
});
}
action
}
/// A collapsed panel: its first entry's icon and a drop-down listing every entry (and their
/// related commands), coming-soon ones greyed with their milestone.
fn collapsed(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, panel: &str, entries: &'static [Capability]) -> Option<&'static str> {
let first = entries.first()?;
let (rect, resp) = ui.allocate_exact_size(vec2(COLLAPSED_W, COMPACT), Sense::click());
for c in entries {
app.toolbar_rects.push((c.id, rect, Fit::Collapsed));
}
if resp.hovered() {
ui.painter().rect_filled(rect, 4.0, t.hover);
}
icon(app, ui, t, first, Rect::from_min_size(rect.min + vec2(6.0, 6.0), vec2(ICON, ICON)));
ui.painter().text(egui::pos2(rect.right() - ARROW_W / 2.0, rect.center().y), Align2::CENTER_CENTER, "▾", FontId::proportional(11.0), t.text_dim);
let resp = resp.on_hover_text(panel);
let mut action = None;
egui::Popup::menu(&resp).show(|ui| {
for c in entries {
match c.milestone() {
Some(m) => {
ui.add_enabled(false, egui::Button::new(format!("{} — coming soon ({m})", c.label)));
}
None => {
if ui.button(c.label).on_hover_text(c.tip).clicked() {
action = Some(c.id);
}
for id in c.more {
if ui.button(format!(" {}", crate::command_label(id))).clicked() {
action = Some(*id);
}
}
}
}
}
});
action
}
/// The toolbar as the control channel reports it: the tabs, the current tab, and every entry
/// with its status and, on the current tab, its button (screen points, last frame).
pub fn describe(app: &ModelApp) -> serde_json::Value {
let entries: Vec<serde_json::Value> = catalogue::CATALOGUE
.iter()
.map(|c| {
let shown = app.toolbar_rects.iter().find(|(id, ..)| *id == c.id);
let rect = shown.map(|(_, r, _)| [r.left(), r.top(), r.width(), r.height()]);
serde_json::json!({
"id": c.id,
"label": c.label,
"tab": c.tab,
"panel": c.panel,
"command": c.command(),
"more": c.more,
"coming_soon": c.milestone(),
"rect": rect,
"fit": shown.map(|(.., f)| f),
})
})
.collect();
serde_json::json!({"tab": app.ui.tab, "tabs": Tab::ALL, "entries": entries})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn panels_go_compact_then_collapse_from_the_right() {
let full = [200.0, 300.0, 100.0];
let compact = [80.0, 120.0, 40.0];
assert_eq!(fit(&full, &compact, 700.0), vec![Fit::Full; 3]);
assert_eq!(fit(&full, &compact, 400.0), vec![Fit::Compact; 3]);
// The last panel is narrower compact than collapsed, so the middle one goes first.
assert_eq!(fit(&full, &compact, 230.0), vec![Fit::Compact, Fit::Collapsed, Fit::Compact]);
assert_eq!(fit(&full, &compact, 10.0), vec![Fit::Collapsed, Fit::Collapsed, Fit::Compact], "nothing is dropped");
assert!(fit(&[], &[], 0.0).is_empty());
}
}