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.
1635 lines
82 KiB
Rust
1635 lines
82 KiB
Rust
//! 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");
|
||
}
|
||
}
|
||
}
|