Merge pull request #437 from bobtherobot/feat/floating-flyouts

Toolbar: tool flyouts tear off into floating toolbars
This commit is contained in:
BFlat
2026-10-08 13:04:27 +07:00
committed by GitHub
12 changed files with 577 additions and 46 deletions
+2 -1
View File
@@ -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,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 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.
+4
View File
@@ -604,3 +604,7 @@
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ů
Float Tool Group Plovoucí skupina nástrojů
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3029,3 +3029,7 @@
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
Float Tool Group Hacer flotar el grupo de herramientas
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3018,3 +3018,7 @@
axis Horizontal 水平方向
axis Vertical 垂直方向
axis Both 両方
Drag to move 移動するにはドラッグ
Drag to float as a toolbar ドラッグしてツールバーとして浮かせる
Put back in the toolbar ツールバーに戻す
Float Tool Group ツールグループをフローティング
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -2621,3 +2621,7 @@
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
Float Tool Group Flutuar Grupo de Ferramentas
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -3275,3 +3275,7 @@
axis Horizontal 水平
axis Vertical 垂直
axis Both 两者
Drag to move 拖动以移动
Drag to float as a toolbar 拖动使其浮动为工具栏
Put back in the toolbar 放回工具栏
Float Tool Group 浮动工具组
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3049,3 +3049,7 @@
axis Horizontal 水平
axis Vertical 垂直
axis Both 兩者
Drag to move 拖曳以移動
Drag to float as a toolbar 拖曳使其浮動為工具列
Put back in the toolbar 放回工具列
Float Tool Group 浮動工具組
Can't render this file because it contains an unexpected character in line 6 and column 47.
+19
View File
@@ -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<R
"window.toolbar" => 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<bool> {
"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("");
+19
View File
@@ -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<String, String>,
/// Tool flyouts torn off the toolbar into floating panels.
#[serde(default)]
pub floating_flyouts: Vec<FloatingFlyout>,
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<String>,
/// 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 {
@@ -380,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
}
}
@@ -398,6 +416,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(),
+500 -44
View File
@@ -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<egui::Id> = 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,183 @@ 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<bool>) -> Result<bool, String> {
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))
}
/// 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<egui::Response>,
/// 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<egui::Vec2> {
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 +571,33 @@ 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();
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, 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 = 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;
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 +607,61 @@ 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) {
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();
let mut chosen = None;
let mut closed = None;
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::<f64>(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 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() {
f.pos = [p.x, p.y];
}
} else if !ctx.input(|i| i.pointer.any_down()) {
ctx.data_mut(|m| m.remove::<egui::Vec2>(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 +967,227 @@ 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::<f64>(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_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();
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::<Vec<_>>(), ["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::<Vec<_>>(), [[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::<Vec<_>>(), ["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::<crate::workspaces::Workspace>(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::<Vec<_>>(), ["pen,addAnchor"]);
}
#[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::<crate::state::UiState>(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<String> {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
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<String> = ["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");
}
}
+5 -1
View File
@@ -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<String>,
pub status_bar: bool,
/// Tool groups torn off the toolbar into floating strips (built-in workspaces: none).
pub floating_flyouts: Vec<FloatingFlyout>,
}
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();
}
+8
View File
@@ -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`,