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 <[email protected]> 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 <[email protected]> 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3d99ab4bb1
commit
2cac9cad6a
7 files changed
+490
-27
No files matched your search
@@ -54,6 +54,8 @@ pub enum Target {
|
||||
ToolbarCommand {
|
||||
id: String,
|
||||
},
|
||||
/// The toolbar's empty space.
|
||||
Toolbar,
|
||||
/// The workspace switcher.
|
||||
Workspace,
|
||||
/// The Named Views folder, and one view (saved or standard) in it.
|
||||
@@ -240,6 +242,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec<String> {
|
||||
| Target::Units
|
||||
| Target::Joint { .. }
|
||||
| Target::ToolbarCommand { .. }
|
||||
| Target::Toolbar
|
||||
| Target::Workspace
|
||||
| Target::NamedViews
|
||||
| Target::NamedView { .. } => Vec::new(),
|
||||
@@ -298,6 +301,7 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
|
||||
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::Toolbar => crate::toolbar_custom::toolbar_items(app),
|
||||
Target::Workspace => {
|
||||
let mut v = vec![act("ui.workspace", "Design", "box").key("current")];
|
||||
for w in ["Generative Design", "Render", "Animation", "Simulation", "Manufacture", "Drawing"] {
|
||||
@@ -879,7 +883,9 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
let _ = app.run("browser.visibility", json!({"visible": true, "paths": [p["path"]], "isolate": true}));
|
||||
}
|
||||
"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.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.toolbarCustomize" | "ui.shortcutPin" => {
|
||||
crate::toolbar_custom::run(app, &item.id, &p)
|
||||
}
|
||||
"ui.workspace" => {}
|
||||
"ui.findJoint" => {
|
||||
if let Some(id) = id_of(&p, "joint") {
|
||||
|
||||
@@ -1007,6 +1007,7 @@ impl SolveApp {
|
||||
}
|
||||
shortcut_box::show(self, ui.ctx());
|
||||
prefs::show(self, ui.ctx());
|
||||
toolbar_custom::window(self, ui.ctx());
|
||||
}
|
||||
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
|
||||
+197
-24
@@ -359,6 +359,12 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
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).
|
||||
@@ -426,6 +432,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
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();
|
||||
for (panel, cmds, promote) in crate::toolbar_custom::panel_lists(&tab) {
|
||||
let panel: &&str = &panel;
|
||||
@@ -447,26 +457,28 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
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));
|
||||
drop = Some((d.id, *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_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(c.id));
|
||||
resp.dnd_set_drag_payload(ToolbarDrag { id: c.id, from_menu: false });
|
||||
}
|
||||
if let Some(d) = resp.dnd_hover_payload::<ToolbarDrag>()
|
||||
&& d.0 != c.id
|
||||
&& 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.0 != c.id
|
||||
&& d.id != c.id
|
||||
{
|
||||
drop = Some((d.0, *panel, Some(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));
|
||||
@@ -542,29 +554,47 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::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(ui, 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(ui, id));
|
||||
m.move_focus(egui::FocusDirection::None);
|
||||
});
|
||||
}
|
||||
}
|
||||
for c in &cmds {
|
||||
let info = c.info(&app.session);
|
||||
ui.horizontal(|ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
icons::paint(ui.painter(), ir, c.icon, t.icon, t.icon_fill, t.accent);
|
||||
let b = ui.add_enabled(
|
||||
info.enabled,
|
||||
egui::Button::new(app.tr_label(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);
|
||||
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")));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if area.response.clicked_elsewhere() && !lresp.clicked() {
|
||||
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() && !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")));
|
||||
}
|
||||
}
|
||||
@@ -598,9 +628,27 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
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() });
|
||||
@@ -608,9 +656,134 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A toolbar button being dragged (its command id).
|
||||
/// 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).
|
||||
fn dropdown_row_id(ui: &egui::Ui, id: &str) -> egui::Id {
|
||||
ui.id().with(("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(ui, c.id), sense);
|
||||
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(&'static str);
|
||||
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) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! 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
|
||||
//! 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;
|
||||
@@ -20,6 +24,8 @@ 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 {
|
||||
@@ -130,6 +136,15 @@ pub fn reorder(app: &mut SolveApp, tab: &str, id: &str, panel: &str, before: Opt
|
||||
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}/");
|
||||
@@ -149,10 +164,19 @@ pub fn command_items(app: &SolveApp, id: &str) -> Vec<Item> {
|
||||
});
|
||||
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());
|
||||
@@ -165,6 +189,7 @@ pub fn run(app: &mut SolveApp, action: &str, p: &Value) {
|
||||
}
|
||||
"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),
|
||||
_ => {}
|
||||
}
|
||||
@@ -190,3 +215,67 @@ pub fn import(app: &mut SolveApp, v: &Value) -> Result<(), String> {
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
[
|
||||
{
|
||||
"note": "#288: a panel drop-down highlights the row under the pointer with the hover background, and the row with keyboard focus. Drop-down rows publish `dropdown:<command id>`, the highlighted one also `dropdown_hot:<command id>`."
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "panel:SOLID:CREATE"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown:solid.box", "dropdown:solid.sphere"],
|
||||
"hidden_handles": ["dropdown_hot:solid.box", "dropdown_hot:solid.sphere"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"handle": "dropdown:solid.box"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.box"],
|
||||
"hidden_handles": ["dropdown_hot:solid.sphere", "dropdown_hot:solid.cylinder"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"handle": "dropdown:solid.sphere"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.sphere"],
|
||||
"hidden_handles": ["dropdown_hot:solid.box"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": [700, 600]
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown:solid.box"],
|
||||
"hidden_handles": ["dropdown_hot:solid.sphere", "dropdown_hot:solid.box"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "The keyboard: Down focuses the first row, Down again the next; the highlight follows."
|
||||
},
|
||||
{
|
||||
"key": "ArrowDown"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:sketch.create"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ArrowDown"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.extrude"],
|
||||
"hidden_handles": ["dropdown_hot:sketch.create"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": ["dropdown:solid.box"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
[
|
||||
{
|
||||
"note": "#289: drag a command from a panel's drop-down onto the toolbar to pin it (a drop line shows where), drag a button off the toolbar or back into the drop-down to unpin it, the pin icon on a hovered drop-down row, and Customize Toolbar… in the toolbar's own menu. Kept with the preferences."
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["toolbar:solid.extrude"],
|
||||
"hidden_handles": ["toolbar:solid.sphere", "toolbar:solid.torus"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {"handle": "panel:SOLID:CREATE"}
|
||||
},
|
||||
{
|
||||
"drag": [{"handle": "dropdown:solid.sphere"}, {"handle": "button:solid.extrude"}],
|
||||
"steps": 12
|
||||
},
|
||||
{
|
||||
"key": "Escape"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": ["dropdown:solid.sphere"],
|
||||
"handle_order": ["toolbar:solid.sphere", "toolbar:solid.extrude"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "A button dragged off the toolbar leaves it."
|
||||
},
|
||||
{
|
||||
"drag": [{"handle": "toolbar:solid.sphere"}, [700, 600]],
|
||||
"steps": 12
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": ["toolbar:solid.sphere"],
|
||||
"shown_handles": ["toolbar:solid.extrude"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "The pin icon on a hovered drop-down row pins the command."
|
||||
},
|
||||
{
|
||||
"click": {"handle": "panel:SOLID:CREATE"}
|
||||
},
|
||||
{
|
||||
"move": {"handle": "dropdown:solid.torus"}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_pin:solid.torus"],
|
||||
"hidden_handles": ["dropdown_pin:solid.box"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {"handle": "dropdown_pin:solid.torus"}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown:solid.box"],
|
||||
"tool": null,
|
||||
"dialog": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Escape"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["toolbar:solid.torus"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "A button dragged back into its open drop-down leaves the toolbar."
|
||||
},
|
||||
{
|
||||
"click": {"handle": "panel:SOLID:CREATE"}
|
||||
},
|
||||
{
|
||||
"drag": [{"handle": "button:solid.torus"}, {"handle": "dropdown:solid.box"}],
|
||||
"steps": 12
|
||||
},
|
||||
{
|
||||
"key": "Escape"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": ["toolbar:solid.torus", "dropdown:solid.box"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "Customize Toolbar… in the toolbar's own menu: tick a command."
|
||||
},
|
||||
{
|
||||
"call": "ui.menu",
|
||||
"params": {"x": 1000, "y": 60, "target": {"type": "toolbar"}}
|
||||
},
|
||||
{
|
||||
"call": "ui.menuPick",
|
||||
"params": {"item": "Customize Toolbar…"}
|
||||
},
|
||||
{
|
||||
"click": {"handle": "customize:solid.sphere"}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["toolbar:solid.sphere"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"restart": "close"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["toolbar:solid.sphere", "toolbar:solid.extrude"],
|
||||
"hidden_handles": ["toolbar:solid.torus"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -63,7 +63,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
|
||||
| `ui.sketchToScreen` | `points`: `[[x, y]…]` in active-sketch coordinates | their screen points (null when behind the camera) |
|
||||
| `ui.dialogInput` | `index` | what clicking an input's Select chip does: picks go to that input |
|
||||
| `ui.at` | `world: [x,y,z]` \| `sketch: [x,y]` \| `plane: "XY"` \| `axis: "Z"` \| `dimension: "d1"` (its value text) \| `handle: "arrow"` (a manipulator handle, see `scenario::publish_handle`) | the screen point (a scenario step's target; see `crates/ui-egui/src/scenario.rs`) |
|
||||
| `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:<command id>`, panel labels `panel:<tab>:<panel>`, browser rows `row:<label>` and fold arrows `fold:<label>`, manipulator handles; orientation tips `axis_view:x`, `axis_view:-x` (and y/z) |
|
||||
| `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:<command id>` (the button alone: `button:<command id>`; a drop-down row: `dropdown:<command id>`, highlighted `dropdown_hot:<command id>`, its pin `dropdown_pin:<command id>`; Customize Toolbar's check boxes `customize:<command id>`), panel labels `panel:<tab>:<panel>`, browser rows `row:<label>` and fold arrows `fold:<label>`, manipulator handles; orientation tips `axis_view:x`, `axis_view:-x` (and y/z) |
|
||||
| `ui.editFeature` | `feature` (id or name) | what double-clicking a timeline item does: rolls back to it and opens its dialog filled in (sketches open in sketch mode) |
|
||||
| `ui.scroll` | `x`, `y`, `delta?` | wheel zoom at the cursor |
|
||||
| `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | |
|
||||
|
||||
Reference in new issue
Block a user