Toolbar customisation: a command's menu (right-click 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; drag buttons to reorder within and between panels (drop line); Reset Toolbar; per workspace and tab, kept with the preferences, exported and imported with the keyboard shortcuts. Workspace switcher spans the toolbar's height and opens the workspace menu (Design; the others coming later)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-10 07:19:58 +00:00
1 parent b5e74030c0
commit 4812ec6c0b
7 files changed
+370 -21

No files matched your search

+20 -2
View File
@@ -46,6 +46,12 @@ pub enum Target {
Joint {
id: u64,
},
/// A command (toolbar button, panel drop-down row, S box row).
ToolbarCommand {
id: String,
},
/// The workspace switcher.
Workspace,
/// The Named Views folder, and one view (saved or standard) in it.
NamedViews,
NamedView {
@@ -228,6 +234,8 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec<String> {
| Target::Origin
| Target::Units
| Target::Joint { .. }
| Target::ToolbarCommand { .. }
| Target::Workspace
| Target::NamedViews
| Target::NamedView { .. } => Vec::new(),
}
@@ -290,11 +298,19 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
Target::Origin => crate::browser::origin_items(app),
Target::NamedViews => vec![act("ui.newView", "New Named View", "perspective")],
Target::Joint { id } => joint_items(app, *id),
Target::ToolbarCommand { id } => crate::toolbar_custom::command_items(app, id),
Target::Workspace => {
let mut v = vec![act("ui.workspace", "Design", "box").key("current")];
for w in ["Generative Design", "Render", "Animation", "Simulation", "Manufacture", "Drawing"] {
v.push(act("ui.workspace", w, "").key("coming later").on(false));
}
v
}
Target::Units => crate::prefs::UNITS
.iter()
.map(|u| {
let label = if app.session.doc.units == *u { format!("{u} ✓") } else { (*u).to_string() };
cmd(app, "document.units", &label).with(json!({ "units": u }))
let item = cmd(app, "document.units", u).with(json!({ "units": u }));
if app.session.doc.units == *u { item.key("current") } else { item }
})
.collect(),
Target::NamedView { name } => crate::browser::view_items(app, name),
@@ -773,6 +789,8 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
}
}
"ui.canvasVisible" => drop(app.run("canvas.edit", p)),
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p),
"ui.workspace" => {}
"ui.editJoint" | "ui.driveJoint" => {
if let Some(id) = id_of(&p, "joint") {
let d = if item.id == "ui.editJoint" {
+21
View File
@@ -150,6 +150,12 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
if !app.keymap.read_only && ui.button("Reset All").clicked() {
action = Some((String::new(), "reset_all"));
}
if ui.button("Export…").on_hover_text("Keys, S box favourites and the toolbar, as a file").clicked() {
action = Some((String::new(), "export"));
}
if !app.keymap.read_only && ui.button("Import…").clicked() {
action = Some((String::new(), "import"));
}
});
if let Some(m) = &app.keymap.message {
ui.label(RichText::new(m).color(t.warning));
@@ -216,6 +222,21 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
Some((id, "clear")) => drop(bind(app, &id, "", false)),
Some((id, "reset")) => reset(app, Some(&id)),
Some((_, "reset_all")) => reset(app, None),
Some((_, "export")) => {
let text = crate::toolbar_custom::export(app).to_string();
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f("SolveCraft shortcuts.json", &["json"])) {
app.keymap.message = std::fs::write(&path, text).err().map(|e| format!("export: {e}"));
}
}
Some((_, "import")) => {
if let Some(path) = app.services.pick_open.as_ref().and_then(|f| f()) {
let r = std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|t| serde_json::from_str::<Value>(&t).map_err(|e| e.to_string()))
.and_then(|v| crate::toolbar_custom::import(app, &v));
app.keymap.message = r.err().map(|e| format!("import: {e}"));
}
}
Some((pair, "replace")) => {
if let Some((id, key)) = pair.split_once('\n') {
let _ = bind(app, id, key, true);
+8
View File
@@ -59,6 +59,7 @@ pub mod theme;
pub mod timeline;
pub mod titlebar;
pub mod toolbar;
pub mod toolbar_custom;
pub mod tools;
pub mod viewport;
pub mod workspace;
@@ -181,6 +182,8 @@ pub struct SolveApp {
pub keymap: keymap::Keymap,
/// The Help menu and the About window.
pub help: help::HelpState,
/// Toolbar buttons the user pinned, removed or reordered.
pub toolbar_custom: toolbar_custom::ToolbarCustom,
/// Preferences kept between runs, and their window.
pub preferences: prefs::Prefs,
pub prefs_window: prefs::PrefsWindow,
@@ -244,6 +247,7 @@ impl SolveApp {
sbox: shortcut_box::ShortcutBox::default(),
keymap: keymap::Keymap::default(),
help: help::HelpState::default(),
toolbar_custom: toolbar_custom::ToolbarCustom::default(),
preferences: prefs::Prefs::default(),
prefs_window: prefs::PrefsWindow::default(),
preview: preview::PreviewState::default(),
@@ -295,6 +299,7 @@ impl SolveApp {
"recent": self.home.recent,
"shortcut_box": self.sbox.prefs(),
"shortcuts": self.keymap.prefs(),
"toolbar": self.toolbar_custom.prefs(),
"autosave_minutes": self.autosave_minutes,
"ground_shadow": self.ui.ground_shadow,
"agent_cursor": self.ui.agent_cursor,
@@ -348,6 +353,9 @@ impl SolveApp {
if let Some(s) = v.get("shortcuts") {
self.keymap.load(s);
}
if let Some(t) = v.get("toolbar") {
self.toolbar_custom.load(t);
}
if let Some(p) = v.get("preferences").and_then(|p| serde_json::from_value::<prefs::Prefs>(p.clone()).ok()) {
self.preferences = p;
}
+49
View File
@@ -479,3 +479,52 @@ fn occurrence_moves_turn_about_their_origin_then_move() {
crate::browser::apply_occurrence_move(&mut app, &m);
assert_eq!(*app.session.pending_moves.get(&occ).unwrap(), solvecraft_engine::doc::IDENTITY);
}
#[test]
fn toolbar_pins_removes_reorders_and_keeps_its_layout() {
use crate::toolbar_custom as tc;
let mut app = sample_app();
app.ui.tab = "SOLID".into();
let ids = |app: &SolveApp, panel: &str| -> Vec<&'static str> {
let lists = tc::panel_lists("SOLID");
let (_, cmds, n) = lists.iter().find(|(p, ..)| *p == panel).unwrap();
tc::buttons(app, "SOLID", panel, cmds, *n).iter().map(|c| c.id).collect()
};
let create = ids(&app, "CREATE");
// Pin a command that isn't a button yet: it lands at the end of its panel.
let extra =
tc::panel_lists("SOLID").iter().find(|(p, ..)| *p == "CREATE").unwrap().1.iter().map(|c| c.id).find(|id| !create.contains(id)).unwrap();
act(&mut app, &Target::ToolbarCommand { id: extra.into() }, "Pin to Toolbar");
assert_eq!(ids(&app, "CREATE").last(), Some(&extra));
// Remove a default button.
act(&mut app, &Target::ToolbarCommand { id: create[0].into() }, "Remove from Toolbar");
assert!(!ids(&app, "CREATE").contains(&create[0]));
// Reorder: the pinned one before the second default; then into another panel.
tc::reorder(&mut app, "SOLID", extra, "CREATE", Some(create[1]));
assert_eq!(ids(&app, "CREATE")[0], extra);
tc::reorder(&mut app, "SOLID", extra, "MODIFY", None);
assert!(!ids(&app, "CREATE").contains(&extra) && ids(&app, "MODIFY").last() == Some(&extra));
// Pin to Shortcuts goes to the S box's favourites.
act(&mut app, &Target::ToolbarCommand { id: "Extrude".into() }, "Pin to Shortcuts");
assert!(app.sbox.pinned.iter().any(|p| p == "Extrude"));
// Kept between runs, and exported and imported with the shortcuts.
let mut other = sample_app();
other.load_prefs(&app.prefs());
assert_eq!(other.toolbar_custom, app.toolbar_custom);
let file = tc::export(&app);
let mut third = sample_app();
tc::import(&mut third, &file).unwrap();
assert_eq!(third.toolbar_custom, app.toolbar_custom);
assert!(tc::import(&mut third, &json!({"format": "nope"})).is_err());
// Reset Toolbar puts the tab back.
act(&mut app, &Target::ToolbarCommand { id: extra.into() }, "Reset Toolbar");
assert_eq!(ids(&app, "CREATE"), create);
}
#[test]
fn the_workspace_menu_lists_design_and_greys_the_rest() {
let app = sample_app();
let items = context_menu::items(&app, &Target::Workspace);
assert!(find(&items, "Design").enabled);
assert!(!find(&items, "Render").enabled && find(&items, "Render").shortcut == "coming later");
}
+9
View File
@@ -152,6 +152,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
let mut picked: Option<&'static str> = None;
let mut pin: Option<&'static str> = None;
let mut menu: Option<(Pos2, &'static str)> = None;
let h = 380.0;
let pos = pos2(
at.x.clamp(screen.left() + 4.0, (screen.right() - W - 4.0).max(screen.left())),
@@ -237,6 +238,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
} else if resp.clicked() && enabled {
picked = Some(id);
}
if resp.secondary_clicked() {
menu = Some((resp.interact_pointer_pos().unwrap_or(r.left_bottom()), id));
}
}
});
});
@@ -244,6 +248,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if let Some(id) = pin {
app.sbox.toggle_pin(id);
}
// A row's menu: pin to the toolbar or the shortcuts.
if let Some((at, id)) = menu {
crate::context_menu::open_for(app, at, crate::context_menu::Target::ToolbarCommand { id: id.to_string() });
return;
}
if enter
&& picked.is_none()
&& let Some((id, ..)) = list.get(app.sbox.index)
+71 -19
View File
@@ -2,7 +2,6 @@
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
use solvecraft_engine::command_specs;
use crate::theme::Tokens;
use crate::{SolveApp, icons};
@@ -10,7 +9,7 @@ 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).
fn panels(tab: &str) -> &'static [&'static str] {
pub fn panels(tab: &str) -> &'static [&'static str] {
if let Some(p) = crate::workspace::panels(tab) {
return p;
}
@@ -26,7 +25,7 @@ fn panels(tab: &str) -> &'static [&'static str] {
}
/// How many commands of a panel get a big button.
fn promoted(tab: &str, panel: &str) -> usize {
pub fn promoted(tab: &str, panel: &str) -> usize {
match (tab, panel) {
("SOLID", "CREATE") => 4,
("SOLID", "MODIFY") => 4,
@@ -253,13 +252,25 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let painter = ui.painter().clone();
// Workspace selector and tabs.
let tabs_y = r.top() + 2.0;
let ws = Rect::from_min_size(pos2(r.left() + 8.0, tabs_y + 26.0), vec2(96.0, 56.0));
painter.rect(ws, 4.0, t.field, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
// 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,
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, "workspace", FontId::proportional(10.0), t.text_dim);
if wresp.on_hover_text("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);
painter.text(pos2(ws.center().x - 5.0, ws.top() + 14.0), Align2::CENTER_CENTER, "DESIGN", FontId::proportional(12.0), t.text);
caret(&painter, pos2(ws.center().x + 26.0, ws.top() + 14.0), t.text);
painter.text(pos2(ws.center().x, ws.top() + 34.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim);
let mut x = r.left() + 112.0;
let mut tabs: Vec<&str> = Vec::new();
if sketching {
@@ -293,23 +304,50 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
}
// Panels.
let tab = app.ui.tab.clone();
let specs = command_specs();
let mut px = r.left() + 112.0;
let top = r.top() + 26.0;
for panel in panels(&tab) {
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(), promoted(&tab, panel)),
};
let cmds = crate::sketch_tools::also_in(&tab, panel, cmds, &specs);
let n = promote.min(cmds.len()).max(if cmds.is_empty() { 1 } else { 0 });
let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None;
let mut menu: Option<(egui::Pos2, &'static str)> = None;
for (panel, cmds, promote) in crate::toolbar_custom::panel_lists(&tab) {
let panel: &&str = &panel;
let shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote);
let n = shown.len().max(1);
let width = (n as f32 * 40.0).max(64.0) + 8.0;
let enabled_panel = !cmds.is_empty() || *panel == "SELECT";
for (i, c) in cmds.iter().take(n).enumerate() {
// Dropped on the panel's free space: the button goes to its end.
let free = Rect::from_min_size(
pos2(px + 4.0 + shown.len() as f32 * 40.0, top + 2.0),
vec2((width - 8.0 - shown.len() as f32 * 40.0).max(8.0), 38.0),
);
let fresp = ui.interact(free, ui.id().with(("panel_free", panel)), 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.0, *panel, None));
}
for (i, c) in shown.iter().enumerate() {
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());
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());
if resp.drag_started() {
resp.dnd_set_drag_payload(ToolbarDrag(c.id));
}
if let Some(d) = resp.dnd_hover_payload::<ToolbarDrag>()
&& d.0 != 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.0 != c.id
{
drop = Some((d.0, *panel, Some(c.id)));
}
if resp.secondary_clicked() {
menu = Some((resp.interact_pointer_pos().unwrap_or(br.left_bottom()), c.id));
}
if resp.hovered() && info.enabled {
painter.rect_filled(br, 4.0, t.hover);
}
@@ -329,7 +367,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
app.start(id);
}
}
if cmds.is_empty() {
if shown.is_empty() {
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);
}
@@ -375,6 +413,9 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
icons::paint(ui.painter(), ir, c.icon, t.icon, t.icon_fill, t.accent);
let b = ui.add_enabled(info.enabled, egui::Button::new(c.label).frame(false).shortcut_text(c.shortcut.unwrap_or("")));
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), b.rect.center());
if b.secondary_clicked() {
menu = Some((b.interact_pointer_pos().unwrap_or(b.rect.left_bottom()), c.id));
}
if b.clicked() {
let id = c.id;
app.start(id);
@@ -393,9 +434,20 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
px += 2.0;
}
painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border));
if let Some((id, panel, before)) = drop {
crate::toolbar_custom::reorder(app, &tab, id, panel, before);
}
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() });
}
});
}
/// A toolbar button being dragged (its command id).
#[derive(Clone, Copy, Debug)]
struct ToolbarDrag(&'static str);
/// The SELECT panel: what a click in the view picks.
fn selection_filter(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
+192
View File
@@ -0,0 +1,192 @@
//! 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; 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 solvecraft_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>>,
}
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| solvecraft_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 solvecraft_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);
}
/// 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.toolbarReset", "Reset Toolbar", "").with(json!({ "tab": tab })));
v
}
/// 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.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": "solvecraft-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> {
if v.get("format").and_then(Value::as_str) != Some("solvecraft-shortcuts/1") {
return Err("not a SolveCraft 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(())
}