A panel drop-down taller than the window ran past its bottom edge, so the last rows (Sketch → Create → Intersect and below) could not be reached. The rows now scroll inside the window, and a row reached with the arrow keys scrolls into view. Row ids no longer depend on the enclosing Ui, so the keyboard focus still finds them inside the scroll area.
1275 lines
63 KiB
Rust
1275 lines
63 KiB
Rust
//! Application bar, the workspace toolbar (tabs and panels) and keyboard shortcuts.
|
||
|
||
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"];
|
||
|
||
/// 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", "CREATE") => 6,
|
||
("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 solvecraft_engine::CommandSpec],
|
||
shown: &[&'static solvecraft_engine::CommandSpec],
|
||
) -> Vec<&'static solvecraft_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)
|
||
}
|
||
|
||
pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get();
|
||
egui::Panel::top("sc_appbar").exact_size(34.0).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);
|
||
let s = 20.0 / 412.217;
|
||
let m =
|
||
|pts: &[(f32, f32)]| -> Vec<egui::Pos2> { pts.iter().map(|&(x, y)| pos2(c.x + (x - 178.5) * s, c.y + (y - 206.1) * s)).collect() };
|
||
let p = ui.painter();
|
||
// egui only fills convex shapes, so each face is a few overlapping convex pieces.
|
||
let face = |col: Color32, pts: &[(f32, f32)]| {
|
||
p.add(egui::Shape::convex_polygon(m(pts), col, Stroke::NONE));
|
||
};
|
||
let (light, mid, dark) = (Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197));
|
||
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
|
||
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
|
||
face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
|
||
face(mid, &[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)]);
|
||
face(mid, &[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)]);
|
||
face(dark, &[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)]);
|
||
face(dark, &[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
|
||
face(dark, &[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)]);
|
||
face(dark, &[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)]);
|
||
// White edge lines.
|
||
let edge = Stroke::new(10.0 * s, Color32::WHITE);
|
||
let line = |pts: &[(f32, f32)]| {
|
||
p.add(egui::Shape::line(m(pts), edge));
|
||
};
|
||
line(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)]);
|
||
line(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)]);
|
||
line(&[(177.0, 206.1), (263.8, 156.0)]);
|
||
p.add(egui::Shape::closed_line(
|
||
m(&[
|
||
(178.5, 5.8),
|
||
(5.0, 105.9),
|
||
(5.0, 206.1),
|
||
(91.7, 256.2),
|
||
(91.7, 356.4),
|
||
(178.5, 406.4),
|
||
(352.0, 306.3),
|
||
(352.0, 206.1),
|
||
(265.2, 156.0),
|
||
(265.2, 55.9),
|
||
]),
|
||
edge,
|
||
));
|
||
}
|
||
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
|
||
};
|
||
let file = click(ui, "folder", app.tr("ui.file"), true);
|
||
// Save, Undo and Redo act on the design, which Home hides.
|
||
let shown = design_shown(app);
|
||
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"), true) {
|
||
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);
|
||
let tabs_end = report.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());
|
||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Report a bug"));
|
||
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 a bug",
|
||
FontId::proportional(13.0),
|
||
t.app_bar_text,
|
||
);
|
||
}
|
||
if response.on_hover_text("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{}", env!("CARGO_PKG_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") {
|
||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||
app.open_path_async(&p);
|
||
}
|
||
close = true;
|
||
}
|
||
ui.menu_button(app.tr("ui.open_recent"), |ui| {
|
||
for path in app.home.recent.clone() {
|
||
let missing = !solvecraft_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;
|
||
}
|
||
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 OBJ…", "", shown) {
|
||
export(app, "obj");
|
||
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) {
|
||
let name = format!("{}.{}", app.session.doc.name, solvecraft_engine::io::DESIGN_EXT);
|
||
if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[solvecraft_engine::io::DESIGN_EXT])) {
|
||
let _ = app.run("file.save_as", json!({"path": p}));
|
||
}
|
||
}
|
||
|
||
fn export(app: &mut SolveApp, ext: &str) {
|
||
let name = format!("{}.{ext}", app.session.doc.name);
|
||
if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[ext]))
|
||
&& app.run("file.export", json!({"path": p})).is_ok()
|
||
{
|
||
app.set_status(format!("Exported {p}"), 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 widths: Vec<f32> = lists.iter().map(|(_, _, shown)| panel_width(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(panel_width(shown.len())) } else { panel_width(shown.len()) };
|
||
let enabled_panel = !cmds.is_empty() || *panel == "SELECT";
|
||
let popup_id = ui.id().with(("panel_menu", 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() };
|
||
let free = Rect::from_min_size(pos2(px + 4.0 + drawn as f32 * 40.0, top + 2.0), vec2((width - 8.0 - drawn as f32 * 40.0).max(8.0), 38.0));
|
||
let fresp = ui.interact(free, ui.id().with(("panel_free", panel)), if collapsed { Sense::click() } else { Sense::hover() });
|
||
if fresp.dnd_hover_payload::<ToolbarDrag>().is_some() {
|
||
painter.line_segment([pos2(free.left(), free.top() + 2.0), pos2(free.left(), free.bottom() - 2.0)], Stroke::new(2.0, t.accent));
|
||
}
|
||
if let Some(d) = fresp.dnd_release_payload::<ToolbarDrag>() {
|
||
drop = Some((d.id, *panel, None));
|
||
}
|
||
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 = if tab == "SKETCH" { t.sketch_accent } else { t.accent };
|
||
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) {
|
||
let br = Rect::from_min_size(pos2(px + 4.0 + i as f32 * 40.0, top + 2.0), vec2(38.0, 38.0));
|
||
let info = c.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 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(c.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, if tab == "SKETCH" { t.sketch_accent } else { t.accent })
|
||
} else {
|
||
(t.border, t.panel_header, t.border)
|
||
};
|
||
let icon = match c.id {
|
||
"sketch.finish" => "finish",
|
||
"config.table" => "configuration_table",
|
||
_ => c.icon,
|
||
};
|
||
icons::paint(&painter, br.shrink(6.0), icon, ink, fill, acc);
|
||
let label = match c.id {
|
||
"parameters.change" if *panel == "CONFIGURE" => "Parameters",
|
||
"config.table" if *panel == "CONFIGURE" => "Configurations",
|
||
_ => c.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, c.shortcut.map(|k| format!(" ({k})")).unwrap_or_default()),
|
||
};
|
||
if resp.on_hover_text(tip).clicked() && info.enabled {
|
||
let id = c.id;
|
||
app.start(id);
|
||
}
|
||
}
|
||
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 lr = Rect::from_min_size(pos2(px, top + 42.0), vec2(width, 18.0));
|
||
let label = app.tr_label(panel).to_string();
|
||
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);
|
||
}
|
||
let lc = if *panel == "FINISH SKETCH" {
|
||
t.sketch_accent
|
||
} else if enabled_panel {
|
||
t.text
|
||
} else {
|
||
t.text_dim
|
||
};
|
||
let g = painter.layout_no_wrap(label, FontId::proportional(10.5), lc);
|
||
let gx = lr.center().x - (g.size().x + 9.0) / 2.0;
|
||
let gy = lr.center().y - g.size().y / 2.0;
|
||
let gw = g.size().x;
|
||
painter.galley(pos2(gx, gy), g, lc);
|
||
caret(&painter, pos2(gx + gw + 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));
|
||
}
|
||
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("Not available yet").color(t.text_dim));
|
||
}
|
||
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.
|
||
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).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 *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("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 solvecraft_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 solvecraft_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());
|
||
}
|
||
let (ink, fill, acc, text) =
|
||
if info.enabled { (t.icon, t.icon_fill, t.accent, 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
|
||
}
|
||
|
||
/// 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) = solvecraft_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, 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);
|
||
p.line_segment([x.left_top(), x.right_bottom()], s);
|
||
p.line_segment([x.right_top(), x.left_bottom()], s);
|
||
}
|
||
crate::scenario::publish_handle(if removing { "toolbar_drag:remove" } else { "toolbar_drag" }, at);
|
||
}
|
||
|
||
/// A push pin (drawn in code): upright and filled when the command is on the toolbar, tilted and
|
||
/// outlined when not.
|
||
fn pin_icon(p: &egui::Painter, r: Rect, on: bool, col: Color32) {
|
||
let c = r.center();
|
||
let (sin, cos) = if on { (0.0, 1.0) } else { std::f32::consts::FRAC_PI_4.sin_cos() };
|
||
// Local coordinates: the pin points down, its head on top.
|
||
let at = |x: f32, y: f32| c + vec2(x * cos - y * sin, x * sin + y * cos);
|
||
let head = vec![at(-2.5, -7.0), at(2.5, -7.0), at(3.0, -2.0), at(-3.0, -2.0)];
|
||
let stroke = Stroke::new(1.4, col);
|
||
if on {
|
||
p.add(egui::Shape::convex_polygon(head, col, stroke));
|
||
} else {
|
||
p.add(egui::Shape::closed_line(head, stroke));
|
||
}
|
||
p.line_segment([at(-5.5, -2.0), at(5.5, -2.0)], stroke);
|
||
p.line_segment([at(0.0, -2.0), at(0.0, 7.0)], stroke);
|
||
}
|
||
|
||
/// 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("Selection filter").color(t.text_dim).size(11.0));
|
||
ui.checkbox(&mut app.ui.pick_bodies, "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, label).changed() {
|
||
app.ui.pick_off.retain(|x| x != k);
|
||
if !on {
|
||
app.ui.pick_off.push(k.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A small drop-down caret.
|
||
fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) {
|
||
p.add(egui::Shape::convex_polygon(vec![pos2(c.x - 3.5, c.y - 2.0), pos2(c.x + 3.5, c.y - 2.0), pos2(c.x, c.y + 2.5)], col, Stroke::NONE));
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
if ctx.egui_wants_keyboard_input() {
|
||
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 => {
|
||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||
app.open_path_async(&p);
|
||
}
|
||
}
|
||
_ if !shown => {}
|
||
Key::Z => drop(app.run("edit.undo", json!({}))),
|
||
Key::Y => drop(app.run("edit.redo", json!({}))),
|
||
Key::S => save(app),
|
||
_ => {
|
||
if let Some(id) = crate::keymap::command_for(app, &crate::keymap::key_name(k, mods)) {
|
||
app.start(id);
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
if !shown {
|
||
continue;
|
||
}
|
||
match k {
|
||
Key::Escape => {
|
||
app.esc_handled = true;
|
||
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 => crate::viewport::delete_selection(app),
|
||
_ => {
|
||
if let Some(id) = crate::keymap::command_for(app, &crate::keymap::key_name(k, mods)) {
|
||
app.start(id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod header_report_tests {
|
||
use super::*;
|
||
|
||
#[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 = solvecraft_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![solvecraft_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…"] {
|
||
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");
|
||
}
|
||
|
||
/// 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:?}");
|
||
}
|
||
}
|