From dcc8a24f667fcabac1ac65787cb4d65c49a5d209 Mon Sep 17 00:00:00 2001 From: Michael Gieson Date: Thu, 8 Oct 2026 00:48:25 -0400 Subject: [PATCH 1/2] Toolbar: tool flyouts tear off into floating toolbars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A grab bar runs down the right side of a tool group's flyout. Dragging it tears the flyout off: the group floats as a strip of its tools' buttons (the active one lit, each tool's name and shortcut in its tool tip), with the bar on its right. The same bar moves the strip, and the × at the top of the bar puts the group back in the toolbar. While a group floats, the presses that would open its flyout (right, long or corner press) raise the strip and flash its border instead, so a group is never both a flyout and a floating strip. Floating strips are kept with the UI preferences and kept on screen. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 1 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/state.rs | 13 + crates/ui-egui/src/toolbar.rs | 442 +++++++++++++++++++++++++--- 9 files changed, 430 insertions(+), 44 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index eeeb86f82..5da8ec467 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,6 +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. - 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 0d8cefb4b..5f2f38825 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -604,3 +604,6 @@ Version Verze % of all commits % všech commitů Lines +/− Řádky +/− + 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ů diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ca35313f5..004ef688e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3029,3 +3029,6 @@ axis Horizontal Horizontal axis Vertical Vertical 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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6e796c66d..d0698f483 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3018,3 +3018,6 @@ axis Horizontal 水平方向 axis Vertical 垂直方向 axis Both 両方 + Drag to move 移動するにはドラッグ + Drag to float as a toolbar ドラッグしてツールバーとして浮かせる + Put back in the toolbar ツールバーに戻す diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8a9915a23..e02af56a7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2621,3 +2621,6 @@ Version Versão % of all commits % de todos os commits Lines +/− Linhas +/− + 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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 65fc525d5..7517879ad 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3275,3 +3275,6 @@ axis Horizontal 水平 axis Vertical 垂直 axis Both 两者 + Drag to move 拖动以移动 + Drag to float as a toolbar 拖动使其浮动为工具栏 + Put back in the toolbar 放回工具栏 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f2e731a1b..87a7cf55a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3049,3 +3049,6 @@ axis Horizontal 水平 axis Vertical 垂直 axis Both 兩者 + Drag to move 拖曳以移動 + Drag to float as a toolbar 拖曳使其浮動為工具列 + Put back in the toolbar 放回工具列 diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 1af796f4a..53a2e841c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -255,6 +255,9 @@ pub struct UiState { /// Last tool shown in each toolbar slot (keyed by the slot's first tool id). #[serde(default)] pub slot_tool: std::collections::BTreeMap, + /// Tool flyouts torn off the toolbar into floating panels. + #[serde(default)] + pub floating_flyouts: Vec, pub status_bar: bool, pub dock: bool, pub view: ViewFlags, @@ -368,6 +371,15 @@ pub struct WindowGeometry { pub maximized: bool, } +/// A tool group's flyout torn off the toolbar: it floats as its own panel until its × puts it back. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FloatingFlyout { + /// The group's tools in flyout order; the first one names the toolbar slot. + pub tools: Vec, + /// Top-left corner in screen points. + pub pos: [f32; 2], +} + impl UiState { /// Clear transient state after loading saved preferences. pub fn sanitized(mut self) -> Self { @@ -398,6 +410,7 @@ impl Default for UiState { toolbar_advanced: false, task_bar: true, slot_tool: Default::default(), + floating_flyouts: vec![], status_bar: true, dock: true, view: ViewFlags::default(), diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 5cab08135..ddd956dd8 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -14,6 +14,16 @@ const PITCH: f32 = 30.0; const WIDTH: f32 = 48.0; /// Seconds a press on a tool group's button is held before its flyout opens. const LONG_PRESS: f64 = 0.35; +/// Width of the grab bar down a flyout's right side: dragging it tears the flyout off. +const BAR: f32 = 10.0; +/// Height of the × at the top of a floating flyout's bar. +const CLOSE: f32 = 14.0; +/// Points the pointer travels on a flyout's bar before the flyout tears off. +const TEAR: f32 = 3.0; +/// Seconds a floating flyout flashes when a press that would open its flyout raises it. +const FLASH: f64 = 0.4; +/// Size of a tool button in a floating flyout's strip. +const STRIP_BUTTON: egui::Vec2 = vec2(32.0, PITCH); /// The Basic toolbar: (category, slots); each slot is a flyout group (first = default). pub const BASIC: &[(&str, &[&[&str]])] = &[ @@ -118,6 +128,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } let held: Option = ui.data(|d| d.get_temp(held_id)); let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None; + // A group torn off into a floating flyout is raised instead of opening its flyout. + let mut raise: Option<&'static str> = None; let mut i = 0; while i < all.len() { if let Some(cat) = all[i].0 { @@ -172,7 +184,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let press = if slot.len() > 1 { flyout_press(ui, &resp, rect) } else { None }; let alt = ui.input(|inp| inp.modifiers.alt); if press.is_some() { - open_flyout = Some((slot.clone(), rect)); + if is_floating(app, slot[0]) { + raise = Some(slot[0]); + } else { + open_flyout = Some((slot.clone(), rect)); + } if press == Some(FlyoutPress::Primary) { ui.data_mut(|d| d.insert_temp(held_id, resp.id)); } @@ -192,6 +208,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); i += row.len(); } + if let Some(key) = raise { + raise_floating(ui.ctx(), key); + } if let Some((slot, rect)) = open_flyout { app.ui.flyout = Some(0); ui.data_mut(|d| { @@ -203,6 +222,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { bottom_controls(app, ui, &t); }); }); + // Before the flyout: one torn off this frame floats from the next (its bar mustn't be in two + // layers in one frame). + floating(app, ui.ctx()); flyout(app, ui.ctx()); } @@ -360,6 +382,158 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) { let _ = t; } +/// 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)) +} + +/// The area of the floating flyout of the group whose first tool is `key`. +fn floating_area(key: &str) -> egui::Id { + egui::Id::new(("tool-floating", key)) +} + +/// Bring a floating flyout to the front and flash its border. +fn raise_floating(ctx: &egui::Context, key: &str) { + let id = floating_area(key); + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); + let now = ctx.input(|i| i.time); + ctx.data_mut(|d| d.insert_temp(id, now)); +} + +/// A flyout's frame: drop shadow and border (the accent while it flashes). +fn flyout_frame(t: &Tokens, flash: bool) -> egui::Frame { + egui::Frame::NONE + .fill(t.panel) + .stroke(if flash { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.input_border) }) + .shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(90) }) +} + +/// What a press on a flyout did. +#[derive(Default)] +struct FlyoutInput { + /// The tool row clicked. + chosen: Option<&'static str>, + /// The grab bar's response (its drag tears off or moves the flyout). + bar: Option, + /// A floating flyout's × was clicked. + close: bool, +} + +/// 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. +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); + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + ui.horizontal(|ui| { + let rows = if floating { ui.horizontal(|ui| tool_strip(ui, t, active, tools)) } else { ui.vertical(|ui| tool_rows(ui, t, active, tools)) }; + out.chosen = rows.inner; + let (bar, _) = ui.allocate_exact_size(vec2(BAR, rows.response.rect.height()), Sense::hover()); + ui.painter().rect_filled(bar, 0.0, t.tab_strip); + let grip = if floating { + let x = egui::Rect::from_min_size(bar.min, vec2(BAR, CLOSE)); + let xr = ui.interact(x, egui::Id::new(("tool-flyout-close", key)), Sense::click()); + icons::paint(ui, "x", x.shrink2(vec2(1.0, 3.0)), if xr.hovered() { t.text_strong } else { t.text }); + out.close = xr.on_hover_text(tl!("Put back in the toolbar")).clicked(); + egui::Rect::from_min_max(bar.min + vec2(0.0, CLOSE), bar.max) + } else { + bar + }; + let resp = ui.interact(grip, bar_id(key), Sense::drag()); + let half = (grip.height() / 2.0 - 3.0).clamp(0.0, 9.0); + for k in 0..3 { + let x = grip.center().x - 2.0 + k as f32 * 2.0; + ui.painter().line_segment([pos2(x, grip.center().y - half), pos2(x, grip.center().y + half)], Stroke::new(0.6, t.text_disabled)); + } + let resp = if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + resp + } else { + let tip = if floating { tl!("Drag to move") } else { tl!("Drag to float as a toolbar") }; + resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tip) + }; + out.bar = Some(resp); + }); + out +} + +/// One row per tool: icon, name and shortcut, with a dot by the active tool. Returns the tool clicked. +fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> { + let mut chosen = None; + let w = 250.0; + for id in tools { + let Some(tool) = tool_info(id) else { continue }; + let is_active = active == tool.id; + let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + if is_active { + ui.painter().rect_filled(egui::Rect::from_center_size(r.left_center() + vec2(12.0, 0.0), vec2(5.0, 5.0)), 0.0, t.text); + } + let ir = egui::Rect::from_min_size(r.min + vec2(24.0, 6.0), vec2(18.0, 18.0)); + icons::paint(ui, icons::tool_icon(tool.icon), ir, t.icon); + let color = if is_active { t.flyout_active } else { t.text_strong }; + ui.painter().text(r.left_center() + vec2(52.0, 0.0), egui::Align2::LEFT_CENTER, tl!(tool.label), egui::FontId::proportional(13.0), color); + if let Some(sc) = crate::shortcut_editor::tool_shortcut(tool.id) { + ui.painter().text( + r.right_center() - vec2(18.0, 0.0), + egui::Align2::RIGHT_CENTER, + format!("({sc})"), + egui::FontId::proportional(13.0), + color, + ); + } + if resp.clicked() { + chosen = Some(tool.id); + } + } + chosen +} + +/// The grab bar of the group whose first tool is `key` (one id, flyout or floating, so a drag +/// that tears the flyout off carries on moving it). +fn bar_id(key: &str) -> egui::Id { + egui::Id::new(("tool-flyout-bar", key)) +} + +/// Where the pointer holds a floating flyout's bar, from the flyout's corner, while it's dragged. +fn grab_id(key: &str) -> egui::Id { + bar_id(key).with("grab") +} + +/// One icon button per tool, the active one lit, with its name and shortcut on hover. Returns the +/// tool clicked. +fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> { + let mut chosen = None; + for tool in tools.iter().filter_map(|id| tool_info(id)) { + let (r, resp) = ui.allocate_exact_size(STRIP_BUTTON, Sense::click()); + let well = r.shrink(1.0); + if active == tool.id { + ui.painter().rect_filled(well, CornerRadius::same(1), t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(well, CornerRadius::same(1), t.hover); + } + let ir = egui::Rect::from_center_size(r.center(), vec2(18.0, 18.0)); + icons::paint(ui, icons::tool_icon(tool.icon), ir, if active == tool.id { t.text_strong } else { t.icon }); + if resp.on_hover_text(tip(tool)).clicked() { + chosen = Some(tool.id); + } + } + chosen +} + +/// The width of a floating flyout's strip of `tools`, its bar included. +fn strip_width(tools: &[String]) -> f32 { + tools.iter().filter(|id| tool_info(id).is_some()).count() as f32 * STRIP_BUTTON.x + BAR +} + +/// How far the pointer has moved since the press that's down, if one is. +fn press_travel(ctx: &egui::Context) -> Option { + ctx.input(|i| i.pointer.press_origin().zip(i.pointer.interact_pos()).map(|(a, b)| b - a)) +} + fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { if app.ui.flyout.is_none() { return; @@ -372,54 +546,31 @@ 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 mut chosen = None; + let active = app.session.tool_id().to_string(); + 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| { - egui::Frame::NONE - .fill(t.panel) - .stroke(Stroke::new(1.0, t.input_border)) - .shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(90) }) - .show(ui, |ui| { - ui.spacing_mut().item_spacing = vec2(0.0, 0.0); - let w = 250.0; - for id in &tools { - let Some(tool) = tool_info(id) else { continue }; - let active = app.session.tool_id() == tool.id; - let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover); - } - if active { - ui.painter().rect_filled(egui::Rect::from_center_size(r.left_center() + vec2(12.0, 0.0), vec2(5.0, 5.0)), 0.0, t.text); - } - let ir = egui::Rect::from_min_size(r.min + vec2(24.0, 6.0), vec2(18.0, 18.0)); - icons::paint(ui, icons::tool_icon(tool.icon), ir, t.icon); - let color = if active { t.flyout_active } else { t.text_strong }; - ui.painter().text( - r.left_center() + vec2(52.0, 0.0), - egui::Align2::LEFT_CENTER, - tl!(tool.label), - egui::FontId::proportional(13.0), - color, - ); - if let Some(sc) = crate::shortcut_editor::tool_shortcut(tool.id) { - ui.painter().text( - r.right_center() - vec2(18.0, 0.0), - egui::Align2::RIGHT_CENTER, - format!("({sc})"), - egui::FontId::proportional(13.0), - color, - ); - } - if resp.clicked() { - chosen = Some(tool.id); - } - } - }); + flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, &active, &tools, false)); }, ); - if let Some(id) = chosen { + // 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) + { + // 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); + // (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; + if let Some(key) = tools.first() { + ctx.data_mut(|m| m.insert_temp(grab_id(key), grab)); + } + app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [p.x, p.y] }); + app.ui.flyout = None; + return; + } + if let Some(id) = input.chosen { app.select_tool(id); } else if resp.response.clicked_elsewhere() && !ctx.input(|i| i.pointer.interact_pos().is_some_and(|p| anchor.contains(p))) { // A click on the flyout's own tool button (the right-click that opened it, or the end of a @@ -429,6 +580,59 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { let _ = theme::semibold; } +/// 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) { + 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 mut chosen = None; + let mut closed = None; + for (k, f) in app.ui.floating_flyouts.clone().into_iter().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); + let flash = since.is_some_and(|s| (0.0..FLASH).contains(&s)); + if let Some(s) = since.filter(|_| flash) { + ctx.request_repaint_after_secs((FLASH - s) as f32); + } + // Kept on screen (a smaller window, or a position saved on a bigger one). + let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(strip_width(&f.tools), PITCH), |r| r.size()); + let [x, y] = f.pos; + let (x, y) = if x.is_finite() && y.is_finite() { (x, y) } else { (screen.left() + 60.0, screen.top() + 100.0) }; + 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)); + }); + // 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]; + } + } else if !ctx.input(|i| i.pointer.any_down()) { + ctx.data_mut(|m| m.remove::(grab)); + } + if input.close { + closed = Some(k); + } + chosen = chosen.or(input.chosen); + } + if let Some(k) = closed.filter(|k| *k < app.ui.floating_flyouts.len()) { + app.ui.floating_flyouts.remove(k); + } + if let Some(id) = chosen { + app.select_tool(id); + } +} + /// The Puppet Warp tool's Control bar: Expand (how far the mesh reaches past the art), Show Mesh /// and Select All Pins. fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -734,4 +938,154 @@ pub(crate) mod tests { assert_eq!(app.ui.flyout, None); } } + + /// A press at `from`, dragged through `to` (one frame each), then released. + fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: &[Pos2]) { + let b = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, time, vec![Event::PointerMoved(from), b(from, true)]); + for (k, p) in to.iter().enumerate() { + frame(app, ctx, time + 0.05 * (k + 1) as f64, vec![Event::PointerMoved(*p)]); + } + let end = to.last().copied().unwrap_or(from); + frame(app, ctx, time + 0.05 * (to.len() + 1) as f64, vec![b(end, false)]); + frame(app, ctx, time + 0.05 * (to.len() + 2) as f64, vec![]); + } + + /// A click at `at`. + fn click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2, button: PointerButton) { + let b = |pressed| Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }; + frame(app, ctx, time, vec![Event::PointerMoved(at), b(true)]); + frame(app, ctx, time + 0.05, vec![b(false)]); + frame(app, ctx, time + 0.1, vec![]); + } + + #[test] + fn a_flyout_dragged_by_its_bar_floats_until_its_close_box_puts_it_back() { + 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"))).expect("the flyout is shown"); + // Its grab bar runs down its right side: dragging it tears the flyout off. + let bar = pos2(menu.right() - BAR / 2.0, menu.center().y); + drag(&mut app, &ctx, 2.0, bar, &[bar + vec2(20.0, 0.0), bar + vec2(60.0, 40.0)]); + assert_eq!(app.ui.flyout, None, "the flyout became the floating one"); + let [f] = app.ui.floating_flyouts.as_slice() else { panic!("one floating flyout: {:?}", app.ui.floating_flyouts) }; + assert_eq!(f.tools.first().map(String::as_str), Some("rectangle")); + let float = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).expect("the floating flyout is shown"); + // A strip of its nine tools' buttons, its bar under the pointer. + let drop = bar + vec2(60.0, 40.0); + assert!(float.height() < 40.0 && float.width() > 9.0 * STRIP_BUTTON.x, "a one-row strip: {float:?}"); + assert!( + (float.right() - 1.0 - BAR / 2.0 - drop.x).abs() < 1.0 && float.y_range().contains(drop.y), + "its bar is under the pointer: {float:?}" + ); + // The same bar moves it. + let bar = pos2(float.right() - 1.0 - BAR / 2.0, float.bottom() - 6.0); + drag(&mut app, &ctx, 3.0, bar, &[bar + vec2(0.0, 20.0), bar + vec2(-10.0, 100.0)]); + let moved = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).unwrap(); + assert!((moved.min - (float.min + vec2(-10.0, 100.0))).length() < 2.0, "moved to {moved:?} from {float:?}"); + // Picking a tool in it leaves it open. + click(&mut app, &ctx, 4.0, pos2(moved.left() + STRIP_BUTTON.x * 1.5, moved.center().y), PointerButton::Primary); + assert_eq!((app.session.tool_id(), app.ui.floating_flyouts.len()), ("roundedRectangle", 1)); + // While it floats, the presses that open the group's flyout raise it instead. + click(&mut app, &ctx, 5.0, at, PointerButton::Secondary); + assert_eq!(app.ui.flyout, None, "no flyout beside the floating one"); + assert!(ctx.data(|d| d.get_temp::(floating_area("rectangle"))).is_some(), "the floating flyout was raised"); + let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 6.0, vec![Event::PointerMoved(at), p(true)]); + frame(&mut app, &ctx, 6.0 + LONG_PRESS + 0.01, vec![]); + frame(&mut app, &ctx, 6.5, vec![p(false)]); + assert_eq!((app.ui.flyout, app.session.tool_id()), (None, "roundedRectangle"), "a long press neither opens the flyout nor picks the tool"); + // The × at the top of the bar puts it back: the group opens as a flyout again. + click(&mut app, &ctx, 7.0, pos2(moved.right() - BAR / 2.0, moved.top() + CLOSE / 2.0), PointerButton::Primary); + assert!(app.ui.floating_flyouts.is_empty()); + click(&mut app, &ctx, 8.0, at, PointerButton::Secondary); + assert!(app.ui.flyout.is_some()); + } + + #[test] + fn a_click_on_a_flyouts_bar_doesnt_tear_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()); + } + + #[test] + fn floating_flyouts_survive_saved_preferences_and_stay_on_screen() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools: vec!["pen".into(), "addAnchor".into()], pos: [5000.0, 10.0] }); + let saved = serde_json::to_value(&app.ui).unwrap(); + let back: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap(); + assert_eq!(back.floating_flyouts.len(), 1); + // Preferences saved before floating flyouts load without any. + let mut old = saved; + old.as_object_mut().unwrap().remove("floating_flyouts"); + assert!(serde_json::from_value::(old).unwrap().floating_flyouts.is_empty()); + let ctx = egui::Context::default(); + shapes_button(&mut app, &ctx); + frame(&mut app, &ctx, 1.0, vec![]); + let r = ctx.memory(|m| m.area_rect(floating_area("pen"))).unwrap(); + assert!(r.right() <= 400.5 && r.left() >= 0.0 && r.top() >= 0.0, "on screen: {r:?}"); + // A position that isn't a number puts it near the toolbar. + app.ui.floating_flyouts[0].pos = [f32::NAN, f32::INFINITY]; + frame(&mut app, &ctx, 2.0, vec![]); + let r = ctx.memory(|m| m.area_rect(floating_area("pen"))).unwrap(); + assert!(r.min.x.is_finite() && r.min.y.is_finite() && r.left() >= 0.0, "on screen: {r:?}"); + } + + /// Hover `at` for two seconds, coming from elsewhere: the texts painted meanwhile. + fn hovered_texts(app: &mut VectorcraftApp, ctx: &egui::Context, t0: f64, at: Pos2) -> Vec { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut shown = vec![]; + for i in 0..20 { + let events = match i { + 0 => vec![Event::PointerGone], + 1 => vec![Event::PointerMoved(at)], + _ => vec![], + }; + let time = t0 + f64::from(i) * 0.1; + let raw = egui::RawInput { + events, + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + crate::prefs_dialog::apply_runtime(app, ui.ctx()); + show(app, ui); + }); + out.textures_delta.clear(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + } + shown + } + + #[test] + fn a_floating_flyouts_buttons_have_tool_tips_when_tool_tips_are_on() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let tools: Vec = ["rectangle", "roundedRectangle", "star"].map(String::from).to_vec(); + app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [150.0, 600.0] }); + let ctx = egui::Context::default(); + shapes_button(&mut app, &ctx); + frame(&mut app, &ctx, 1.0, vec![]); + let strip = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).unwrap(); + let star = pos2(strip.left() + STRIP_BUTTON.x * 2.5, strip.center().y); + let tip = tl!("Star Tool").to_string(); + assert!(hovered_texts(&mut app, &ctx, 10.0, star).contains(&tip), "on by default"); + app.session.prefs.show_tool_tips = false; + assert!(!hovered_texts(&mut app, &ctx, 20.0, star).contains(&tip), "off: no tool tip"); + app.session.prefs.show_tool_tips = true; + assert!(hovered_texts(&mut app, &ctx, 30.0, star).contains(&tip), "on again"); + } } From 97906f2104fe33f42d276eff708f308be21ed094 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:34:22 +0700 Subject: [PATCH 2/2] Toolbar: a click on the tear-off bar floats the group, window.floatTools, strips in workspaces (review of #437) - A click on a flyout's tear-off bar, or the release of the long press that opened it over the bar, tears the flyout off too, as in Illustrator; dragging it still does. - window.floatTools {tool, floating?} floats or puts back the group holding a tool, for the control channel and MCP's invoke_menu. - Floating strips are part of user workspaces; the built-in ones float none, so Reset puts them back. - Loaded preferences keep one strip per group, of known tools. - No per-frame clone of the strips or copy of the active tool's id. - docs/control-protocol.md, ROADMAP, Float Tool Group translations. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus.rs | 19 ++++ crates/ui-egui/src/state.rs | 6 ++ crates/ui-egui/src/toolbar.rs | 136 ++++++++++++++++++++++++---- crates/ui-egui/src/workspaces.rs | 6 +- docs/control-protocol.md | 8 ++ 12 files changed, 165 insertions(+), 20 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5da8ec467..527fab29c 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 5f2f38825..0494f9934 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 004ef688e..767e2bd24 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 d0698f483..64a175d33 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 e02af56a7..8fa0ed2d3 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 7517879ad..c83b0cd9e 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 87a7cf55a..77f24eb86 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 3a4ba481e..c4322a901 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 53a2e841c..899e6e33e 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 ddd956dd8..3eec7513f 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 e4ab999db..3a9af4fa9 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 4bf77f5c3..4e1d933d4 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`,