forked from 1b-agent/fieldcraft-upstream
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:
320 files changed
+2990
-88
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user