//! The ribbon: the workspace switcher, the workspace's tabs, and the current tab's panels, all //! from the capability catalogue (`fieldcraft_engine::catalogue`). //! //! Each panel shows its ready entries as icon buttons (up to [`MAX_BUTTONS`]; a panel with none //! ready shows its first entry, greyed with its milestone) over its label with a caret. The label //! opens the panel's drop-down, which lists every entry of the panel: ready ones with their //! related commands (edit, delete…) under them, coming-soon ones greyed with their milestone. //! When the window is too narrow, panels collapse from the right into one button that opens the //! same drop-down, then into the » menu at the end. Nothing is dropped. //! //! A ready entry starts its command ([`ModelApp::start`]); a coming-soon entry does nothing and //! its tooltip says when it comes. Structure follows SolidCraft's `crates/ui-egui/src/toolbar.rs` //! (MIT OR Apache-2.0), see ATTRIBUTION.md; the tabs are craft-ui `Tab`s. use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, Vec2, pos2, vec2}; use fieldcraft_engine::catalogue::{self, Capability, Tab, Workspace}; use fieldcraft_engine::requirement::Unavailable; use serde::Serialize; use crate::ModelApp; use crate::icons::{self, Look}; use crate::theme::{Tokens, label_font}; /// The ribbon's height. pub const HEIGHT: f32 = 92.0; /// Most buttons a panel shows; the rest are in its drop-down. pub const MAX_BUTTONS: usize = 4; /// A button's side and the room it takes. const BUTTON: f32 = 38.0; const STRIDE: f32 = 40.0; /// Where the tabs and the panels start, right of the workspace switcher. const PANELS_X: f32 = 112.0; /// A collapsed panel's width, and the gap after each panel (its separator). pub const COLLAPSED_WIDTH: f32 = 72.0; const PANEL_GAP: f32 = 2.0; /// The » menu's width. const MORE_WIDTH: f32 = 40.0; /// A drop-down row's height. const ROW_H: f32 = 24.0; /// How an entry is shown on a toolbar that may be too narrow for every panel. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum Fit { /// A button of its panel. Full, /// Its panel is one button that opens the drop-down. Collapsed, /// Its panel is in the » menu. More, /// In its panel's drop-down (a row while it is open). Menu, } /// A panel's width with `buttons` buttons. pub fn panel_width(buttons: usize) -> f32 { (buttons.max(1) as f32 * STRIDE).max(64.0) + 8.0 } /// Fit panels of these full widths into `room`: all in full when they fit; otherwise panels /// collapse into their drop-down from the right, then (all collapsed and still too wide) the last /// ones go into the » menu. Nothing is dropped. pub fn fit(widths: &[f32], room: f32) -> Vec { let mut fits = vec![Fit::Full; widths.len()]; let total = |fits: &[Fit]| -> f32 { let more = if fits.contains(&Fit::More) { MORE_WIDTH } else { 0.0 }; more + widths .iter() .zip(fits) .map(|(w, f)| match f { Fit::Full | Fit::Menu => w + PANEL_GAP, Fit::Collapsed => COLLAPSED_WIDTH.min(*w) + PANEL_GAP, Fit::More => 0.0, }) .sum::() }; for stage in [Fit::Collapsed, Fit::More] { for i in (0..widths.len()).rev() { if total(&fits) <= room { return fits; } if let Some(f) = fits.get_mut(i) { *f = stage; } } } fits } /// The buttons a panel shows: its ready entries (at most [`MAX_BUTTONS`]), or its first entry /// when none is ready yet. pub fn buttons(entries: &'static [Capability]) -> Vec<&'static Capability> { let ready: Vec<_> = entries.iter().filter(|c| c.command().is_some()).take(MAX_BUTTONS).collect(); if ready.is_empty() { entries.first().into_iter().collect() } else { ready } } /// The key of a panel's drop-down in [`crate::UiState::menu`]: `/`. pub fn panel_key(tab: Tab, panel: &str) -> String { format!("{}/{panel}", tab.id()) } /// A small drop-down caret. pub fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) { p.add(egui::Shape::convex_polygon(vec![pos2(c.x - 3.5, c.y - 2.0), pos2(c.x + 3.5, c.y - 2.0), pos2(c.x, c.y + 2.5)], col, Stroke::NONE)); } /// A milestone badge ("M5") with its top right corner at `corner`. fn badge(p: &egui::Painter, t: &Tokens, corner: egui::Pos2, m: &str) { let text = p.layout_no_wrap(m.to_string(), FontId::proportional(9.0), t.text_dim); let r = Rect::from_min_size(pos2(corner.x - text.size().x - 4.0, corner.y), text.size() + vec2(4.0, 0.0)); p.rect_filled(r, 3.0, t.panel_header); p.rect_stroke(r, 3.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); p.galley(r.min + vec2(2.0, 0.0), text, t.text_dim); } pub fn toolbar(app: &mut ModelApp, ui: &mut egui::Ui) { let t = Tokens::get(app.ui.dark); let mut start: Option<&'static str> = None; egui::Panel::top("fc_toolbar").exact_size(HEIGHT).show_separator_line(false).frame(egui::Frame::NONE.fill(t.toolbar)).show(ui, |ui| { let r = ui.max_rect(); app.place("ribbon", r); app.toolbar_rects.clear(); app.panel_rects.clear(); let painter = ui.painter().clone(); workspace_switcher(app, ui, &t, r); // Tabs. let mut x = r.left() + PANELS_X; for &tab in app.ui.workspace.tabs() { let label = tab.label().to_uppercase(); let w = 12.0 + painter.layout_no_wrap(label.clone(), label_font(ui.ctx(), 12.0), t.text).size().x.max(28.0); let tr = Rect::from_min_size(pos2(x, r.top() + 2.0), vec2(w, 22.0)); let active = app.ui.tab == tab; let resp = craft_ui::tabs::Tab::new(egui::Id::new(("fc_tab", panel_key(tab, ""))), &label, active) .focus_stroke(Stroke::new(1.0, t.accent)) .show_at(ui, tr, |ui, resp| { let p = ui.painter(); if active { p.line_segment( [pos2(tr.left() + 4.0, tr.bottom() - 1.0), pos2(tr.right() - 4.0, tr.bottom() - 1.0)], Stroke::new(2.0, t.accent), ); } else if resp.hovered() { p.rect_filled(tr, 4.0, t.hover); } p.text(tr.center(), Align2::CENTER_CENTER, &label, label_font(ui.ctx(), 12.0), if active { t.text } else { t.text_dim }); }); if resp.clicked() { app.ui.tab = tab; app.ui.menu = None; } x += w + 4.0; } // Panels. let tab = app.ui.tab; let top = r.top() + 26.0; let panels: Vec<&'static [Capability]> = catalogue::panels(tab).collect(); let shown: Vec> = panels.iter().map(|p| buttons(p)).collect(); // A panel is as wide as its buttons, and wide enough for its label's longest word (plus // the caret beside it). let label_word = |entries: &[Capability]| { let label = entries.first().map_or(String::new(), |c| c.panel.to_uppercase()); label.split_whitespace().map(|w| painter.layout_no_wrap(w.to_string(), label_font(ui.ctx(), 10.5), t.text).size().x).fold(0.0, f32::max) }; let widths: Vec = shown.iter().zip(&panels).map(|(b, p)| panel_width(b.len()).max(label_word(p) + 26.0)).collect(); let fits = fit(&widths, r.right() - 8.0 - (r.left() + PANELS_X)); let mut px = r.left() + PANELS_X; let mut overflow: Vec<&'static [Capability]> = Vec::new(); for ((entries, shown), (fit, full)) in panels.iter().zip(&shown).zip(fits.iter().zip(&widths)) { let Some(panel) = entries.first().map(|c| c.panel) else { continue }; if *fit == Fit::More { for c in entries.iter() { app.toolbar_rects.push((c.id, Rect::NOTHING, Fit::More)); } overflow.push(entries); continue; } let key = panel_key(tab, panel); let collapsed = *fit == Fit::Collapsed; let width = if collapsed { COLLAPSED_WIDTH.min(*full) } else { *full }; if collapsed { // One button: the panel's first button's icon over a caret; it opens the drop-down. let br = Rect::from_center_size(pos2(px + width / 2.0, top + 21.0), Vec2::splat(BUTTON)); let resp = ui.interact(br, ui.id().with(("fc_collapsed", &key)), Sense::click()); if resp.hovered() { painter.rect_filled(br, 4.0, t.hover); } if let Some(c) = shown.first() { icons::paint( app, ui, c.id, Rect::from_center_size(br.center() - vec2(0.0, 3.0), Vec2::splat(26.0)), Look::of(c.command().is_some() && app.unavailable(c.id).is_none()), ); } caret(&painter, pos2(br.center().x, br.bottom() - 3.0), t.text_dim); for c in entries.iter() { app.toolbar_rects.push((c.id, br, Fit::Collapsed)); } if resp.on_hover_text(format!("{panel} ({})", entries.len())).clicked() { app.toggle_menu(&key); } } else { let inset = ((width - 8.0 - (shown.len() as f32 * STRIDE - 2.0)) / 2.0).max(0.0).floor(); for (i, c) in shown.iter().enumerate() { let br = Rect::from_min_size(pos2(px + 4.0 + inset + i as f32 * STRIDE, top + 2.0), Vec2::splat(BUTTON)); if let Some(id) = button(app, ui, &t, c, br) { start = Some(id); } } for c in entries.iter().filter(|c| !shown.iter().any(|s| s.id == c.id)) { app.toolbar_rects.push((c.id, Rect::NOTHING, Fit::Menu)); } } // The panel's label with its caret opens the drop-down. // A label too wide wraps between words onto a second line; a single word wider than // the panel stays whole. let label = panel.to_uppercase(); let widest_word = label .split_whitespace() .map(|w| painter.layout_no_wrap(w.to_string(), label_font(ui.ctx(), 10.5), t.text).size().x) .fold(0.0, f32::max); let mut job = egui::text::LayoutJob::simple(label, label_font(ui.ctx(), 10.5), t.text, (width - 14.0).max(widest_word + 1.0)); job.wrap.overflow_character = None; job.wrap.max_rows = 2; job.halign = egui::Align::Center; let g = painter.layout_job(job); let gw = g.size().x; // One line centred under the buttons; two lines start higher to stay inside the ribbon. let lh = (g.size().y + 2.0).max(18.0); let lr = if lh > 20.0 { Rect::from_min_size(pos2(px, top + 40.0), vec2(width, lh)) } else { Rect::from_center_size(pos2(px + width / 2.0, top + 51.0), vec2(width, lh)) }; let lresp = ui.interact(lr, ui.id().with(("fc_panel", &key)), Sense::click()); let open = app.ui.menu.as_deref() == Some(key.as_str()); if lresp.hovered() || open { painter.rect_filled(lr, 3.0, t.hover); } let gx = lr.center().x - 4.5; painter.galley(pos2(gx, lr.center().y - g.size().y / 2.0), g, t.text); caret(&painter, pos2(gx + gw / 2.0 + 6.0, lr.center().y), t.text); app.panel_rects.push((key.clone(), lr)); if lresp.on_hover_text(format!("All {panel} commands")).clicked() { app.toggle_menu(&key); } if let Some(rect) = crate::frame::menu(app, ui, &key, lr, |app, ui| dropdown(app, ui, entries)) { app.place("toolbar_dropdown", rect); } px += width; painter.line_segment([pos2(px, top + 4.0), pos2(px, top + 58.0)], Stroke::new(1.0, t.border)); px += PANEL_GAP; } if !overflow.is_empty() { let mr = Rect::from_min_size(pos2(px.min(r.right() - MORE_WIDTH), top + 4.0), vec2(MORE_WIDTH - 2.0, 52.0)); let resp = ui.interact(mr, ui.id().with("fc_more"), Sense::click()); if resp.hovered() || app.ui.menu.as_deref() == Some("more") { painter.rect_filled(mr, 4.0, t.hover); } painter.text(mr.center(), Align2::CENTER_CENTER, "»", FontId::proportional(18.0), t.text); if resp.on_hover_text("More toolbar panels").clicked() { app.toggle_menu("more"); } crate::frame::menu(app, ui, "more", mr, |app, ui| { for entries in &overflow { let panel = entries.first().map_or("", |c| c.panel); ui.menu_button(panel, |ui| dropdown(app, ui, entries)); } }); } painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border)); }); if let Some(id) = start { app.ui.menu = None; // Errors already show in the status bar. let _ = app.start(id); } } /// The workspace switcher: a box spanning the ribbon's height with the workspace's icon and name; /// it opens the list of workspaces. fn workspace_switcher(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, r: Rect) { let ws = Rect::from_min_max(pos2(r.left() + 6.0, r.top() + 4.0), pos2(r.left() + 104.0, r.bottom() - 5.0)); let resp = ui.interact(ws, ui.id().with("fc_workspace"), Sense::click()); let open = app.ui.menu.as_deref() == Some("workspace"); ui.painter().rect(ws, 4.0, if resp.hovered() || open { t.hover } else { t.field }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let w = app.ui.workspace; icons::paint(app, ui, w.icon(), Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), Vec2::splat(26.0)), Look::Normal); let p = ui.painter(); let label = w.label().to_uppercase(); let font = label_font(ui.ctx(), 12.0); let label_w = p.layout_no_wrap(label.clone(), font.clone(), t.text).size().x; p.text(pos2(ws.center().x - 5.0, ws.top() + 52.0), Align2::CENTER_CENTER, label, font, t.text); caret(p, pos2(ws.center().x - 5.0 + label_w / 2.0 + 7.0, ws.top() + 52.0), t.text); p.text(pos2(ws.center().x, ws.top() + 69.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim); app.place("workspace_switcher", ws); if resp.on_hover_text("Change workspace").clicked() { app.toggle_menu("workspace"); } crate::frame::menu(app, ui, "workspace", ws, |app, ui| { let t = Tokens::get(app.ui.dark); for w in Workspace::ALL { let (rect, resp) = ui.allocate_exact_size(vec2(360.0, 40.0), Sense::click()); if resp.hovered() || w == app.ui.workspace { ui.painter().rect_filled(rect, 3.0, if w == app.ui.workspace { t.accent_soft } else { t.hover }); } icons::paint(app, ui, w.icon(), Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), Vec2::splat(24.0)), Look::Normal); let p = ui.painter(); p.text(pos2(rect.left() + 40.0, rect.top() + 12.0), Align2::LEFT_CENTER, w.label().to_uppercase(), label_font(ui.ctx(), 12.5), t.text); p.text(pos2(rect.left() + 40.0, rect.top() + 28.0), Align2::LEFT_CENTER, w.about(), FontId::proportional(11.0), t.text_dim); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, w.label())); if resp.clicked() { app.ui.select_workspace(w); app.ui.menu = None; } } }); } /// One entry's button; returns the id to start when it was clicked. fn button(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, c: &'static Capability, br: Rect) -> Option<&'static str> { let missing = app.unavailable(c.id); let enabled = c.command().is_some() && missing.is_none(); let resp = ui.interact(br, ui.id().with(("fc_button", c.id)), if enabled { Sense::click() } else { Sense::hover() }); app.toolbar_rects.push((c.id, br, Fit::Full)); let running = app.dialog.as_ref().is_some_and(|d| c.command() == Some(d.command)); if running { ui.painter().rect_filled(br, 4.0, t.border.gamma_multiply(0.7)); } else if resp.hovered() && enabled { ui.painter().rect_filled(br, 4.0, t.hover); } icons::paint(app, ui, c.id, br.shrink(6.0), Look::of(enabled)); if let Some(m) = c.milestone() { badge(ui.painter(), t, br.right_top() + vec2(4.0, -2.0), m); } resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, c.label)); let resp = resp.on_hover_text(Unavailable::tip(&c.tooltip(), missing.as_ref())); resp.clicked().then_some(c.id) } /// The rows of a panel's drop-down: every entry with its related commands under it, coming-soon /// entries greyed with their milestone. A click starts the row's command. pub fn dropdown(app: &mut ModelApp, ui: &mut egui::Ui, entries: &'static [Capability]) { let t = Tokens::get(app.ui.dark); let font = FontId::proportional(13.0); let width = entries .iter() .flat_map(|c| std::iter::once(c.label).chain(c.more.iter().map(|id| crate::command_label(id)))) .map(|l| ui.painter().layout_no_wrap(l.to_string(), font.clone(), t.text).size().x) .fold(0.0_f32, f32::max) .max(140.0) + 96.0; let mut start = None; for c in entries { let missing = app.unavailable(c.id); let enabled = c.command().is_some() && missing.is_none(); let (rect, resp) = ui.allocate_exact_size(vec2(width, ROW_H), if enabled { Sense::click() } else { Sense::hover() }); app.toolbar_rects.push((c.id, rect, Fit::Menu)); if resp.hovered() && enabled { ui.painter().rect_filled(rect, 3.0, t.hover); } icons::paint(app, ui, c.id, Rect::from_center_size(pos2(rect.left() + 15.0, rect.center().y), Vec2::splat(18.0)), Look::of(enabled)); let p = ui.painter(); p.text(pos2(rect.left() + 32.0, rect.center().y), Align2::LEFT_CENTER, c.label, font.clone(), if enabled { t.text } else { t.text_dim }); if let Some(m) = c.milestone() { p.text(pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, format!("soon · {m}"), FontId::proportional(11.0), t.text_dim); } resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, c.label)); if resp.on_hover_text(Unavailable::tip(&c.tooltip(), missing.as_ref())).clicked() && enabled { start = Some(c.id); } for id in c.more { let missing = app.unavailable(id); let enabled = missing.is_none(); let (rect, resp) = ui.allocate_exact_size(vec2(width, ROW_H - 2.0), if enabled { Sense::click() } else { Sense::hover() }); if resp.hovered() && enabled { ui.painter().rect_filled(rect, 3.0, t.hover); } let label = crate::command_label(id); let p = ui.painter(); p.text( pos2(rect.left() + 32.0, rect.center().y), Align2::LEFT_CENTER, label, FontId::proportional(12.5), if enabled { t.text } else { t.text_dim }, ); p.line_segment([pos2(rect.left() + 15.0, rect.top()), pos2(rect.left() + 15.0, rect.center().y)], Stroke::new(1.0, t.border)); p.line_segment([pos2(rect.left() + 15.0, rect.center().y), pos2(rect.left() + 25.0, rect.center().y)], Stroke::new(1.0, t.border)); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); let tip = Unavailable::tip(id, missing.as_ref()); if resp.on_hover_text(tip).clicked() && enabled { start = Some(id); } } } if let Some(id) = start { app.ui.menu = None; ui.close(); let _ = app.start(id); } } /// The toolbar as the control channel reports it: the workspaces and tabs, the current ones, the /// panels' labels (screen points, last frame), the open menu, and every entry with its status /// (`enabled`, and `unavailable`: what its command is missing, `Unavailable::to_json`) and where /// it is: a button (`full`), a collapsed panel, the » menu, or its panel's drop-down /// (`menu`, with the row's rect while the drop-down is open). pub fn describe(app: &ModelApp) -> serde_json::Value { let rect = |r: &Rect| { if r.is_finite() && r.is_positive() { serde_json::json!([r.left(), r.top(), r.width(), r.height()]) } else { serde_json::Value::Null } }; let entries: Vec = catalogue::CATALOGUE .iter() .map(|c| { let shown = app.toolbar_rects.iter().rev().find(|(id, ..)| *id == c.id); let missing = app.unavailable(c.id); serde_json::json!({ "id": c.id, "label": c.label, "tab": c.tab, "panel": c.panel, "command": c.command(), "more": c.more, "covers": c.covers, "coming_soon": c.milestone(), "enabled": c.command().is_some() && missing.is_none(), "unavailable": missing.map(|u| u.to_json()), "rect": shown.map(|(_, r, _)| rect(r)), "fit": shown.map(|(.., f)| f), }) }) .collect(); let panels: Vec = app.panel_rects.iter().map(|(k, r)| serde_json::json!({"menu": k, "rect": rect(r)})).collect(); serde_json::json!({ "workspace": app.ui.workspace, "workspaces": Workspace::ALL, "tab": app.ui.tab, "tabs": app.ui.workspace.tabs(), "menu": app.ui.menu, "panels": panels, "entries": entries, }) } #[cfg(test)] mod tests { use super::*; #[test] fn panels_collapse_from_the_right_then_go_to_the_more_menu() { let widths = [200.0, 300.0, 100.0]; assert_eq!(fit(&widths, 700.0), vec![Fit::Full; 3]); assert_eq!(fit(&widths, 500.0), vec![Fit::Full, Fit::Collapsed, Fit::Collapsed]); assert_eq!(fit(&widths, 230.0), vec![Fit::Collapsed; 3]); let tight = fit(&widths, 120.0); assert_eq!(tight.first(), Some(&Fit::Collapsed)); assert!(tight.contains(&Fit::More), "nothing is dropped: {tight:?}"); assert!(fit(&[], 0.0).is_empty()); } #[test] fn panels_show_their_ready_entries_or_their_first_one() { for tab in Tab::ALL { for p in catalogue::panels(tab) { let b = buttons(p); assert!(!b.is_empty() && b.len() <= MAX_BUTTONS, "{tab:?}"); if p.iter().any(|c| c.command().is_some()) { assert!(b.iter().all(|c| c.command().is_some())); } } } assert_eq!(panel_key(Tab::Loads, "Loads"), "loads/Loads"); } }