diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 342feb568c..bfea75d60a 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -563,7 +563,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{name: string} delete the toolbar made with New Toolbar called `name`, and the groups torn off it, from the current workspace, as Manage Toolbars' trash can does", ), - ("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"), + ( + "window.toolbar.dock", + "Dock Toolbar", + "", + "{name: string, side?: left|right, order?: int} dock the toolbar made with New Toolbar called `name` in the lane along the window's left or right edge (omitted: the edge it docked at last, the left at first), as Dock to Left and Dock to Right in its title bar's context menu and dropping its title bar on that edge do; `order` is its place in that edge's stack, 0 at the top (omitted: the end, or where it is when it is docked at that edge already); a bad `side` or `order` is an error and changes nothing; returns {toolbar, floating, side, order, pos}", + ), + ( + "window.toolbar.float", + "Float Toolbar", + "", + "{name: string, x?: number, y?: number} float the toolbar made with New Toolbar called `name` with its top-left corner at x, y in screen points (both or neither; omitted: where it floated last), as Float Toolbar in its title bar's context menu and dragging its title bar out of its lane do; returns {toolbar, floating, side, order, pos}", + ), + ( + "window.toolbar.list", + "List Toolbars", + "", + "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots, floating, side, order}], `side` the edge it docks at (while floating, the edge it docked at last) and `order` its place in that edge's stack while docked (else null)", + ), ("window.taskBar", "Contextual Task Bar", "", "{}"), ( "window.pathfinder.compoundShapes", diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 67e67ac444..60d9b61027 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -599,6 +599,8 @@ impl VectorcraftApp { // Toolbar too. toolbar::show_custom(self, ui.ctx()); } + // The lanes along the window's edges where toolbars made with New Toolbar dock. + crate::custom_toolbars::lanes(self, ui); panels::library_panel::tidy(self); // The canvas and the panels: one docking layout. Tab hides the panels but not the canvas. egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| panel_docking::show(self, ui)); diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 29baa37d23..95f719d08b 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -16,6 +16,7 @@ impl VectorcraftApp { native_menu::run(self, ctx); if self.fonts_ready { tools_float::cancel_move_on_escape(self, ctx); + crate::custom_toolbars::cancel_move_on_escape(self, ctx); shortcuts::handle(self, ctx); } // A Copy taken in this UI phase (an egui copy event, or a command it runs) is published in diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index f356f18b68..2b4618048a 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -8,6 +8,17 @@ //! item shows or hides it, and is kept with the workspace it was made in (`Workspace::toolbars`). //! Window › Toolbars › Manage Toolbars (also in the drawer's menu) renames, copies and deletes them. //! +//! A toolbar docks in a lane along the window's left or right edge (on the left, right of the Tools +//! panel docked there or beside the canvas), when its title bar is dropped on the edge (lit up while it is dragged), or with +//! Dock to Left and Dock to Right in its title bar's context menu (`window.toolbar.dock`). Several +//! toolbars docked at one edge stack from the top in the order they docked, each with its share of +//! the lane's height, and the lane is as wide as its widest toolbar (a lane too short for its +//! stack wraps it into more columns, up to half the width beside it, and past that the toolbars +//! show only their title bars, which the mouse wheel scrolls through when even they do not fit, so +//! none covers another). Dragging a docked toolbar's +//! title bar out of it floats it under the pointer, as Float Toolbar does where it floated last +//! (`window.toolbar.float`); Escape during the drag puts it back. These are VectorCraft's own rules. +//! //! Behavior from the public documentation: //! (Create and manage toolbars: New Toolbar //! from Window › Toolbars or the drawer's flyout menu, a name and OK make a blank toolbar, its Edit @@ -41,6 +52,14 @@ const GRIP: f32 = 14.0; const TITLE_BUTTON: f32 = 12.0; /// Where the first toolbar made floats, right of the Tools panel; the next ones go lower. const NEW_AT: [f32; 2] = [96.0, 120.0]; +/// The space around each toolbar docked in a lane, and between two of them. +const LANE_GAP: f32 = 6.0; +/// Width of the strip along the window's edge where a dropped title bar docks its toolbar, while +/// that edge's lane is narrower or empty. +const ZONE: f32 = 48.0; +/// Points the pointer travels from its press before a drop docks (a click on the title bar of a +/// toolbar floating near the edge doesn't dock it). +const TRAVEL: f32 = 4.0; /// A toolbar's name without leading and trailing spaces, in lowercase. Names with the same key are /// the same name: lookups, the check that a name is free, sanitizing and each toolbar's area @@ -105,6 +124,8 @@ pub(crate) fn create(ui: &mut UiState, name: &str) -> Result { slots: vec![], shown: true, pos, + dock: None, + dock_right: false, slot_tool: Default::default(), double: false, floating: vec![], @@ -113,6 +134,95 @@ pub(crate) fn create(ui: &mut UiState, name: &str) -> Result { Ok(name.to_string()) } +/// Number the toolbars docked at each edge 0, 1, 2… from the top, in their order (in the list's +/// order where two have the same place). +fn renumber(bars: &mut [CustomToolbar]) { + for right in [false, true] { + let mut on: Vec<(Option, usize)> = + bars.iter().enumerate().filter(|(_, b)| b.dock.is_some() && b.dock_right == right).map(|(k, b)| (b.dock, k)).collect(); + on.sort_unstable(); + for (n, (_, k)) in on.into_iter().enumerate() { + if let Some(b) = bars.get_mut(k) { + b.dock = Some(n as u32); + } + } + } +} + +/// Dock the toolbar at `k` at the right edge or the left, at place `at` of that edge's stack (none +/// or past the end: at the end); the toolbars from that place down move down one. Returns its place. +fn dock_at(bars: &mut [CustomToolbar], k: usize, right: bool, at: Option) -> u32 { + if let Some(b) = bars.get_mut(k) { + b.dock = None; + } + renumber(bars); + let n = bars.iter().filter(|b| b.dock.is_some() && b.dock_right == right).count() as u32; + let at = at.map_or(n, |at| at.min(n)); + for b in bars.iter_mut().filter(|b| b.dock_right == right) { + if let Some(o) = b.dock.as_mut() + && *o >= at + { + *o += 1; + } + } + if let Some(b) = bars.get_mut(k) { + b.dock = Some(at); + b.dock_right = right; + } + at +} + +/// Where the toolbar is: `{toolbar, floating, side, order, pos}`, the edge it docks at (while it +/// floats, the edge it docked at last), its place in that edge's stack while docked (else null) and +/// where it floats (while docked, where it floated last). +fn placement(bar: &CustomToolbar) -> Value { + let side = if bar.dock_right { "right" } else { "left" }; + json!({ "toolbar": bar.name, "floating": bar.dock.is_none(), "side": side, "order": bar.dock, "pos": bar.pos }) +} + +/// The toolbars that show docked at the right edge or the left, by index, from the top. +fn docked(ui: &UiState, right: bool) -> Vec { + let mut on: Vec<(Option, usize)> = ui + .custom_toolbars + .iter() + .enumerate() + .filter(|(_, b)| b.shown && b.dock.is_some() && b.dock_right == right) + .map(|(k, b)| (b.dock, k)) + .collect(); + on.sort_unstable(); + on.into_iter().map(|(_, k)| k).collect() +} + +/// Width of `bar` inside its border: one column of tool buttons, or two. +fn bar_width(bar: &CustomToolbar) -> f32 { + WIDTH + if bar.double { BUTTON } else { 0.0 } +} + +/// The least height a docked toolbar draws at: its border, its title bar and the gap under it, the +/// least height its tools scroll in (`egui::ScrollArea`'s default `min_scrolled_height`, 64 points) +/// and its bottom margin. +const DOCKED_LEAST: f32 = 2.0 + GRIP + 4.0 + 64.0 + 4.0; + +/// The columns a lane `height` points tall lays `n` docked toolbars out in, at most `most` of them, +/// and the most toolbars each column holds: one column while each toolbar gets at least +/// [`DOCKED_LEAST`] points of the lane's height, else as few more columns as give every toolbar +/// that much, so none covers another. With `most` columns too few for that, the toolbars spread +/// evenly over `most` columns, and those with less than [`DOCKED_LEAST`] points show only their +/// title bars ([`docked_places`]). +fn lane_columns(n: usize, height: f32, most: usize) -> (usize, usize) { + let fit = ((height - LANE_GAP) / (DOCKED_LEAST + LANE_GAP)).floor(); + let fit = if fit.is_finite() && fit >= 1.0 { fit as usize } else { 1 }; + let per = n.div_ceil(n.div_ceil(fit).clamp(1, most.max(1))).max(1); + (n.div_ceil(per).max(1), per) +} + +/// The most columns of toolbars at most `widest` points wide inside their borders that `width` +/// points hold with [`LANE_GAP`] around each: at least one. +fn most_columns(width: f32, widest: f32) -> usize { + let most = ((width - LANE_GAP) / (widest.max(1.0) + LANE_GAP) + 1e-3).floor(); + if most.is_finite() && most >= 1.0 { most as usize } else { 1 } +} + /// Toolbar `name`, shown after Tab hid the toolbars, shows alone, as Window › Tools shows the /// Tools panel: the other toolbars Tab hid are turned off, and the next Tab hides it. fn show_alone_after_tab(ui: &mut UiState, name: &str) { @@ -193,6 +303,8 @@ pub(crate) fn duplicate(ui: &mut UiState, name: Option<&str>) -> Result, name: &str) let k = index(ui, name)?; close_popups(ui, ctx, name, None); ui.custom_toolbars.remove(k); + renumber(&mut ui.custom_toolbars); Ok(()) } /// Keep named toolbars, each name once whatever its case and at most [`MAX_NAME`] characters, of /// known tools, each once in its toolbar, at a position that is a number, with one strip per group -/// torn off them, and each group's tool chosen last only when the group holds it (a hand-edited -/// preferences file or workspace). +/// torn off them, each group's tool chosen last only when the group holds it, and the docked ones +/// numbered 0, 1, 2… at each edge (a hand-edited preferences file or workspace). pub(crate) fn sanitize(bars: &mut Vec) { let mut names = std::collections::BTreeSet::new(); bars.retain_mut(|b| { @@ -237,6 +350,7 @@ pub(crate) fn sanitize(bars: &mut Vec) { !b.name.is_empty() && b.name.chars().count() <= MAX_NAME && names.insert(key(&b.name)) }); bars.truncate(MAX); + renumber(bars); } /// Keep the strips torn off `bar` whose group it still holds, each with the group's tools as they @@ -306,11 +420,54 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option (|| { + let name = p.get("name").and_then(Value::as_str).ok_or("name (a toolbar's name) is required")?; + let k = index(&app.ui, name)?; + let bar = app.ui.custom_toolbars.get(k).ok_or_else(|| format!("no toolbar named `{name}`"))?; + // Docking: at the edge it docked at last unless `side` says which. + let right = match p.get("side") { + None | Some(Value::Null) => bar.dock_right, + // `Start` and `End`, as panels take them, too. + Some(Value::String(s)) if s.eq_ignore_ascii_case("left") || s.eq_ignore_ascii_case("start") => false, + Some(Value::String(s)) if s.eq_ignore_ascii_case("right") || s.eq_ignore_ascii_case("end") => true, + Some(Value::String(s)) => return Err(format!("side is left or right, not `{s}`")), + Some(v) => return Err(format!("side is left or right, not `{v}`")), + }; + let others = app.ui.custom_toolbars.iter().enumerate().filter(|&(i, b)| i != k && b.dock.is_some() && b.dock_right == right).count(); + let at = match p.get("order") { + None | Some(Value::Null) => None, + Some(v) => match v.as_u64().filter(|&n| n <= others as u64) { + Some(n) => Some(n as u32), + None => return Err(format!("order is a whole number from 0 to {others}, not `{v}`")), + }, + }; + // Docked at that edge already, with no place asked for, it stays where it is. + if !(bar.dock.is_some() && bar.dock_right == right && at.is_none()) { + dock_at(&mut app.ui.custom_toolbars, k, right, at); + } + app.ui.custom_toolbars.get(k).map(placement).ok_or_else(|| format!("no toolbar named `{name}`")) + })(), + "window.toolbar.float" => (|| { + let name = p.get("name").and_then(Value::as_str).ok_or("name (a toolbar's name) is required")?; + let k = index(&app.ui, name)?; + let at = crate::tools_float::position(p)?; + if let Some(b) = app.ui.custom_toolbars.get_mut(k) { + b.dock = None; + if let Some(at) = at { + b.pos = [at.x, at.y]; + } + } + renumber(&mut app.ui.custom_toolbars); + app.ui.custom_toolbars.get(k).map(placement).ok_or_else(|| format!("no toolbar named `{name}`")) + })(), "window.toolbar.list" => Ok(json!( app.ui .custom_toolbars .iter() - .map(|b| json!({"name": b.name, "shown": b.shown, "double": b.double, "slots": b.slots})) + .map(|b| { + let side = if b.dock_right { "right" } else { "left" }; + json!({"name": b.name, "shown": b.shown, "double": b.double, "slots": b.slots, "floating": b.dock.is_none(), "side": side, "order": b.dock}) + }) .collect::>() )), _ => return None, @@ -563,17 +720,268 @@ enum Action { Hide(String), /// The title bar's double arrow: two columns (true) or one. Columns(String, bool), + /// Dock to Left (false) or Dock to Right (true) in the title bar's context menu. + Dock(String, bool), + /// Float Toolbar in the title bar's context menu. + Float(String), } -/// The toolbars made with New Toolbar that show, each floating where its title bar was dragged: the -/// title bar's × hides it and its double arrow lays the tools out in one column or two; then its -/// tool buttons (a group's flyout opens as the Tools panel's do), the + button whose menu adds and -/// removes tools, and the ••• button that opens the All Tools drawer beside it. +/// The lane along the window's right edge (true) or left edge where docked toolbars show, as last +/// laid out; removed while it is empty. +fn lane_id(right: bool) -> egui::Id { + egui::Id::unique(("custom-toolbar-lane", right)) +} + +/// Where a title bar dropped docks its toolbar at the right edge (true) or the left, as last laid +/// out. +fn zone_id(right: bool) -> egui::Id { + egui::Id::unique(("custom-toolbar-zone", right)) +} + +/// A drag of a toolbar's title bar in progress: the toolbar, where the press began and how far that +/// is from the toolbar's corner, where the toolbar was before the drag (to put it back on Escape) +/// and whether Escape put it back. +#[derive(Clone, Debug)] +struct Move { + name: String, + from: egui::Pos2, + grab: egui::Vec2, + dock: Option, + right: bool, + pos: [f32; 2], + cancelled: bool, +} + +fn move_id() -> egui::Id { + egui::Id::unique("custom-toolbar-move") +} + +/// The lanes along the window's left and right edges where the docked toolbars that show are drawn, +/// each as wide as its widest toolbar with [`LANE_GAP`] either side (times the columns of +/// [`lane_columns`] when the lane is too short for one, at most half the width left for the lane), +/// and the targets where a title +/// bar dropped docks its toolbar: from the window's edge across the lane, or across a strip +/// [`ZONE`] points wide when the lane is narrower or empty, and on the left across the Tools panel +/// docked there too. Laid out before the canvas and the panels, which take the space left, except +/// the left lane while Tools is docked beside the canvas ([`left_lane_beside_canvas`]). A lane +/// laid out anew draws again, since its toolbars were placed by the lane of the frame before. +pub(crate) fn lanes(app: &VectorcraftApp, ui: &mut egui::Ui) { + for right in [false, true] { + if right || !left_lane_beside_canvas(&app.ui) { + let (edge, room) = (ui.ctx().content_rect(), ui.available_rect_before_wrap().width() / 2.0); + lane(app, ui, right, edge, room); + } + } +} + +/// The width the canvas panel keeps for the left lane while it stands beside the canvas +/// ([`left_lane_beside_canvas`]): one column of its widest toolbar, none while no docked toolbar +/// shows there. +pub(crate) fn beside_canvas_reserve(ui: &UiState) -> f32 { + if !left_lane_beside_canvas(ui) || ui.tab_hid.is_some() { + return 0.0; + } + let widest = docked(ui, false).iter().filter_map(|&k| ui.custom_toolbars.get(k)).map(bar_width).fold(0.0, f32::max); + if widest > 0.0 { widest + 2.0 * LANE_GAP } else { 0.0 } +} + +/// With Keep Tools Beside the Canvas on and Tools docked on the left, Tools draws inside the canvas +/// panel, right of the panels docked on the left, and the left lane goes right of it there +/// ([`lane_beside_canvas`]). +pub(crate) fn left_lane_beside_canvas(ui: &UiState) -> bool { + ui.tools_beside_canvas && ui.toolbar && ui.screen_mode < 3 && ui.toolbar_pos.is_none() && !ui.toolbar_right +} + +/// The left lane inside the canvas panel, right of the Tools panel docked beside the canvas +/// (`toolbar::show_beside_canvas`, after Tools). Its target reaches from the canvas's left edge, +/// across Tools, over the lane. It wraps into more columns only as far as the canvas keeps its +/// least width, 200 points (`panel_docking`'s canvas limits, which add [`beside_canvas_reserve`]). +pub(crate) fn lane_beside_canvas(app: &VectorcraftApp, ui: &mut egui::Ui) { + if left_lane_beside_canvas(&app.ui) { + let avail = ui.available_rect_before_wrap(); + let tools = ui.ctx().data(|d| d.get_temp::(crate::toolbar::docked_rect_id())); + let edge = tools.map_or(avail, |t| t.union(avail)); + lane(app, ui, false, edge, (avail.width() / 2.0).min(avail.width() - 200.0)); + } +} + +/// The lane at the right edge (true) or the left of `ui`'s space, at most `room` points wide unless +/// one column is wider, and its target, which reaches out to `edge`'s side. +fn lane(app: &VectorcraftApp, ui: &mut egui::Ui, right: bool, edge: egui::Rect, room: f32) { + let ctx = ui.ctx().clone(); + let shown = app.ui.tab_hid.is_none() && app.ui.screen_mode < 3; + let on = if shown { docked(&app.ui, right) } else { vec![] }; + let widest = on.iter().filter_map(|&k| app.ui.custom_toolbars.get(k)).map(bar_width).fold(0.0, f32::max); + let avail = ui.available_rect_before_wrap(); + let most = most_columns(room, widest); + let cols = lane_columns(on.len(), avail.height(), most).0 as f32; + let width = if widest > 0.0 { cols * widest + (cols - 1.0) * LANE_GAP } else { 0.0 }; + let reach = (width + 2.0 * LANE_GAP).max(ZONE); + let zone = if right { + egui::Rect::from_min_max(pos2(avail.right() - reach, avail.top()), pos2(edge.right(), avail.bottom())) + } else { + egui::Rect::from_min_max(pos2(edge.left(), avail.top()), pos2(avail.left() + reach, avail.bottom())) + }; + let lane = (width > 0.0).then(|| { + let panel = if right { egui::Panel::right("custom-toolbar-lane-right") } else { egui::Panel::left("custom-toolbar-lane-left") }; + panel.resizable(false).exact_size(width + 2.0 * LANE_GAP).frame(egui::Frame::NONE).show(ui, |_| {}).response.rect + }); + if ctx.data(|d| d.get_temp::(lane_id(right))) != lane { + ctx.request_repaint(); + } + ctx.data_mut(|d| { + d.insert_temp(zone_id(right), zone); + match lane { + Some(lane) => { + d.insert_temp(lane_id(right), lane); + } + None => d.remove::(lane_id(right)), + } + }); +} + +/// Where each docked toolbar that shows is drawn, by index: its top-left corner and the most height +/// it takes, and whether it shows only its title bar. Each lane stacks its toolbars from the top in +/// their order, each with an equal share of its column's height; a toolbar shorter than its share +/// leaves the rest to the ones below it. A lane too short for one column wraps its stack into the +/// columns of [`lane_columns`], left to right, as many as the lane is wide enough for, so every +/// toolbar's title bar and tools stay in view; when even those columns give a toolbar less than +/// [`DOCKED_LEAST`] points, the toolbars of that column show only their title bars, which still +/// float or dock them. When a column is too short for even their title bars, it shows as many as +/// fit and the mouse wheel over it scrolls through the rest; those scrolled out of view have no +/// corner. Every toolbar stays inside the lane. A toolbar docked since the lanes were last laid +/// out is not drawn until they are. +fn docked_places(ui: &UiState, ctx: &egui::Context) -> Vec<(usize, Option, f32, bool)> { + let mut out = vec![]; + // The least height a toolbar takes: its title bar, its border and its bottom margin. + let least = GRIP + LANE_GAP; + for right in [false, true] { + let Some(lane) = ctx.data(|d| d.get_temp::(lane_id(right))) else { continue }; + let on = docked(ui, right); + let widest = on.iter().filter_map(|&k| ui.custom_toolbars.get(k)).map(bar_width).fold(0.0, f32::max); + let (cols, per) = lane_columns(on.len(), lane.height(), most_columns(lane.width(), widest)); + let pitch = (lane.width() - LANE_GAP) / cols as f32; + for (c, column) in on.chunks(per).enumerate() { + let middle = lane.left() + LANE_GAP / 2.0 + pitch * (c as f32 + 0.5); + // More than even their title bars fit: the column shows as many as fit, and the wheel + // over the column scrolls through the others, one toolbar a notch. + let fits = ((lane.height() - LANE_GAP) / (least + LANE_GAP) + 1e-3).floor().max(1.0) as usize; + let column = if column.len() > fits { + let id = lane_id(right).with(("first", c)); + let left = lane.left() + LANE_GAP / 2.0 + pitch * c as f32; + let strip = egui::Rect::from_x_y_ranges(left..=left + pitch, lane.y_range()); + let notch = ctx.input(|i| { + let over = i.pointer.hover_pos().is_some_and(|p| strip.contains(p)); + let wheel = i.events.iter().filter_map(|e| if let egui::Event::MouseWheel { delta, .. } = e { Some(delta.y) } else { None }); + if over { wheel.sum::() } else { 0.0 } + }); + let mut first = ctx.data(|d| d.get_temp::(id)).unwrap_or(0); + if notch > 0.0 { + first = first.saturating_sub(1); + } else if notch < 0.0 { + first += 1; + } + let first = first.min(column.len() - fits); + ctx.data_mut(|d| d.insert_temp(id, first)); + let (before, rest) = column.split_at(first); + let (shown, after) = rest.split_at(fits); + out.extend(before.iter().chain(after).map(|&k| (k, None, 0.0, true))); + shown + } else { + column + }; + let n = column.len() as f32; + let share = (lane.height() - LANE_GAP * (n + 1.0)) / n.max(1.0); + // Too little height for its tools: the title bar alone. + let bare = share < DOCKED_LEAST; + let share = share.max(least); + let mut y = lane.top() + LANE_GAP; + for &k in column { + let Some(bar) = ui.custom_toolbars.get(k) else { continue }; + let drawn = ctx.memory(|m| m.area_rect(area_id(&bar.name))); + let w = drawn.map_or(bar_width(bar) + 2.0, |r| r.width()); + // Drawn last frame taller than its share (its tools scroll only above a least + // height), it moves up to end at the lane's bottom. + let tall = drawn.map_or(least, |r| r.height().max(least)); + let top = y.min(lane.bottom() - tall).max(lane.top()); + let room = share.min(lane.bottom() - top).max(least); + out.push((k, Some(pos2((middle - w / 2.0).round(), top)), room, bare)); + y = top + drawn.map_or(room, |r| r.height().min(room)) + LANE_GAP; + } + } + } + out +} + +/// The edge a title bar released at the pointer docks its toolbar at (true: the right), with the +/// target to light up: none until the pointer has moved [`TRAVEL`] points from the press at `from`. +fn drop_target(ctx: &egui::Context, from: egui::Pos2) -> Option<(egui::Rect, bool)> { + let at = ctx.input(|i| i.pointer.interact_pos()).filter(|at| at.distance(from) > TRAVEL)?; + [false, true].into_iter().find_map(|right| ctx.data(|d| d.get_temp::(zone_id(right))).filter(|z| z.contains(at)).map(|z| (z, right))) +} + +/// Put the toolbar being dragged back where the drag began; returns whether a drag was in progress. +fn cancel(ui: &mut UiState, ctx: &egui::Context) -> bool { + let Some(mut m) = ctx.data(|d| d.get_temp::(move_id())).filter(|m| !m.cancelled) else { return false }; + if let Ok(k) = index(ui, &m.name) { + match m.dock { + Some(at) => { + dock_at(&mut ui.custom_toolbars, k, m.right, Some(at)); + } + None => { + if let Some(b) = ui.custom_toolbars.get_mut(k) { + b.dock = None; + b.dock_right = m.right; + } + renumber(&mut ui.custom_toolbars); + } + } + if let Some(b) = ui.custom_toolbars.get_mut(k) { + b.pos = m.pos; + } + } + m.cancelled = true; + ctx.data_mut(|d| d.insert_temp(move_id(), m)); + true +} + +/// Escape while a toolbar's title bar is dragged puts the toolbar back where the drag began, and the +/// press runs no shortcut ([`crate::tools_float::escape_cancelled_move`]). +pub(crate) fn cancel_move_on_escape(app: &mut VectorcraftApp, ctx: &egui::Context) { + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) && cancel(&mut app.ui, ctx) { + crate::tools_float::mark_escape_cancelled(ctx); + } +} + +/// Light up the target a title bar dropped now docks its toolbar at. +fn highlight(ctx: &egui::Context, zone: egui::Rect) { + let t = Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("custom-toolbar-drop"))); + painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); +} + +/// The toolbars made with New Toolbar that show, each floating where its title bar was dragged or +/// docked in its edge's lane ([`lanes`]): the title bar's × hides it, its double arrow lays the +/// tools out in one column or two, and its context menu docks or floats it; then its tool buttons +/// (a group's flyout opens as the Tools panel's do), the + button whose menu adds and removes +/// tools, and the ••• button that opens the All Tools drawer beside it. pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { + // A drag ends with the press, also for a toolbar hidden while it was dragged. + let end_move = || { + if !ctx.input(|i| i.pointer.primary_down()) { + ctx.data_mut(|d| d.remove::(move_id())); + } + }; // Tab hides them, also when Window › Tools shows the Tools panel again. if app.ui.tab_hid.is_some() || app.ui.custom_toolbars.iter().all(|b| !b.shown) { + end_move(); return; } + // Losing the window's focus puts a toolbar being dragged back, as Escape does. + if !ctx.input(|i| i.focused) { + cancel(&mut app.ui, ctx); + } + let places = docked_places(&app.ui, ctx); let t = Tokens::get(ctx); let screen = ctx.content_rect(); let active = app.session.tool_id(); @@ -592,11 +1000,27 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let id = area_id(&bar.name); let cols = if bar.double { 2 } else { 1 }; let width = WIDTH + BUTTON * (cols - 1) as f32; - // Kept on screen by its title bar and first tools: the rest scrolls. - let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(width, 60.0), |r| vec2(r.width(), r.height().min(160.0))); - let pos = crate::tools_float::clamp(bar.pos, size, screen); + // Docked: where its lane puts it, its tools scrolling in its share of the lane, or only its + // title bar when that share is too small for them. + let (pos, limit, bare) = if bar.dock.is_some() { + let Some(&(_, pos, share, bare)) = places.iter().find(|(i, ..)| *i == k) else { + // Docked since the lanes were laid out: drawn in the next frame. + ctx.request_repaint(); + continue; + }; + // Scrolled out of its column's view. + let Some(pos) = pos else { continue }; + (pos, share - LANE_GAP, bare) + } else { + // Kept on screen by its title bar and first tools: the rest scrolls. + let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(width, 60.0), |r| vec2(r.width(), r.height().min(160.0))); + let pos = crate::tools_float::clamp(bar.pos, size, screen); + (pos, screen.bottom() - pos.y - 12.0, false) + }; let edit_this = editing.as_deref() == Some(bar.name.as_str()); let mut moved = None; + // The title bar's drag this frame: started, going on, ended. + let mut drag = (false, false, false); let mut drops = None; // A Learn link locating a tool of this toolbar: its button, scrolled into view once. let locate = crate::panels::learn::locate::wanted(&app.ui, Some(&bar.name)); @@ -616,9 +1040,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { crate::toolbar::flyout_frame(&t, false).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).show(ui, |ui| { ui.set_width(width); - // The tools and the ••• button scroll when they don't fit in the window under the - // title bar. - ui.set_max_height(screen.bottom() - pos.y - 12.0); + // The tools and the ••• button scroll when they don't fit in the window (docked: in + // its share of the lane) under the title bar. + ui.set_max_height(limit); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); let middle = ui.max_rect().center().x; // The title bar: × at its left end, the double arrow at its right end, and the grip @@ -641,14 +1065,31 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if aresp.on_hover_text(tl!("Toggle single/double column")).clicked() { actions.push(Action::Columns(bar.name.clone(), !bar.double)); } - // Not focusable (`Sense::drag()` is): only the pointer drags the grip, so Tab does not stop on it. - let gresp = ui.interact(grip, id.with("grip"), Sense::DRAG); + // Not focusable (`Sense::drag()` is): only the pointer drags the grip, so Tab does not + // stop on it. It senses clicks for its context menu. + let gresp = ui.interact(grip, id.with("grip"), Sense::CLICK | Sense::DRAG); for n in 0..3 { let y = grip.center().y - 2.0 + n as f32 * 2.0; let half = (grip.width() / 2.0 - 2.0).clamp(0.0, 8.0); ui.painter().line_segment([pos2(grip.center().x - half, y), pos2(grip.center().x + half, y)], Stroke::new(0.5, t.text_disabled)); } gresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &bar.name)); + // Its context menu: Dock to Left and Dock to Right, the edge it is docked at checked, + // and Float Toolbar while it is docked. + gresp.context_menu(|ui| { + for (right, label) in [(false, tl!("Dock to Left")), (true, tl!("Dock to Right"))] { + let here = bar.dock.is_some() && bar.dock_right == right; + if widgets::menu_item(ui, label, !here, here) { + actions.push(Action::Dock(bar.name.clone(), right)); + ui.close(); + } + } + if widgets::menu_item(ui, tl!("Float Toolbar"), bar.dock.is_some(), false) { + actions.push(Action::Float(bar.name.clone())); + ui.close(); + } + }); + drag = (gresp.drag_started(), gresp.dragged(), gresp.drag_stopped()); // Held where the press took it, from the toolbar's corner, while it is dragged. let grab = id.with("grab"); if gresp.dragged() { @@ -662,6 +1103,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.data_mut(|d| d.remove::(grab)); gresp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(&bar.name); } + if bare { + return; + } // A gap keeps a press near the grip bar's lower edge on the bar. ui.add_space(4.0); let mut buttons: Vec<(usize, egui::Rect)> = vec![]; @@ -713,11 +1157,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { crate::edit_toolbar::toolbar_drop(app, ui, &buttons, cols, bounds, Some(&bar.name)); } }); - if let Some(p) = moved.filter(|p| p.x.is_finite() && p.y.is_finite()) - && let Some(b) = app.ui.custom_toolbars.get_mut(k) - { - b.pos = [p.x, p.y]; - } + drag_title_bar(app, ctx, k, drag, moved); // The button is marked when it shows the located tool, else the group's flyout opens on it; // a group torn off into a strip is marked there. if did_scroll { @@ -788,8 +1228,71 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { app.status(e); } } + Action::Dock(name, right) => { + if let Err(e) = app.run("window.toolbar.dock", json!({ "name": name, "side": if right { "right" } else { "left" } })) { + app.status(e); + } + } + Action::Float(name) => { + if let Err(e) = app.run("window.toolbar.float", json!({ "name": name })) { + app.status(e); + } + } } } + end_move(); +} + +/// The drag of the title bar of the toolbar at `k` this frame (`drag`: started, going on, ended), +/// with the pointer holding its corner at `moved`. A floating toolbar follows the pointer, and the +/// target under the pointer lights up; released there, it docks at the end of that edge's stack. A +/// docked toolbar stays in its lane until the pointer leaves the lane, and then floats under the +/// pointer, also when the press is released in the frame it leaves the lane. +/// After Escape, the rest of the drag moves nothing. +fn drag_title_bar(app: &mut VectorcraftApp, ctx: &egui::Context, k: usize, drag: (bool, bool, bool), moved: Option) { + let (started, dragging, stopped) = drag; + let Some(bar) = app.ui.custom_toolbars.get(k) else { return }; + if started && let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) { + let grab = ctx.memory(|m| m.area_rect(area_id(&bar.name))).map_or(egui::Vec2::ZERO, |r| from - r.min); + let m = Move { name: bar.name.clone(), from, grab, dock: bar.dock, right: bar.dock_right, pos: bar.pos, cancelled: false }; + ctx.data_mut(|d| d.insert_temp(move_id(), m)); + } + let Some(m) = ctx.data(|d| d.get_temp::(move_id())).filter(|m| !m.cancelled && key(&m.name) == key(&bar.name)) else { + return; + }; + let from = m.from; + let mut docked = bar.dock.is_some(); + if docked && (dragging || stopped) { + let at = ctx.input(|i| i.pointer.interact_pos()); + // Where the pointer holds its corner; on the release frame, which reports no move, from + // where the press took it. + let corner = moved.or_else(|| at.map(|at| at - m.grab)).filter(|p| p.x.is_finite() && p.y.is_finite()); + let lane = ctx.data(|d| d.get_temp::(lane_id(bar.dock_right))).or_else(|| ctx.memory(|m| m.area_rect(area_id(&bar.name)))); + let inside = lane.zip(at).is_some_and(|(r, at)| r.contains(at)); + if !inside && let Some(p) = corner { + if let Some(b) = app.ui.custom_toolbars.get_mut(k) { + b.dock = None; + b.pos = [p.x, p.y]; + } + renumber(&mut app.ui.custom_toolbars); + docked = false; + } + } else if dragging + && let Some(p) = moved.filter(|p| p.x.is_finite() && p.y.is_finite()) + && let Some(b) = app.ui.custom_toolbars.get_mut(k) + { + b.pos = [p.x, p.y]; + } + if docked { + return; + } + let target = drop_target(ctx, from); + if dragging && let Some((zone, _)) = target { + highlight(ctx, zone); + } + if stopped && let Some((_, right)) = target { + dock_at(&mut app.ui.custom_toolbars, k, right, None); + } } /// What a toolbar's tool buttons need from the frame: the toolbar, the active tool, whether the @@ -970,10 +1473,8 @@ mod tests { let names: Vec<&str> = toolbars_menu(&app).into_iter().filter(|(_, id, _)| *id == "window.toolbar.custom").map(|(l, ..)| l).collect(); assert_eq!(names, ["Mine", "Toolbar 1"]); let list = app.run("window.toolbar.list", json!({})).unwrap(); - assert_eq!( - list, - json!([{"name": "Mine", "shown": true, "double": false, "slots": []}, {"name": "Toolbar 1", "shown": true, "double": false, "slots": []}]) - ); + let blank = |name: &str| json!({"name": name, "shown": true, "double": false, "slots": [], "floating": true, "side": "left", "order": null}); + assert_eq!(list, json!([blank("Mine"), blank("Toolbar 1")])); } /// The New Toolbar dialog: a name typed in it and Enter make the toolbar; a name that is taken is @@ -1691,6 +2192,8 @@ mod tests { slots: slots.iter().map(|s| s.iter().map(|id| id.to_string()).collect()).collect(), shown: true, pos: [f32::NAN, 0.0], + dock: None, + dock_right: false, slot_tool: Default::default(), double: false, floating: ["pen", "nope", "pen"] @@ -1712,7 +2215,10 @@ mod tests { bars[0].slot_tool = chosen(&[("pen", "curvature"), ("curvature", "pen"), ("nope", "pen")]); bars[4].slot_tool = chosen(&[("pen", "curvature")]); bars[5].slot_tool = chosen(&[("pen", "curvature")]); + // Docked places with gaps and repeats, and one dropped with its toolbar, are numbered from 0. + (bars[0].dock, bars[1].dock, bars[4].dock, bars[5].dock) = (Some(7), Some(0), Some(7), Some(3)); sanitize(&mut bars); + assert_eq!(bars.iter().map(|b| b.dock).collect::>(), [Some(1), Some(2), Some(0)]); assert_eq!(bars.iter().map(|b| b.name.as_str()).collect::>(), ["A", "B", "C"], "a name past {MAX_NAME} characters goes too"); assert_eq!(bars[0].slot_tool, chosen(&[("pen", "curvature")])); assert!(bars[1].slot_tool.is_empty() && bars[2].slot_tool.is_empty(), "{:?} {:?}", bars[1].slot_tool, bars[2].slot_tool); @@ -1722,4 +2228,503 @@ mod tests { assert_eq!(bars[0].floating.iter().map(|f| f.tools.join(",")).collect::>(), ["pen,curvature"]); assert!(bars[1].floating.is_empty(), "B holds no group"); } + + /// Where the toolbar named `name` is: its place in its edge's stack (none: floating) and + /// whether that edge is the right one. + fn place(app: &VectorcraftApp, name: &str) -> (Option, bool) { + let b = find(&app.ui, name).unwrap(); + (b.dock, b.dock_right) + } + + /// `window.toolbar.dock` docks a toolbar at an edge, at the end of its stack or at the place + /// asked for, and `window.toolbar.float` floats it where it floated last or at x, y; bad params + /// change nothing. Hiding and renaming keep its place, a copy floats, and deleting closes the + /// stack up. The places are listed, and kept with the workspace and the preferences. + #[test] + fn toolbars_dock_and_float_by_their_commands() { + let mut app = app(); + app.run("window.workspace.new", json!({"name": "Drawing"})).unwrap(); + for name in ["A", "B", "C"] { + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + } + assert_eq!(place(&app, "A"), (None, false), "New Toolbar floats"); + let a_pos = find(&app.ui, "A").unwrap().pos; + let r = app.run("window.toolbar.dock", json!({"name": "a"})).unwrap(); + assert_eq!(r, json!({"toolbar": "A", "floating": false, "side": "left", "order": 0, "pos": a_pos})); + app.run("window.toolbar.dock", json!({"name": "B", "side": "left"})).unwrap(); + app.run("window.toolbar.dock", json!({"name": "C", "side": "start", "order": 0})).unwrap(); + assert_eq!([place(&app, "C"), place(&app, "A"), place(&app, "B")], [(Some(0), false), (Some(1), false), (Some(2), false)]); + // Docked again at the edge it is docked at, it stays where it is. + app.run("window.toolbar.dock", json!({"name": "A"})).unwrap(); + assert_eq!(place(&app, "A"), (Some(1), false)); + app.run("window.toolbar.dock", json!({"name": "A", "order": 2})).unwrap(); + assert_eq!([place(&app, "C"), place(&app, "B"), place(&app, "A")], [(Some(0), false), (Some(1), false), (Some(2), false)]); + // Bad params are errors and change nothing. + let before = app.ui.custom_toolbars.clone(); + for p in [ + json!({"name": "A", "side": "top"}), + json!({"name": "A", "side": 1}), + json!({"name": "A", "order": 3}), + json!({"name": "A", "order": -1}), + json!({"name": "A", "order": 0.5}), + json!({"name": "A", "order": "1"}), + json!({"name": "Nope"}), + json!({}), + ] { + assert!(app.run("window.toolbar.dock", p.clone()).is_err(), "{p}"); + } + for p in + [json!({"name": "A", "x": 10}), json!({"name": "A", "x": 1e7, "y": 0}), json!({"name": "A", "x": "1", "y": 2}), json!({"name": "Nope"})] + { + assert!(app.run("window.toolbar.float", p.clone()).is_err(), "{p}"); + } + assert_eq!(app.ui.custom_toolbars, before); + // At the right edge; the left edge's stack closes up. + let r = app.run("window.toolbar.dock", json!({"name": "A", "side": "RIGHT"})).unwrap(); + assert_eq!((&r["side"], &r["order"]), (&json!("right"), &json!(0))); + assert_eq!([place(&app, "C"), place(&app, "B")], [(Some(0), false), (Some(1), false)]); + // Floating where it floated last or at x, y; docked again at the edge it docked at last. + let r = app.run("window.toolbar.float", json!({"name": "A"})).unwrap(); + assert_eq!(r, json!({"toolbar": "A", "floating": true, "side": "right", "order": null, "pos": a_pos})); + app.run("window.toolbar.float", json!({"name": "A", "x": 300, "y": 200})).unwrap(); + assert_eq!(find(&app.ui, "A").unwrap().pos, [300.0, 200.0]); + assert_eq!(app.run("window.toolbar.dock", json!({"name": "A"})).unwrap()["side"], json!("right")); + // Hidden and renamed, it keeps its place; a copy floats; deleted, the stack closes up. + app.run("window.toolbar.custom", json!({"name": "C", "show": false})).unwrap(); + app.run("window.toolbar.rename", json!({"name": "C", "to": "Cee"})).unwrap(); + assert_eq!(place(&app, "Cee"), (Some(0), false)); + let copy = app.run("window.toolbar.duplicate", json!({"name": "B"})).unwrap(); + assert_eq!(place(&app, copy["toolbar"].as_str().unwrap()), (None, false)); + app.run("window.toolbar.delete", json!({"name": "Cee"})).unwrap(); + assert_eq!(place(&app, "B"), (Some(0), false)); + let list = app.run("window.toolbar.list", json!({})).unwrap(); + let rows: Vec<_> = list.as_array().unwrap().iter().map(|b| json!([b["name"], b["floating"], b["side"], b["order"]])).collect(); + assert_eq!(rows, [json!(["A", false, "right", 0]), json!(["B", false, "left", 0]), json!(["B Copy", true, "left", null])]); + // Kept with the workspace and the preferences. + let kept = app.ui.custom_toolbars.clone(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + app.run("window.workspace", json!({"name": "Drawing"})).unwrap(); + assert_eq!(app.ui.custom_toolbars, kept); + let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(prefs.sanitized().custom_toolbars, kept); + // Saved before toolbars docked, a toolbar floats. + let mut old = serde_json::to_value(&kept[0]).unwrap(); + let fields = old.as_object_mut().unwrap(); + assert!(fields.remove("dock").is_some() && fields.remove("dock_right").is_some()); + let old: CustomToolbar = serde_json::from_value(old).unwrap(); + assert_eq!((old.dock, old.dock_right), (None, false)); + } + + /// Docked toolbars show in lanes along the window's edges, the left one right of the Tools + /// panel: stacked from the top, centered, each in its share of the lane, the lane as wide as its + /// widest toolbar and gone while none shows. The canvas and the panels take the space left. + #[test] + fn docked_toolbars_stack_in_lanes_along_the_window_edges() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + for name in ["A", "B", "R"] { + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + } + for tool in vectorcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).take(30) { + app.run("toolbar.addTool", json!({"tool": tool.id, "toolbar": "A"})).unwrap(); + } + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "B"})).unwrap(); + let mut time = 0.0; + let mut frames = |app: &mut VectorcraftApp| { + for _ in 0..4 { + time += 0.05; + window_frame(app, &ctx, time, 600.0); + } + }; + frames(&mut app); + let rect = |name: &str| ctx.memory(|m| m.area_rect(area_id(name))).unwrap(); + let lane = |right: bool| ctx.data(|d| d.get_temp::(lane_id(right))); + assert!(lane(false).is_none() && lane(true).is_none(), "no lane while every toolbar floats"); + for (name, side) in [("A", "left"), ("B", "left"), ("R", "right")] { + app.run("window.toolbar.dock", json!({ "name": name, "side": side })).unwrap(); + } + frames(&mut app); + let tools = ctx.data(|d| d.get_temp::(crate::toolbar::docked_rect_id())).unwrap(); + let (left, right) = (lane(false).unwrap(), lane(true).unwrap()); + let (a, b, r) = (rect("A"), rect("B"), rect("R")); + assert!((left.left() - tools.right()).abs() < 1.0 && (left.width() - WIDTH - 2.0 * LANE_GAP).abs() < 0.5, "{left:?} {tools:?}"); + assert!(left.contains_rect(a) && left.contains_rect(b) && right.contains_rect(r), "{left:?} {a:?} {b:?} {right:?} {r:?}"); + assert!((right.right() - 1200.0).abs() < 0.5, "{right:?}"); + assert!(b.top() >= a.bottom() && (a.center().x - left.center().x).abs() < 1.0 && (b.center().x - a.center().x).abs() < 1.0, "{a:?} {b:?}"); + // A's 30 tools scroll in its half of the lane. + assert!(a.height() <= left.height() / 2.0, "{a:?} {left:?}"); + // The canvas ends left of the right lane. + let canvas_right = ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_right_id())).unwrap().right(); + assert!(canvas_right <= right.left() + 0.5, "{canvas_right} {right:?}"); + // Two columns widen the lane, and the toolbar stays docked. + app.run("window.toolbarColumns", json!({"toolbar": "B", "double": true})).unwrap(); + frames(&mut app); + assert!((lane(false).unwrap().width() - WIDTH - BUTTON - 2.0 * LANE_GAP).abs() < 0.5); + assert!(lane(false).unwrap().contains_rect(rect("B")) && place(&app, "B") == (Some(1), false)); + // Hidden, it keeps its place, and an empty lane goes. + app.run("window.toolbar.custom", json!({"name": "R", "show": false})).unwrap(); + frames(&mut app); + assert!(lane(true).is_none() && !drawn(&ctx, area_id("R"), egui::Order::Middle) && place(&app, "R") == (Some(0), true)); + // Tab hides the lanes with the toolbars. + app.run("window.dock", json!({})).unwrap(); + frames(&mut app); + assert!(lane(false).is_none() && !drawn(&ctx, area_id("A"), egui::Order::Middle)); + } + + /// With Keep Tools Beside the Canvas on, the left lane stands right of Tools, which stands right + /// of the panels docked on the left, and its target covers Tools and the lane, not the panels. In a + /// narrow window the lane leaves the canvas its least width of 200 points. With the setting off + /// again, the lane goes back to the window's edge, right of Tools. + #[test] + fn the_left_lane_follows_tools_docked_beside_the_canvas() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + app.run("window.toolbar.new", json!({"name": "A"})).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "A"})).unwrap(); + app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + app.run("window.toolsBesideCanvas", json!({"on": true})).unwrap(); + app.run("window.toolbar.dock", json!({"name": "A", "side": "left"})).unwrap(); + let mut time = 0.0; + let mut frames = |app: &mut VectorcraftApp, size: egui::Vec2| { + for _ in 0..6 { + time += 0.05; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + let raw = egui::RawInput { time: Some(time), screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + }; + frames(&mut app, vec2(1200.0, 600.0)); + let tools = || ctx.data(|d| d.get_temp::(crate::toolbar::docked_rect_id())).unwrap(); + let lane = || ctx.data(|d| d.get_temp::(lane_id(false))).unwrap(); + let zone = ctx.data(|d| d.get_temp::(zone_id(false))).unwrap(); + let a = ctx.memory(|m| m.area_rect(area_id("A"))).unwrap(); + assert!(tools().left() > 100.0, "Tools stands right of the left dock: {:?}", tools()); + assert!((lane().left() - tools().right()).abs() < 1.0, "{:?} {:?}", lane(), tools()); + assert!(lane().contains_rect(a), "{a:?} {:?}", lane()); + assert!(zone.contains(tools().center()) && zone.right() >= lane().right() - 0.5, "{zone:?}"); + assert!(!zone.contains(pos2(4.0, tools().center().y)), "the left dock is not a target: {zone:?}"); + // In a narrow, short window, a stack that wraps leaves the canvas its 200 points. + for k in 0..12 { + let name = format!("T{k}"); + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": name})).unwrap(); + app.run("window.toolbar.dock", json!({ "name": name, "side": "left" })).unwrap(); + } + frames(&mut app, vec2(700.0, 320.0)); + // The canvas panel's right edge, and the canvas drawn right of the lane. + let right = ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_right_id())).unwrap().right(); + let canvas = app.canvas_rect.unwrap(); + assert!(right - lane().right() >= 199.5 && canvas.left() >= lane().right() - 0.5, "{right} {canvas:?} {:?} {:?}", lane(), tools()); + app.run("window.toolsBesideCanvas", json!({"on": false})).unwrap(); + frames(&mut app, vec2(1200.0, 600.0)); + assert!(tools().left() < 1.0 && (lane().left() - tools().right()).abs() < 1.0, "{:?} {:?}", lane(), tools()); + } + + /// More docked toolbars than a short lane holds in one column wrap into more columns: each stays + /// inside the lane with its title bar, and none covers another. With more than half the window's + /// width of columns needed (60 toolbars), the lane stops at half the width and the toolbars show + /// only their title bars, still each in view. + #[test] + fn a_short_lane_keeps_every_docked_toolbar_inside_it() { + for count in [12, 60] { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let names: Vec = (0..count).map(|k| format!("T{k}")).collect(); + for name in &names { + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": name})).unwrap(); + app.run("window.toolbar.dock", json!({ "name": name, "side": "right" })).unwrap(); + } + for k in 0..6 { + window_frame(&mut app, &ctx, 0.05 * f64::from(k + 1), 320.0); + } + let lane = ctx.data(|d| d.get_temp::(lane_id(true))).unwrap(); + let rects: Vec = names.iter().map(|name| ctx.memory(|m| m.area_rect(area_id(name))).unwrap()).collect(); + for (name, r) in names.iter().zip(&rects) { + assert!(lane.expand(0.5).contains_rect(*r), "{count}: {name} {r:?} {lane:?}"); + assert!(r.height() >= GRIP, "{count}: {name} {r:?}"); + } + for (i, a) in rects.iter().enumerate() { + for (j, b) in rects.iter().enumerate().skip(i + 1) { + assert!(a.intersect(*b).area() < 1.0, "{count}: {} {a:?} covers {} {b:?}", names[i], names[j]); + } + } + assert!(lane.width() > WIDTH + 2.0 * LANE_GAP + 1.0, "the stack wraps into more columns: {lane:?}"); + assert!(lane.width() <= 600.0, "{count}: the lane leaves the canvas at least half the window: {lane:?}"); + assert!((lane.right() - 1200.0).abs() < 0.5, "{lane:?}"); + let bare = rects.iter().all(|r| r.height() < DOCKED_LEAST - 1.0); + assert_eq!(bare, count == 60, "{count}: title bars alone only when the columns run out: {rects:?}"); + } + } + + /// A lone docked toolbar in a lane too short for its tools shows only its title bar, inside the + /// lane. + #[test] + fn a_lone_toolbar_in_a_short_lane_stays_inside_it() { + let mut short = 0; + for height in (120..=240).step_by(10) { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + app.run("window.toolbar.new", json!({"name": "A"})).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "A"})).unwrap(); + app.run("window.toolbar.dock", json!({"name": "A", "side": "right"})).unwrap(); + for k in 0..6 { + window_frame(&mut app, &ctx, 0.05 * f64::from(k + 1), height as f32); + } + let lane = ctx.data(|d| d.get_temp::(lane_id(true))).unwrap(); + if lane.height() >= DOCKED_LEAST + 2.0 * LANE_GAP { + continue; + } + short += 1; + let a = ctx.memory(|m| m.area_rect(area_id("A"))).unwrap(); + assert!(lane.expand(0.5).contains_rect(a), "{height}: {a:?} {lane:?}"); + assert!(a.height() >= GRIP, "{height}: the title bar shows: {a:?}"); + } + assert!(short > 0, "some window heights leave a lane shorter than one toolbar"); + } + + /// A column too short for even its toolbars' title bars shows as many as fit, none covering + /// another, and the mouse wheel over it scrolls through the rest, one toolbar a notch. + #[test] + fn a_column_too_short_for_its_title_bars_scrolls() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let names: Vec = (0..64).map(|k| format!("T{k}")).collect(); + for name in &names { + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + app.run("window.toolbar.dock", json!({ "name": name, "side": "right" })).unwrap(); + } + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.05; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 200.0)); + let raw = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); + }; + for _ in 0..6 { + frame(&mut app, vec![]); + } + let shown = + |ctx: &egui::Context| -> Vec { (0..names.len()).filter(|&k| drawn(ctx, area_id(&names[k]), egui::Order::Middle)).collect() }; + let lane = ctx.data(|d| d.get_temp::(lane_id(true))).unwrap(); + let before = shown(&ctx); + assert!(!before.is_empty() && before.len() < names.len(), "{before:?} {lane:?}"); + let rects: Vec = before.iter().map(|&k| ctx.memory(|m| m.area_rect(area_id(&names[k]))).unwrap()).collect(); + for (i, a) in rects.iter().enumerate() { + assert!(lane.expand(0.5).contains_rect(*a), "{a:?} {lane:?}"); + for b in rects.iter().skip(i + 1) { + assert!(a.intersect(*b).area() < 1.0, "{a:?} covers {b:?}"); + } + } + assert!(before.contains(&0)); + // The wheel over the first column shows the next toolbar of that column. + let at = pos2(lane.left() + LANE_GAP + WIDTH / 2.0, lane.center().y); + let wheel = |dy: f32| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + frame(&mut app, vec![egui::Event::PointerMoved(at), wheel(-1.0)]); + frame(&mut app, vec![]); + let after = shown(&ctx); + assert!(!after.contains(&0) && after.len() == before.len(), "{before:?} {after:?}"); + frame(&mut app, vec![egui::Event::PointerMoved(at), wheel(1.0)]); + frame(&mut app, vec![]); + assert_eq!(shown(&ctx), before, "the wheel back shows the first again"); + } + + /// A lane holds one column while each docked toolbar gets [`DOCKED_LEAST`] points, and as few + /// more columns as give each that much, the toolbars spread evenly over them, up to the most + /// columns the lane may take. + #[test] + fn short_lanes_wrap_their_stack_into_columns() { + let tall = 2.0 * (DOCKED_LEAST + LANE_GAP) + LANE_GAP; + assert_eq!(lane_columns(0, 500.0, 8), (1, 1)); + assert_eq!(lane_columns(2, tall, 8), (1, 2)); + assert_eq!(lane_columns(3, tall, 8), (2, 2)); + assert_eq!(lane_columns(5, tall, 8), (3, 2)); + assert_eq!(lane_columns(4, 10.0, 8), (4, 1), "a lane shorter than one toolbar puts each in its own column"); + assert_eq!(lane_columns(3, f32::NAN, 8), (3, 1)); + assert_eq!(lane_columns(9, tall, 2), (2, 5), "at most the columns allowed"); + assert_eq!(lane_columns(9, tall, 0), (1, 9)); + assert_eq!(most_columns(3.0 * WIDTH + 4.0 * LANE_GAP, WIDTH), 3); + assert_eq!(most_columns(10.0, WIDTH), 1); + assert_eq!(most_columns(f32::NAN, WIDTH), 1); + } + + /// The frame that docks a toolbar lays its lane out after the toolbars are drawn, so it asks for + /// the next frame at once, which draws the toolbar in the lane, without other input. + #[test] + fn a_new_lane_draws_its_toolbar_in_the_next_frame() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp| { + time += 0.05; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 600.0)); + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), screen_rect: Some(screen), ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.viewport_output.values().map(|v| v.repaint_delay).min().unwrap_or(std::time::Duration::MAX) + }; + for _ in 0..4 { + frame(&mut app); + } + app.run("window.toolbar.dock", json!({"name": "Mine"})).unwrap(); + assert_eq!(frame(&mut app), std::time::Duration::ZERO, "the frame that lays the new lane out asks for another"); + frame(&mut app); + let lane = ctx.data(|d| d.get_temp::(lane_id(false))).unwrap(); + assert!(lane.contains_rect(ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap())); + } + + /// A docked toolbar dragged inside its lane stays docked. A toolbar's title bar dropped on the + /// window's edge docks it there; dragged out of its lane, + /// it floats under the pointer; Escape during the drag puts it back; a click near the edge does + /// not dock it. The title bar's context menu docks and floats it. + #[test] + fn a_title_bar_dropped_on_an_edge_docks_the_toolbar() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap(); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.05; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0)); + let raw = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + // The texts drawn, with where each is. + let texts = out.shapes.iter().filter_map(|c| { + if let egui::Shape::Text(t) = &c.shape { Some((t.galley.text().to_string(), t.visual_bounding_rect())) } else { None } + }); + texts.collect::>() + }; + let button = |pos, pressed, button| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }; + let primary = egui::PointerButton::Primary; + let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + // Docked and dragged down its lane, past its own bottom, it stays docked. + app.run("window.toolbar.dock", json!({"name": "Mine", "side": "left"})).unwrap(); + for _ in 0..3 { + frame(&mut app, vec![]); + } + let r = ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap(); + let lane = ctx.data(|d| d.get_temp::(lane_id(false))).unwrap(); + let (from, below) = (pos2(r.center().x, r.top() + 1.0 + GRIP / 2.0), pos2(lane.center().x, lane.bottom() - 20.0)); + assert!(!r.contains(below), "{r:?} {lane:?}"); + frame(&mut app, vec![egui::Event::PointerMoved(from), button(from, true, primary)]); + for k in 1..=6 { + frame(&mut app, vec![egui::Event::PointerMoved(from + (below - from) * (k as f32 / 6.0))]); + assert_eq!(place(&app, "Mine"), (Some(0), false), "a drag inside the lane keeps it docked, step {k}"); + } + frame(&mut app, vec![button(below, false, primary)]); + app.run("window.toolbar.float", json!({"name": "Mine"})).unwrap(); + for _ in 0..3 { + frame(&mut app, vec![]); + } + // A press on the title bar's grip, moved in steps to `to` (`cut` after the step it names), + // then released there. + let mut drag = |app: &mut VectorcraftApp, from: egui::Pos2, to: egui::Pos2, cut: Option| { + frame(app, vec![egui::Event::PointerMoved(from), button(from, true, primary)]); + for k in 1..=6 { + let mut events = vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]; + if cut == Some(k) { + events.push(escape.clone()); + } + frame(app, events); + } + frame(app, vec![button(to, false, primary)]); + frame(app, vec![]); + frame(app, vec![]); + }; + let grip = |ctx: &egui::Context| { + let r = ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap(); + pos2(r.center().x, r.top() + 1.0 + GRIP / 2.0) + }; + // Dropped on the window's left edge, it docks there. + drag(&mut app, grip(&ctx), pos2(20.0, 400.0), None); + assert_eq!(place(&app, "Mine"), (Some(0), false)); + let lane = ctx.data(|d| d.get_temp::(lane_id(false))).unwrap(); + assert!(lane.contains_rect(ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap())); + // Escape while it is dragged out of the lane puts it back in its place. + drag(&mut app, grip(&ctx), pos2(600.0, 300.0), Some(4)); + assert_eq!(place(&app, "Mine"), (Some(0), false), "Escape keeps it docked"); + // Dragged out of the lane, it floats under the pointer. + drag(&mut app, grip(&ctx), pos2(600.0, 300.0), None); + assert_eq!(place(&app, "Mine"), (None, false)); + assert!(ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap().contains(pos2(600.0, 300.0))); + // Escape while it is dragged to the right edge leaves it floating where it was. + let at = find(&app.ui, "Mine").unwrap().pos; + drag(&mut app, grip(&ctx), pos2(1195.0, 400.0), Some(5)); + assert_eq!((place(&app, "Mine"), find(&app.ui, "Mine").unwrap().pos), ((None, false), at)); + // Dropped on the right edge, it docks there. + drag(&mut app, grip(&ctx), pos2(1195.0, 400.0), None); + assert_eq!(place(&app, "Mine"), (Some(0), true)); + // Floating over the edge, a click on its title bar does not dock it. + app.run("window.toolbar.float", json!({"name": "Mine", "x": 2, "y": 300})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let at = grip(&ctx); + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true, primary)]); + frame(&mut app, vec![button(at, false, primary)]); + frame(&mut app, vec![]); + assert_eq!(place(&app, "Mine"), (None, true)); + // The title bar's context menu: Dock to Left docks it, and Float Toolbar floats it again. + for (item, after) in [("Dock to Left", (Some(0), false)), ("Float Toolbar", (None, false))] { + let at = grip(&ctx); + let secondary = egui::PointerButton::Secondary; + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true, secondary)]); + frame(&mut app, vec![button(at, false, secondary)]); + let texts = frame(&mut app, vec![]); + let found = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == item); + let at = found.unwrap_or_else(|| panic!("no {item} in {texts:?}")).1.center(); + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true, primary)]); + frame(&mut app, vec![button(at, false, primary)]); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + assert_eq!(place(&app, "Mine"), after, "after {item}"); + } + // Docked, and released in the frame the pointer leaves the lane, it floats there. + app.run("window.toolbar.dock", json!({"name": "Mine"})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let at = grip(&ctx); + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true, primary)]); + frame(&mut app, vec![egui::Event::PointerMoved(at + vec2(0.0, 8.0))]); + assert_eq!(place(&app, "Mine"), (Some(0), false), "still in its lane"); + let out = pos2(600.0, 300.0); + frame(&mut app, vec![egui::Event::PointerMoved(out), button(out, false, primary)]); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + assert_eq!(place(&app, "Mine"), (None, false), "released outside its lane"); + assert!(ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap().contains(out)); + } } diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs index e47561094e..cac6338418 100644 --- a/crates/ui-egui/src/edit_toolbar/tests.rs +++ b/crates/ui-egui/src/edit_toolbar/tests.rs @@ -508,7 +508,10 @@ fn the_add_tool_menu_fills_a_new_toolbar_without_a_drag() { assert_eq!(slots(&app), shipped, "the Tools panel's toolbar is untouched"); assert!(!app.ui.tools_drawer, "no drawer opened"); let list = app.run("window.toolbar.list", json!({})).unwrap(); - assert_eq!(list, json!([{"name": "Mine", "shown": true, "double": false, "slots": [["lasso"]]}])); + assert_eq!( + list, + json!([{"name": "Mine", "shown": true, "double": false, "slots": [["lasso"]], "floating": true, "side": "left", "order": null}]) + ); // A second tool goes after the first. let shown = open(&mut app, 19.0); click(&mut app, &ctx, 20.0, items(&shown, "Magic Wand Tool")[0].0); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3828d9ba2d..cf3dec273d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4052,6 +4052,10 @@ axis Both И двете Dock Panel Закотвен панел Dock to Top Закотвяне горе Dock to Bottom Закотвяне долу + Dock to Left Закотвяне вляво + Dock to Right Закотвяне вдясно + Dock Toolbar Закотвяне на лентата с инструменти + Float Toolbar Плаваща лента с инструменти Control Bar Options Опции на контролната лента Tool Options Опции за инструмент Object Options Опции за обект diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 81fee3a079..1c6efe09b4 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -908,6 +908,10 @@ Dock Panel Usidri panel Dock to Top Usidri na vrh Dock to Bottom Usidri na dno + Dock to Left Usidri lijevo + Dock to Right Usidri desno + Dock Toolbar Usidri traku s alatima + Float Toolbar Odvoji traku s alatima Control Bar Options Opcije kontrolne trake Tool Options Opcije alata Object Options Opcije objekta diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 02ae0f4a09..af4e171e47 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1247,6 +1247,10 @@ antiAlias Sharp Nítid Dock Panel Acobla el panell Dock to Top Acobla a dalt Dock to Bottom Acobla a baix + Dock to Left Acobla a l’esquerra + Dock to Right Acobla a la dreta + Dock Toolbar Acobla la barra d’eines + Float Toolbar Flota la barra d’eines Control Bar Options Opcions de la barra de control Tool Options Opcions de l’eina Object Options Opcions de l’objecte diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e3794e8a1..4431c720e5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -923,6 +923,10 @@ Dock Panel Ukotvit panel Dock to Top Ukotvit nahoru Dock to Bottom Ukotvit dolů + Dock to Left Ukotvit vlevo + Dock to Right Ukotvit vpravo + Dock Toolbar Ukotvit panel nástrojů + Float Toolbar Plovoucí panel nástrojů Control Bar Options Možnosti ovládacího panelu Tool Options Možnosti nástroje Object Options Možnosti objektu diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f7011e23bf..06b554631e 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -853,6 +853,10 @@ Dock Panel Dok panel Dock to Top Dok øverst Dock to Bottom Dok nederst + Dock to Left Dok til venstre + Dock to Right Dok til højre + Dock Toolbar Dok værktøjslinjen + Float Toolbar Flydende værktøjslinje Control Bar Options Indstillinger for kontrollinjen Tool Options Værktøjsindstillinger Object Options Objektindstillinger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4174537f87..0b5f1887b4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4074,6 +4074,10 @@ theme Appearance Erscheinungsbild Dock Panel Bedienfeld andocken Dock to Top Oben andocken Dock to Bottom Unten andocken + Dock to Left Links andocken + Dock to Right Rechts andocken + Dock Toolbar Werkzeugleiste andocken + Float Toolbar Werkzeugleiste ablösen Control Bar Options Optionen der Steuerungsleiste Tool Options Werkzeugoptionen Object Options Objektoptionen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bde2fbee78..ee7e173604 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2257,6 +2257,10 @@ axis Both Και τα δύο Dock Panel Αγκύρωση πάνελ Dock to Top Αγκύρωση επάνω Dock to Bottom Αγκύρωση κάτω + Dock to Left Αγκύρωση αριστερά + Dock to Right Αγκύρωση δεξιά + Dock Toolbar Αγκύρωση γραμμής εργαλείων + Float Toolbar Αιωρούμενη γραμμή εργαλείων Control Bar Options Επιλογές γραμμής ελέγχου Tool Options Επιλογές εργαλείου Object Options Επιλογές αντικειμένου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ff28d9dfc5..08c8ab13fb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4150,6 +4150,10 @@ axis Both Ambos Dock Panel Anclar panel Dock to Top Anclar arriba Dock to Bottom Anclar abajo + Dock to Left Anclar a la izquierda + Dock to Right Anclar a la derecha + Dock Toolbar Anclar barra de herramientas + Float Toolbar Hacer flotar la barra de herramientas Control Bar Options Opciones de la barra de control Tool Options Opciones de herramienta Object Options Opciones de objeto diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index bb74188ed8..bafb620fc0 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -911,6 +911,10 @@ Dock Panel Telakoi paneeli Dock to Top Telakoi ylös Dock to Bottom Telakoi alas + Dock to Left Telakoi vasemmalle + Dock to Right Telakoi oikealle + Dock Toolbar Telakoi työkalurivi + Float Toolbar Kelluta työkalurivi Control Bar Options Ohjauspalkin asetukset Tool Options Työkalun asetukset Object Options Objektin asetukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 09c9c35b0d..3cd787bd97 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4139,6 +4139,10 @@ axis Both Les deux Dock Panel Ancrer le panneau Dock to Top Ancrer en haut Dock to Bottom Ancrer en bas + Dock to Left Ancrer à gauche + Dock to Right Ancrer à droite + Dock Toolbar Ancrer la barre d’outils + Float Toolbar Rendre la barre d’outils flottante Control Bar Options Options de la barre de contrôle Tool Options Options de l’outil Object Options Options de l’objet diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9aa4bd7b99..cdc1de5b8a 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1386,6 +1386,10 @@ antiAlias Sharp शार्प Dock Panel पैनल डॉक करें Dock to Top ऊपर डॉक करें Dock to Bottom नीचे डॉक करें + Dock to Left बाईं ओर डॉक करें + Dock to Right दाईं ओर डॉक करें + Dock Toolbar टूलबार डॉक करें + Float Toolbar टूलबार फ़्लोट करें Control Bar Options नियंत्रण बार ऑप्शन्स Tool Options टूल ऑप्शन्स Object Options ऑब्जेक्ट ऑप्शन्स diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index af6f586880..5bb08d52fc 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1114,6 +1114,10 @@ font-technology Variable Változó Dock Panel Panel dokkolása Dock to Top Dokkolás felülre Dock to Bottom Dokkolás alulra + Dock to Left Dokkolás balra + Dock to Right Dokkolás jobbra + Dock Toolbar Eszköztár dokkolása + Float Toolbar Eszköztár lebegtetése Control Bar Options Vezérlősáv beállításai Tool Options Eszközbeállítások Object Options Objektumbeállítások diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e0ba31ecd5..e1499a5821 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -853,6 +853,10 @@ Dock Panel Tambatkan panel Dock to Top Tambatkan ke atas Dock to Bottom Tambatkan ke bawah + Dock to Left Tambatkan ke kiri + Dock to Right Tambatkan ke kanan + Dock Toolbar Tambatkan bilah alat + Float Toolbar Mengambangkan bilah alat Control Bar Options Opsi bilah kontrol Tool Options Opsi alat Object Options Opsi objek diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ce0bc58ce5..8f01c8358a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4201,6 +4201,10 @@ axis Both Entrambi Dock Panel Ancora pannello Dock to Top Ancora in alto Dock to Bottom Ancora in basso + Dock to Left Ancora a sinistra + Dock to Right Ancora a destra + Dock Toolbar Ancora barra degli strumenti + Float Toolbar Rendi mobile barra degli strumenti Control Bar Options Opzioni barra di controllo Tool Options Opzioni strumento Object Options Opzioni oggetto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ecce4618a..817d515334 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4143,6 +4143,10 @@ axis Both 両方 Dock Panel パネルをドッキング Dock to Top 上部にドッキング Dock to Bottom 下部にドッキング + Dock to Left 左側にドッキング + Dock to Right 右側にドッキング + Dock Toolbar ツールバーをドッキング + Float Toolbar ツールバーをフローティング Control Bar Options コントロールバーオプション Tool Options ツールオプション Object Options オブジェクトオプション diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 3d6425c373..93ff9f8e96 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -816,6 +816,10 @@ Dock Panel პანელის მიმაგრება Dock to Top ზემოთ მიმაგრება Dock to Bottom ქვემოთ მიმაგრება + Dock to Left მარცხნივ მიმაგრება + Dock to Right მარჯვნივ მიმაგრება + Dock Toolbar ხელსაწყოთა ზოლის მიმაგრება + Float Toolbar მცურავი ხელსაწყოთა ზოლი Control Bar Options მართვის ზოლის პარამეტრები Tool Options ხელსაწყოს პარამეტრები Object Options ობიექტის პარამეტრები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index fb87488d64..5132cf1484 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4127,6 +4127,10 @@ axis Both 양쪽 Dock Panel 패널 고정 Dock to Top 위쪽에 고정 Dock to Bottom 아래쪽에 고정 + Dock to Left 왼쪽에 고정 + Dock to Right 오른쪽에 고정 + Dock Toolbar 도구 막대 고정 + Float Toolbar 도구 막대 분리 Control Bar Options 컨트롤 막대 옵션 Tool Options 도구 옵션 Object Options 개체 옵션 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b3388fa82a..8f3c2da6b9 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -867,6 +867,10 @@ Dock Panel Paneel dokken Dock to Top Bovenaan dokken Dock to Bottom Onderaan dokken + Dock to Left Links dokken + Dock to Right Rechts dokken + Dock Toolbar Werkbalk dokken + Float Toolbar Werkbalk laten zweven Control Bar Options Opties voor bedieningsbalk Tool Options Gereedschapsopties Object Options Objectopties diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0729e35c79..049de20359 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -868,6 +868,10 @@ Dock Panel Zadokuj panel Dock to Top Zadokuj u góry Dock to Bottom Zadokuj u dołu + Dock to Left Zadokuj po lewej + Dock to Right Zadokuj po prawej + Dock Toolbar Zadokuj pasek narzędzi + Float Toolbar Odepnij pasek narzędzi Control Bar Options Opcje paska sterowania Tool Options Opcje narzędzia Object Options Opcje obiektu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 36f778dbd5..83ffa09c26 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3379,6 +3379,10 @@ imageTrace Outline Contorno Dock Panel Encaixar Painel Dock to Top Encaixar na Parte Superior Dock to Bottom Encaixar na Parte Inferior + Dock to Left Encaixar à Esquerda + Dock to Right Encaixar à Direita + Dock Toolbar Encaixar Barra de Ferramentas + Float Toolbar Flutuar Barra de Ferramentas Control Bar Options Opções da Barra de Controle Tool Options Opções de Ferramenta Object Options Opções de Objeto diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 442a9612d4..76d1ce271b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4187,6 +4187,10 @@ axis Both Оба Dock Panel Прикрепить панель Dock to Top Прикрепить сверху Dock to Bottom Прикрепить снизу + Dock to Left Прикрепить слева + Dock to Right Прикрепить справа + Dock Toolbar Прикрепить панель инструментов + Float Toolbar Сделать панель инструментов плавающей Control Bar Options Параметры панели управления Tool Options Параметры инструмента Object Options Параметры объекта diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 8a8f8453b6..0eec252189 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4066,6 +4066,10 @@ axis Both Båda Dock Panel Docka panel Dock to Top Docka överst Dock to Bottom Docka nederst + Dock to Left Docka till vänster + Dock to Right Docka till höger + Dock Toolbar Docka verktygsfältet + Float Toolbar Gör verktygsfältet flytande Control Bar Options Alternativ för kontrollfältet Tool Options Verktygsalternativ Object Options Objektalternativ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ef8773c31b..a30a5547af 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3950,6 +3950,10 @@ axis Both Her İkisi Dock Panel Bağlantı İstasyonu Paneli Dock to Top Üste yerleştir Dock to Bottom Alta yerleştir + Dock to Left Sola yerleştir + Dock to Right Sağa yerleştir + Dock Toolbar Araç çubuğunu yerleştir + Float Toolbar Araç Çubuğunu Yüzer Yap Control Bar Options Kontrol Çubuğu Seçenekleri Tool Options Araç Seçenekleri Object Options Nesne Seçenekleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0bccbd8cb6..31e42b581e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4190,6 +4190,10 @@ axis Both Обидві Dock Panel Прикріпити панель Dock to Top Прикріпити вгорі Dock to Bottom Прикріпити внизу + Dock to Left Прикріпити ліворуч + Dock to Right Прикріпити праворуч + Dock Toolbar Прикріпити панель інструментів + Float Toolbar Від’єднати панель інструментів Control Bar Options Параметри панелі керування Tool Options Параметри інструменту Object Options Параметри об’єкта diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4f084029db..70e11365c5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1012,6 +1012,10 @@ font-technology Variable Biến thiên Dock Panel Neo bảng Dock to Top Neo lên trên Dock to Bottom Neo xuống dưới + Dock to Left Neo sang trái + Dock to Right Neo sang phải + Dock Toolbar Neo thanh công cụ + Float Toolbar Thả nổi thanh công cụ Control Bar Options Tùy chọn thanh điều khiển Tool Options Tùy chọn công cụ Object Options Tùy chọn đối tượng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b04e65a65b..ed292747a2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5437,6 +5437,10 @@ axis Both 两者 Dock Panel 停放面板 Dock to Top 停放到顶部 Dock to Bottom 停放到底部 + Dock to Left 停放到左侧 + Dock to Right 停放到右侧 + Dock Toolbar 停放工具栏 + Float Toolbar 浮动工具栏 Control Bar Options 控制栏选项 Tool Options 工具选项 Object Options 对象选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index be62fde8a7..24abfce36f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5266,6 +5266,10 @@ axis Both 兩者 Dock Panel 停駐面板 Dock to Top 停駐到頂部 Dock to Bottom 停駐到底部 + Dock to Left 停駐到左側 + Dock to Right 停駐到右側 + Dock Toolbar 停駐工具列 + Float Toolbar 浮動工具列 Control Bar Options 控制列選項 Tool Options 工具選項 Object Options 物件選項 diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 20a2c58314..cd3a391793 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -564,7 +564,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{name: string} delete the toolbar made with New Toolbar called `name`, and the groups torn off it, from the current workspace, as Manage Toolbars' trash can does", ), - ("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"), + ( + "window.toolbar.dock", + "Dock Toolbar", + "", + "{name: string, side?: left|right, order?: int} dock the toolbar made with New Toolbar called `name` in the lane along the window's left or right edge (omitted: the edge it docked at last, the left at first), as Dock to Left and Dock to Right in its title bar's context menu and dropping its title bar on that edge do; `order` is its place in that edge's stack, 0 at the top (omitted: the end, or where it is when it is docked at that edge already); a bad `side` or `order` is an error and changes nothing; returns {toolbar, floating, side, order, pos}", + ), + ( + "window.toolbar.float", + "Float Toolbar", + "", + "{name: string, x?: number, y?: number} float the toolbar made with New Toolbar called `name` with its top-left corner at x, y in screen points (both or neither; omitted: where it floated last), as Float Toolbar in its title bar's context menu and dragging its title bar out of its lane do; returns {toolbar, floating, side, order, pos}", + ), + ( + "window.toolbar.list", + "List Toolbars", + "", + "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots, floating, side, order}], `side` the edge it docks at (while floating, the edge it docked at last) and `order` its place in that edge's stack while docked (else null)", + ), ("window.taskBar", "Contextual Task Bar", "", "{}"), ( "window.pathfinder.compoundShapes", diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index cc72f74b77..e22dcd9cac 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1435,8 +1435,9 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } fn limits(&self, id: &String) -> craft_ui::docking::PanelLimits { - let min = - if id == CANVAS { egui::vec2(200.0 + crate::toolbar::docked_width(&self.app.ui), 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) }; + // The canvas keeps room for Tools and the lane of toolbars docked beside it. + let beside = crate::toolbar::docked_width(&self.app.ui) + crate::custom_toolbars::beside_canvas_reserve(&self.app.ui); + let min = if id == CANVAS { egui::vec2(200.0 + beside, 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) }; craft_ui::docking::PanelLimits { min, ..Default::default() } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f84b8f53b0..91fb21ac05 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -864,7 +864,8 @@ impl FloatingFlyout { } /// A toolbar made with Window › Toolbars › New Toolbar (`window.toolbar.new`): it floats on its -/// own, filled with tools from the All Tools drawer, and is kept with the workspace it was made in. +/// own or docks along the window's left or right edge, filled with tools from the All Tools drawer, +/// and is kept with the workspace it was made in. /// The groups torn off it are kept with it (`floating`), so the Tools panel's strips /// (`UiState::floating_flyouts`) hold none of them. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -877,8 +878,16 @@ pub struct CustomToolbar { /// It shows (Window › Toolbars › its name toggles it). #[serde(default = "yes")] pub shown: bool, - /// Top-left corner in screen points. + /// Top-left corner in screen points while it floats; while it is docked, where it floated last. pub pos: [f32; 2], + /// Its place in the stack of toolbars docked along one of the window's edges (0 at the top), + /// while it is docked there; none while it floats at `pos`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dock: Option, + /// It docks along the window's right edge, not the left: the edge it is docked at, or while it + /// floats the edge it docked at last. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub dock_right: bool, /// The tool each slot of several tools shows, chosen last (keyed by the slot's first tool). #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub slot_tool: std::collections::BTreeMap, diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index b1788e1e8a..90f5f74646 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -145,6 +145,8 @@ pub(crate) fn show_beside_canvas(app: &mut VectorcraftApp, ui: &mut Ui) { if app.ui.toolbar_pos.is_none() && (app.ui.tools_beside_canvas || app.ui.toolbar_right) { show(app, ui); } + // The lane where toolbars made with New Toolbar dock on the left, right of Tools. + crate::custom_toolbars::lane_beside_canvas(app, ui); } /// Is the Tools panel docked on the right, between the canvas and the panels? diff --git a/crates/ui-egui/src/tools_float.rs b/crates/ui-egui/src/tools_float.rs index 0cc6433429..b5fb04d384 100644 --- a/crates/ui-egui/src/tools_float.rs +++ b/crates/ui-egui/src/tools_float.rs @@ -46,6 +46,13 @@ pub(crate) fn escape_cancelled_move(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(escape_cancel_id())) == Some(ctx.cumulative_frame_nr_for(ctx.viewport_id())) } +/// This frame's Escape put a moved bar back ([`escape_cancelled_move`]): a toolbar made with New +/// Toolbar ([`crate::custom_toolbars::cancel_move_on_escape`]) as well as the Tools panel. +pub(crate) fn mark_escape_cancelled(ctx: &egui::Context) { + let frame = ctx.cumulative_frame_nr_for(ctx.viewport_id()); + ctx.data_mut(|d| d.insert_temp(escape_cancel_id(), frame)); +} + /// The strip along the canvas's left edge where the Tools panel docks while Keep Tools Beside the /// Canvas is on, as last laid out. pub(crate) fn tools_zone_id() -> Id { @@ -194,7 +201,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { } /// The `x`, `y` params: a top-left corner in screen points, if given. -fn position(p: &Value) -> Result, String> { +pub(crate) fn position(p: &Value) -> Result, String> { let coord = |k: &str| match p.get(k) { None | Some(Value::Null) => Some(None), Some(v) => v.as_f64().filter(|f| f.abs() < 1e6).map(|f| Some(f as f32)), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5694d9582d..1a0d5c7d7d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -417,7 +417,10 @@ button (Add Tool…) opens a menu of every tool under the drawer's headings: a c {tool, toolbar}`; the tools already in the toolbar are dimmed under their headings. The drawer has no Show section there. `window.floatTools {tool, toolbar}` floats one of its groups, and a group torn off it shows and hides with it. `window.toolbar.list` returns the toolbars (`ui.custom_toolbars` in `ui.inspect`: name, slots, shown flag, -two-column flag, top-left corner, the groups torn off it). They are kept with the workspace they were made in: +two-column flag, top-left corner, the groups torn off it, and `dock` and `dock_right` while docked or once docked). +`window.toolbar.dock {name, side?, order?}` docks a toolbar in the lane along the window's left or right edge, and +`window.toolbar.float {name, x?, y?}` floats it again; dropping its title bar on an edge or dragging it out of its +lane does the same (see the docking section in `docs/mcp.md`). They are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them. diff --git a/docs/gaps.md b/docs/gaps.md index d34956d4e0..cdb15df0bd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -684,8 +684,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen. - **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged. - **Done (#2849):** View › Rulers › Show Video Rulers (`view.videoRulers`): the rulers show and count the active artboard in video pixels from its top-left corner, a pixel as many points wide as the artboard's Video Ruler Pixel Aspect Ratio and one point tall ([public source](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)); Hide Video Rulers returns them to the General unit and the ruler origin. Shift-snapping a new guide to ruler ticks still uses the General unit's ticks. Estimate unchanged. -- **Partial:** custom toolbars (#1759): Window › Toolbars › New Toolbar makes them, kept with the workspace they - were made in, and Window › Toolbars › Manage Toolbars renames, copies and deletes them; docking them is missing. +- **Done:** custom toolbars (#1759): Window › Toolbars › New Toolbar makes them, kept with the workspace they + were made in, and Window › Toolbars › Manage Toolbars renames, copies and deletes them; they dock in lanes along + the window's left and right edges (`window.toolbar.dock`, `window.toolbar.float`). Inside the estimate below. - **Done (#1753):** a saved workspace keeps the width of the dock column. - **Done (#4135):** floating panels open in OS windows of their own on the desktop, so they can sit on another monitor; on the web and on Wayland they float inside the window. diff --git a/docs/mcp.md b/docs/mcp.md index 274db05a34..13ad15ab4c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2672,6 +2672,43 @@ kept with the workspace, and built-in workspaces and workspaces saved without it {"name":"invoke_menu","arguments":{"command":"window.toolsBesideCanvas","params":{"on":true}}} ``` +## Docking toolbars made with New Toolbar + +A toolbar made with New Toolbar floats when it is made. `window.toolbar.dock {name, side?, order?}` (Dock to Left and +Dock to Right in the context menu of the toolbar's title bar, remote mode, `invoke_menu`) docks it in a lane along the +window's left or right edge. On the left, the lane is right of the Tools panel when the Tools panel is docked at the +window's edge. With Keep Tools Beside the Canvas on and the Tools panel docked on the left, the lane is right of the +Tools panel beside the canvas, and dropping a title bar on the Tools panel or on the lane docks the toolbar there. `side` is `left` or `right` (`start` and `end` also work); without it, the toolbar docks at the edge it +docked at last, or at the left the first time. Toolbars docked at one edge stack from the top. `order` is the +toolbar's place in that stack, 0 at the top, from 0 to the number of other toolbars docked there; without it, the +toolbar goes at the end, or stays where it is when it is already docked at that edge. Each toolbar takes an equal +share of the lane's height and its tools scroll in it, and the lane is as wide as its widest toolbar. When the lane +is too short to give each toolbar 88 points of height, the stack wraps into more columns, left to right, and the lane +widens, so no toolbar covers another. A lane takes at most half the width the canvas and panels would have (beside +the canvas, it also leaves the canvas at least 200 points, and the canvas keeps room for one column); when that +many columns still give a toolbar less than 88 points, the toolbars in those columns show only their title bars, which +still float, dock and show the context menu. A column too short for all of its title bars shows as many as fit, and +the mouse wheel over the column scrolls through the others, one toolbar per notch. +`window.toolbar.float {name, x?, y?}` (Float Toolbar in the same menu) floats it with its top-left corner at `x`, `y` +in screen points, or where it floated last when both are omitted. Both commands answer +`{toolbar, floating, side, order, pos}`: `side` is the edge the toolbar docks at (while it floats, the edge it docked +at last), `order` its place in that edge's stack (null while it floats) and `pos` where it floats (while it is docked, +where it floated last). A missing or unknown name, a `side` other than left or right, an `order` that is not a whole +number in range, or only one of `x` and `y` is an error and changes nothing. `window.toolbar.list` reports `floating`, +`side` and `order` for each toolbar. + +With the pointer, dropping a floating toolbar's title bar on the window's left or right edge, or on that edge's lane, +docks it at the end of that edge's stack; the target lights up while the title bar is dragged over it. Dragging a +docked toolbar's title bar out of its lane floats it under the pointer. Escape, or the window losing focus, during +the drag puts the toolbar back where it was. Hiding a docked toolbar keeps its place, renaming it keeps its place, a +copy made with Manage Toolbars' + floats, and deleting one closes up the stack. Placement is kept with the workspace. +Toolbars saved before docking existed load floating. The lanes, the edge targets and the stacking are VectorCraft's +own layout rules. + +```json +{"name":"invoke_menu","arguments":{"command":"window.toolbar.dock","params":{"name":"Toolbar 1","side":"left","order":0}}} +``` + ## Learn panel Window › Learn and the Explore tutorials button beside the search icon at the right end of the app bar show and hide diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..7ee5253df3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -840,6 +840,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. The toolbars are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them (; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). - A toolbar made with New Toolbar has a + button (Add Tool…) above its ••• button. A click on it, or Enter while it has the keyboard focus, opens a menu of every tool under the All Tools drawer's headings, without the drawer: a tool picked there goes at the end of that toolbar, and the tools already in it are dimmed. Under Remove Tool, the menu lists the toolbar's tools, and a click takes one out. This menu is VectorCraft's own way to fill a toolbar without a drag (`toolbar.addTool` and `toolbar.removeTool` with `toolbar`; `the_add_tool_menu_fills_a_new_toolbar_without_a_drag`). - Window › Toolbars › Manage Toolbars… and Manage Toolbars… in the All Tools drawer's menu open a dialog that lists the toolbars made with New Toolbar: a new name typed for the selected toolbar renames it, + copies it with its tools (with none selected, + makes a blank toolbar), and the trash can deletes it. Each change shows under Window › Toolbars and stays in the workspace (; `window.toolbar.manage`, `window.toolbar.rename`, `window.toolbar.duplicate`, `window.toolbar.delete`; `manage_toolbars_renames_duplicates_and_deletes`). + - A toolbar made with New Toolbar docks in a lane along the window's left or right edge when its title bar is dropped on that edge, or with Dock to Left and Dock to Right in the title bar's context menu. Toolbars docked at one edge stack from the top, each with its share of the lane's height, and the lane is as wide as its widest toolbar; a lane too short for its stack wraps it into more columns, up to half the window's width, and past that the toolbars show only their title bars, which the mouse wheel scrolls through when even they do not fit. Dragging the title bar out of the lane, or Float Toolbar, floats it again, and Escape during the drag puts it back. Hiding and renaming keep its place, and the workspace keeps it. New toolbars still start floating. The lanes and the stacking are VectorCraft's own rules (#1759; `window.toolbar.dock`, `window.toolbar.float`; `toolbars_dock_and_float_by_their_commands`, `docked_toolbars_stack_in_lanes_along_the_window_edges`, `a_title_bar_dropped_on_an_edge_docks_the_toolbar`). - Window › Toolbars › Keep Tools Beside the Canvas, a VectorCraft layout option that is off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge; the canvas's left edge then also docks a floating Tools panel, and workspaces keep the setting. Scores are unchanged (`window.toolsBesideCanvas`; `docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas`, `tools_redock_at_the_targets_of_the_chosen_placement`). - Hindi (2026-11-06): Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged. [Terminology and limitations](localization-hi.md). - Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`).