From 2cac9cad6a4b523c494a36d9ac41dc0f17a5f157 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Mon, 12 Oct 2026 10:27:25 -0400 Subject: [PATCH] =?UTF-8?q?Toolbar:=20drag=20commands=20from=20drop-downs?= =?UTF-8?q?=20onto=20the=20toolbar;=20pin=20icon;=20Customize=20Toolbar?= =?UTF-8?q?=E2=80=A6=20(#295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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:` and the highlighted one `dropdown_hot:`; scenario s46 checks pointer and keyboard highlight. Fixes #288 Co-Authored-By: Claude Opus 5.5 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:`; 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 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 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/context_menu.rs | 8 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/toolbar.rs | 221 ++++++++++++++++-- crates/ui-egui/src/toolbar_custom.rs | 91 +++++++- .../scenarios/s46_toolbar_dropdown_hover.json | 75 ++++++ .../s47_toolbar_drag_from_dropdown.json | 119 ++++++++++ docs/control-protocol.md | 2 +- 7 files changed, 490 insertions(+), 27 deletions(-) create mode 100644 crates/ui-egui/tests/scenarios/s46_toolbar_dropdown_hover.json create mode 100644 crates/ui-egui/tests/scenarios/s47_toolbar_drag_from_dropdown.json diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 2b1a62e2..d78397ca 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -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 { | 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 { 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") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1daf67fc..d2a43804 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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) { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e1857258..a680e75e 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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 = 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::() { - 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:` 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::() - && 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::() - && 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::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::(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::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::(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::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) { diff --git a/crates/ui-egui/src/toolbar_custom.rs b/crates/ui-egui/src/toolbar_custom.rs index f6c8421d..5936dac8 100644 --- a/crates/ui-egui/src/toolbar_custom.rs +++ b/crates/ui-egui/src/toolbar_custom.rs @@ -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 { /// `"//"` → the panel's buttons, in order (only panels the user /// changed). pub panels: BTreeMap>, + /// 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 { }); 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 { + 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; +} diff --git a/crates/ui-egui/tests/scenarios/s46_toolbar_dropdown_hover.json b/crates/ui-egui/tests/scenarios/s46_toolbar_dropdown_hover.json new file mode 100644 index 00000000..675bdf60 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s46_toolbar_dropdown_hover.json @@ -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:`, the highlighted one also `dropdown_hot:`." + }, + { + "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"] + } + } +] diff --git a/crates/ui-egui/tests/scenarios/s47_toolbar_drag_from_dropdown.json b/crates/ui-egui/tests/scenarios/s47_toolbar_drag_from_dropdown.json new file mode 100644 index 00000000..f207487d --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s47_toolbar_drag_from_dropdown.json @@ -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"] + } + } +] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 36415fab..baa4e240 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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:`, panel labels `panel::`, browser rows `row: