Files
solidcraft-upstream/crates/ui-egui/src/toolbar_custom.rs
T
bherbruckandClaude Opus 5.5 2cac9cad6a Toolbar: drag commands from drop-downs onto the toolbar; pin icon; Customize Toolbar… (#295)
* Toolbar drop-downs highlight the row under the pointer and the keyboard focus (#288)

A panel drop-down's command rows are full-width rows that take the theme's hover background
(icon, label and shortcut on it) when hovered or focused. Up/Down move the focus through the
enabled rows, Enter starts the command, Escape closes the drop-down. Rows publish
`dropdown:<id>` and the highlighted one `dropdown_hot:<id>`; scenario s46 checks pointer and
keyboard highlight.

Fixes #288

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* Toolbar: drag commands from drop-downs onto the toolbar; pin icon; Customize Toolbar… (#289)

- A panel drop-down row drags onto the toolbar (a button or a panel's free space, with the
  drop line) and is pinned there; the dragged command's icon follows the pointer.
- A toolbar button dragged off the toolbar, or back into the open drop-down, leaves it (its
  icon shows a cross while releasing would remove it).
- A hovered drop-down row shows a pin at its end: click to pin or unpin.
- Right-click on the toolbar's empty space: Customize Toolbar… (a window listing the tab's
  commands with check boxes, and Reset Toolbar) and Reset Toolbar; command menus have
  Customize Toolbar… too.
- Still per workspace and tab, kept with the preferences. Toolbar buttons also publish
  `button:<id>`; scenario s47 drags a row to the toolbar, unpins by dragging off and into the
  drop-down, uses the pin icon and Customize Toolbar…, and checks it survives a restart.

Fixes #289

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* Toolbar drop-down pin: a push pin drawn in code (upright and filled when pinned); control protocol lists the toolbar handles

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>
2026-10-12 10:27:25 -04:00

282 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 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>>,
/// 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| 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);
}
/// 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": "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 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;
}