Files
solidcraft-upstream/crates/ui-egui/src/toolbar.rs
T
Karl Von Richter 37547f8eba Keep Open and Save asynchronous to prevent macOS resize crashes (#1127)
Open and Save use parented AsyncFileDialog sheets; their answers run on the UI thread in a later frame, so resizing the window while a picker is open no longer re-enters winit and aborts. Editors, tab changes, Close/Quit and model-changing control requests wait for the answer; the local library Open and Export in the Appearance dialog use the same flow. Fixes #1088.
2026-10-14 13:41:29 -04:00

1635 lines
82 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Application bar, the workspace toolbar (tabs and panels) and keyboard shortcuts.
use crate::icons::glyphs::caret;
use crate::icons::glyphs::pin_icon;
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
use crate::theme::Tokens;
use crate::{SolveApp, icons};
pub const TABS: &[&str] = &["SOLID", "SURFACE", "MESH", "SHEET METAL", "PLASTIC", "UTILITIES"];
/// The accent stroke colour for a panel's icons: constraint commands use the same red as the
/// constraint glyphs they draw in the sketch (`glyph_text`), sketch tools otherwise keep
/// `sketch_accent`, and every other tab keeps the plain `accent`.
fn panel_accent(t: Tokens, tab: &str, panel: &str) -> Color32 {
if panel == "CONSTRAINTS" {
t.glyph_text
} else if tab == "SKETCH" {
t.sketch_accent
} else {
t.accent
}
}
/// The accent stroke colour a command's icon keeps on every surface that draws it, where the
/// command's own meaning fixes it. Construction geometry carries one fixed meaning across the
/// whole app. `sketch.construction` and `sketch.centerline` therefore draw their icon in the
/// theme's construction orange on the ribbon, in a panel's drop-down and under the pointer while
/// being dragged. The Sketch Palette's own line type buttons pass that same orange. `icons::paint`
/// draws both of these icon names in the accent its caller hands it, never in a colour of its own.
/// The caller is the only place the rule can live. Every other command has no fixed colour. It
/// keeps the accent of whichever surface draws it.
fn fixed_accent(t: Tokens, id: &str) -> Option<Color32> {
match id {
"sketch.construction" | "sketch.centerline" => Some(t.construction_geom),
_ => None,
}
}
/// The accent stroke colour for one command's icon on the ribbon. It covers the command's own
/// button and a collapsed panel's single button. A command with a `fixed_accent` keeps that.
/// Every other command takes its `panel_accent`.
fn command_accent(t: Tokens, tab: &str, panel: &str, id: &str) -> Color32 {
fixed_accent(t, id).unwrap_or_else(|| panel_accent(t, tab, panel))
}
/// Panels shown on each tab (in order). Commands come from the registry by (tab, panel).
pub fn panels(tab: &str) -> &'static [&'static str] {
if let Some(p) = crate::workspace::panels(tab) {
return p;
}
match tab {
"SOLID" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"SKETCH" => &["CREATE", "MODIFY", "CONSTRAINTS", "INSPECT", "INSERT", "SELECT", "FINISH SKETCH"],
"SURFACE" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"MESH" => &["CREATE", "PREPARE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"SHEET METAL" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"PLASTIC" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
_ => &["MAKE", "ADD-INS", "UTILITY", "INSPECT", "SELECT"],
}
}
/// How many commands of a panel get a button on a fresh (or reset) toolbar. The user's own
/// layout (`toolbar_custom`) has no cap: every pinned command gets a button.
pub fn promoted(tab: &str, panel: &str) -> usize {
match (tab, panel) {
("SOLID", "CREATE") => 4,
("SOLID", "MODIFY") => 4,
("SKETCH", "CONSTRAINTS") => 6,
(_, "FINISH SKETCH") => 1,
_ => 2,
}
}
/// How a panel is drawn on a toolbar that may be too narrow for every button.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Fit {
/// Every button of the panel.
Full,
/// One button that opens the panel's drop-down (which lists every command, pinned or not).
Collapsed,
/// In the » menu at the end of the toolbar.
More,
}
/// A panel's width with `buttons` buttons.
pub fn panel_width(buttons: usize) -> f32 {
(buttons.max(1) as f32 * 40.0).max(64.0) + 8.0
}
/// A collapsed panel's width.
pub const COLLAPSED_WIDTH: f32 = 72.0;
/// The gap after each panel (its separator).
const PANEL_GAP: f32 = 2.0;
/// Fit panels of these full widths into `room`: all of them 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<Fit> {
let mut fits = vec![Fit::Full; widths.len()];
let total = |fits: &[Fit]| -> f32 {
widths
.iter()
.zip(fits)
.map(|(w, f)| match f {
Fit::Full => w + PANEL_GAP,
Fit::Collapsed => COLLAPSED_WIDTH.min(*w) + PANEL_GAP,
Fit::More => 0.0,
})
.sum()
};
for i in (0..widths.len()).rev() {
if total(&fits) <= room {
return fits;
}
if let Some(f) = fits.get_mut(i) {
*f = Fit::Collapsed;
}
}
for i in (0..widths.len()).rev() {
if total(&fits) <= room {
return fits;
}
if let Some(f) = fits.get_mut(i) {
*f = Fit::More;
}
}
fits
}
/// The commands a panel's drop-down (or its » submenu) lists: its own, then any button moved
/// into it from another panel.
fn menu_commands(
cmds: &[&'static solidcraft_engine::CommandSpec],
shown: &[&'static solidcraft_engine::CommandSpec],
) -> Vec<&'static solidcraft_engine::CommandSpec> {
let mut out = cmds.to_vec();
for c in shown {
if !out.iter().any(|o| o.id == c.id) {
out.push(*c);
}
}
out
}
fn report_rect(bar: Rect, captions: f32, tabs_start: f32) -> (Rect, bool) {
let right = bar.right() - captions - 90.0;
let labeled = right - tabs_start >= 340.0;
let width = if labeled { 124.0 } else { 28.0 };
(Rect::from_min_size(pos2(right - width, bar.top() + 4.0), vec2(width, 26.0)), labeled)
}
/// The application bar's height: the window's title bar on desktop builds.
pub const APP_BAR_HEIGHT: f32 = 34.0;
pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
egui::Panel::top("sc_appbar").exact_size(APP_BAR_HEIGHT).show_separator_line(false).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| {
let r = ui.max_rect();
// The bar is the window's title bar: its empty space moves the window.
if app.custom_titlebar || app.integrated_titlebar {
crate::titlebar::drag_area(ui, r);
}
let left = r.left() + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 };
// Keep the current header controls and product name when merging the new mark.
let compact_actions = r.right() - left < 640.0;
// Brand mark: the SolidCraft logo, drawn in code from the SVG's own coordinates
// (viewBox 357 x 412). Same 20 px height and centre as the old block.
{
let c = pos2(left + 20.0, r.center().y);
crate::icons::glyphs::brand_mark(ui.painter(), c);
}
if !compact_actions {
ui.painter().text(pos2(left + 36.0, r.center().y), Align2::LEFT_CENTER, "SolidCraft", FontId::proportional(14.0), t.app_bar_text);
}
let mut x = left + if compact_actions { 60.0 } else { 128.0 };
// A disabled button is dimmed and ignores clicks (its tooltip still shows).
let mut click = |ui: &mut egui::Ui, icon: &str, tip: &str, enabled: bool| -> bool {
let width = if compact_actions { 22.0 } else { 26.0 };
let br = Rect::from_center_size(pos2(x, r.center().y), vec2(width, 26.0));
x += if compact_actions { 22.0 } else { 30.0 };
let resp = ui.interact(br, ui.id().with(("ab", icon)), if enabled { Sense::click() } else { Sense::hover() });
crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center());
if enabled && resp.hovered() {
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
}
let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.35) };
icons::paint(
ui.painter(),
br.shrink(5.0),
icon,
dim(Color32::from_rgb(225, 230, 238)),
dim(Color32::from_rgb(120, 140, 170)),
dim(Color32::from_rgb(120, 190, 255)),
);
let clicked = resp.on_hover_text(tip).clicked();
enabled && clicked
};
// While a file loads the design waits: no menus or commands from the bar.
let idle = !app.file_load.busy();
let file = click(ui, "folder", app.tr("ui.file"), idle);
// Save, Undo and Redo act on the design, which Home hides.
let shown = design_shown(app) && idle;
let tip = |id: &str, key: &str| {
let label = app.tr(id);
if shown { format!("{label} ({key})") } else { crate::i18n::format("ui.needs_design", &[("command", label)]) }
};
let (save_tip, undo_tip, redo_tip) = (tip("ui.save", "Ctrl+S"), tip("ui.undo", "Ctrl+Z"), tip("ui.redo", "Ctrl+Y"));
if click(ui, "save", &save_tip, shown) {
save(app);
}
if click(ui, "undo", &undo_tip, shown) {
let _ = app.run("edit.undo", json!({}));
}
if click(ui, "redo", &redo_tip, shown) {
let _ = app.run("edit.redo", json!({}));
}
if click(ui, "home", app.tr("ui.home"), idle) {
let rev = app.session.revision;
app.home.show(rev);
}
let theme_tip = if app.ui.dark { app.tr("ui.light") } else { app.tr("ui.dark") };
if !compact_actions && click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip, true) {
app.ui.dark = !app.ui.dark;
app.preferences.theme = if app.ui.dark { "dark" } else { "light" }.into();
}
if click(ui, "help", app.tr("ui.help"), true) {
app.help.menu = !app.help.menu;
let at = ui.ctx().input(|i| i.pointer.latest_pos()).map_or(300.0, |p| p.x);
ui.ctx().data_mut(|d| {
d.insert_temp(egui::Id::new("sc_help_opened"), true);
d.insert_temp(egui::Id::new("sc_help_anchor"), at);
});
}
if file {
app.ui.palette_open = false;
let ctx = ui.ctx().clone();
let open = ctx.data(|d| d.get_temp::<bool>(egui::Id::new("sc_file_menu")).unwrap_or(false));
let pass = ctx.cumulative_pass_nr();
// The folder button toggles the menu. Remember the pass it opened on so the same
// click isn't also treated as a click outside the menu (which would close it at once).
ctx.data_mut(|d| {
d.insert_temp(egui::Id::new("sc_file_menu"), !open);
d.insert_temp(egui::Id::new("sc_file_menu_opened"), pass);
});
}
// Document tabs.
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
let tabs_start = x + 16.0;
let (report, report_label) = report_rect(r, captions, tabs_start);
// A newer release: its Update button, left of Report a bug (`update`).
let update = crate::update::button(app, ui, report.left() - 6.0, report.top(), report_label);
let tabs_end = update.map_or(report.left(), |u| u.left()) - 8.0;
// Narrow title bars can fit only part of the tab strip. Its hit regions must not
// extend into the persistent report and window controls.
let tabs_rect = Rect::from_min_max(pos2(tabs_start.min(tabs_end), r.top()), pos2(tabs_end, r.bottom()));
let mut tabs_ui = ui.new_child(egui::UiBuilder::new().max_rect(tabs_rect));
tabs_ui.set_clip_rect(tabs_rect.intersect(ui.clip_rect()));
crate::documents::tabs(app, &mut tabs_ui, r, tabs_start, tabs_end);
let response = ui.interact(report, ui.id().with("sc_report_button"), Sense::click());
let report_text = crate::i18n::tr("ui.report_a_bug");
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), report_text));
crate::scenario::publish_handle("titlebar:report", report.center());
ui.painter().rect_filled(report, 4.0, if response.hovered() || response.has_focus() { t.hover } else { t.app_bar });
ui.painter().rect_stroke(report, 4.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0));
icons::paint(ui.painter(), icon, "bug_report", t.app_bar_text, t.app_bar_text, t.app_bar);
if report_label {
ui.painter().text(
pos2(report.left() + 29.0, report.center().y),
Align2::LEFT_CENTER,
report_text,
FontId::proportional(13.0),
t.app_bar_text,
);
}
if response.on_hover_text(crate::i18n::tr_label("Report a bug — review a screenshot and open a GitHub draft")).clicked() {
crate::help::run(app, &ui.ctx().clone(), "report");
}
// Preferences, at the top right like Fusion's profile menu (also Ctrl+,).
let gear = Rect::from_center_size(pos2(r.right() - captions - 70.0, r.center().y), vec2(24.0, 24.0));
let gresp = ui.interact(gear, ui.id().with("sc_prefs_button"), Sense::click());
if gresp.hovered() {
ui.painter().rect_filled(gear, 4.0, Color32::from_white_alpha(30));
}
icons::paint(ui.painter(), gear.shrink(4.0), "settings", t.app_bar_text, t.app_bar_text, t.app_bar);
crate::scenario::publish_handle("titlebar:preferences", gear.center());
let prefs_tip = if cfg!(target_os = "macos") {
format!("{} (Cmd+,)", app.tr("ui.preferences"))
} else {
format!("{} (Ctrl+,)", app.tr("ui.preferences"))
};
if gresp.on_hover_text(prefs_tip).clicked() {
crate::prefs::open_at(app, 0);
}
ui.painter().text(
pos2(r.right() - 12.0 - captions, r.center().y),
Align2::RIGHT_CENTER,
format!("v{}", crate::update::current_version()),
FontId::proportional(11.0),
Color32::from_rgb(160, 168, 182),
);
if app.custom_titlebar {
crate::titlebar::caption_buttons(app, ui, r);
}
});
file_menu(app, ui.ctx());
let help_x = ui.ctx().data(|d| d.get_temp::<f32>(egui::Id::new("sc_help_anchor")).unwrap_or(300.0));
crate::help::show(app, &ui.ctx().clone(), pos2(help_x - 14.0, 34.0));
}
fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
let id = egui::Id::new("sc_file_menu");
if !ctx.data(|d| d.get_temp::<bool>(id).unwrap_or(false)) {
return;
}
let mut close = false;
let resp = crate::frame::area(egui::Id::new("sc_file_area"))
.fixed_pos(pos2(100.0 + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 }, 34.0))
.order(egui::Order::Foreground)
.show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(190.0);
ui.visuals_mut().widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::NONE;
ui.visuals_mut().widgets.hovered.weak_bg_fill = Tokens::get().hover;
let item_if = |ui: &mut egui::Ui, label: &str, key: &str, enabled: bool| {
ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(key).min_size(vec2(180.0, 22.0))).clicked()
};
let item = |ui: &mut egui::Ui, label: &str, key: &str| item_if(ui, label, key, true);
// Insert, Save and Export act on the design, which Home hides.
let shown = design_shown(app);
if item(ui, app.tr("ui.new_design"), "Ctrl+N") {
crate::documents::new_design(app);
close = true;
}
if item(ui, &format!("{}…", app.tr("ui.open")), "Ctrl+O") {
app.pick_open(|app, path| {
if let Some(path) = path {
app.open_path_async(&path);
}
});
close = true;
}
ui.menu_button(app.tr("ui.open_recent"), |ui| {
for path in app.home.recent.clone() {
let missing = !solidcraft_engine::io::vfs::exists(&path);
let label = format!("{path}{}", if missing { " (missing)" } else { "" });
if ui.button(label).clicked() {
crate::home::open_recent(app, &path);
close = true;
ui.close();
}
}
ui.separator();
if ui.button(app.tr("ui.clear_recent")).clicked() {
app.home.recent.clear();
app.home.opened_at.clear();
close = true;
ui.close();
}
});
if item_if(ui, "Insert STEP…", "", shown) {
app.start("file.insert_step");
close = true;
}
if item_if(ui, "Insert Mesh…", "", shown) {
app.start("file.insert_mesh");
close = true;
}
if item_if(ui, app.tr("ui.save"), "Ctrl+S", shown) {
save(app);
close = true;
}
if item_if(ui, &format!("{}…", app.tr("ui.save_as")), "", shown) {
save_as(app);
close = true;
}
if item(ui, "Save As JSON…", "") {
save_as_with_extension(app, "json");
close = true;
}
ui.separator();
if item_if(ui, "Export STL…", "", shown) {
export(app, "stl");
close = true;
}
if item_if(ui, "Export STEP…", "", shown) {
export(app, "step");
close = true;
}
if item_if(ui, "Export 3MF…", "", shown) {
export(app, "3mf");
close = true;
}
if item_if(ui, "Export 3MF with Face Tags…", "", shown) {
export_as(app, "3mf", Some("3mf-tagged"));
close = true;
}
if item_if(ui, "Export OBJ…", "", shown) {
export(app, "obj");
close = true;
}
if item_if(ui, "Export URDF…", "", shown) {
export(app, "urdf");
close = true;
}
if item(ui, "Export Design as JSON…", "") {
export(app, "json");
close = true;
}
ui.separator();
if item(ui, &format!("{}…", app.tr("ui.preferences")), "") {
app.prefs_window.open = true;
close = true;
}
if item(ui, &format!("{}…", app.tr("ui.keyboard_shortcuts")), "") {
app.keymap.open = true;
app.keymap.read_only = false;
close = true;
}
if item(ui, app.tr("ui.quit"), "") {
crate::documents::request_quit(app);
close = true;
}
});
});
let just_opened = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("sc_file_menu_opened"))) == Some(ctx.cumulative_pass_nr());
if close || (!just_opened && resp.response.clicked_elsewhere()) {
ctx.data_mut(|d| d.insert_temp(id, false));
}
}
/// Whether the open design is on screen. Home hides it, so commands that act on it (save,
/// undo, insert, export, tool shortcuts) are off there.
pub fn design_shown(app: &SolveApp) -> bool {
!app.home.open
}
pub fn save(app: &mut SolveApp) {
if crate::browser::needs_capture(app, crate::browser::SAVE) {
app.tree.capture_prompt = Some(crate::browser::SAVE.into());
return;
}
if app.session.path.is_some() {
let _ = app.run("file.save", json!({}));
} else {
save_as(app);
}
}
fn save_as(app: &mut SolveApp) {
save_as_with_extension(app, solidcraft_engine::io::DESIGN_EXT);
}
fn save_as_with_extension(app: &mut SolveApp, extension: &str) {
let name = format!("{}.{extension}", app.session.doc.name);
app.pick_save(&name, &[solidcraft_engine::io::DESIGN_EXT, "json"], |app, path| {
if let Some(path) = path {
let _ = app.run("file.save_as", json!({"path": path}));
}
crate::documents::saved_for_close(app);
});
}
fn export(app: &mut SolveApp, ext: &str) {
export_as(app, ext, None);
}
/// Export to a file with extension `ext`, in `format` when the extension alone doesn't name it.
fn export_as(app: &mut SolveApp, ext: &str, format: Option<&str>) {
let name = format!("{}.{ext}", app.session.doc.name);
let format = format.map(str::to_string);
app.pick_save(&name, &[ext], move |app, path| {
let Some(path) = path else { return };
let mut params = json!({"path": path});
if let Some(format) = format {
params["format"] = json!(format);
}
if app.run("file.export", params).is_ok() {
app.set_status(format!("Exported {path}"), false);
}
});
}
pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let sketching = app.session.active_sketch.is_some();
egui::Panel::top("sc_toolbar").exact_size(92.0).frame(egui::Frame::NONE.fill(t.toolbar)).show(ui, |ui| {
let r = ui.max_rect();
let painter = ui.painter().clone();
// Right-click on the toolbar's empty space: its own menu. Interacted first, so every
// button, tab and panel label is above it.
let bg = ui.interact(r, ui.id().with("toolbar_bg"), Sense::click());
if bg.secondary_clicked() {
crate::context_menu::open_for(app, bg.interact_pointer_pos().unwrap_or(r.left_bottom()), crate::context_menu::Target::Toolbar);
}
// Workspace selector and tabs.
let tabs_y = r.top() + 2.0;
// The workspace switcher spans the toolbar's height (the tab row and the panel row).
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 wresp = ui.interact(ws, ui.id().with("workspace_switcher"), Sense::click());
painter.rect(ws, 4.0, if wresp.hovered() { t.hover } else { t.field }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
icons::paint(&painter, Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), vec2(26.0, 26.0)), "box", t.icon, t.icon_fill, t.accent);
painter.text(
pos2(ws.center().x - 5.0, ws.top() + 52.0),
Align2::CENTER_CENTER,
app.tr_label(crate::toolbar_custom::WORKSPACE),
FontId::proportional(12.0),
t.text,
);
caret(&painter, pos2(ws.center().x + 27.0, ws.top() + 52.0), t.text);
painter.text(pos2(ws.center().x, ws.top() + 69.0), Align2::CENTER_CENTER, app.tr("ui.workspace"), FontId::proportional(10.0), t.text_dim);
if wresp.on_hover_text(app.tr("ui.change_workspace")).clicked() {
crate::context_menu::open_for(app, ws.left_bottom(), crate::context_menu::Target::Workspace);
}
crate::scenario::publish_rect("toolbar", r);
crate::scenario::publish_rect("workspace_switcher", ws);
let mut x = r.left() + 112.0;
let mut tabs: Vec<&str> = Vec::new();
if sketching {
tabs.push("SKETCH");
}
tabs.extend(TABS.iter());
for tab in tabs {
let display_tab = app.tr_label(tab);
let text_width = painter.layout_no_wrap(display_tab.to_string(), FontId::proportional(12.0), t.text).size().x;
let w = 12.0 + text_width.max(28.0);
let tr = Rect::from_min_size(pos2(x, tabs_y), vec2(w, 22.0));
let active = app.ui.tab == tab;
let resp = ui.interact(tr, ui.id().with(("tab", tab)), Sense::click());
if active {
let c = if tab == "SKETCH" { t.sketch_accent } else { t.accent };
painter.line_segment([pos2(tr.left() + 4.0, tr.bottom() - 1.0), pos2(tr.right() - 4.0, tr.bottom() - 1.0)], Stroke::new(2.0, c));
} else if resp.hovered() {
painter.rect_filled(tr, 4.0, t.hover);
}
let col = if tab == "SKETCH" {
t.sketch_accent
} else if active {
t.text
} else {
t.text_dim
};
painter.text(tr.center(), Align2::CENTER_CENTER, display_tab, FontId::proportional(12.0), col);
if resp.clicked() {
app.ui.tab = tab.to_string();
}
x += w + 4.0;
}
// Panels.
let tab = app.ui.tab.clone();
// The command whose tool or dialog is open right now, shown pressed in the toolbar.
let running: Option<String> = match (&app.tool, &app.dialog) {
(Some(tool), _) => Some(tool.cmd.to_string()),
// Create Sketch never lands in last_command (it is not repeatable).
(None, Some(d)) if matches!(d.kind, crate::dialogs::Kind::Sketch) => Some("sketch.create".to_string()),
(None, Some(_)) => app.last_command.as_ref().map(|(id, _)| id.clone()),
_ => None,
};
let mut px = r.left() + 112.0;
let top = r.top() + 26.0;
let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None;
let mut menu: Option<(egui::Pos2, &'static str)> = None;
// A drop-down row's pin icon clicked: pin or unpin that command.
let mut toggle: Option<&'static str> = None;
// The open drop-down (a button dropped on it leaves the toolbar).
let mut popup_rect: Option<Rect> = None;
let mut overflow = Vec::new();
// Every panel with all its buttons (the user's pins have no cap); a toolbar too narrow
// for them collapses panels into their drop-down, then into the » menu.
let lists: Vec<_> = crate::toolbar_custom::panel_lists(&tab)
.into_iter()
.map(|(panel, cmds, promote)| {
let shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote);
(panel, cmds, shown)
})
.collect();
let width_of = |panel: &str, count: usize| {
if tab == "SKETCH" && panel == "CREATE" { (count.max(1) as f32 * 68.0).max(64.0) + 8.0 } else { panel_width(count) }
};
let widths: Vec<f32> = lists.iter().map(|(panel, _, shown)| width_of(panel, shown.len())).collect();
let fits = fit(&widths, r.right() - 40.0 - px);
crate::scenario::publish_count("toolbar_collapsed", fits.iter().filter(|f| **f == Fit::Collapsed).count() as f64);
crate::scenario::publish_count("toolbar_more", fits.iter().filter(|f| **f == Fit::More).count() as f64);
for ((panel, cmds, shown), fit) in lists.into_iter().zip(fits) {
let panel: &&str = &panel;
if fit == Fit::More {
overflow.push((*panel, menu_commands(&cmds, &shown)));
continue;
}
let collapsed = fit == Fit::Collapsed;
let width = if collapsed { COLLAPSED_WIDTH.min(width_of(panel, shown.len())) } else { width_of(panel, shown.len()) };
let sketch_create = tab == "SKETCH" && *panel == "CREATE";
let stride = if sketch_create { 68.0 } else { 40.0 };
let enabled_panel = !cmds.is_empty() || *panel == "SELECT";
// Per tab: panels of the same name on other tabs (MODIFY, CREATE…) list other commands,
// and the drop-down's area remembers its size by this id (#874).
let popup_id = ui.id().with(("panel_menu", &tab, panel));
// Dropped on the panel's free space (all of a collapsed panel): the button goes to
// its end.
let drawn = if collapsed { 0 } else { shown.len() };
// Buttons narrower than the panel's minimum width (a one-button panel) are centred.
let inset = if drawn == 0 { 0.0 } else { ((width - 8.0 - (drawn as f32 * stride - 2.0)) / 2.0).max(0.0).floor() };
let bx = px + 4.0 + inset;
let free_x = bx + drawn as f32 * stride;
let free = Rect::from_min_size(pos2(free_x, top + 2.0), vec2((px + width - 4.0 - free_x).max(8.0), 38.0));
let fresp = ui.interact(free, ui.id().with(("panel_free", panel)), if collapsed { Sense::click() } else { Sense::hover() });
let mut opened = false;
if collapsed {
// One button: the panel's first command's icon over a caret; it opens the
// drop-down, which lists every command of the panel.
let br = Rect::from_center_size(free.center(), vec2(38.0, 38.0));
crate::scenario::publish_handle(&format!("collapsed:{tab}:{panel}"), br.center());
if fresp.hovered() && enabled_panel {
painter.rect_filled(br, 4.0, t.hover);
}
if let Some(c) = shown.first().or(cmds.first()) {
let acc = command_accent(t, &tab, panel, c.id);
icons::paint(&painter, Rect::from_center_size(br.center() - vec2(0.0, 3.0), vec2(26.0, 26.0)), c.icon, t.icon, t.icon_fill, acc);
}
caret(&painter, pos2(br.center().x, br.bottom() - 3.0), t.text_dim);
let tip = format!("{} ({})", app.tr_label(panel), shown.len().max(cmds.len()));
if fresp.on_hover_text(tip).clicked() && enabled_panel {
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_panel_open"), popup_id));
opened = true;
}
}
for (i, c) in shown.iter().enumerate().take(drawn) {
// Only the family's default pin follows the part's choice. Explicitly pinned
// variants remain exact commands, including in customised toolbars.
let family = crate::sketch_families::family(c.id).filter(|f| sketch_create && f.commands.first().copied() == Some(c.id));
let selected = family.and_then(|f| app.sketch_choices.selected(f)).unwrap_or(c);
let whole = Rect::from_min_size(pos2(bx + i as f32 * stride, top + 2.0), vec2(stride - 2.0, 38.0));
let mut br = whole;
if family.is_some() {
br.max.y -= 12.0;
}
let info = selected.info(&app.session);
let resp = ui.interact(br, ui.id().with(("cmd", c.id)), Sense::click_and_drag());
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), br.center());
// The button itself (`toolbar:<id>` is also a drop-down row's while one is open).
crate::scenario::publish_handle(&format!("button:{}", c.id), br.center());
if selected.id != c.id {
crate::scenario::publish_handle(&format!("button:{}", selected.id), br.center());
}
if resp.drag_started() {
resp.dnd_set_drag_payload(ToolbarDrag { id: c.id, from_menu: false });
}
if let Some(d) = resp.dnd_hover_payload::<ToolbarDrag>()
&& d.id != c.id
{
painter
.line_segment([pos2(br.left() - 1.0, br.top() + 2.0), pos2(br.left() - 1.0, br.bottom() - 2.0)], Stroke::new(2.0, t.accent));
}
if let Some(d) = resp.dnd_release_payload::<ToolbarDrag>()
&& d.id != c.id
{
drop = Some((d.id, *panel, Some(c.id)));
}
if resp.secondary_clicked() {
menu = Some((resp.interact_pointer_pos().unwrap_or(br.left_bottom()), c.id));
}
if running.as_deref() == Some(selected.id) {
painter.rect_filled(br, 4.0, t.border.gamma_multiply(0.7));
} else if resp.hovered() && info.enabled {
painter.rect_filled(br, 4.0, t.hover);
}
let (ink, fill, acc) = if info.enabled {
(t.icon, t.icon_fill, command_accent(t, &tab, panel, selected.id))
} else {
(t.border, t.panel_header, t.border)
};
let icon = match selected.id {
"sketch.finish" => "finish",
"config.table" => "configuration_table",
_ => selected.icon,
};
let icon_rect =
if sketch_create { Rect::from_center_size(pos2(br.center().x, br.top() + 13.0), vec2(24.0, 24.0)) } else { br.shrink(6.0) };
icons::paint(&painter, icon_rect, icon, ink, fill, acc);
let label = match c.id {
"parameters.change" if *panel == "CONFIGURE" => "Parameters",
"config.table" if *panel == "CONFIGURE" => "Configurations",
_ => selected.label,
};
let tr_label = app.tr_label(label);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, tr_label));
let tip = match &info.disabled_reason {
Some(why) => format!("{}\n{why}", tr_label),
None => format!("{}{}", tr_label, selected.shortcut.map(|k| format!(" ({k})")).unwrap_or_default()),
};
if resp.on_hover_text(tip).clicked() && info.enabled {
let id = selected.id;
app.start(id);
}
if let Some(f) = family {
let ar = Rect::from_min_max(pos2(whole.left(), br.bottom()), whole.right_bottom());
let arrow = ui.interact(ar, ui.id().with(("family", f.label)), Sense::click());
crate::scenario::publish_handle(&format!("family:{}", f.label), ar.center());
if arrow.hovered() {
painter.rect_filled(ar, 3.0, t.hover);
}
painter.text(ar.center() - vec2(4.0, 0.0), Align2::CENTER_CENTER, app.tr_label(f.label), FontId::proportional(10.0), t.text);
caret(&painter, pos2(ar.right() - 5.0, ar.center().y), t.text);
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{} variants", f.label)));
egui::Popup::menu(&arrow).show(|ui| {
let variants: Vec<_> = f.commands.iter().filter_map(|id| solidcraft_engine::find_command(id)).collect();
dropdown_keys(app, ui, &variants);
sketch_menu_rows(app, ui, &variants, &mut menu, &mut toggle);
});
} else if sketch_create {
painter.text(
pos2(br.center().x, br.bottom() - 5.0),
Align2::CENTER_CENTER,
app.tr_label(c.label),
FontId::proportional(10.0),
t.text,
);
}
}
if shown.is_empty() && !collapsed {
let br = Rect::from_min_size(pos2(px + 4.0, top + 2.0), vec2(width - 8.0, 38.0));
painter.text(br.center(), Align2::CENTER_CENTER, "—", FontId::proportional(14.0), t.border);
}
// Panel label with drop-down.
let label = app.tr_label(panel).to_string();
let lc = if *panel == "FINISH SKETCH" {
t.sketch_accent
} else if enabled_panel {
t.text
} else {
t.text_dim
};
// A label too wide for its panel (beside the caret) wraps between words onto a
// second line; a single word wider than the panel stays whole. Each line is
// centred, and the hover area grows to hold them.
let font = FontId::proportional(10.5);
let widest_word = label.split_whitespace().map(|w| painter.layout_no_wrap(w.to_string(), font.clone(), lc).size().x).fold(0.0, f32::max);
let mut job = egui::text::LayoutJob::simple(label, font, lc, (width - 14.0).max(widest_word + 1.0));
job.wrap.max_rows = 2;
job.halign = egui::Align::Center;
let g = painter.layout_job(job);
let gw = g.size().x;
let lr = Rect::from_center_size(pos2(px + width / 2.0, top + 51.0), vec2(width, (g.size().y + 2.0).max(18.0)));
let lresp = ui.interact(lr, ui.id().with(("panel", panel)), Sense::click());
crate::scenario::publish_handle(&format!("panel:{tab}:{panel}"), lr.center());
if lresp.hovered() && enabled_panel {
painter.rect_filled(lr, 3.0, t.hover);
}
// Centred rows sit around x = 0, so the galley is placed at the text's centre.
let gx = lr.center().x - 4.5;
let gy = lr.center().y - g.size().y / 2.0;
painter.galley(pos2(gx, gy), g, lc);
caret(&painter, pos2(gx + gw / 2.0 + 6.0, lr.center().y), lc);
if lresp.clicked() {
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_panel_open"), popup_id));
}
// Labels, gaps and margins append; actual buttons keep their insert-before target.
// Respect the current layer so a dropdown covering the panel is not a drop target.
let panel_rect = Rect::from_min_max(pos2(px, top + 2.0), pos2(px + width, r.bottom()));
let over_button = ui.input(|input| input.pointer.hover_pos()).is_some_and(|p| {
(0..drawn).any(|i| Rect::from_min_size(pos2(bx + i as f32 * stride, top + 2.0), vec2(stride - 2.0, 38.0)).contains(p))
});
if !over_button && ui.rect_contains_pointer(panel_rect) && egui::DragAndDrop::payload::<ToolbarDrag>(ui.ctx()).is_some() {
painter.line_segment([pos2(px + width - 4.0, top + 4.0), pos2(px + width - 4.0, top + 38.0)], Stroke::new(2.0, t.accent));
if ui.input(|input| input.pointer.any_released())
&& let Some(d) = egui::DragAndDrop::take_payload::<ToolbarDrag>(ui.ctx())
{
drop = Some((d.id, *panel, None));
}
}
let open = ui.ctx().data(|d| d.get_temp::<egui::Id>(egui::Id::new("sc_panel_open"))) == Some(popup_id);
if open {
let cmds = if collapsed { menu_commands(&cmds, &shown) } else { cmds.clone() };
let area = crate::frame::area(popup_id).fixed_pos(lr.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
if *panel == "SELECT" {
selection_filter(app, ui);
} else if cmds.is_empty() {
ui.label(egui::RichText::new(crate::i18n::tr_label("Not available yet")).color(t.text_dim));
}
if sketch_create {
sketch_create_menu(app, ui, &cmds, &mut menu, &mut toggle);
return;
}
let row_w = dropdown_width(app, ui, &cmds);
// Up and Down move the keyboard focus through the enabled rows (from none
// focused: the first or the last), instead of egui's own spatial move.
let (down, up) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp)));
if down || up {
let enabled: Vec<&str> = cmds.iter().filter(|c| c.info(&app.session).enabled).map(|c| c.id).collect();
let focused = ui.ctx().memory(|m| m.focused());
let at = enabled.iter().position(|id| focused == Some(dropdown_row_id(id)));
let n = enabled.len();
let next = match (at, down) {
(None, true) => Some(0),
(None, false) => n.checked_sub(1),
(Some(i), true) => Some((i + 1) % n.max(1)),
(Some(i), false) => Some((i + n.max(1) - 1) % n.max(1)),
};
if let Some(id) = next.and_then(|i| enabled.get(i)) {
ui.ctx().memory_mut(|m| {
m.request_focus(dropdown_row_id(id));
m.move_focus(egui::FocusDirection::None);
});
}
}
// The rows scroll when the drop-down would run past the window's bottom.
// `min_scrolled_height` lets the rows grow past the size the area kept from
// an earlier, shorter list (#874); they still shrink to fit their content.
let room = (ui.ctx().content_rect().bottom() - ui.cursor().top() - 12.0).max(ROW_H * 3.0);
egui::ScrollArea::vertical().id_salt("panel_menu_rows").max_height(room).min_scrolled_height(room).show(ui, |ui| {
for c in &cmds {
let pinned = crate::toolbar_custom::pinned_on(app, &tab, c.id).is_some();
match dropdown_row(app, ui, c, row_w, pinned) {
RowAction::None => {}
RowAction::Menu(at) => menu = Some((at, c.id)),
RowAction::TogglePin => toggle = Some(c.id),
RowAction::Start => {
app.start(c.id);
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
}
}
}
});
});
});
popup_rect = Some(area.response.rect);
crate::scenario::publish_rect("toolbar_dropdown", area.response.rect);
// A drag out of the drop-down ends with a release elsewhere: not a click elsewhere.
let dragging = egui::DragAndDrop::has_payload_of_type::<ToolbarDrag>(ui.ctx());
if (area.response.clicked_elsewhere() && !lresp.clicked() && !opened && !dragging) || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
}
}
px += width;
painter.line_segment([pos2(px, top + 4.0), pos2(px, top + 58.0)], Stroke::new(1.0, t.border));
px += 2.0;
}
if !overflow.is_empty() {
let at = Rect::from_min_size(pos2(px.min(r.right() - 40.0), top + 4.0), vec2(38.0, 52.0));
ui.scope_builder(egui::UiBuilder::new().max_rect(at), |ui| {
let response = ui.menu_button("»", |ui| {
for (panel, cmds) in &overflow {
ui.menu_button(*panel, |ui| {
if tab == "SKETCH" && *panel == "CREATE" {
sketch_create_menu(app, ui, cmds, &mut menu, &mut toggle);
return;
}
if *panel == "SELECT" {
selection_filter(app, ui);
}
for c in cmds {
let info = c.info(&app.session);
let response = ui.add_enabled(info.enabled, egui::Button::new(c.label).shortcut_text(c.shortcut.unwrap_or("")));
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), response.rect.center());
if response.clicked() {
app.start(c.id);
ui.close();
}
}
});
}
});
crate::scenario::publish_handle("toolbar:overflow", response.response.rect.center());
response.response.on_hover_text(crate::i18n::tr_label("More toolbar panels"));
});
}
painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border));
// Dragging a command: its icon follows the pointer; a toolbar button dragged off the
// toolbar (or into the open drop-down) shows it will be removed.
let pointer = ui.input(|i| i.pointer.latest_pos());
let over_popup = |p: egui::Pos2| popup_rect.is_some_and(|pr| pr.contains(p));
let leaves = |p: egui::Pos2| !r.contains(p) || over_popup(p);
if let (Some(d), Some(p)) = (egui::DragAndDrop::payload::<ToolbarDrag>(ui.ctx()), pointer) {
let removing = !d.from_menu && leaves(p);
drag_ghost(ui.ctx(), d.id, p, removing);
if ui.input(|i| i.pointer.any_released()) && drop.is_none() {
egui::DragAndDrop::clear_payload(ui.ctx());
if removing {
crate::toolbar_custom::remove(app, &tab, d.id);
}
}
}
if let Some((id, panel, before)) = drop {
crate::toolbar_custom::reorder(app, &tab, id, panel, before);
}
if let Some(id) = toggle {
crate::toolbar_custom::toggle(app, &tab, id);
}
if let Some((at, id)) = menu {
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
crate::context_menu::open_for(app, at, crate::context_menu::Target::ToolbarCommand { id: id.to_string() });
}
});
}
/// What a click on a panel drop-down row asks for.
enum RowAction {
None,
Start,
Menu(egui::Pos2),
TogglePin,
}
const ROW_H: f32 = 24.0;
/// The id of a drop-down row (for keyboard focus).
/// A drop-down row's id, the same inside and outside the rows' scroll area (one drop-down is
/// open at a time).
fn dropdown_row_id(id: &str) -> egui::Id {
egui::Id::new(("dropdown_row", id))
}
/// How wide a panel drop-down's rows are: the widest label and shortcut, at least 200.
fn dropdown_width(app: &SolveApp, ui: &egui::Ui, cmds: &[&'static solidcraft_engine::CommandSpec]) -> f32 {
let t = Tokens::get();
let font = FontId::proportional(13.0);
cmds.iter()
.map(|c| {
let label = ui.painter().layout_no_wrap(app.tr_label(c.label).to_string(), font.clone(), t.text).size().x;
let key = c.shortcut.map(|k| ui.painter().layout_no_wrap(k.to_string(), font.clone(), t.text).size().x + 24.0).unwrap_or(0.0);
6.0 + 18.0 + 8.0 + label + key + 30.0
})
.fold(200.0, f32::max)
}
/// One command row of a panel drop-down: icon, label and shortcut, with the hover background
/// under the pointer or keyboard focus (like every menu).
fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solidcraft_engine::CommandSpec, width: f32, pinned: bool) -> RowAction {
let t = Tokens::get();
let info = c.info(&app.session);
let (rect, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
// Every row drags onto the toolbar (to pin it there); enabled rows also start their command.
let sense = if info.enabled { Sense::click_and_drag() } else { Sense::drag() };
let resp = ui.interact(rect, dropdown_row_id(c.id), sense);
// Rows reached with the arrow keys scroll into view in a drop-down taller than the window.
if resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::ArrowDown) || i.key_pressed(egui::Key::ArrowUp)) {
resp.scroll_to_me_animation(None, egui::style::ScrollAnimation::none());
}
if resp.drag_started() {
resp.dnd_set_drag_payload(ToolbarDrag { id: c.id, from_menu: true });
}
// The pin at the row's end (above the row, so it takes its own clicks).
let pr = Rect::from_center_size(pos2(rect.right() - 13.0, rect.center().y), vec2(20.0, 20.0));
let pin = ui.interact(pr, ui.id().with(("dropdown_pin", c.id)), Sense::click());
let pointer_on = (resp.hovered() || pin.hovered()) && !egui::DragAndDrop::has_any_payload(ui.ctx());
let label = app.tr_label(c.label).to_string();
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, &label));
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), rect.center());
crate::scenario::publish_handle(&format!("dropdown:{}", c.id), rect.center());
let hot = info.enabled && (pointer_on || resp.has_focus());
let p = ui.painter();
if hot {
p.rect_filled(rect, 3.0, t.hover);
crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center());
}
// Every other row keeps the plain accent it always has. A constraint command's row switches
// to the constraint red, to match its button on the ribbon. A command with a `fixed_accent`
// keeps that colour ahead of either.
let (ink, fill, acc, text) = if info.enabled {
let acc = fixed_accent(t, c.id).unwrap_or(if c.panel == "CONSTRAINTS" { t.glyph_text } else { t.accent });
(t.icon, t.icon_fill, acc, t.text)
} else {
(t.border, t.panel_header, t.border, t.text_dim)
};
let ir = Rect::from_center_size(pos2(rect.left() + 6.0 + 9.0, rect.center().y), vec2(18.0, 18.0));
icons::paint(p, ir, c.icon, ink, fill, acc);
p.text(pos2(ir.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &label, FontId::proportional(13.0), text);
if let Some(k) = c.shortcut {
p.text(pos2(pr.left() - 4.0, rect.center().y), Align2::RIGHT_CENTER, k, FontId::proportional(13.0), t.text_dim);
}
if pointer_on || resp.has_focus() {
if pin.hovered() {
p.rect_filled(pr, 3.0, t.border.gamma_multiply(0.6));
}
pin_icon(p, pr, pinned, if pinned { t.accent } else { t.text_dim });
crate::scenario::publish_handle(&format!("dropdown_pin:{}", c.id), pr.center());
}
let tip = if pinned { "Remove from Toolbar" } else { "Pin to Toolbar" };
if pin.on_hover_text(tip).clicked() {
return RowAction::TogglePin;
}
let resp = match &info.disabled_reason {
Some(why) if !info.enabled => resp.on_hover_text(why),
_ => resp,
};
if resp.secondary_clicked() {
return RowAction::Menu(resp.interact_pointer_pos().unwrap_or(rect.left_bottom()));
}
if resp.clicked() && info.enabled {
return RowAction::Start;
}
RowAction::None
}
/// Creation menus keep related variants together, including collapsed and overflow panels.
fn sketch_create_menu(
app: &mut SolveApp,
ui: &mut egui::Ui,
cmds: &[&'static solidcraft_engine::CommandSpec],
menu: &mut Option<(egui::Pos2, &'static str)>,
toggle: &mut Option<&'static str>,
) {
let room = (ui.ctx().content_rect().bottom() - ui.cursor().top() - 12.0).max(ROW_H * 3.0);
egui::ScrollArea::vertical().id_salt("sketch_create_rows").max_height(room).show(ui, |ui| {
for f in crate::sketch_families::FAMILIES {
let variants: Vec<_> = f.commands.iter().filter_map(|id| cmds.iter().find(|c| c.id == *id).copied()).collect();
if !variants.is_empty() {
let response = ui.menu_button(app.tr_label(f.label).to_string(), |ui| {
dropdown_keys(app, ui, &variants);
sketch_menu_rows(app, ui, &variants, menu, toggle);
});
crate::scenario::publish_handle(&format!("create_family:{}", f.label), response.response.rect.center());
}
}
ui.separator();
let rest: Vec<_> = cmds.iter().copied().filter(|c| crate::sketch_families::family(c.id).is_none()).collect();
dropdown_keys(app, ui, &rest);
sketch_menu_rows(app, ui, &rest, menu, toggle);
});
}
/// Family popups use the same ordered arrow-key navigation as a panel's command list.
fn dropdown_keys(app: &SolveApp, ui: &egui::Ui, cmds: &[&'static solidcraft_engine::CommandSpec]) {
let (down, up) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp)));
if !down && !up {
return;
}
// A submenu renders before the remaining rows of its parent. Handle a key once, but
// leave the event available for the focused row's scroll-into-view behaviour.
let handled = egui::Id::new("sketch_menu_key_pass");
let pass = ui.ctx().cumulative_pass_nr();
if ui.ctx().data(|d| d.get_temp::<u64>(handled)) == Some(pass) {
return;
}
ui.ctx().data_mut(|d| d.insert_temp(handled, pass));
let enabled: Vec<_> = cmds.iter().filter(|c| c.info(&app.session).enabled).collect();
if enabled.is_empty() {
return;
}
let focused = ui.ctx().memory(|m| m.focused());
let at = enabled.iter().position(|c| focused == Some(dropdown_row_id(c.id)));
let n = enabled.len();
let next = match (at, down) {
(None, true) => 0,
(None, false) => n - 1,
(Some(i), true) => (i + 1) % n,
(Some(i), false) => (i + n - 1) % n,
};
if let Some(c) = enabled.get(next) {
ui.ctx().memory_mut(|m| {
m.request_focus(dropdown_row_id(c.id));
m.move_focus(egui::FocusDirection::None);
});
}
}
fn sketch_menu_rows(
app: &mut SolveApp,
ui: &mut egui::Ui,
cmds: &[&'static solidcraft_engine::CommandSpec],
menu: &mut Option<(egui::Pos2, &'static str)>,
toggle: &mut Option<&'static str>,
) {
let width = dropdown_width(app, ui, cmds);
for c in cmds {
let pinned = crate::toolbar_custom::pinned_on(app, "SKETCH", c.id).is_some();
match dropdown_row(app, ui, c, width, pinned) {
RowAction::None => {}
RowAction::Menu(at) => *menu = Some((at, c.id)),
RowAction::TogglePin => *toggle = Some(c.id),
RowAction::Start => {
app.start(c.id);
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
ui.close();
}
}
}
}
/// A command being dragged: a toolbar button, or a row of a panel drop-down (`from_menu`).
#[derive(Clone, Copy, Debug)]
struct ToolbarDrag {
id: &'static str,
from_menu: bool,
}
/// The dragged command's icon under the pointer, crossed out when releasing removes it.
fn drag_ghost(ctx: &egui::Context, id: &str, at: egui::Pos2, removing: bool) {
let t = Tokens::get();
let Some(c) = solidcraft_engine::find_command(id) else { return };
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("sc_toolbar_drag")));
let r = Rect::from_center_size(at + vec2(14.0, 16.0), vec2(30.0, 30.0));
p.rect(r, 4.0, t.toolbar.gamma_multiply(0.92), Stroke::new(1.0, if removing { t.error } else { t.accent }), egui::StrokeKind::Inside);
icons::paint(&p, r.shrink(5.0), c.icon, t.icon, t.icon_fill, fixed_accent(t, c.id).unwrap_or(t.accent));
if removing {
let s = Stroke::new(2.0, t.error);
let x = Rect::from_center_size(r.right_top(), vec2(9.0, 9.0));
p.circle_filled(x.center(), 7.0, t.toolbar);
crate::icons::glyphs::cross(&p, x, s);
}
crate::scenario::publish_handle(if removing { "toolbar_drag:remove" } else { "toolbar_drag" }, at);
}
/// The SELECT panel: what a click in the view picks.
fn selection_filter(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
ui.label(egui::RichText::new(crate::i18n::tr_label("Selection filter")).color(t.text_dim).size(11.0));
ui.checkbox(&mut app.ui.pick_bodies, crate::i18n::tr_label("Select bodies (a face picks its body)"));
for (k, label) in [
("faces", "Faces"),
("edges", "Edges"),
("vertices", "Vertices"),
("sketch", "Sketch geometry and profiles"),
("sketch_curves", "Sketch curves"),
("sketch_points", "Sketch points"),
("dimensions", "Sketch dimensions"),
("constraints", "Sketch constraints"),
] {
let mut on = !app.ui.pick_off.iter().any(|x| x == k);
if ui.checkbox(&mut on, crate::i18n::tr_label(label)).changed() {
app.ui.pick_off.retain(|x| x != k);
if !on {
app.ui.pick_off.push(k.to_string());
}
}
}
}
/// Global keyboard shortcuts (when no text field has focus).
pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
if app.file_load.busy() {
return;
}
// The Keyboard Shortcuts window is waiting for a key: it is not a shortcut.
if crate::keymap::capturing(app) {
return;
}
// A dimension value that is taking the keyboard: letters belong to it (a parameter name
// starting with L or D), even on the frame before its field holds the focus.
if ctx.egui_wants_keyboard_input() || crate::dim_view::editing() {
return;
}
let (keys, mods) = ctx.input(|i| {
let keys: Vec<egui::Key> =
i.events.iter().filter_map(|e| if let egui::Event::Key { key, pressed: true, .. } = e { Some(*key) } else { None }).collect();
(keys, i.modifiers)
});
// Home hides the design: only New, Open and Preferences work there.
let shown = design_shown(app);
for k in keys {
use egui::Key;
if mods.command {
match k {
Key::N => crate::documents::new_design(app),
Key::Comma => crate::prefs::open_at(app, 0),
Key::O => {
app.pick_open(|app, path| {
if let Some(path) = path {
app.open_path_async(&path);
}
});
}
_ if !shown => {}
Key::Z => drop(app.run("edit.undo", json!({}))),
Key::Y => drop(app.run("edit.redo", json!({}))),
Key::S => save(app),
_ => crate::keymap::press(app, &crate::keymap::key_name(k, mods)),
}
continue;
}
if !shown {
continue;
}
match k {
Key::Escape => {
app.esc_handled = true;
if crate::sketch_text_move::cancel_drag(app) {
continue;
}
if crate::select_other::is_open(app) {
crate::select_other::close(app);
} else if app.tool.is_some() {
crate::tools::finish(app);
} else if app.dialog.is_some() {
crate::dialogs::cancel(app);
} else if !app.session.selection.is_empty() || app.session.active_sketch.is_none() {
let _ = app.run("select.clear", json!({}));
} else {
// Nothing left to cancel: Esc finishes the sketch.
app.finish_sketch();
}
}
Key::S if !mods.shift && !mods.alt => {
let at = ctx.input(|i| i.pointer.latest_pos());
crate::shortcut_box::open(app, at);
}
Key::V => crate::context_menu::toggle_visibility(app),
Key::F2 => {
let at = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(ctx.content_rect().center());
crate::context_menu::rename_selection(app, at);
}
Key::F6 => app.animate_view("fit"),
Key::Delete | Key::Backspace => {
if !crate::timeline_left::delete_selected_folders(app) {
crate::viewport::delete_selection(app);
}
}
_ => crate::keymap::press(app, &crate::keymap::key_name(k, mods)),
}
}
}
#[cfg(test)]
mod header_report_tests {
use super::*;
#[test]
fn file_menu_json_save_and_export_use_commands_and_keep_distinct_paths() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":2,"width":3,"height":4})).unwrap();
let dir = h.dir.clone();
h.app.services.pick_save =
Some(Box::new(move |name, _extensions| Box::pin(std::future::ready(Some(dir.join(name).to_string_lossy().into_owned())))));
h.frames(3);
h.step(&json!({"click":{"handle":"appbar:folder"}})).unwrap();
h.step(&json!({"widget":"Save As JSON…"})).unwrap();
let saved = h.app.session.path.clone().unwrap();
let bytes = std::fs::read(&saved).unwrap();
assert!(!solidcraft_engine::io::is_design_container(&bytes));
assert_eq!(solidcraft_engine::io::read_design(&bytes).unwrap(), *h.app.session.doc);
h.app.session.doc_mut().name = "Exported copy".into();
h.frames(3);
h.step(&json!({"click":{"handle":"appbar:folder"}})).unwrap();
h.step(&json!({"widget":"Export Design as JSON…"})).unwrap();
assert_eq!(h.app.session.path.as_deref(), Some(saved.as_str()));
assert!(h.dir.join("Exported copy.json").is_file());
h.app.run("file.save", json!({})).unwrap();
assert!(!solidcraft_engine::io::is_design_container(&std::fs::read(saved).unwrap()), "subsequent Save keeps a .json choice");
}
#[test]
fn panels_fit_collapse_then_move_to_more_and_none_is_dropped() {
// A panel with 14 buttons and three default ones.
let widths = [panel_width(14), panel_width(4), panel_width(2), panel_width(2)];
let full: f32 = widths.iter().map(|w| w + 2.0).sum();
assert_eq!(fit(&widths, full), vec![Fit::Full; 4]);
// A little short: the last panel collapses first.
assert_eq!(fit(&widths, full - 10.0), vec![Fit::Full, Fit::Full, Fit::Full, Fit::Collapsed]);
// Every panel collapsed fits.
let collapsed = 4.0 * (COLLAPSED_WIDTH + 2.0);
assert_eq!(fit(&widths, collapsed), vec![Fit::Collapsed; 4]);
// Narrower: the last ones go into the » menu; then all of them.
assert_eq!(fit(&widths, collapsed - 1.0), vec![Fit::Collapsed, Fit::Collapsed, Fit::Collapsed, Fit::More]);
assert_eq!(fit(&widths, 0.0), vec![Fit::More; 4]);
assert_eq!(fit(&widths, f32::NAN).len(), 4);
// No cap: 40 px a button however many there are.
assert_eq!(panel_width(30) - panel_width(20), 400.0);
}
fn captured_frame(h: &mut crate::scenario::Harness) {
let token = h.app.shot_token;
crate::issue_report::receive(&mut h.app, token, std::sync::Arc::new(egui::ColorImage::new([2, 2], vec![Color32::GREEN; 4])));
h.frames(3);
}
#[test]
fn header_report_reserves_controls_at_normal_and_double_scale_widths() {
for physical_width in [900.0, 1400.0] {
for scale in [1.0, 2.0] {
let width = physical_width / scale;
let bar = Rect::from_min_size(egui::Pos2::ZERO, vec2(width, 34.0));
for captions in [0.0, crate::titlebar::WIDTH] {
let (report, labeled) = report_rect(bar, captions, 354.0);
assert!(bar.contains_rect(report));
let prefs = Rect::from_center_size(pos2(width - captions - 70.0, 17.0), vec2(24.0, 24.0));
assert!(!report.intersects(prefs));
assert!(report.right() < width - captions - 82.0);
assert_eq!(labeled, width - captions - 90.0 - 354.0 >= 340.0);
}
}
}
}
#[test]
fn header_report_actual_click_opens_capture_without_changing_healthy_design() {
for width in [450.0, 700.0, 900.0, 1400.0] {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.custom_titlebar = true;
h.app.run("solid.box", json!({"length":35,"width":12,"height":4})).unwrap();
h.call("ui.resize", json!({"width":width,"height":720}));
h.frames(3);
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.revision);
let point = h.widget("Report a bug").expect("compact and full buttons retain an accessible name");
h.step(&json!({"click":point})).unwrap();
assert!(h.app.issue_report.draft.is_some(), "width {width}: header click must reach the report");
assert!(crate::issue_report::pending(&h.app));
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.revision));
captured_frame(&mut h);
// Existing review content scrolls vertically when the window is narrow.
if width < 700.0 {
let [x, y] = h.widget("Close draft").unwrap();
h.call("ui.scroll", json!({"x":x,"y":y - 60.0,"delta":-1200}));
// Let the scroll animation finish before locating the click target.
h.frames(60);
}
h.step(&json!({"widget":"Close draft"})).unwrap();
assert!(h.app.issue_report.draft.is_none(), "width {width}: review closes through its actual button");
h.step(&json!({"click":{"handle":"appbar:home"}})).unwrap();
assert!(h.app.home.open, "width {width}: Home remains reachable beside Report");
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.revision));
}
}
#[test]
fn header_report_preserves_new_dimension_draft_and_single_commit() {
crate::dim_view::cancel();
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 80.0;
h.app.cam.target = solidcraft_engine::geom::Vec3::new(10.0, 0.0, 0.0);
h.settle();
h.step(&json!({"start":"sketch.dimension"})).unwrap();
for point in [[0, 0, 0], [20, 0, 0], [10, 5, 0]] {
h.step(&json!({"click":{"world":point}})).unwrap();
}
h.step(&json!({"text":"30 mm"})).unwrap();
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.revision);
h.step(&json!({"widget":"Report a bug"})).unwrap();
h.frames(3);
assert!(h.app.issue_report.draft.is_some() && crate::dim_view::editing());
captured_frame(&mut h);
h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("issue_report_body")));
h.step(&json!({"text":"Describe this dimension problem."})).unwrap();
h.step(&json!({"key":"Enter"})).unwrap();
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.revision));
h.step(&json!({"widget":"Close draft"})).unwrap();
h.frames(2);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit")));
h.step(&json!({"key":"Enter"})).unwrap();
assert!(!crate::dim_view::editing());
assert_eq!(h.app.session.undo.len(), before.1 + 1);
assert_eq!(h.app.session.doc.param("d1").unwrap().expr, "30 mm");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before.0);
}
}
#[cfg(test)]
mod home_tests {
use super::*;
/// Home hides the design: its Save, Undo and Redo buttons, the File menu's Insert, Save
/// and Export, and the design's keyboard shortcuts leave it alone. They work again once
/// the design is back.
#[test]
fn design_actions_are_off_on_home() {
// A design with history both ways, a selection and unsaved changes, behind Home.
let on_home = || {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Main"})).unwrap();
h.app.run("solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]})).unwrap();
h.app.run("edit.undo", json!({})).unwrap();
h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Main".into() }];
let rev = h.app.session.revision;
h.app.home.show(rev);
h.frames(2);
h
};
let mut h = on_home();
assert!(!design_shown(&h.app));
// Everything an action could change: the design, its history and file, files written,
// the selection and visibility, and whether it left Home or opened a tool, dialog or the command box.
let state = |h: &crate::scenario::Harness| {
let files = std::fs::read_dir(&h.dir).map_or(0, |d| d.count());
let doc = serde_json::to_value(&*h.app.session.doc).unwrap();
let busy = (h.app.tool.is_some(), h.app.dialog.is_some(), h.app.ui.palette_open);
(
doc,
h.app.session.undo.len(),
h.app.session.redo.len(),
h.app.session.path.clone(),
files,
h.app.session.selection.clone(),
h.app.session.visibility.hidden_bodies.clone(),
h.app.home.open,
busy,
)
};
let click = |h: &mut crate::scenario::Harness, handle: &str| {
let at = crate::scenario::handle_at(handle).unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
};
let mut actions: Vec<(String, Box<dyn Fn(&mut crate::scenario::Harness)>)> = Vec::new();
for icon in ["save", "undo", "redo"] {
actions.push((format!("{icon} button"), Box::new(move |h| click(h, &format!("appbar:{icon}")))));
}
for (key, cmd) in [("S", true), ("Z", true), ("Y", true), ("Delete", false), ("V", false), ("E", false), ("S", false)] {
let name = if cmd { format!("Ctrl+{key}") } else { key.to_string() };
actions.push((name, Box::new(move |h| drop(h.call("ui.key", json!({"key": key, "cmd": cmd}))))));
}
for item in ["Insert STEP…", "Insert Mesh…", "Save", "Save As…", "Export STL…", "Export STEP…", "Export 3MF…", "Export OBJ…", "Export URDF…"]
{
actions.push((
format!("File › {item}"),
Box::new(move |h| {
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu"), false));
click(h, "appbar:folder");
let _ = h.step(&json!({"widget": item}));
}),
));
}
// Each action on its own design, so one cannot hide another.
let mut acted = Vec::new();
for (name, act) in &actions {
let mut h = on_home();
let before = state(&h);
act(&mut h);
h.settle();
if state(&h) != before {
acted.push(name.clone());
}
}
assert!(acted.is_empty(), "these acted on the hidden design from Home: {acted:?}");
h.app.home.open = false;
h.frames(2);
let at = crate::scenario::handle_at("appbar:redo").unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
assert_eq!(h.app.session.redo.len(), 0, "Redo works once Home is closed");
h.call("ui.key", json!({"key": "Z", "cmd": true}));
h.settle();
assert_eq!(h.app.session.redo.len(), 1, "Ctrl+Z works once Home is closed");
let at = crate::scenario::handle_at("appbar:save").unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
h.settle();
assert!(h.app.session.path.is_some() && !h.app.session.is_dirty(), "the design saves once Home is closed");
}
/// File › Export URDF… writes the robot description and its meshes next to the picked file.
#[test]
fn file_menu_exports_a_urdf() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
h.frames(2);
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu"), false));
let at = crate::scenario::handle_at("appbar:folder").unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
let _ = h.step(&json!({"widget": "Export URDF…"}));
h.settle();
let files: Vec<String> = std::fs::read_dir(&h.dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
assert!(files.iter().any(|f| f.ends_with(".urdf")) && files.iter().any(|f| f.ends_with("-base.stl")), "{files:?}");
}
/// The disabled buttons' tooltip is translated in every catalog (not English fallback).
#[test]
fn needs_design_tooltip_is_translated() {
let english = crate::i18n::Locale::for_language("en").text("ui.needs_design");
assert!(english.contains("{command}"));
for catalog in crate::i18n::LOCALES.iter().filter(|c| c.id != "en") {
let text = crate::i18n::Locale::for_language(catalog.id).text("ui.needs_design");
assert!(text != english && text.contains("{command}"), "{} must translate ui.needs_design", catalog.id);
}
}
}
#[cfg(test)]
mod dropdown_tests {
use super::*;
/// A drop-down taller than the window stops at its bottom and scrolls: the last row is
/// reached with the arrow keys and comes into view.
#[test]
fn a_tall_drop_down_scrolls_inside_a_short_window() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.call("ui.resize", json!({"width":1200,"height":480}));
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.frames(4);
h.step(&json!({"click":{"handle":"panel:SKETCH:CREATE"}})).unwrap();
h.frames(4);
let menu = crate::scenario::rect_of("toolbar_dropdown").expect("the drop-down is open");
assert!(menu.bottom() <= 480.0, "the drop-down runs past the window: {menu:?}");
assert!(crate::scenario::handle_at("dropdown:sketch.intersect").is_some_and(|p| !menu.contains(p)), "Intersect starts below the fold");
// Up from no focus goes to the last enabled row; it scrolls into view.
h.step(&json!({"key":"ArrowUp"})).unwrap();
h.frames(10);
let hot = crate::scenario::handles("dropdown_hot:");
let [(_, at)] = hot.as_slice() else { panic!("one highlighted row: {hot:?}") };
let menu = crate::scenario::rect_of("toolbar_dropdown").unwrap();
assert!(menu.contains(*at), "the focused last row is out of view: {at:?} in {menu:?}");
}
/// Every row of a drop-down is shown when the window has room, even after a shorter list was
/// shown first: another tab's panel of the same name, or the same panel collapsed (#874).
#[test]
fn a_drop_down_shows_all_its_rows_after_a_shorter_one() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.call("ui.resize", json!({"width":1600,"height":1000}));
let open = |h: &mut crate::scenario::Harness, tab: &str| {
h.app.ui.tab = tab.into();
h.frames(4);
h.step(&json!({"click":{"handle":format!("panel:{tab}:MODIFY")}})).unwrap();
h.frames(6);
let menu = crate::scenario::rect_of("toolbar_dropdown").expect("the drop-down is open");
let rows = crate::scenario::handles("dropdown:");
h.step(&json!({"key":"Escape"})).unwrap();
h.frames(4);
(menu, rows)
};
let (short, short_rows) = open(&mut h, "MESH");
let (menu, rows) = open(&mut h, "PLASTIC");
assert!(rows.len() > short_rows.len(), "PLASTIC MODIFY lists more than MESH MODIFY: {rows:?} vs {short_rows:?}");
assert!(menu.height() > short.height(), "the longer list is taller: {menu:?} vs {short:?}");
for (id, at) in &rows {
assert!(menu.contains(*at), "{id} at {at:?} is cut off by the drop-down {menu:?}");
}
}
}
#[cfg(test)]
mod panel_drop_tests {
use super::*;
#[test]
fn panel_label_gap_and_margins_append_while_buttons_insert_before() {
for target in ["label", "gap", "left margin", "right margin", "button"] {
let mut h = crate::scenario::Harness::new();
h.step(&json!({"click":{"handle":"panel:SOLID:CREATE"}})).unwrap();
let label = crate::scenario::handle_at("panel:SOLID:CREATE").unwrap();
let destination = match target {
"gap" => label - vec2(0.0, 10.0),
"left margin" => pos2(label.x - panel_width(4) / 2.0 + 1.0, label.y - 30.0),
"right margin" => pos2(label.x + panel_width(4) / 2.0 - 1.0, label.y - 30.0),
"button" => crate::scenario::handle_at("button:solid.extrude").unwrap(),
_ => label,
};
h.step(&json!({"drag":[{"handle":"dropdown:solid.cylinder"}, [destination.x,destination.y]],"steps":12})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
let order = if target == "button" {
json!(["button:sketch.create", "button:solid.cylinder", "button:solid.extrude", "button:solid.revolve", "button:solid.sweep"])
} else {
json!(["button:sketch.create", "button:solid.extrude", "button:solid.revolve", "button:solid.sweep", "button:solid.cylinder"])
};
h.step(&json!({"expect":{"handle_order":order,"tool":null,"dialog":null,"errors":0}})).unwrap();
assert!(h.app.session.doc.features.is_empty(), "{target}: pinning must not run the command");
h.step(&json!({"restart":"close"})).unwrap();
h.step(&json!({"expect":{"handle_order":order}})).unwrap();
}
}
#[test]
fn panel_label_drop_moves_a_button_between_panels() {
let mut h = crate::scenario::Harness::new();
h.step(&json!({"drag":[{"handle":"button:solid.extrude"},{"handle":"panel:SOLID:MODIFY"}],"steps":12})).unwrap();
h.step(&json!({"expect":{"handle_order":["button:solid.fillet","button:solid.chamfer","button:solid.scale","button:solid.extrude"],"tool":null,"dialog":null,"errors":0}})).unwrap();
h.step(&json!({"restart":"close"})).unwrap();
h.step(&json!({"expect":{"handle_order":["button:solid.scale","button:solid.extrude"]}})).unwrap();
}
}
#[cfg(test)]
mod icon_accent_tests {
use super::*;
/// `icons.rs` pins one half of the construction icon contract. `construction` and
/// `centerline` take their colour from whatever accent their caller hands over. That half
/// stays green when the caller hands over the wrong colour. That is exactly what the toolbar
/// did. This pins the other half: the colour the toolbar actually passes for those two
/// commands, on every surface the toolbar draws them on.
#[test]
fn the_toolbar_passes_construction_orange_for_both_line_type_commands() {
for t in [Tokens::light(), Tokens::dark()] {
// The regression itself: the SKETCH tab's accent is green. These two icons drew in
// it. Construction orange has to be a colour no panel accent can supply.
assert_ne!(t.construction_geom, t.sketch_accent, "construction orange must differ from the sketch accent");
assert_ne!(t.construction_geom, t.accent, "construction orange must differ from the plain accent");
assert_ne!(t.construction_geom, t.glyph_text, "construction orange must differ from the constraint red");
for id in ["sketch.construction", "sketch.centerline"] {
assert_eq!(fixed_accent(t, id), Some(t.construction_geom), "{id} must have a fixed accent");
// Its ribbon button, a collapsed panel's single button, and a drop-down row.
assert_eq!(command_accent(t, "SKETCH", "CREATE", id), t.construction_geom, "{id} on the SKETCH ribbon");
assert_eq!(command_accent(t, "SOLID", "CREATE", id), t.construction_geom, "{id} on the SOLID ribbon");
assert_eq!(fixed_accent(t, id).unwrap_or(t.accent), t.construction_geom, "{id} in a drop-down row");
}
}
}
/// Those two and no others. Every other command's icon keeps the accent of the panel it sits
/// on, so the constraint red and the sketch green both survive the exception above.
#[test]
fn no_other_command_overrides_the_accent_of_its_own_panel() {
let t = Tokens::light();
let specs = solidcraft_engine::command_specs();
let mut fixed: Vec<&str> = specs.iter().map(|c| c.id).filter(|id| fixed_accent(t, id).is_some()).collect();
fixed.sort_unstable();
assert_eq!(fixed, ["sketch.centerline", "sketch.construction"], "only the two line type commands fix their own accent");
for c in &specs {
if fixed.contains(&c.id) {
continue;
}
let want = panel_accent(t, c.tab, c.panel);
assert_eq!(command_accent(t, c.tab, c.panel, c.id), want, "{} must keep the accent of its own panel", c.id);
}
// The fix did not flatten every icon to one colour. A sketch tool is still green, a
// constraint still red, a solid tool still blue. A missing id fails this as well.
let accent_of = |id: &str| solidcraft_engine::find_command(id).map(|c| command_accent(t, c.tab, c.panel, c.id));
assert_eq!(accent_of("sketch.text"), Some(t.sketch_accent));
assert_eq!(accent_of("sketch.constraint.polygon"), Some(t.glyph_text));
assert_eq!(accent_of("solid.extrude"), Some(t.accent));
}
/// Both line type commands sit on the SKETCH tab's CREATE panel. The accent above is
/// therefore a colour a person can actually see. Their icons are the two `icons.rs` draws
/// from the caller's accent.
#[test]
fn both_line_type_commands_are_create_panel_commands_with_their_own_icons() {
for (id, icon) in [("sketch.construction", "construction"), ("sketch.centerline", "centerline")] {
let seen = solidcraft_engine::find_command(id).map(|c| (c.tab, c.panel, c.icon));
assert_eq!(seen, Some(("SKETCH", "CREATE", icon)), "{id} must be on the SKETCH CREATE panel");
}
}
}