diff --git a/ROADMAP.md b/ROADMAP.md index 5da8ec46..527fab29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -97,7 +97,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. - - Tool flyouts tear off: dragging the grab bar on a flyout's right side 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. While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences. + - 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. - 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 and Spanish ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. 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. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5f2f3882..0494f993 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -607,3 +607,4 @@ Drag to move Přetažením přesunete 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 Tool Group Plovoucí skupina nástrojů diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 004ef688..767e2bd2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3032,3 +3032,4 @@ axis Both Ambos Drag to move Arrastrar para mover 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 Tool Group Hacer flotar el grupo de herramientas diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d0698f48..64a175d3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3021,3 +3021,4 @@ axis Both 両方 Drag to move 移動するにはドラッグ Drag to float as a toolbar ドラッグしてツールバーとして浮かせる Put back in the toolbar ツールバーに戻す + Float Tool Group ツールグループをフローティング diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e02af56a..8fa0ed2d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2624,3 +2624,4 @@ Drag to move Arraste para mover Drag to float as a toolbar Arraste para flutuar como barra de ferramentas Put back in the toolbar Devolver à barra de ferramentas + Float Tool Group Flutuar Grupo de Ferramentas diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7517879a..c83b0cd9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3278,3 +3278,4 @@ axis Both 两者 Drag to move 拖动以移动 Drag to float as a toolbar 拖动使其浮动为工具栏 Put back in the toolbar 放回工具栏 + Float Tool Group 浮动工具组 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 87a7cf55..77f24eb8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3052,3 +3052,4 @@ axis Both 兩者 Drag to move 拖曳以移動 Drag to float as a toolbar 拖曳使其浮動為工具列 Put back in the toolbar 放回工具列 + Float Tool Group 浮動工具組 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3a4ba481..c4322a90 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -199,6 +199,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.toolbar", "Tools", "", "{}"), ("window.toolbarColumns", "Toolbar: Single/Double Column", "", "{}"), ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"), + ( + "window.floatTools", + "Float Tool Group", + "", + "{tool: id, floating?: bool} float the toolbar group holding `tool` (in the current Basic or Advanced layout) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state", + ), ("window.taskBar", "Contextual Task Bar", "", "{}"), ("window.dock", "Panels", "Tab", "{} show/hide all panels"), ("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"), @@ -980,6 +986,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.toolbar), "window.toolbarColumns" => flag(&mut app.ui.toolbar_double), "window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced), + "window.floatTools" => { + let floating = match p.get("floating") { + None | Some(Value::Null) => None, + Some(Value::Bool(b)) => Some(*b), + Some(_) => return Some(Err("floating must be true or false".into())), + }; + let Some(tool) = s("tool") else { return Some(Err("tool (a tool id) is required".into())) }; + crate::toolbar::float_group(app, &tool, floating).map(Value::Bool) + } "window.taskBar" => flag(&mut app.ui.task_bar), "window.dock" => { let on = !(app.ui.dock && app.ui.toolbar); @@ -1302,6 +1317,10 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "window.control" => app.ui.control_bar, "window.toolbar" => app.ui.toolbar, "window.toolbarAdvanced" => app.ui.toolbar_advanced, + "window.floatTools" => { + let tool = p.get("tool").and_then(Value::as_str).unwrap_or(""); + app.ui.floating_flyouts.iter().any(|f| f.tools.iter().any(|id| id == tool)) + } "window.taskBar" => app.ui.task_bar, "window.panel" => { let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 53a2e841..899e6e33 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -392,6 +392,12 @@ impl UiState { if self.group_tool.len() != vectorcraft_tools::TOOL_GROUPS.len() { self.group_tool = UiState::default().group_tool; } + // One strip per group, of known tools (a hand-edited preferences file). + let mut seen = std::collections::BTreeSet::new(); + self.floating_flyouts.retain_mut(|f| { + f.tools.retain(|id| vectorcraft_tools::tool_info(id).is_some()); + f.tools.first().is_some_and(|k| seen.insert(k.clone())) + }); self } } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index ddd956dd..3eec7513 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -382,6 +382,31 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) { let _ = t; } +/// Where `window.floatTools` puts a strip (the next one a row lower), right of the toolbar; it's +/// kept on screen and moved by its bar. +const FLOAT_AT: [f32; 2] = [90.0, 100.0]; + +/// Float the toolbar group (of the current layout) holding `tool` as a strip of tool buttons, put +/// it back in the toolbar, or toggle (`floating` omitted), as a flyout's tear-off bar and the +/// strip's × do (`window.floatTools`). Returns the new state. +pub fn float_group(app: &mut VectorcraftApp, tool: &str, floating: Option) -> Result { + let Some((_, group)) = slots(app).into_iter().find(|(_, s)| s.len() > 1 && s.contains(&tool)) else { + return Err(format!("{tool} isn't in a toolbar group of several tools")); + }; + let key = group.first().copied().unwrap_or_default(); + let was = is_floating(app, key); + let on = floating.unwrap_or(!was); + if on && !was { + let n = app.ui.floating_flyouts.len() as f32; + let tools = group.iter().map(|id| id.to_string()).collect(); + app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [FLOAT_AT[0], FLOAT_AT[1] + n * (PITCH + 12.0)] }); + app.ui.flyout = None; + } else if !on { + app.ui.floating_flyouts.retain(|f| f.tools.first().is_none_or(|k| k != key)); + } + Ok(on) +} + /// Whether the group whose first tool is `key` floats as its own panel. fn is_floating(app: &VectorcraftApp, key: &str) -> bool { app.ui.floating_flyouts.iter().any(|f| f.tools.first().is_some_and(|k| k == key)) @@ -420,8 +445,8 @@ struct FlyoutInput { } /// A flyout's tool rows (a floating flyout's strip of tool buttons) with the grab bar down their -/// right side; a floating flyout's bar has a × at its top. The bar's id is the group's, so a drag that tears a flyout off carries on moving -/// the floating flyout it becomes. +/// right side; a floating flyout's bar has a × at its top. The bar's id is the group's, so a drag +/// that tears a flyout off carries on moving the floating flyout it becomes. fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool) -> FlyoutInput { let mut out = FlyoutInput::default(); let key = tools.first().map_or("", String::as_str); @@ -546,20 +571,22 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { let anchor: egui::Rect = ctx.data(|d| d.get_temp(egui::Id::new("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0))); let t = Tokens::get(ctx); - let active = app.session.tool_id().to_string(); + let active = app.session.tool_id(); let mut input = FlyoutInput::default(); let resp = egui::Area::new(egui::Id::new("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show( ctx, |ui| { - flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, &active, &tools, false)); + flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false)); }, ); - // Dragging the bar tears the flyout off: it floats where the drag puts it. - if input.bar.is_some_and(|b| b.dragged()) - && let Some(d) = press_travel(ctx).filter(|d| d.length() > TEAR) - { + // Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the + // end of the long press that opened the flyout): it floats where the pointer is. + let at = ctx.input(|i| i.pointer.interact_pos()); + let released_on_bar = input.bar.as_ref().is_some_and(|b| ctx.input(|i| i.pointer.primary_released()) && at.is_some_and(|p| b.rect.contains(p))); + let dragged_off = input.bar.as_ref().is_some_and(egui::Response::dragged) && press_travel(ctx).is_some_and(|d| d.length() > TEAR); + if released_on_bar || dragged_off { // The strip is far narrower than the flyout: it floats with its bar under the pointer. - let at = ctx.input(|i| i.pointer.interact_pos()).unwrap_or(resp.response.rect.min + d); + let at = at.unwrap_or(resp.response.rect.right_center()); // (Past the frame's 1 pt border.) let grab = vec2(1.0 + strip_width(&tools) - BAR / 2.0, 1.0 + CLOSE + 4.0); let p = at - grab; @@ -582,13 +609,16 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { /// The flyouts torn off the toolbar, each floating where its bar was dragged; its × puts it back. fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) { + if app.ui.floating_flyouts.is_empty() { + return; + } let t = Tokens::get(ctx); let screen = ctx.content_rect(); let now = ctx.input(|i| i.time); - let active = app.session.tool_id().to_string(); + let active = app.session.tool_id(); let mut chosen = None; let mut closed = None; - for (k, f) in app.ui.floating_flyouts.clone().into_iter().enumerate() { + for (k, f) in app.ui.floating_flyouts.iter_mut().enumerate() { let Some(key) = f.tools.first() else { continue }; let id = floating_area(key); let since = ctx.data(|d| d.get_temp::(id)).map(|t0| now - t0); @@ -603,19 +633,18 @@ fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) { let pos = pos2(x.min(screen.right() - size.x).max(screen.left()), y.min(screen.bottom() - size.y).max(screen.top())); let mut input = FlyoutInput::default(); egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { - flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, &active, &f.tools, true)); + flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true)); }); // Dragging the bar keeps the flyout where the pointer holds it (by an offset rather than // each frame's motion, which a just torn-off flyout's first, unseen frame would drop). let grab = grab_id(key); if ctx.is_being_dragged(bar_id(key)) && let Some(at) = ctx.input(|i| i.pointer.interact_pos()) - && let Some(slot) = app.ui.floating_flyouts.get_mut(k) { let off = ctx.data_mut(|m| *m.get_temp_mut_or_insert_with(grab, || at - pos)); let p = at - off; if p.x.is_finite() && p.y.is_finite() { - slot.pos = [p.x, p.y]; + f.pos = [p.x, p.y]; } } else if !ctx.input(|i| i.pointer.any_down()) { ctx.data_mut(|m| m.remove::(grab)); @@ -1005,14 +1034,87 @@ pub(crate) mod tests { } #[test] - fn a_click_on_a_flyouts_bar_doesnt_tear_it_off() { + fn a_click_on_a_flyouts_bar_tears_it_off() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); let ctx = egui::Context::default(); let at = shapes_button(&mut app, &ctx).center(); click(&mut app, &ctx, 1.0, at, PointerButton::Secondary); let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).unwrap(); - click(&mut app, &ctx, 2.0, pos2(menu.right() - BAR / 2.0, menu.center().y), PointerButton::Primary); - assert!(app.ui.flyout.is_some() && app.ui.floating_flyouts.is_empty()); + let bar = pos2(menu.right() - BAR / 2.0, menu.center().y); + click(&mut app, &ctx, 2.0, bar, PointerButton::Primary); + frame(&mut app, &ctx, 2.2, vec![]); + assert_eq!(app.ui.flyout, None); + assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.tools[0].as_str()).collect::>(), ["rectangle"]); + let float = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).expect("the floating flyout is shown"); + assert!(float.contains(bar), "its bar is under the pointer: {float:?}"); + assert_eq!(app.session.tool_id(), "selection", "no tool picked"); + } + + #[test] + fn releasing_a_long_press_over_the_flyouts_bar_tears_it_off() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let at = shapes_button(&mut app, &ctx).center(); + let b = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), b(at, true)]); + frame(&mut app, &ctx, 1.0 + LONG_PRESS + 0.01, vec![]); + frame(&mut app, &ctx, 1.45, vec![]); + let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).expect("the long press opens the flyout"); + let bar = pos2(menu.right() - BAR / 2.0, menu.center().y); + frame(&mut app, &ctx, 1.5, vec![Event::PointerMoved(bar)]); + frame(&mut app, &ctx, 1.55, vec![b(bar, false)]); + frame(&mut app, &ctx, 1.6, vec![]); + assert_eq!((app.ui.flyout, app.ui.floating_flyouts.len(), app.session.tool_id()), (None, 1, "selection")); + } + + #[test] + fn window_float_tools_floats_and_puts_back_a_group() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let check = |app: &VectorcraftApp| crate::menus::checked(app, "window.floatTools", &json!({"tool": "star"})); + assert_eq!(app.run("window.floatTools", json!({"tool": "star"})).unwrap(), json!(true)); + let [f] = app.ui.floating_flyouts.as_slice() else { panic!("one strip: {:?}", app.ui.floating_flyouts) }; + assert_eq!(f.tools.first().map(String::as_str), Some("rectangle"), "the Basic toolbar's Shapes group"); + assert_eq!(check(&app), Some(true)); + // Already floating: stays where it is. + app.ui.floating_flyouts[0].pos = [300.0, 400.0]; + assert_eq!(app.run("window.floatTools", json!({"tool": "rectangle", "floating": true})).unwrap(), json!(true)); + assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.pos).collect::>(), [[300.0, 400.0]]); + // Omitted toggles it back. + assert_eq!(app.run("window.floatTools", json!({"tool": "star"})).unwrap(), json!(false)); + assert!(app.ui.floating_flyouts.is_empty() && check(&app) == Some(false)); + assert_eq!(app.run("window.floatTools", json!({"tool": "pen", "floating": false})).unwrap(), json!(false)); + // A tool alone in its slot, an unknown tool, a bad flag. + assert!(app.run("window.floatTools", json!({"tool": "ellipse"})).is_err()); + assert!(app.run("window.floatTools", json!({"tool": "nope"})).is_err()); + assert!(app.run("window.floatTools", json!({})).is_err()); + assert!(app.run("window.floatTools", json!({"tool": "pen", "floating": "yes"})).is_err()); + assert!(app.ui.floating_flyouts.is_empty()); + } + + #[test] + fn workspaces_keep_floating_flyouts_and_reset_puts_them_back() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.floatTools", json!({"tool": "pen"})).unwrap(); + app.run("window.workspace.new", json!({"name": "Strips"})).unwrap(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + assert!(app.ui.floating_flyouts.is_empty(), "the built-in workspaces float none"); + app.run("window.workspace", json!({"name": "Strips"})).unwrap(); + assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.tools[0].as_str()).collect::>(), ["pen"]); + // Workspaces saved before floating strips load without any. + let mut old = serde_json::to_value(crate::workspaces::Workspace::default()).unwrap(); + old.as_object_mut().unwrap().remove("floatingFlyouts"); + assert!(serde_json::from_value::(old).unwrap().floating_flyouts.is_empty()); + } + + #[test] + fn loaded_floating_flyouts_keep_known_tools_one_strip_per_group() { + let f = |tools: &[&str]| crate::state::FloatingFlyout { tools: tools.iter().map(|s| s.to_string()).collect(), pos: [0.0, 0.0] }; + let ui = crate::state::UiState { + floating_flyouts: vec![f(&["pen", "nope", "addAnchor"]), f(&["pen", "addAnchor"]), f(&["nope"]), f(&[])], + ..Default::default() + }; + let ui = ui.sanitized(); + assert_eq!(ui.floating_flyouts.iter().map(|f| f.tools.join(",")).collect::>(), ["pen,addAnchor"]); } #[test] diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index e4ab999d..3a9af4fa 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -7,7 +7,7 @@ use std::sync::atomic::Ordering; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use crate::state::{Dialog, DockTab, UiState}; +use crate::state::{Dialog, DockTab, FloatingFlyout, UiState}; use crate::theme::Tokens; use crate::{VectorcraftApp, widgets}; @@ -27,6 +27,8 @@ pub struct Workspace { pub dock_collapsed: bool, pub open_panel: Option, pub status_bar: bool, + /// Tool groups torn off the toolbar into floating strips (built-in workspaces: none). + pub floating_flyouts: Vec, } impl Default for Workspace { @@ -120,6 +122,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { dock_collapsed: ui.dock_collapsed, open_panel: ui.open_panel.clone(), status_bar: ui.status_bar, + floating_flyouts: ui.floating_flyouts.clone(), } } @@ -136,6 +139,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { ui.dock_collapsed = w.dock_collapsed; ui.open_panel = w.open_panel.clone(); ui.status_bar = w.status_bar; + ui.floating_flyouts = w.floating_flyouts.clone(); ui.flyout = None; ui.workspace = w.name.clone(); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4bf77f5c..4e1d933d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -192,6 +192,14 @@ pop the panel out next to the column like the other icon panels (`ui.dock_collap `ui.inspect`); expanding with one popped out shows its tab. The state is saved with the preferences and in user workspaces; the built-in workspaces expand the dock. +Floating tool groups: dragging or clicking the tear-off bar down a tool group's flyout (or releasing the long press +that opened it over the bar) floats the 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 {tool, floating?}` does the same for the group of the current +layout holding `tool` (omitted toggles; a tool alone in its slot is an error). While a group floats, the presses +that open its flyout raise the strip instead. The strips (`ui.floating_flyouts` in `ui.inspect`: the group's tools +and the strip's top-left corner) are saved with the preferences and in user workspaces; the built-in workspaces +float none. + Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTransparency` dialog for the selection (fields `preset`: a preset name, setting it loads that preset's options; the option keys of `object.flattenTransparency`: `balance` 0–100, `lineArtPpi` and `gradientPpi` 1–2400, `textToOutlines`,