diff --git a/ROADMAP.md b/ROADMAP.md index 9c018a01..7397cbdb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index a202ba76..899d56c4 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1,5 +1,6 @@ //! The right dock: Properties | Layers | Libraries tabs, plus the collapsed icon-panel column -//! whose panels pop out to the left. The double arrow at the top of the dock collapses the tabbed +//! whose panels pop out to the left, and left of it the floating sets docked as columns +//! ([`floating::docked_columns`]). The double arrow at the top of the dock collapses the tabbed //! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column //! then expands it again. @@ -23,6 +24,12 @@ fn column_left_id() -> egui::Id { egui::Id::new("dock-icon-column-left") } +/// Where the dock's left edge was drawn last: the icon column's, or the leftmost dock column's +/// (egui temp memory, one frame old). Popped-out panels open left of it. +fn dock_left_id() -> egui::Id { + egui::Id::new("dock-left") +} + /// Collapse the tabbed group to icons or expand it again (`window.collapseDock`). Expanding while /// one of its panels is popped out shows that panel's tab instead. pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) { @@ -148,7 +155,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); let column = column.response.rect; let dock = group.map_or(column, |g| g.union(column)); + // Floating sets docked as columns, left of the icon column. + let left = floating::docked_columns(app, ui).iter().map(|r| r.left()).fold(column.left(), f32::min); ui.ctx().data_mut(|d| { + d.insert_temp(dock_left_id(), left); d.insert_temp(column_left_id(), column.left()); d.insert_temp(floating::dock_rect_id(), dock); d.insert_temp(floating::icons_rect_id(), column); @@ -226,7 +236,9 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { } else { screen.right() }; - let right = column - 6.0; + let left = ctx.data(|d| d.get_temp::(dock_left_id())).filter(|x| x.is_finite() && *x > screen.left() && *x <= column).unwrap_or(column); + let right = if app.ui.dock && app.ui.screen_mode < 3 { left } else { column }; + let right = right - 6.0; // The tabbed group's panels fill their height (Layers) or scroll (Properties): give them one // that keeps the flyout on screen. let width = panel_width(id); diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 3080a25c..d435a7a0 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -9,9 +9,18 @@ //! × of a group puts its panels back in the dock too. The Tools panel floats the same way by its //! title bar ([`crate::toolbar`]) and docks again on the window's left edge. //! -//! `window.panel.float` / `window.panel.dock` do the same for agents. The groups and the Tools -//! panel's position are kept with the preferences and in user workspaces, and a position saved on -//! a bigger window is clamped into this one when drawn. +//! Dropped on the upper or lower half of another group (a line shows where), a group joins it in +//! a set right above or below it: the groups stack top to bottom, each showing one of its panels, +//! and move together by the top group's title bar, whose × docks them all; dropped on its tabs (lit +//! up), its panels join that group as tabs. A group dragged by its tab strip (a lone panel's +//! tab, or the strip right of the tabs) leaves its set and floats on its own. A double-click on a +//! tab collapses a group to its tabs, and another expands it. A dock column whose panels don't fit +//! the window scrolls. +//! +//! `window.panel.float` (`onto`, `below`, `above`, `collapsed`) / `window.panel.dock` do the same +//! for agents. The groups, their sets and the Tools panel's position are kept with the preferences +//! and in user workspaces, and a position saved on a bigger window is clamped into this one when +//! drawn. use egui::{CornerRadius, Id, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2}; use serde_json::{Value, json}; @@ -24,6 +33,12 @@ use crate::{VectorcraftApp, dock, icons, panels}; pub const TITLE: f32 = 14.0; /// Height of a floating group's tab strip. pub const STRIP: f32 = 26.0; +/// Height of the bar between two groups of a set. +pub const GAP: f32 = 3.0; +/// The least height of an open panel in a dock column: more panels than fit scroll. +const MIN_TALL: f32 = 160.0; +/// How far from a group's top or bottom edge a drop stacks it above or below the group. +const EDGE: f32 = 7.0; /// Where `window.panel.float` floats a group given no position; each further group 24 points /// lower right. const FLOAT_AT: [f32; 2] = [420.0, 120.0]; @@ -58,6 +73,12 @@ enum Drop { Dock, /// Stacked with the floating group holding this panel. Stack(&'static str), + /// In a set with the floating group holding this panel, below it. + Below(&'static str), + /// In a set with the floating group holding this panel, above it (the top group of its set). + Above(&'static str), + /// In the dock as a new column at this place (1: next to the icon column, counting left). + Column(u32), } fn move_id() -> Id { @@ -80,11 +101,17 @@ pub(crate) fn tools_zone_id() -> Id { Id::new("floating-tools-zone") } -/// The area of the floating group whose first panel is `first`. +/// The area of the floating group whose first panel is `first` (of the set it heads). pub fn area_id(first: &str) -> Id { Id::new(("floating-panels", first)) } +/// The floating group whose first panel is `first`, as last drawn (egui temp memory): in a set, +/// from the bar above its tabs to its panel's bottom. +pub(crate) fn group_rect_id(first: &str) -> Id { + Id::new(("floating-group-rect", first)) +} + /// Panel `id` (`window.panel`) as a static string and its English label, if it is a panel. fn panel(id: &str) -> Option<(&'static str, &'static str)> { all_panels().find(|(p, _)| *p == id) @@ -95,6 +122,72 @@ pub fn group_of(ui: &UiState, id: &str) -> Option { ui.floating_panels.iter().position(|g| g.panels.iter().any(|p| p == id)) } +/// The floating groups drawn with group `gi`, top to bottom: the groups of its set, or it alone. +pub fn set_of(ui: &UiState, gi: usize) -> Vec { + match ui.floating_panels.get(gi).and_then(|g| g.column) { + Some(c) => ui.floating_panels.iter().enumerate().filter(|(_, g)| g.column == Some(c)).map(|(i, _)| i).collect(), + None => vec![gi], + } +} + +/// Group `gi` heads its set (or floats alone): it draws the set's title bar. +fn leads(ui: &UiState, gi: usize) -> bool { + set_of(ui, gi).first() == Some(&gi) +} + +/// Move the set of group `gi` to `pos`. +fn set_pos(ui: &mut UiState, gi: usize, pos: [f32; 2]) { + for i in set_of(ui, gi) { + if let Some(g) = ui.floating_panels.get_mut(i) { + g.pos = pos; + } + } +} + +/// The panels of the groups `set`, in order. +fn ids_in(ui: &UiState, set: &[usize]) -> Vec<&'static str> { + set.iter().filter_map(|&i| ui.floating_panels.get(i)).flat_map(ids_of).collect() +} + +/// The panel shown by group `gi`. +fn shown(ui: &UiState, gi: usize) -> Option<&'static str> { + let g = ui.floating_panels.get(gi)?; + let ids = ids_of(g); + ids.get(g.active).or(ids.first()).copied() +} + +/// Put the groups `moved` in a set with the group holding `onto` (not one of them), right below or +/// right above it, at its position. +pub fn attach(ui: &mut UiState, moved: &[usize], onto: &str, below: bool) { + let Some(target) = group_of(ui, onto).filter(|t| !moved.contains(t)) else { return }; + let column = match ui.floating_panels.get(target).and_then(|g| g.column) { + Some(c) => c, + None => ui.floating_panels.iter().filter_map(|g| g.column).max().map_or(1, |c| c.saturating_add(1)), + }; + let Some((pos, docked)) = ui.floating_panels.get(target).map(|g| (g.pos, g.docked)) else { return }; + let (mut taken, mut kept) = (vec![], vec![]); + for (i, mut g) in ui.floating_panels.drain(..).enumerate() { + if i == target { + g.column = Some(column); + } + if moved.contains(&i) { + g.column = Some(column); + g.pos = pos; + g.docked = docked; + taken.push(g); + } else { + kept.push(g); + } + } + let at = kept.iter().position(|g| g.panels.iter().any(|p| p == onto)).map_or(kept.len(), |i| if below { i + 1 } else { i }); + kept.splice(at..at, taken); + ui.floating_panels = kept; + FloatingPanels::tidy(&mut ui.floating_panels); + if let Some(tab) = shown_tab(ui) { + ui.dock_tab = tab; + } +} + /// The panels of group `g`, as static ids. fn ids_of(g: &FloatingPanels) -> Vec<&'static str> { g.panels.iter().filter_map(|p| panel(p)).map(|(id, _)| id).collect() @@ -118,6 +211,7 @@ fn take(ui: &mut UiState, ids: &[&str]) { g.active = active.and_then(|a| g.panels.iter().position(|p| *p == a)).unwrap_or(0); !g.panels.is_empty() }); + FloatingPanels::tidy(&mut ui.floating_panels); } /// Float `ids` (out of the dock or of the group they were in) as a new group with its top-left @@ -129,7 +223,7 @@ pub fn float(ui: &mut UiState, ids: &[&str], active: &str, pos: Pos2) { } let panels: Vec = ids.iter().map(|id| id.to_string()).collect(); let active = panels.iter().position(|p| p == active).unwrap_or(0); - ui.floating_panels.push(FloatingPanels { panels, active, pos: [pos.x, pos.y], width: None }); + ui.floating_panels.push(FloatingPanels { panels, active, pos: [pos.x, pos.y], ..Default::default() }); // The tabbed group shows another of its tabs when the one it showed floats. if let Some(tab) = shown_tab(ui) { ui.dock_tab = tab; @@ -214,13 +308,55 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2 let zone = |id: Id| ctx.data(|d| d.get_temp::(id)).filter(|r| r.contains(at)); let Moving::Panel(id) = what else { return zone(tools_zone_id()).map(|r| (Drop::Dock, r)) }; let own = group_of(&app.ui, id); - // Another group's title bar and tabs stack with it. + let moving = own.map(|i| set_of(&app.ui, i)).unwrap_or_default(); + // The left edge of the icon column or of a dock column docks the moved groups as a new column + // there. + if app.ui.dock && app.ui.screen_mode < 3 { + let icons = ctx.data(|d| d.get_temp::(icons_rect_id())); + let columns = ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default(); + for (k, r) in icons.into_iter().chain(columns).enumerate() { + if Rect::from_x_y_ranges((r.left() - EDGE - 3.0)..=(r.left() + EDGE - 1.0), r.y_range()).contains(at) { + let line = Rect::from_x_y_ranges((r.left() - 2.0)..=(r.left() + 2.0), r.y_range()); + return Some((Drop::Column(u32::try_from(k + 1).unwrap_or(u32::MAX)), line)); + } + } + } + // Another group (floating or docked): its tabs (and a set's title bar) stack the moved groups + // with it as tabs; the upper half of the group (and just above a set) puts them in its set + // right above it, the lower half (and just below it) right below it. + let columns = ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default(); for (i, g) in app.ui.floating_panels.iter().enumerate() { - let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| Some(i) != own) else { continue }; - let head = ctx.memory(|m| m.area_rect(area_id(first))).map(|r| Rect::from_min_size(r.min, vec2(r.width(), 1.0 + TITLE + STRIP))); - if let Some(head) = head.filter(|h| h.contains(at)) { + let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue }; + let Some(mut r) = ctx.data(|d| d.get_temp::(group_rect_id(first))) else { continue }; + // In a dock column that scrolls, only the part in view: a group scrolled away takes nothing. + if let Some(col) = g.docked.and_then(|d| columns.get(usize::try_from(d).ok()?.checked_sub(1)?)) { + r = r.intersect(*col); + if r.height() <= 0.0 || r.width() <= 0.0 { + continue; + } + } + let line = |y: f32| Rect::from_x_y_ranges(r.x_range(), (y - 2.0)..=(y + 2.0)); + let lead = leads(&app.ui, i); + let head = Rect::from_min_size(r.min, vec2(r.width(), if lead { 1.0 + TITLE + STRIP } else { GAP + STRIP })); + if head.contains(at) { return Some((Drop::Stack(first), head)); } + let above = if lead { EDGE } else { 0.0 }; + if Rect::from_x_y_ranges(r.x_range(), (r.top() - above)..=(r.bottom() + EDGE)).contains(at) { + let mid = (head.bottom() + r.bottom()) / 2.0; + return Some(if at.y < mid { (Drop::Above(first), line(r.top())) } else { (Drop::Below(first), line(r.bottom())) }); + } + } + // A dock column's room under its groups puts them at its bottom. + for ((_, set), col) in docked_sets(&app.ui).into_iter().zip(columns) { + if !col.contains(at) || set.iter().any(|i| moving.contains(i)) { + continue; + } + let Some(last) = set.last().and_then(|&i| app.ui.floating_panels.get(i)).and_then(|g| g.panels.first()).and_then(|p| panel(p)) else { + continue; + }; + let y = ctx.data(|d| d.get_temp::(group_rect_id(last.0))).map_or(col.bottom(), |r| r.bottom()).min(col.bottom() - 2.0); + return Some((Drop::Below(last.0), Rect::from_x_y_ranges(col.x_range(), (y - 2.0)..=(y + 2.0)))); } let dock = zone(dock_rect_id()).filter(|_| app.ui.dock && app.ui.screen_mode < 3)?; // Panels that aren't tabs of the tabbed group (or all of them, with it collapsed) go back to @@ -238,6 +374,20 @@ fn highlight(ctx: &egui::Context, zone: Rect) { painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); } +/// Light up the tabs a drop stacks the moved panels with, over the floating and docked groups. +fn tab_zone(ctx: &egui::Context, tabs: Rect) { + let t = Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("floating-drop-tabs"))); + painter.rect(tabs.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.25), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); +} + +/// Show where a drop puts the moved groups in a set: a line over the floating groups. +fn insertion_line(ctx: &egui::Context, line: Rect) { + let t = Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("floating-drop-line"))); + painter.rect_filled(line, CornerRadius::same(2), t.accent); +} + /// Carry a move on (at the start of a frame, before the panels are drawn): the group or Tools panel /// follows the pointer and the zone it would drop on lights up; the release drops it there. pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { @@ -251,16 +401,19 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { { match m.what { Moving::Panel(id) => { - if let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) { - g.pos = [p.x, p.y]; + if let Some(gi) = group_of(&app.ui, id) { + set_pos(&mut app.ui, gi, [p.x, p.y]); } } Moving::Tools if app.ui.toolbar_pos.is_some() => app.ui.toolbar_pos = Some([p.x, p.y]), Moving::Tools => {} } } - if let Some((_, zone)) = target { - highlight(ctx, zone); + match target { + Some((Drop::Below(_) | Drop::Above(_) | Drop::Column(_), line)) => insertion_line(ctx, line), + Some((Drop::Stack(_), tabs)) => tab_zone(ctx, tabs), + Some((_, zone)) => highlight(ctx, zone), + None => {} } return; } @@ -270,9 +423,11 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { app.ui.toolbar_pos = None; return; }; - let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)) else { return }; - let ids = ids_of(g); - let active = ids.get(g.active).copied().unwrap_or(id); + // The moved set: its groups' panels, showing the top group's. + let Some(gi) = group_of(&app.ui, id) else { return }; + let set = set_of(&app.ui, gi); + let ids = ids_in(&app.ui, &set); + let active = set.first().and_then(|&i| shown(&app.ui, i)).unwrap_or(id); match drop { Drop::Dock => { dock(&mut app.ui, &ids, active); @@ -282,136 +437,370 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { } } Drop::Stack(onto) => stack(&mut app.ui, &ids, active, onto), + Drop::Below(onto) => attach(&mut app.ui, &set, onto, true), + Drop::Above(onto) => attach(&mut app.ui, &set, onto, false), + Drop::Column(slot) => dock_column(&mut app.ui, &set, slot), } } -/// What a press on a floating group did. +/// What a press on a floating or docked group did. enum Action { /// Show the group's `k`th tab. Activate(usize, usize), - /// Its × puts the group's panels back in the dock. - Close(usize), - /// Move the group holding this panel, drawn at this corner. + /// A double-click on a tab collapses the group to its tabs, or expands it. + Collapse(usize), + /// Its × puts the panels of the set's groups back in the dock. + Close(Vec), + /// Move the set of the group holding this panel, drawn at this corner. Move(&'static str, Pos2), + /// Take this group out of its set and move it on its own; its tab strip began this far right. + Detach(usize, f32), /// Float this panel out of its group; its tab began this far right. Tear(&'static str, f32), + /// Float the docked column of this group, its top-left corner this far from the pointer. + Undock(usize, Vec2), } -/// The floating groups, each a stack of tabs under a title bar. Hidden with the dock (Tab, -/// Presentation Mode). +/// A floating group as drawn: its index, panels, the one shown and whether it's collapsed. +struct Shown { + gi: usize, + ids: Vec<&'static str>, + active: &'static str, + collapsed: bool, +} + +/// The groups `set` as drawn, top to bottom. +fn shown_groups(ui: &UiState, set: &[usize]) -> Vec { + set.iter() + .filter_map(|&gi| { + let g = ui.floating_panels.get(gi)?; + let ids = ids_of(g); + let active = ids.get(g.active).or(ids.first()).copied()?; + Some(Shown { gi, ids, active, collapsed: g.collapsed }) + }) + .collect() +} + +/// The groups `set` heads are docked as a dock column. +fn docked(ui: &UiState, set: &[usize]) -> bool { + set.first().and_then(|&i| ui.floating_panels.get(i)).is_some_and(|g| g.docked.is_some()) +} + +/// The floating groups, each a stack of tabs, stacked in their sets under a title bar. Hidden with +/// the dock (Tab, Presentation Mode). Sets docked as dock columns are drawn by [`docked_columns`]. pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if app.ui.floating_panels.is_empty() || !app.ui.dock || app.ui.screen_mode >= 3 { return; } - let t = Tokens::get(ctx); - let screen = ctx.content_rect(); - let pointer = ctx.input(|i| i.pointer.interact_pos()); let mut action = None; for gi in 0..app.ui.floating_panels.len() { - let Some(g) = app.ui.floating_panels.get(gi) else { break }; - let ids = ids_of(g); - let Some(&first) = ids.first() else { continue }; - let active = ids.get(g.active).copied().unwrap_or(first); - let given = g.width.map(|w| w.min(screen.width() - 16.0)); - let area = area_id(first); - let size = ctx.memory(|m| m.area_rect(area)).map_or(vec2(dock::panel_width(active), 200.0), |r| r.size()); - let pos = clamp(g.pos, size, screen); - // The tabbed group's panels fill (Layers) or scroll (Properties) to near the window's bottom, - // leaving room for their margins (a group that only just fitted would creep up a little each - // frame). - let tall = (screen.bottom() - pos.y - TITLE - STRIP - 60.0).clamp(120.0, 560.0); - let lone = ids.len() == 1; - egui::Area::new(area).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { - let frame = egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO); - frame.show(ui, |ui| { + if leads(&app.ui, gi) { + let set = set_of(&app.ui, gi); + if !docked(&app.ui, &set) { + show_set(app, ctx, &set, &mut action); + } + } + } + apply(app, ctx, action); +} + +/// Carry out what a press on a floating or docked group did. +fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) { + match action { + Some(Action::Activate(gi, k)) => { + if let Some(g) = app.ui.floating_panels.get_mut(gi) + && g.active != k + { + g.active = k; + g.collapsed = false; + } + } + Some(Action::Collapse(gi)) => { + if let Some(g) = app.ui.floating_panels.get_mut(gi) { + g.collapsed = !g.collapsed; + } + } + Some(Action::Close(set)) => { + let ids = ids_in(&app.ui, &set); + let active = set.first().and_then(|&i| shown(&app.ui, i)).unwrap_or_default(); + dock(&mut app.ui, &ids, active); + } + Some(Action::Move(id, pos)) => { + // From where it is drawn (a position saved on a bigger window was clamped into this one). + if let Some(gi) = group_of(&app.ui, id) { + set_pos(&mut app.ui, gi, [pos.x, pos.y]); + } + if let Some(at) = ctx.input(|i| i.pointer.interact_pos()) { + start(ctx, Moving::Panel(id), at - pos); + } + } + Some(Action::Detach(gi, left)) => detach(app, ctx, gi, left), + Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)), + Some(Action::Undock(gi, grab)) => { + let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + let Some(id) = set_of(&app.ui, gi).first().and_then(|&i| shown(&app.ui, i)) else { return }; + let p = at - grab; + for i in set_of(&app.ui, gi) { + if let Some(g) = app.ui.floating_panels.get_mut(i) { + g.docked = None; + g.pos = [p.x, p.y]; + } + } + FloatingPanels::tidy(&mut app.ui.floating_panels); + start(ctx, Moving::Panel(id), grab); + } + None => {} + } +} + +/// Take group `gi` out of its set (or its dock column) under the pointer, its tab strip having +/// begun `left` points from the window's left, and carry on moving it with the pointer. +fn detach(app: &mut VectorcraftApp, ctx: &egui::Context, gi: usize, left: f32) { + let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + let Some(id) = shown(&app.ui, gi) else { return }; + let grab = strip_grab(ctx, left); + let p = at - grab; + if let Some(g) = app.ui.floating_panels.get_mut(gi) { + g.column = None; + g.docked = None; + g.pos = [p.x, p.y]; + } + FloatingPanels::tidy(&mut app.ui.floating_panels); + start(ctx, Moving::Panel(id), grab); +} + +/// Put the groups `set` in the dock as a new column at `slot` (1: next to the icon column, counting +/// left), moving the columns from there one further left. +pub fn dock_column(ui: &mut UiState, set: &[usize], slot: u32) { + let slot = slot.max(1); + for (i, g) in ui.floating_panels.iter_mut().enumerate() { + if set.contains(&i) { + g.docked = Some(slot); + } else if let Some(d) = g.docked.filter(|d| *d >= slot) { + g.docked = Some(d.saturating_add(1)); + } + } + FloatingPanels::tidy(&mut ui.floating_panels); + if let Some(tab) = shown_tab(ui) { + ui.dock_tab = tab; + } +} + +/// The dock columns, as last drawn (egui temp memory), from the one next to the icon column +/// leftwards. +pub(crate) fn columns_id() -> Id { + Id::new("dock-columns") +} + +/// The sets docked as columns, by their column number (next to the icon column first). +fn docked_sets(ui: &UiState) -> Vec<(u32, Vec)> { + let mut sets: Vec<(u32, Vec)> = (0..ui.floating_panels.len()) + .filter(|&gi| leads(ui, gi)) + .filter_map(|gi| Some((ui.floating_panels.get(gi)?.docked?, set_of(ui, gi)))) + .collect(); + sets.sort_by_key(|(d, _)| *d); + sets +} + +/// The sets docked as columns, each a column left of the icon column (`ui`, the window's root, as +/// [`dock::show`] lays it out): a strip along its top that floats the column when dragged and +/// whose × puts its panels back in the dock, then its groups top to bottom. Returns the columns' +/// bounds, next to the icon column first. +pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec { + let t = Tokens::get(ui.ctx()); + let mut rects = vec![]; + let mut action = None; + for (_, set) in docked_sets(&app.ui) { + let groups = shown_groups(&app.ui, &set); + let Some(first) = groups.first().and_then(|g| g.ids.first().copied()) else { continue }; + let natural = groups.iter().flat_map(|g| g.ids.iter()).map(|id| dock::panel_width(id)).fold(200.0, f32::max); + let panel = egui::Panel::right(Id::new(("dock-column", first))) + .resizable(true) + .default_size(natural) + .size_range(200.0..=520.0) + .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border))) + .show(ui, |ui| { ui.spacing_mut().item_spacing = Vec2::ZERO; - let tabs: Vec<_> = ids - .iter() - .map(|id| { - let label = panel(id).map_or(*id, |(_, label)| label); - let color = if *id == active { t.text_strong } else { t.text_dim }; - ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color) - }) - .collect(); - let tabs_width: f32 = tabs.iter().map(|g| g.size().x + 24.0).sum(); - let width = ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max).max(given.unwrap_or(0.0)); - ui.set_width(width); - // Title bar: a grip that moves the group, and the × that docks it. + let width = ui.available_width(); let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover()); - ui.painter().rect_filled(bar, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_strip); + ui.painter().rect_filled(bar, 0.0, t.tab_strip); let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE)); - let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("title"), Sense::drag()); + let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("column-bar"), Sense::drag()); for k in 0..2 { let y = bar.center().y - 1.5 + k as f32 * 3.0; ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled)); } - if grip.drag_started() { - action = Some(Action::Move(active, pos)); + if grip.drag_started() + && let (Some(at), Some(&gi)) = (ui.input(|i| i.pointer.press_origin()), set.first()) + { + action = Some(Action::Undock(gi, at - bar.min)); } - grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move")); - let x = ui.interact(close, ui.id().with("close"), Sense::click()); + grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group")); + let x = ui.interact(close, ui.id().with("column-close"), Sense::click()); icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim }); if x.on_hover_text(tl!("Put back in the dock")).clicked() { - action = Some(Action::Close(gi)); + action = Some(Action::Close(set.clone())); } - // Tab strip: a click shows a tab, a drag out of the strip floats it on its own. - let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover()); - ui.painter().rect_filled(strip, 0.0, t.panel_darker); - let mut left = strip.left(); - for (k, (&id, galley)) in ids.iter().zip(tabs).enumerate() { - let r = Rect::from_min_size(pos2(left, strip.top()), vec2(galley.size().x + 24.0, STRIP)); - left = r.right(); - let resp = ui.interact(r, ui.id().with(("tab", id)), Sense::click_and_drag()); - if id == active { - ui.painter().rect_filled(r, 0.0, t.panel); - } - ui.painter().galley(pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); - if resp.clicked() { - action = Some(Action::Activate(gi, k)); - } else if lone && resp.drag_started() { - action = Some(Action::Move(id, pos)); - } else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) { - action = Some(Action::Tear(id, r.left())); - } - } - let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); - let rest = Rect::from_min_max(pos2(left, strip.top()), pos2(menu.left() - 4.0, strip.bottom())); - if rest.width() > 0.0 { - let resp = ui.interact(rest, ui.id().with("strip"), Sense::drag()); - if resp.drag_started() { - action = Some(Action::Move(active, pos)); - } - resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move")); - } - panels::panel_menu(app, ui, active, menu); - dock::panel_body(app, ui, active, width, tall); + let n = groups.len() as f32; + let open = groups.iter().filter(|g| !g.collapsed).count().max(1) as f32; + // The open panels share the column's height, each at least `MIN_TALL`: more than fit + // scroll, with the column. + let tall = ((ui.available_height() - n * STRIP - (n - 1.0) * GAP - 24.0 * open) / open).max(MIN_TALL); + egui::ScrollArea::vertical().id_salt(("dock-column-scroll", first)).auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing = Vec2::ZERO; + let width = ui.available_width(); + let top = ui.cursor().top(); + draw_groups(app, ui, &groups, width, tall, top, None, &mut action); + }); }); - }); + rects.push(panel.response.rect); } - match action { - Some(Action::Activate(gi, k)) => { - if let Some(g) = app.ui.floating_panels.get_mut(gi) { - g.active = k; + ui.ctx().data_mut(|d| d.insert_temp(columns_id(), rects.clone())); + let ctx = ui.ctx().clone(); + apply(app, &ctx, action); + rects +} + +/// Draw the set of floating groups `set` (top to bottom) as one floating box. +fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action: &mut Option) { + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + let groups = shown_groups(&app.ui, set); + let Some((lead, first)) = groups.first().and_then(|g| Some((g, *g.ids.first()?))) else { return }; + let Some(saved) = app.ui.floating_panels.get(lead.gi).map(|g| g.pos) else { return }; + let given = set.iter().filter_map(|&i| app.ui.floating_panels.get(i)?.width).reduce(f32::max).map(|w| w.min(screen.width() - 16.0)); + let area = area_id(first); + let size = ctx.memory(|m| m.area_rect(area)).map_or(vec2(dock::panel_width(lead.active), 200.0), |r| r.size()); + let pos = clamp(saved, size, screen); + // The tabbed group's panels fill (Layers) or scroll (Properties) to near the window's bottom, + // leaving room for their margins (a group that only just fitted would creep up a little each + // frame); the open panels of a set share that height. + let n = groups.len() as f32; + let open = groups.iter().filter(|g| !g.collapsed).count().max(1) as f32; + let tall = ((screen.bottom() - pos.y - TITLE - n * STRIP - (n - 1.0) * GAP - 60.0) / open).clamp(120.0, 560.0); + let lead_active = lead.active; + egui::Area::new(area).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { + let frame = egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO); + frame.show(ui, |ui| { + ui.spacing_mut().item_spacing = Vec2::ZERO; + let width = groups + .iter() + .map(|g| { + let tabs_width: f32 = g + .ids + .iter() + .map(|id| { + let label = panel(id).map_or(*id, |(_, label)| label); + ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0 + }) + .sum(); + g.ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max) + }) + .fold(given.unwrap_or(0.0), f32::max); + ui.set_width(width); + // Title bar: a grip that moves the set, and the × that docks it. + let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover()); + ui.painter().rect_filled(bar, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_strip); + let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE)); + let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("title"), Sense::drag()); + for k in 0..2 { + let y = bar.center().y - 1.5 + k as f32 * 3.0; + ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled)); + } + if grip.drag_started() { + *action = Some(Action::Move(lead_active, pos)); + } + grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move")); + let x = ui.interact(close, ui.id().with("close"), Sense::click()); + icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim }); + if x.on_hover_text(tl!("Put back in the dock")).clicked() { + *action = Some(Action::Close(set.to_vec())); + } + let alone = (groups.len() == 1).then_some(pos); + draw_groups(app, ui, &groups, width, tall, bar.top(), alone, action); + }); + }); +} + +/// Draw `groups` top to bottom, `width` wide, their open panels `tall`, the first one's bar having +/// begun at `top`: a bar between groups, each group's tab strip, then its shown panel. `alone`, a +/// lone floating group drawn at that corner: its tab strip moves it (else it takes its group out of +/// the set or dock column). +#[allow(clippy::too_many_arguments)] +fn draw_groups( + app: &mut VectorcraftApp, + ui: &mut egui::Ui, + groups: &[Shown], + width: f32, + tall: f32, + top: f32, + alone: Option, + action: &mut Option, +) { + let t = Tokens::get(ui.ctx()); + let pointer = ui.input(|i| i.pointer.interact_pos()); + for (k, g) in groups.iter().enumerate() { + let top = if k == 0 { + top + } else { + let (gap, _) = ui.allocate_exact_size(vec2(width, GAP), Sense::hover()); + ui.painter().rect_filled(gap, 0.0, t.tab_strip); + gap.top() + }; + // Tab strip: a click shows a tab, a double-click collapses the group, a drag out of the + // strip floats a tab on its own; a lone tab or the strip right of the tabs moves the group + // (out of its set). + let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover()); + ui.painter().rect_filled(strip, 0.0, t.panel_darker); + let lone = g.ids.len() == 1; + let mut left = strip.left(); + for (j, &id) in g.ids.iter().enumerate() { + let label = panel(id).map_or(id, |(_, label)| label); + let color = if id == g.active { t.text_strong } else { t.text_dim }; + let galley = ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color); + let r = Rect::from_min_size(pos2(left, strip.top()), vec2(galley.size().x + 24.0, STRIP)); + left = r.right(); + let resp = ui.interact(r, ui.id().with(("tab", id)), Sense::click_and_drag()); + if id == g.active && !g.collapsed { + ui.painter().rect_filled(r, 0.0, t.panel); + } + ui.painter().galley(pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); + if resp.double_clicked() { + *action = Some(Action::Collapse(g.gi)); + } else if resp.clicked() { + *action = Some(Action::Activate(g.gi, j)); + } else if lone && resp.drag_started() { + *action = Some(match alone { + Some(pos) => Action::Move(id, pos), + None => Action::Detach(g.gi, strip.left()), + }); + } else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) { + *action = Some(Action::Tear(id, r.left())); } } - Some(Action::Close(gi)) => { - if let Some(g) = app.ui.floating_panels.get(gi) { - let ids = ids_of(g); - let active = ids.get(g.active).copied().unwrap_or_default(); - dock(&mut app.ui, &ids, active); + let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); + let rest = Rect::from_min_max(pos2(left, strip.top()), pos2(menu.left() - 4.0, strip.bottom())); + if rest.width() > 0.0 { + let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::drag()); + if resp.drag_started() { + *action = Some(match alone { + Some(pos) => Action::Move(g.active, pos), + None => Action::Detach(g.gi, strip.left()), + }); } + let tip = if alone.is_some() { tl!("Drag to move") } else { tl!("Drag to move out of the set") }; + resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tip); } - Some(Action::Move(id, pos)) => { - // From where it is drawn (a position saved on a bigger window was clamped into this one). - if let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) { - g.pos = [pos.x, pos.y]; - } - if let Some(at) = pointer { - start(ctx, Moving::Panel(id), at - pos); - } + panels::panel_menu(app, ui, g.active, menu); + if !g.collapsed { + dock::panel_body(app, ui, g.active, width, tall); + } + // Where the next widget would go: in a dock column, the ui's own bounds reach its bottom. + let bottom = ui.cursor().top().max(strip.bottom()); + if let Some(&id) = g.ids.first() { + let rect = Rect::from_min_max(pos2(strip.left(), top), pos2(strip.left() + width, bottom)); + ui.ctx().data_mut(|d| d.insert_temp(group_rect_id(id), rect)); } - Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)), - None => {} } } @@ -455,35 +844,121 @@ fn float_or_dock(app: &mut VectorcraftApp, float_it: bool, p: &Value) -> Result< (true, None) if DockTab::from_id(id).is_some() => docked_tabs(&app.ui).map(|t| t.info().0).collect(), _ => vec![id], }; - app.ui.dock = true; - if !float_it { - dock(&mut app.ui, &ids, id); - return Ok(json!({ "panel": id, "floating": false })); + let collapsed = crate::menus::opt_bool(p, "collapsed")?; + let onto = p.get("onto").and_then(Value::as_str); + let below = p.get("below").and_then(Value::as_str); + let above = p.get("above").and_then(Value::as_str); + if [onto, below, above].iter().filter(|o| o.is_some()).count() > 1 { + return Err("give only one of onto, below and above".into()); } - match p.get("onto").and_then(Value::as_str) { + let slot = column_slot(&app.ui, p)?; + app.ui.dock = true; + // Its own group already (not a panel taken out of its group). + let whole = own.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.panels.len() == ids.len())); + if !float_it { + let Some(slot) = slot else { + dock(&mut app.ui, &ids, id); + return Ok(json!({ "panel": id, "floating": false })); + }; + // A dock column of its own group (floated out of its group first) and the rest of its set. + if whole.is_none() { + float(&mut app.ui, &ids, id, Pos2::ZERO); + } + if let Some(gi) = group_of(&app.ui, id) { + let set = set_of(&app.ui, gi); + dock_column(&mut app.ui, &set, slot); + } + let column = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)).and_then(|g| g.docked); + return Ok(json!({ "panel": id, "floating": false, "column": column })); + } + // Floating a docked group floats its dock column. + if let Some(gi) = whole.filter(|&i| app.ui.floating_panels.get(i).is_some_and(|g| g.docked.is_some())) { + for i in set_of(&app.ui, gi) { + if let Some(g) = app.ui.floating_panels.get_mut(i) { + g.docked = None; + } + } + FloatingPanels::tidy(&mut app.ui.floating_panels); + } + if let Some((next, under)) = below.map(|b| (b, true)).or(above.map(|a| (a, false))) { + let next = crate::menus::normalize_panel(next) + .filter(|o| !ids.contains(o) && group_of(&app.ui, o).is_some()) + .ok_or("below and above must name a panel floating in another group")?; + // The panels as a group of their own (their group already, or floated out), in the set. + if whole.is_none() { + float(&mut app.ui, &ids, id, Pos2::ZERO); + } + if let Some(gi) = group_of(&app.ui, id) { + attach(&mut app.ui, &[gi], next, under); + } + } else { + float_onto(app, &ids, id, own, onto, at)?; + } + if let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) { + g.active = g.panels.iter().position(|q| q == id).unwrap_or(g.active); + if let Some(c) = collapsed { + g.collapsed = c; + } + } + let gi = group_of(&app.ui, id); + let g = gi.and_then(|i| app.ui.floating_panels.get(i)); + let set: Vec<&Vec> = + gi.map(|i| set_of(&app.ui, i)).unwrap_or_default().iter().filter_map(|&i| app.ui.floating_panels.get(i)).map(|g| &g.panels).collect(); + let (group, pos, collapsed, column) = (g.map(|g| &g.panels), g.map(|g| g.pos), g.is_some_and(|g| g.collapsed), g.and_then(|g| g.docked)); + Ok(json!({ "panel": id, "floating": column.is_none(), "group": group, "set": set, "pos": pos, "collapsed": collapsed, "column": column })) +} + +/// The `column` param of `window.panel.dock`: `true` for a new dock column at the dock's left, or +/// the place of the new column (1: next to the icon column, counting left); none to dock the panel +/// where it lives. +fn column_slot(ui: &UiState, p: &Value) -> Result, String> { + let next = ui.floating_panels.iter().filter_map(|g| g.docked).max().unwrap_or(0).saturating_add(1); + match p.get("column") { + None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None), + Some(Value::Bool(true)) => Ok(Some(next)), + Some(v) => match v.as_u64().filter(|n| *n >= 1) { + Some(n) => Ok(Some(u32::try_from(n).unwrap_or(u32::MAX).min(next))), + None => Err("column must be true or a number from 1".into()), + }, + } +} + +/// Float `ids` (`id` among them; `own`, the group holding `id`) as their own group at `at`, or +/// stacked as tabs `onto` another group. +fn float_onto( + app: &mut VectorcraftApp, + ids: &[&'static str], + id: &'static str, + own: Option, + onto: Option<&str>, + at: Option, +) -> Result<(), String> { + match onto { Some(onto) => { let onto = crate::menus::normalize_panel(onto) .filter(|o| !ids.contains(o) && group_of(&app.ui, o).is_some_and(|g| Some(g) != own)) .ok_or("onto must name a panel floating in another group")?; - stack(&mut app.ui, &ids, id, onto); + stack(&mut app.ui, ids, id, onto); } // Its own group already: moved there (or left where it is), showing the panel. None => match own.and_then(|i| app.ui.floating_panels.get_mut(i)).filter(|g| g.panels.len() == ids.len()) { + // Moved to `at` takes it out of its set; else it stays where it is. Some(g) => { - g.active = g.panels.iter().position(|p| p == id).unwrap_or(g.active); if let Some(at) = at { g.pos = [at.x, at.y]; + g.column = None; + g.docked = None; + FloatingPanels::tidy(&mut app.ui.floating_panels); } } None => { let n = app.ui.floating_panels.len() as f32; let at = at.unwrap_or(pos2(FLOAT_AT[0] + 24.0 * n, FLOAT_AT[1] + 24.0 * n)); - float(&mut app.ui, &ids, id, at); + float(&mut app.ui, ids, id, at); } }, } - let g = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)); - Ok(json!({ "panel": id, "floating": true, "group": g.map(|g| &g.panels), "pos": g.map(|g| g.pos) })) + Ok(()) } #[cfg(test)] @@ -775,7 +1250,7 @@ mod tests { })) .unwrap(); let edited = edited.sanitized(); - assert_eq!(edited.floating_panels, [FloatingPanels { panels: vec!["layers".into()], active: 0, pos: [1.0, 2.0], width: None }]); + assert_eq!(edited.floating_panels, [FloatingPanels { panels: vec!["layers".into()], active: 0, pos: [1.0, 2.0], ..Default::default() }]); h.settle(); let g = h.group("layers"); let screen = Rect::from_min_size(Pos2::ZERO, SCREEN); @@ -811,6 +1286,326 @@ mod tests { assert!(app.ui.toolbar && app.ui.toolbar_pos == Some(TOOLS_AT), "floating shows the Tools panel"); } + /// The groups' sets: each group's panels and its column, in list order. + fn sets(groups: &[FloatingPanels]) -> Vec<(Vec<&str>, Option)> { + groups.iter().map(|g| (g.panels.iter().map(String::as_str).collect(), g.column)).collect() + } + + #[test] + fn groups_dropped_on_an_edge_stack_into_a_set_that_moves_together_and_tears_apart() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "color", "x": 500, "y": 150})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "swatches", "x": 950, "y": 450})).unwrap(); + h.settle(); + // Swatches' title bar held over Color's bottom edge: a line shows it goes below. + let color = h.temp_rect(group_rect_id("color")); + let below = pos2(color.center().x, color.bottom() + 2.0); + h.hold(h.title("swatches"), below); + let target = drop_target(&h.app, &h.ctx, Moving::Panel("swatches"), below); + assert_eq!(target.map(|t| t.0), Some(Drop::Below("color"))); + assert!(target.is_some_and(|t| t.1.height() <= 4.0), "a line, not a box: {target:?}"); + h.release(below); + let s = sets(&h.app.ui.floating_panels); + assert_eq!((s[0].0.clone(), s[1].0.clone()), (vec!["color"], vec!["swatches"]), "Color, then Swatches"); + assert!(s[0].1.is_some() && s[0].1 == s[1].1, "one set: {s:?}"); + assert_eq!(h.app.ui.floating_panels[0].pos, h.app.ui.floating_panels[1].pos, "at the set's corner"); + // Drawn as one box: Swatches right under Color. + let (color, swatches) = (h.temp_rect(group_rect_id("color")), h.temp_rect(group_rect_id("swatches"))); + assert!((swatches.top() - color.bottom()).abs() < 1.0 && swatches.left() == color.left(), "{color:?} {swatches:?}"); + assert!(h.group("color").contains_rect(swatches.shrink(1.0)), "in Color's area"); + // The set moves by its title bar. + let to = pos2(300.0, 120.0); + let grab = h.title("color") - h.group("color").min; + h.drag(h.title("color"), to); + let moved = [to.x - grab.x, to.y - grab.y]; + assert!(h.app.ui.floating_panels.iter().all(|g| (g.pos[0] - moved[0]).abs() < 1.0 && (g.pos[1] - moved[1]).abs() < 1.0)); + assert_eq!(sets(&h.app.ui.floating_panels).len(), 2); + // Swatches' lone tab dragged away takes it out of the set: both float on their own. + let swatches = h.temp_rect(group_rect_id("swatches")); + let tab = pos2(swatches.left() + 20.0, swatches.top() + GAP + STRIP / 2.0); + let away = pos2(1000.0, 600.0); + h.drag(tab, away); + assert_eq!(sets(&h.app.ui.floating_panels), [(vec!["color"], None), (vec!["swatches"], None)]); + assert!(h.group("swatches").contains(away), "under the pointer: {:?}", h.group("swatches")); + // Dropped on Color's top edge, it goes above Color, which it now heads. + let color = h.temp_rect(group_rect_id("color")); + let above = pos2(color.center().x, color.top() - 3.0); + h.hold(h.title("swatches"), above); + assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("swatches"), above).map(|t| t.0), Some(Drop::Above("color"))); + h.release(above); + let s = sets(&h.app.ui.floating_panels); + assert_eq!((s[0].0.clone(), s[1].0.clone()), (vec!["swatches"], vec!["color"])); + assert!(s[0].1.is_some() && s[0].1 == s[1].1); + // A group's tab strip still takes tabs: Stroke dropped on Color's tabs joins Color's group. + h.app.run("window.panel.float", json!({"panel": "stroke", "x": 1000, "y": 650})).unwrap(); + h.settle(); + let color = h.temp_rect(group_rect_id("color")); + let tabs = pos2(color.center().x, color.top() + GAP + STRIP / 2.0); + h.drag(h.title("stroke"), tabs); + assert_eq!(sets(&h.app.ui.floating_panels).iter().map(|s| s.0.clone()).collect::>(), [vec!["swatches"], vec!["color", "stroke"]]); + // The set's × docks every group. + let g = h.group("swatches"); + h.click(pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0)); + assert!(h.groups().is_empty(), "docked: {:?}", h.groups()); + } + + #[test] + fn a_double_click_on_a_tab_collapses_a_floating_group_and_another_expands_it() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "layers", "x": 500, "y": 150})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "stroke", "below": "layers"})).unwrap(); + h.settle(); + let open = h.temp_rect(group_rect_id("layers")); + let tab = pos2(open.left() + 20.0, open.top() + 1.0 + TITLE + STRIP / 2.0); + let double = |h: &mut Harness| { + // Well after any earlier click, so the first click doesn't make a double-click with it. + h.time += 1.0; + h.frame(vec![Event::PointerMoved(tab), button(tab, true)]); + h.frame(vec![button(tab, false)]); + h.frame(vec![button(tab, true)]); + h.frame(vec![button(tab, false)]); + h.settle(); + }; + double(&mut h); + assert!(h.app.ui.floating_panels[0].collapsed, "collapsed"); + let shut = h.temp_rect(group_rect_id("layers")); + assert!(shut.height() < 1.0 + TITLE + STRIP + 2.0, "only its tabs: {shut:?}"); + assert!(h.temp_rect(group_rect_id("stroke")).top() < open.bottom(), "Stroke moved up under it"); + double(&mut h); + assert!(!h.app.ui.floating_panels[0].collapsed, "expanded"); + // Collapsed by command, Window › Layers expands it. + h.app.run("window.panel.float", json!({"panel": "layers", "collapsed": true})).unwrap(); + assert!(h.app.ui.floating_panels[0].collapsed); + h.app.run("window.panel", json!({"panel": "layers"})).unwrap(); + assert!(!h.app.ui.floating_panels[0].collapsed); + } + + #[test] + fn sets_by_command_are_checked_and_kept_in_workspaces_and_preferences() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.panel.float", json!({"panel": "layers", "x": 100, "y": 50})).unwrap(); + assert!(app.run("window.panel.float", json!({"panel": "color", "below": "swatches"})).is_err(), "Swatches isn't floating"); + assert!(app.run("window.panel.float", json!({"panel": "color", "onto": "layers", "below": "layers"})).is_err(), "one of them"); + assert!(app.run("window.panel.float", json!({"panel": "layers", "below": "layers"})).is_err(), "not below itself"); + assert!(app.run("window.panel.float", json!({"panel": "layers", "collapsed": "yes"})).is_err()); + assert_eq!(app.ui.floating_panels.len(), 1, "nothing floated by the errors"); + // Color floats straight into the set, below Layers; Stroke above it. + let r = app.run("window.panel.float", json!({"panel": "color", "below": "Layers"})).unwrap(); + assert_eq!(r["set"], json!([["layers"], ["color"]])); + let r = app.run("window.panel.float", json!({"panel": "stroke", "above": "color"})).unwrap(); + assert_eq!(r["set"], json!([["layers"], ["stroke"], ["color"]])); + assert!(app.ui.floating_panels.iter().all(|g| g.pos == [100.0, 50.0]), "at the set's corner"); + // Stroke's whole group moves to the set's bottom. + app.run("window.panel.float", json!({"panel": "stroke", "below": "color"})).unwrap(); + assert_eq!(sets(&app.ui.floating_panels).iter().map(|s| s.0.clone()).collect::>(), [vec!["layers"], vec!["color"], vec!["stroke"]]); + // Kept in a workspace and the preferences. + app.run("window.panel.float", json!({"panel": "color", "collapsed": true})).unwrap(); + app.run("window.workspace.new", json!({"name": "Sets"})).unwrap(); + let saved = app.ui.floating_panels.clone(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + assert!(app.ui.floating_panels.is_empty()); + app.run("window.workspace", json!({"name": "Sets"})).unwrap(); + assert_eq!(app.ui.floating_panels, saved); + let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(prefs.sanitized().floating_panels, saved); + // Moved to x, y, a group leaves its set; the set left with one group floats on its own. + let r = app.run("window.panel.float", json!({"panel": "color", "x": 600, "y": 400})).unwrap(); + assert_eq!((r["set"].clone(), r["pos"].clone()), (json!([["color"]]), json!([600.0, 400.0]))); + app.run("window.panel.dock", json!({"panel": "layers"})).unwrap(); + assert_eq!(sets(&app.ui.floating_panels), [(vec!["stroke"], None), (vec!["color"], None)]); + // A hand-edited file: a set's groups kept together at its first group's corner, a lone one alone. + let edited: UiState = serde_json::from_value(json!({ + "floating_panels": [ + {"panels": ["layers"], "pos": [1, 2], "column": 4}, + {"panels": ["color"], "pos": [9, 9]}, + {"panels": ["stroke"], "pos": [5, 6], "column": 4}, + {"panels": ["swatches"], "pos": [7, 8], "column": 9}, + ], + })) + .unwrap(); + let edited = edited.sanitized(); + let s = sets(&edited.floating_panels); + assert_eq!(s, [(vec!["layers"], Some(4)), (vec!["stroke"], Some(4)), (vec!["color"], None), (vec!["swatches"], None)]); + assert_eq!(edited.floating_panels[1].pos, [1.0, 2.0]); + } + + /// The dock columns as drawn, next to the icon column first. + fn columns(h: &Harness) -> Vec { + h.ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default() + } + + /// Each group's panels and dock column, in list order. + fn docked_of(groups: &[FloatingPanels]) -> Vec<(Vec<&str>, Option)> { + groups.iter().map(|g| (g.panels.iter().map(String::as_str).collect(), g.docked)).collect() + } + + #[test] + fn sets_dropped_on_the_docks_left_edge_become_dock_columns() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "color", "x": 300, "y": 150})).unwrap(); + h.settle(); + // Held over the icon column's left edge, a line shows the new column; dropped, it docks. + let icons = h.temp_rect(icons_rect_id()); + let edge = pos2(icons.left() - 2.0, icons.center().y); + h.hold(h.title("color"), edge); + let target = drop_target(&h.app, &h.ctx, Moving::Panel("color"), edge); + assert_eq!(target.map(|t| t.0), Some(Drop::Column(1))); + assert!(target.is_some_and(|t| t.1.width() <= 4.0), "a line: {target:?}"); + h.release(edge); + assert_eq!(docked_of(&h.app.ui.floating_panels), [(vec!["color"], Some(1))]); + let cols = columns(&h); + assert_eq!(cols.len(), 1); + let icons = h.temp_rect(icons_rect_id()); + assert!((cols[0].right() - icons.left()).abs() < 2.0, "right of it the icon column: {cols:?} {icons:?}"); + // Drawn in the column, not floating too (a floating box, drawn after the dock, would move it). + assert!(cols[0].contains_rect(h.temp_rect(group_rect_id("color")).shrink(1.0)), "Color in the column"); + // Swatches dropped on the docked Color's bottom edge joins its column. + h.app.run("window.panel.float", json!({"panel": "swatches", "x": 300, "y": 300})).unwrap(); + h.settle(); + let color = h.temp_rect(group_rect_id("color")); + let below = pos2(color.center().x, color.bottom() + 2.0); + h.drag(h.title("swatches"), below); + assert_eq!(docked_of(&h.app.ui.floating_panels), [(vec!["color"], Some(1)), (vec!["swatches"], Some(1))]); + assert!(columns(&h)[0].contains_rect(h.temp_rect(group_rect_id("swatches")).shrink(1.0))); + // Stroke dropped on the column's left edge makes a second column, left of it. + h.app.run("window.panel.float", json!({"panel": "stroke", "x": 300, "y": 400})).unwrap(); + h.settle(); + let col = columns(&h)[0]; + h.drag(h.title("stroke"), pos2(col.left() - 2.0, col.center().y)); + assert_eq!(docked_of(&h.app.ui.floating_panels).last(), Some(&(vec!["stroke"], Some(2)))); + let cols = columns(&h); + assert!(cols.len() == 2 && cols[1].right() <= cols[0].left() + 2.0, "{cols:?}"); + // A docked lone tab dragged away floats its group, leaving the rest of the column docked. + let swatches = h.temp_rect(group_rect_id("swatches")); + h.drag(pos2(swatches.left() + 20.0, swatches.top() + GAP + STRIP / 2.0), pos2(500.0, 500.0)); + let d = docked_of(&h.app.ui.floating_panels); + assert!(d.contains(&(vec!["swatches"], None)) && d.contains(&(vec!["color"], Some(1))), "{d:?}"); + // The column's top strip floats the whole column. + let col = columns(&h)[0]; + h.drag(pos2(col.left() + 30.0, col.top() + TITLE / 2.0), pos2(500.0, 200.0)); + let d = docked_of(&h.app.ui.floating_panels); + assert!(d.contains(&(vec!["color"], None)) && d.contains(&(vec!["stroke"], Some(1))), "renumbered: {d:?}"); + assert!(h.group("color").contains(pos2(500.0, 200.0)), "under the pointer"); + // The column's × puts its panels back in the dock. + let col = columns(&h)[0]; + h.click(pos2(col.right() - 11.0, col.top() + TITLE / 2.0)); + assert!(!h.app.ui.floating_panels.iter().any(|g| g.panels.contains(&"stroke".to_string()))); + assert!(columns(&h).is_empty()); + } + + #[test] + fn dock_columns_by_command_are_checked_and_kept_in_workspaces() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + assert!(app.run("window.panel.dock", json!({"panel": "color", "column": 0})).is_err()); + assert!(app.run("window.panel.dock", json!({"panel": "color", "column": "left"})).is_err()); + assert!(app.ui.floating_panels.is_empty()); + let r = app.run("window.panel.dock", json!({"panel": "color", "column": true})).unwrap(); + assert_eq!((r["column"].clone(), r["floating"].clone()), (json!(1), json!(false))); + app.run("window.panel.dock", json!({"panel": "layers", "column": true})).unwrap(); + // Next to the icon column, moving Color and Layers one column left. + app.run("window.panel.dock", json!({"panel": "stroke", "column": 1})).unwrap(); + app.run("window.panel.float", json!({"panel": "gradient", "below": "stroke"})).unwrap(); + let d = docked_of(&app.ui.floating_panels); + for (panel, column) in [("stroke", 1), ("gradient", 1), ("color", 2), ("layers", 3)] { + assert!(d.contains(&(vec![panel], Some(column))), "{panel} in column {column}: {d:?}"); + } + // Kept in a workspace and the preferences. + app.run("window.workspace.new", json!({"name": "Columns"})).unwrap(); + let saved = app.ui.floating_panels.clone(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + app.run("window.workspace", json!({"name": "Columns"})).unwrap(); + assert_eq!(app.ui.floating_panels, saved); + let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(prefs.sanitized().floating_panels, saved); + // Floated, Color's column leaves the dock and the columns close up. + let r = app.run("window.panel.float", json!({"panel": "color"})).unwrap(); + assert_eq!((r["floating"].clone(), r["column"].clone()), (json!(true), json!(null))); + assert!(docked_of(&app.ui.floating_panels).contains(&(vec!["layers"], Some(2)))); + // A hand-edited file: columns numbered without gaps, a set's groups in its first one's. + let edited: UiState = serde_json::from_value(json!({ + "floating_panels": [ + {"panels": ["layers"], "pos": [1, 2], "docked": 7}, + {"panels": ["color"], "pos": [1, 2], "column": 3, "docked": 3}, + {"panels": ["stroke"], "pos": [5, 6], "column": 3}, + ], + })) + .unwrap(); + let edited = edited.sanitized(); + let d = docked_of(&edited.floating_panels); + assert_eq!(d, [(vec!["layers"], Some(2)), (vec!["color"], Some(1)), (vec!["stroke"], Some(1))]); + } + + #[test] + fn a_dock_column_takes_groups_anywhere_in_it_and_as_tabs() { + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "color", "column": true})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "swatches", "below": "color"})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "stroke", "below": "swatches"})).unwrap(); + h.settle(); + let order = |h: &Harness| h.app.ui.floating_panels.iter().map(|g| g.panels.join("+")).collect::>(); + // Dropped on the upper half of Swatches, Gradient goes in right above it. + h.app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 200})).unwrap(); + h.settle(); + let r = h.temp_rect(group_rect_id("swatches")); + let tabs_bottom = r.top() + GAP + STRIP; + let upper = pos2(r.center().x, tabs_bottom + (r.bottom() - tabs_bottom) / 4.0); + h.hold(h.title("gradient"), upper); + assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("gradient"), upper).map(|t| t.0), Some(Drop::Above("swatches"))); + h.release(upper); + assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke"]); + assert!(h.app.ui.floating_panels.iter().all(|g| g.docked == Some(1)), "all in the column"); + // Dropped on Stroke's tabs, Transparency joins Stroke's group as a tab (Color and Gradient + // collapsed, so Stroke is in view in the column, which scrolls). + for g in h.app.ui.floating_panels.iter_mut().take(2) { + g.collapsed = true; + } + h.app.run("window.panel.float", json!({"panel": "transparency", "x": 300, "y": 200})).unwrap(); + h.settle(); + let r = h.temp_rect(group_rect_id("stroke")); + let tabs = pos2(r.left() + 120.0, r.top() + GAP + STRIP / 2.0); + h.hold(h.title("transparency"), tabs); + assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("transparency"), tabs).map(|t| t.0), Some(Drop::Stack("stroke"))); + h.release(tabs); + assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke+transparency"]); + // Dropped in the room under the groups (three collapsed to make some), Align goes at the + // column's bottom. + for g in h.app.ui.floating_panels.iter_mut().take(3) { + g.collapsed = true; + } + h.app.run("window.panel.float", json!({"panel": "align", "x": 300, "y": 200})).unwrap(); + h.settle(); + let col = columns(&h)[0]; + h.drag(h.title("align"), pos2(col.center().x, col.bottom() - 6.0)); + assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke+transparency", "align"]); + assert!(h.app.ui.floating_panels.iter().all(|g| g.docked == Some(1))); + } + + #[test] + fn a_dock_column_too_tall_for_the_window_scrolls() { + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "layers", "column": true})).unwrap(); + let mut above = "layers"; + for p in ["properties", "color", "swatches", "stroke", "gradient", "transparency", "align"] { + h.app.run("window.panel.float", json!({"panel": p, "below": above})).unwrap(); + above = p; + } + h.settle(); + let col = columns(&h)[0]; + assert!(col.bottom() <= SCREEN.y + 1.0, "the column stays in the window: {col:?}"); + let last = h.temp_rect(group_rect_id("align")); + assert!(last.top() > col.bottom(), "Align starts below the window: {last:?} {col:?}"); + // The wheel over the column (Color's tabs, outside any panel's own scrolling) scrolls it, + // bringing Align up. + let color = h.temp_rect(group_rect_id("color")); + let over = pos2(color.left() + 150.0, color.top() + GAP + STRIP / 2.0); + for _ in 0..4 { + h.frame(crate::toolbar::tests::wheel(over, 300.0)); + } + h.settle(); + let moved = h.temp_rect(group_rect_id("align")); + assert!(moved.top() < last.top() - 100.0, "scrolled up: {moved:?} from {last:?}"); + } + fn floating_pos(app: &VectorcraftApp, id: &str) -> [f32; 2] { group_of(&app.ui, id).map(|i| app.ui.floating_panels[i].pos).unwrap() } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4cafe577..e3a0dda6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -628,6 +628,7 @@ % of all commits % všech commitů Lines +/− Řádky +/− Drag to move Přetažením přesunete + Drag to move out of the set Přetažením přesunete mimo sadu Drag to float as a toolbar Přetažením vytvoříte plovoucí panel nástrojů Put back in the toolbar Vrátit do panelu nástrojů Float Panel Plovoucí panel diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 79021e6c..fd6bff03 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3213,6 +3213,7 @@ axis Vertical Vertikal axis Both Beide theme Appearance Erscheinungsbild Drag to move Zum Verschieben ziehen + Drag to move out of the set Ziehen, um aus dem Satz zu lösen Drag to float as a toolbar Ziehen, um als eigene Werkzeugleiste abzulösen Put back in the toolbar Zurück in die Werkzeugleiste setzen Float Panel Bedienfeld ablösen @@ -3769,6 +3770,9 @@ theme Appearance Erscheinungsbild @msg tool (a tool id) is required tool (eine Werkzeug-ID) ist erforderlich @msg {tool} isn't in a toolbar group of several tools {tool} gehört zu keiner Werkzeuggruppe mit mehreren Werkzeugen @msg onto must name a panel floating in another group onto muss ein Bedienfeld nennen, das in einer anderen Gruppe schwebt +@msg below and above must name a panel floating in another group below und above müssen ein Bedienfeld nennen, das in einer anderen Gruppe schwebt +@msg give only one of onto, below and above nur eines von onto, below und above angeben +@msg column must be true or a number from 1 column muss true oder eine Zahl ab 1 sein @msg x and y must both be numbers x und y müssen beide Zahlen sein @msg "{0}" is not a colour: write it as #rrggbb "{0}" ist keine Farbe: Schreiben Sie sie als #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead dies ist kein Logo aus flächigen Farben ({_1} % seiner einfarbigen Bereiche entsprechen wenigen flächigen Farben), daher würde der Modus „Flaches Logo“ seine Verläufe in Flecken zerlegen: Verwenden Sie stattdessen den Modus „Farbe“ diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9769b110..22b4dc9b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3317,6 +3317,7 @@ axis Horizontal Horizontal axis Vertical Vertical axis Both Ambos Drag to move Arrastrar para mover + Drag to move out of the set Arrastrar para sacar del conjunto Drag to float as a toolbar Arrastrar para flotar como barra de herramientas Put back in the toolbar Devolver a la barra de herramientas Float Panel Hacer flotar el panel @@ -3873,6 +3874,9 @@ axis Both Ambos @msg tool (a tool id) is required se requiere tool (un id de herramienta) @msg {tool} isn't in a toolbar group of several tools {tool} no está en un grupo de la barra de herramientas con varias herramientas @msg onto must name a panel floating in another group onto debe nombrar un panel que flote en otro grupo +@msg below and above must name a panel floating in another group below y above deben nombrar un panel que flote en otro grupo +@msg give only one of onto, below and above indica solo uno de onto, below y above +@msg column must be true or a number from 1 column debe ser true o un número desde 1 @msg x and y must both be numbers x e y deben ser números @msg "{0}" is not a colour: write it as #rrggbb "{0}" no es un color: escríbelo como #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead esto no es un logotipo de colores planos ({_1}% de sus zonas sólidas coinciden con unos pocos colores planos), así que el modo Logotipo plano convertiría sus degradados en manchas: usa el modo Color diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 74c18533..0c4c1467 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3314,6 +3314,7 @@ axis Horizontal Horizontal axis Vertical Vertical axis Both Les deux Drag to move Faire glisser pour déplacer + Drag to move out of the set Faire glisser pour sortir de l’ensemble Drag to float as a toolbar Faire glisser pour détacher en barre d’outils flottante Put back in the toolbar Remettre dans la barre d’outils Float Panel Rendre le panneau flottant @@ -3870,6 +3871,9 @@ axis Both Les deux @msg tool (a tool id) is required tool (un identifiant d’outil) est obligatoire @msg {tool} isn't in a toolbar group of several tools {tool} ne fait pas partie d’un groupe de plusieurs outils dans la barre d’outils @msg onto must name a panel floating in another group onto doit désigner un panneau flottant dans un autre groupe +@msg below and above must name a panel floating in another group below et above doivent désigner un panneau flottant dans un autre groupe +@msg give only one of onto, below and above indiquez un seul de onto, below et above +@msg column must be true or a number from 1 column doit être true ou un nombre à partir de 1 @msg x and y must both be numbers x et y doivent tous deux être des nombres @msg "{0}" is not a colour: write it as #rrggbb « {0} » n’est pas une couleur : écrivez-la sous la forme #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead ce n’est pas un logo en aplats ({_1} % de ses zones pleines correspondent à quelques couleurs unies), donc le mode Logo en aplats transformerait ses dégradés en taches : utilisez le mode Couleur diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5d423c7b..070b9318 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3317,6 +3317,7 @@ axis Horizontal Orizzontale axis Vertical Verticale axis Both Entrambi Drag to move Trascina per spostare + Drag to move out of the set Trascina per spostare fuori dall’insieme Drag to float as a toolbar Trascina per rendere mobile come barra degli strumenti Put back in the toolbar Riporta nella barra degli strumenti Float Panel Rendi mobile pannello @@ -3873,6 +3874,9 @@ axis Both Entrambi @msg tool (a tool id) is required tool (un id di strumento) è obbligatorio @msg {tool} isn't in a toolbar group of several tools {tool} non fa parte di un gruppo di più strumenti nella barra degli strumenti @msg onto must name a panel floating in another group onto deve indicare un pannello mobile in un altro gruppo +@msg below and above must name a panel floating in another group below e above devono indicare un pannello mobile in un altro gruppo +@msg give only one of onto, below and above indica solo uno tra onto, below e above +@msg column must be true or a number from 1 column deve essere true o un numero da 1 @msg x and y must both be numbers x e y devono essere entrambi numeri @msg "{0}" is not a colour: write it as #rrggbb "{0}" non è un colore: scrivilo come #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead questo non è un logo a colori piatti ({_1}% delle sue aree piene corrisponde a pochi colori piatti), quindi la modalità Logo piatto trasformerebbe i suoi sfumati in macchie: usa la modalità Colore diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ec170582..bc7c5ac2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3265,6 +3265,7 @@ axis Horizontal 水平方向 axis Vertical 垂直方向 axis Both 両方 Drag to move 移動するにはドラッグ + Drag to move out of the set ドラッグしてセットから出す Drag to float as a toolbar ドラッグしてツールバーとして浮かせる Put back in the toolbar ツールバーに戻す Float Panel パネルをフローティング @@ -3796,6 +3797,9 @@ theme Appearance 外観 @msg give the path `id` and the width point `index` パスの `id` と幅ポイントの `index` を指定してください @msg no such width point 指定された幅ポイントが見つかりません @msg onto must name a panel floating in another group onto には、別のグループでフローティングしているパネルを指定してください +@msg below and above must name a panel floating in another group below と above には別のグループでフローティングしているパネルを指定してください +@msg give only one of onto, below and above onto、below、above のうち 1 つだけを指定してください +@msg column must be true or a number from 1 column には true または 1 以上の数値を指定してください @msg unknown panel `{raw}` `{raw}` は不明なパネルです @msg x and y must both be numbers x と y はどちらも数値で指定してください @msg Downloaded {what} {what} をダウンロードしました diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c1d67d4b..b7df5bd0 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2697,6 +2697,7 @@ % of all commits % de todos os commits Lines +/− Linhas +/− Drag to move Arraste para mover + Drag to move out of the set Arraste para tirar do conjunto Drag to float as a toolbar Arraste para flutuar como barra de ferramentas Put back in the toolbar Devolver à barra de ferramentas Float Panel Flutuar Painel diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 14543a6c..b950958f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3313,6 +3313,7 @@ axis Horizontal Горизонтально axis Vertical Вертикально axis Both Оба Drag to move Перетащите для перемещения + Drag to move out of the set Перетащите, чтобы вынести из набора Drag to float as a toolbar Перетащите, чтобы сделать плавающей панелью инструментов Put back in the toolbar Вернуть на панель инструментов Float Panel Сделать панель плавающей @@ -3869,6 +3870,9 @@ axis Both Оба @msg tool (a tool id) is required требуется tool (идентификатор инструмента) @msg {tool} isn't in a toolbar group of several tools {tool} не входит в группу панели инструментов из нескольких инструментов @msg onto must name a panel floating in another group onto должен называть панель, плавающую в другой группе +@msg below and above must name a panel floating in another group below и above должны называть панель, плавающую в другой группе +@msg give only one of onto, below and above укажите только один из onto, below и above +@msg column must be true or a number from 1 column должен быть true или числом от 1 @msg x and y must both be numbers x и y должны быть числами @msg "{0}" is not a colour: write it as #rrggbb «{0}» не является цветом: запишите его как #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead это не логотип из плоских цветов ({_1}% его сплошных областей совпадает с несколькими плоскими цветами), поэтому режим «Плоский логотип» превратил бы градиенты в пятна: используйте режим «Цвет» diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f46baa1e..8171d890 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3305,6 +3305,7 @@ axis Horizontal Горизонтальна axis Vertical Вертикальна axis Both Обидві Drag to move Перетягніть для переміщення + Drag to move out of the set Перетягніть, щоб винести з набору Drag to float as a toolbar Перетягніть, щоб від’єднати панель інструментів Put back in the toolbar Повернути на панель інструментів Float Panel Від’єднати панель @@ -3856,6 +3857,9 @@ axis Both Обидві @msg tool (a tool id) is required потрібно tool (ідентифікатор інструменту) @msg {tool} isn't in a toolbar group of several tools {tool} не входить до групи панелі інструментів з кількох інструментів @msg onto must name a panel floating in another group onto має називати панель, що плаває в іншій групі +@msg below and above must name a panel floating in another group below і above мають називати панель, що плаває в іншій групі +@msg give only one of onto, below and above вкажіть лише один із onto, below і above +@msg column must be true or a number from 1 column має бути true або числом від 1 @msg x and y must both be numbers x і y мають бути числами @msg "{0}" is not a colour: write it as #rrggbb "{0}" не є кольором: запишіть його як #rrggbb @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead це не логотип із плоских кольорів ({_1}% його суцільних областей збігається з кількома плоскими кольорами), тому режим «Плоский логотип» перетворив би градієнти на плями: використовуйте режим «Колір» diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6358d2ae..05d705b5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3517,6 +3517,7 @@ axis Horizontal 水平 axis Vertical 垂直 axis Both 两者 Drag to move 拖动以移动 + Drag to move out of the set 拖动以移出该组合 Drag to float as a toolbar 拖动使其浮动为工具栏 Put back in the toolbar 放回工具栏 Float Panel 浮动面板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2df10453..e5e99980 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3295,6 +3295,7 @@ axis Horizontal 水平 axis Vertical 垂直 axis Both 兩者 Drag to move 拖曳以移動 + Drag to move out of the set 拖曳以移出該組合 Drag to float as a toolbar 拖曳使其浮動為工具列 Put back in the toolbar 放回工具列 Float Panel 浮動面板 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 091fb5da..d2b6af8b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -260,13 +260,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel.float", "Float Panel", "", - "{panel: id or \"tools\", x?, y?, onto?: id, group?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it with the floating group holding that panel instead; `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved. \"tools\" floats the Tools panel. Returns {panel, floating, group, pos}", + "{panel: id or \"tools\", x?, y?, onto?: id, below?: id, above?: id, group?: bool, collapsed?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it as a tab with the floating group holding that panel instead; `below` / `above` puts its group in a set with that group, right below or above it, as dropping it on that group's lower or upper half does (the set's groups stack top to bottom and move together); `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved (out of its set, given x, y); `collapsed` shows its group's tabs only, as a double-click on a tab does; a docked group floats its dock column. \"tools\" floats the Tools panel. Returns {panel, floating, group, set, pos, collapsed, column}", ), ( "window.panel.dock", "Dock Panel", "", - "{panel: id or \"tools\", group?: bool} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; \"tools\" docks the Tools panel at the window's left edge", + "{panel: id or \"tools\", group?: bool, column?: true or n} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; with `column`, dock its group and the rest of its set as a dock column instead, as dropping it on the left edge of the icon column or of a dock column does: `true` at the dock's left, n at that place (1: next to the icon column, counting left); \"tools\" docks the Tools panel at the window's left edge. Returns {panel, floating, column}", ), ( "window.collapseDock", @@ -1126,6 +1126,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option): the set moves by the top +/// group's title bar, and a group dragged by its tab strip leaves it. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct FloatingPanels { /// Panel ids (`window.panel`), in tab order. pub panels: Vec, @@ -446,6 +451,17 @@ pub struct FloatingPanels { /// A width the group was given (the Tabs panel sized over its text), else its panels' own. #[serde(default, skip_serializing_if = "Option::is_none")] pub width: Option, + /// The set this group is stacked in with others (the same number), top to bottom in list + /// order; none when it floats on its own. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub column: Option, + /// Collapsed to its tabs (a double-click on a tab), its panel hidden. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub collapsed: bool, + /// Docked as a column of the dock (with the rest of its set) instead of floating: the columns + /// stand left of the icon column, 1 next to it, counting left. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docked: Option, } impl FloatingPanels { @@ -458,10 +474,45 @@ impl FloatingPanels { g.active = g.active.min(g.panels.len().saturating_sub(1)); !g.panels.is_empty() }); + Self::tidy(groups); if toolbar_pos.is_some_and(|p| !p.iter().all(|v| v.is_finite())) { *toolbar_pos = None; } } + + /// Keep each set's groups together in the list, after its first one and in the order they + /// come, at its first one's position and in its dock column; a set left with one group floats + /// (or stays docked) on its own; the dock columns are numbered 1, 2… without gaps. + pub fn tidy(groups: &mut Vec) { + let mut out: Vec = Vec::with_capacity(groups.len()); + for mut g in groups.drain(..) { + match g.column.and_then(|c| out.iter().rposition(|o| o.column == Some(c))) { + Some(i) => { + if let Some(first) = out.iter().find(|o| o.column == g.column) { + g.pos = first.pos; + g.docked = first.docked; + } + out.insert(i + 1, g); + } + None => out.push(g), + } + } + let alone: Vec = out.iter().filter_map(|g| g.column).filter(|c| out.iter().filter(|g| g.column == Some(*c)).count() < 2).collect(); + for g in &mut out { + if g.column.is_some_and(|c| alone.contains(&c)) { + g.column = None; + } + } + let mut numbers: Vec = out.iter().filter_map(|g| g.docked).collect(); + numbers.sort_unstable(); + numbers.dedup(); + for g in &mut out { + if let Some(d) = g.docked { + g.docked = numbers.iter().position(|n| *n == d).and_then(|k| u32::try_from(k + 1).ok()); + } + } + *groups = out; + } } impl UiState { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8ad6a967..36cd8364 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -260,13 +260,26 @@ Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own; dropped on another group's title bar or tabs it stacks with that group, and dropped on the dock (lit up while the -pointer is over it) or closed with its × its panels go back to the tabbed group or the icon column. -`window.panel.float {panel, x?, y?, onto?, group?}` floats a panel (with `group`, its whole group) with its top-left -corner at `x`, `y` (window points; default cascaded), or stacks it with the floating group holding `onto`; -`window.panel.dock {panel, group?}` puts it back. `panel` takes the `window.panel` ids and labels, and `"tools"` for +pointer is over it) or closed with its × its panels go back to the tabbed group or the icon column. Dropped on the upper or +lower half of another group, floating or docked (a line shows where), a group joins it in a set right above or below +it: the groups stack top to bottom, each +showing one of its panels, and move together by the top group's title bar, whose × docks them all; a group dragged by +its tab strip (a lone panel's tab or the strip right of the tabs) leaves the set. A double-click on a tab collapses a +group to its tabs, and another expands it. Dropped on the left edge of the icon column or of a dock column, a set +docks as a new dock column there: its groups stack in it as in a floating set, take groups dropped on their edges and +tabs, and leave it dragged by their tab strip; the strip along the column's top floats the whole column again, and +its × puts its panels back where they live. +`window.panel.float {panel, x?, y?, onto?, below?, above?, group?, collapsed?}` floats a panel (with `group`, its whole +group) with its top-left corner at `x`, `y` (window points; default cascaded), or stacks it as a tab with the floating +group holding `onto`, or puts its group in a set right `below` or `above` the group holding that panel; `collapsed` +collapses or expands its group; +`window.panel.dock {panel, group?, column?}` puts it back, or with `column` (`true`, or the place: 1 next to the icon +column, counting left) docks its group and set as a dock column; `window.panel.float` on a docked panel floats its +column. `panel` takes the `window.panel` ids and labels, and `"tools"` for the Tools panel, which floats by its title bar and docks on the window's left edge. `window.panel` on a floating -panel shows its tab. `ui.floating_panels` in `ui.inspect` lists the groups (panel ids, the index of the tab shown, -the top-left corner) and `ui.toolbar_pos` the Tools panel's corner (null: docked); both are saved with the +panel shows its tab (expanding its group). `ui.floating_panels` in `ui.inspect` lists the groups (panel ids, the +index of the tab shown, the top-left corner, `column`: the set it is stacked in, top to bottom in list order, +`collapsed`, and `docked`: the dock column it stands in) and `ui.toolbar_pos` the Tools panel's corner (null: docked); both are saved with the preferences and in user workspaces (the built-in workspaces dock everything), and a corner saved on a bigger window is clamped into this one when drawn. Floating panels stay inside the app window (no separate OS windows). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dbab50ee..d428e05f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -375,7 +375,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Hint bar, contextual task bar, 19 dock panels with ≡ menus. - The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it). - Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it. - - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels. + - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into the default tabbed group it doesn't belong to, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, German, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ffe3bb2b..036208f5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -143,6 +143,7 @@ Layers panel. |---|---|---| | ~40 panels with ≡ menus; dock, icon column, collapse, tabs | done | 51 panel modules incl. tests | | Panels float inside the window, stack, dock back | done | #495 | +| Floating groups stack into sets; sets dock as extra dock columns (a multi-column dock); groups drop anywhere in a set or column, or onto its tabs; a column taller than the window scrolls | done | `window.panel.float {below, above}`, `window.panel.dock {column}` | | Panels outside the app window (another monitor) | missing | single OS window | | Properties panel per context (every selection type's Quick Actions) | partial | | | Contextual task bar | done | |