diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index ab529c4a..fc563cb0 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -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 { | 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 { 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" { diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 0a1654ec..81a6a383 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -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::(&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); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8cad317f..cf3b6494 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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::(p.clone()).ok()) { self.preferences = p; } diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index 64925bb0..1fb5800e 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -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"); +} diff --git a/crates/ui-egui/src/shortcut_box.rs b/crates/ui-egui/src/shortcut_box.rs index c088c95f..380e9d58 100644 --- a/crates/ui-egui/src/shortcut_box.rs +++ b/crates/ui-egui/src/shortcut_box.rs @@ -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) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6031f422..c928c0f3 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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::().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::() { + 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::() + && 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::() + && 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::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(); diff --git a/crates/ui-egui/src/toolbar_custom.rs b/crates/ui-egui/src/toolbar_custom.rs new file mode 100644 index 00000000..b04ef2e5 --- /dev/null +++ b/crates/ui-egui/src/toolbar_custom.rs @@ -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 { + /// `"//"` → the panel's buttons, in order (only panels the user + /// changed). + pub panels: BTreeMap>, +} + +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 = 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 { + 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 = + 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 { + 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(()) +}