* WIP deep rename (not for push) * WIP2 * WIP3 * WIP4 wxs fix * rustfmt * Rename the remaining identifiers in merged crash-report code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
11 KiB
Rust
283 lines
11 KiB
Rust
//! Toolbar customisation: which commands each panel shows as buttons, per workspace and tab.
|
||
//! A command's menu (right-click on a toolbar button, a panel drop-down row or an S box row)
|
||
//! pins it to the toolbar, removes it, or pins it to the S box's favourites; buttons are dragged
|
||
//! to reorder them within and between panels; a drop-down row dragged onto the toolbar pins it
|
||
//! there, a button dragged off the toolbar (or back into the drop-down) leaves it; the pin icon
|
||
//! on a hovered drop-down row pins or unpins; the toolbar's own menu (right-click its empty
|
||
//! space) has Customize Toolbar… (a window listing every command of the tab with a check box)
|
||
//! and Reset Toolbar; Reset Toolbar restores the tab. Kept with the
|
||
//! preferences and exported or imported with the keyboard shortcuts.
|
||
|
||
use std::collections::BTreeMap;
|
||
|
||
use serde_json::{Value, json};
|
||
use solidcraft_engine::{CommandSpec, command_specs};
|
||
|
||
use crate::SolveApp;
|
||
use crate::context_menu::Item;
|
||
|
||
/// The only workspace so far.
|
||
pub const WORKSPACE: &str = "DESIGN";
|
||
|
||
#[derive(Default, Clone, Debug, PartialEq)]
|
||
pub struct ToolbarCustom {
|
||
/// `"<workspace>/<tab>/<panel>"` → the panel's buttons, in order (only panels the user
|
||
/// changed).
|
||
pub panels: BTreeMap<String, Vec<String>>,
|
||
/// The Customize Toolbar window is open (not kept).
|
||
pub customizing: bool,
|
||
}
|
||
|
||
impl ToolbarCustom {
|
||
pub fn prefs(&self) -> Value {
|
||
json!(self.panels)
|
||
}
|
||
pub fn load(&mut self, v: &Value) {
|
||
self.panels = v
|
||
.as_object()
|
||
.map(|o| {
|
||
o.iter()
|
||
.take(500)
|
||
.filter_map(|(k, v)| {
|
||
let ids: Vec<String> = v.as_array()?.iter().take(100).filter_map(|x| x.as_str().map(str::to_string)).collect();
|
||
Some((k.clone(), ids))
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
}
|
||
}
|
||
|
||
fn key(tab: &str, panel: &str) -> String {
|
||
format!("{WORKSPACE}/{tab}/{panel}")
|
||
}
|
||
|
||
/// The buttons a panel shows: the user's list, or the first `promoted` commands of the panel.
|
||
pub fn buttons(app: &SolveApp, tab: &str, panel: &str, cmds: &[&'static CommandSpec], promoted: usize) -> Vec<&'static CommandSpec> {
|
||
match app.toolbar_custom.panels.get(&key(tab, panel)) {
|
||
Some(ids) => ids.iter().filter_map(|id| solidcraft_engine::find_command(id)).collect(),
|
||
None => cmds.iter().take(promoted).copied().collect(),
|
||
}
|
||
}
|
||
|
||
/// The panels of a tab with their commands and default button counts (what the toolbar draws).
|
||
pub fn panel_lists(tab: &str) -> Vec<(&'static str, Vec<&'static CommandSpec>, usize)> {
|
||
let specs = command_specs();
|
||
crate::toolbar::panels(tab)
|
||
.iter()
|
||
.map(|panel| {
|
||
let (cmds, promote) = match crate::workspace::layout(tab, panel, &specs) {
|
||
Some(l) => l,
|
||
None => (specs.iter().filter(|c| c.tab == tab && c.panel == *panel).copied().collect(), crate::toolbar::promoted(tab, panel)),
|
||
};
|
||
let cmds = crate::sketch_tools::also_in(tab, panel, cmds, &specs);
|
||
(*panel, cmds, promote)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The buttons of a panel as ids, made the user's own list (so it can change).
|
||
fn own(app: &mut SolveApp, tab: &str, panel: &str) -> Vec<String> {
|
||
let k = key(tab, panel);
|
||
if let Some(v) = app.toolbar_custom.panels.get(&k) {
|
||
return v.clone();
|
||
}
|
||
let lists = panel_lists(tab);
|
||
let ids: Vec<String> =
|
||
lists.iter().find(|(p, ..)| *p == panel).map(|(_, cmds, n)| cmds.iter().take(*n).map(|c| c.id.to_string()).collect()).unwrap_or_default();
|
||
ids
|
||
}
|
||
|
||
/// Where a command is a button on a tab now: its panel.
|
||
pub fn pinned_on(app: &SolveApp, tab: &str, id: &str) -> Option<&'static str> {
|
||
panel_lists(tab).into_iter().find(|(panel, cmds, n)| buttons(app, tab, panel, cmds, *n).iter().any(|c| c.id == id)).map(|(p, ..)| p)
|
||
}
|
||
|
||
/// The panel a command belongs to on a tab (its drop-down), or the tab's first panel.
|
||
fn home_panel(tab: &str, id: &str) -> Option<&'static str> {
|
||
let lists = panel_lists(tab);
|
||
lists.iter().find(|(_, cmds, _)| cmds.iter().any(|c| c.id == id)).or(lists.first()).map(|(p, ..)| *p)
|
||
}
|
||
|
||
/// Pin a command to the toolbar of a tab (as a button of its panel, at the end).
|
||
pub fn pin(app: &mut SolveApp, tab: &str, id: &str) -> Result<(), String> {
|
||
if solidcraft_engine::find_command(id).is_none() {
|
||
return Err(format!("no command `{id}`"));
|
||
}
|
||
if pinned_on(app, tab, id).is_some() {
|
||
return Ok(());
|
||
}
|
||
let panel = home_panel(tab, id).ok_or_else(|| format!("the {tab} tab has no panels"))?;
|
||
let mut ids = own(app, tab, panel);
|
||
ids.push(id.to_string());
|
||
app.toolbar_custom.panels.insert(key(tab, panel), ids);
|
||
Ok(())
|
||
}
|
||
|
||
/// Remove a command's button from a tab (it stays in its panel's drop-down).
|
||
pub fn remove(app: &mut SolveApp, tab: &str, id: &str) {
|
||
let Some(panel) = pinned_on(app, tab, id) else { return };
|
||
let mut ids = own(app, tab, panel);
|
||
ids.retain(|x| x != id);
|
||
app.toolbar_custom.panels.insert(key(tab, panel), ids);
|
||
}
|
||
|
||
/// Move a button before another one (`before`: None puts it at the end of `panel`).
|
||
pub fn reorder(app: &mut SolveApp, tab: &str, id: &str, panel: &str, before: Option<&str>) {
|
||
if let Some(from) = pinned_on(app, tab, id) {
|
||
let mut ids = own(app, tab, from);
|
||
ids.retain(|x| x != id);
|
||
app.toolbar_custom.panels.insert(key(tab, from), ids);
|
||
}
|
||
let mut ids = own(app, tab, panel);
|
||
ids.retain(|x| x != id);
|
||
let at = before.and_then(|b| ids.iter().position(|x| x == b)).unwrap_or(ids.len());
|
||
ids.insert(at, id.to_string());
|
||
app.toolbar_custom.panels.insert(key(tab, panel), ids);
|
||
}
|
||
|
||
/// The pin icon of a drop-down row: pin the command, or remove it when it is pinned.
|
||
pub fn toggle(app: &mut SolveApp, tab: &str, id: &str) {
|
||
if pinned_on(app, tab, id).is_some() {
|
||
remove(app, tab, id);
|
||
} else if let Err(e) = pin(app, tab, id) {
|
||
app.set_status(e, true);
|
||
}
|
||
}
|
||
|
||
/// Reset Toolbar: a tab's panels back to their defaults.
|
||
pub fn reset(app: &mut SolveApp, tab: &str) {
|
||
let prefix = format!("{WORKSPACE}/{tab}/");
|
||
app.toolbar_custom.panels.retain(|k, _| !k.starts_with(&prefix));
|
||
}
|
||
|
||
/// The menu of a command (toolbar button, drop-down row, S box row).
|
||
pub fn command_items(app: &SolveApp, id: &str) -> Vec<Item> {
|
||
let tab = app.ui.tab.clone();
|
||
let on = pinned_on(app, &tab, id).is_some();
|
||
let fav = app.sbox.pinned.iter().any(|p| p == id);
|
||
let mut v = Vec::new();
|
||
v.push(if on {
|
||
Item::action("ui.toolbarRemove", "Remove from Toolbar", "").with(json!({ "command": id }))
|
||
} else {
|
||
Item::action("ui.toolbarPin", "Pin to Toolbar", "").with(json!({ "command": id }))
|
||
});
|
||
v.push(Item::action("ui.shortcutPin", if fav { "Unpin from Shortcuts" } else { "Pin to Shortcuts" }, "").with(json!({ "command": id })));
|
||
v.push(Item::sep());
|
||
v.push(Item::action("ui.toolbarCustomize", "Customize Toolbar…", ""));
|
||
v.push(Item::action("ui.toolbarReset", "Reset Toolbar", "").with(json!({ "tab": tab })));
|
||
v
|
||
}
|
||
|
||
/// The toolbar's own menu (right-click on its empty space).
|
||
pub fn toolbar_items(app: &SolveApp) -> Vec<Item> {
|
||
vec![
|
||
Item::action("ui.toolbarCustomize", "Customize Toolbar…", ""),
|
||
Item::action("ui.toolbarReset", "Reset Toolbar", "").with(json!({ "tab": app.ui.tab })),
|
||
]
|
||
}
|
||
|
||
/// Run a toolbar item from a menu.
|
||
pub fn run(app: &mut SolveApp, action: &str, p: &Value) {
|
||
let tab = p.get("tab").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| app.ui.tab.clone());
|
||
let id = p.get("command").and_then(Value::as_str).unwrap_or("");
|
||
match action {
|
||
"ui.toolbarPin" => {
|
||
if let Err(e) = pin(app, &tab, id) {
|
||
app.set_status(e, true);
|
||
}
|
||
}
|
||
"ui.toolbarRemove" => remove(app, &tab, id),
|
||
"ui.toolbarReset" => reset(app, &tab),
|
||
"ui.toolbarCustomize" => app.toolbar_custom.customizing = true,
|
||
"ui.shortcutPin" => app.sbox.toggle_pin(id),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// Shortcut keys, S box favourites and the toolbar as one file (Keyboard Shortcuts › Export).
|
||
pub fn export(app: &SolveApp) -> Value {
|
||
json!({ "format": "solidcraft-shortcuts/1", "shortcuts": app.keymap.prefs(), "favourites": app.sbox.pinned, "toolbar": app.toolbar_custom.prefs() })
|
||
}
|
||
|
||
pub fn import(app: &mut SolveApp, v: &Value) -> Result<(), String> {
|
||
// "solvecraft-shortcuts/1": files exported before the rename (compatibility shim).
|
||
if !matches!(v.get("format").and_then(Value::as_str), Some("solidcraft-shortcuts/1" | "solvecraft-shortcuts/1")) {
|
||
return Err("not a SolidCraft shortcuts file".into());
|
||
}
|
||
if let Some(s) = v.get("shortcuts") {
|
||
app.keymap.load(s);
|
||
}
|
||
if let Some(f) = v.get("favourites").and_then(Value::as_array) {
|
||
app.sbox.pinned = f.iter().take(64).filter_map(|x| x.as_str().map(str::to_string)).collect();
|
||
}
|
||
if let Some(t) = v.get("toolbar") {
|
||
app.toolbar_custom.load(t);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Customize Toolbar: every command of the tab's panels with a check box (on the toolbar or
|
||
/// not), how to drag them, and Reset Toolbar.
|
||
pub fn window(app: &mut SolveApp, ctx: &egui::Context) {
|
||
if !app.toolbar_custom.customizing {
|
||
return;
|
||
}
|
||
let t = crate::theme::Tokens::get();
|
||
let tab = app.ui.tab.clone();
|
||
let mut open = true;
|
||
let mut changes: Vec<(&'static str, bool)> = Vec::new();
|
||
let mut reset_tab = false;
|
||
crate::frame::window(ctx, "Customize Toolbar", crate::frame::Width::Normal)
|
||
.id(egui::Id::new("sc_toolbar_customize"))
|
||
.open(&mut open)
|
||
.default_height(520.0)
|
||
.show(ctx, |ui| {
|
||
ui.label(
|
||
egui::RichText::new(format!(
|
||
"{} tab. Tick a command to give it a toolbar button. You can also drag a row of a panel's drop-down onto the toolbar, drag a button off the toolbar to remove it, or use the pin on a drop-down row.",
|
||
app.tr_label(&tab)
|
||
))
|
||
.color(t.text_dim),
|
||
);
|
||
ui.separator();
|
||
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
|
||
for (panel, cmds, n) in panel_lists(&tab) {
|
||
if cmds.is_empty() {
|
||
continue;
|
||
}
|
||
ui.label(egui::RichText::new(app.tr_label(panel)).strong().size(11.0));
|
||
let shown = buttons(app, &tab, panel, &cmds, n);
|
||
for c in &cmds {
|
||
let mut on = shown.iter().any(|b| b.id == c.id) || pinned_on(app, &tab, c.id).is_some();
|
||
let r = ui.checkbox(&mut on, app.tr_label(c.label));
|
||
crate::scenario::publish_handle(&format!("customize:{}", c.id), r.rect.left_center() + egui::vec2(8.0, 0.0));
|
||
if r.changed() {
|
||
changes.push((c.id, on));
|
||
}
|
||
}
|
||
ui.add_space(4.0);
|
||
}
|
||
});
|
||
ui.separator();
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Reset Toolbar").clicked() {
|
||
reset_tab = true;
|
||
}
|
||
});
|
||
});
|
||
for (id, on) in changes {
|
||
if on {
|
||
if let Err(e) = pin(app, &tab, id) {
|
||
app.set_status(e, true);
|
||
}
|
||
} else {
|
||
remove(app, &tab, id);
|
||
}
|
||
}
|
||
if reset_tab {
|
||
reset(app, &tab);
|
||
}
|
||
app.toolbar_custom.customizing = open;
|
||
}
|