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

+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());
}
}