mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #436 from bobtherobot/fix/toolbar-flyouts
Toolbar: a right press or a corner press opens a tool group's flyout at once
This commit is contained in:
2 files changed
+113
-15
No files matched your search
+2
-2
@@ -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); 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); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -95,7 +95,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
|
||||
- Actions panel that records and plays back commands.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
|
||||
- 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.
|
||||
- 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).
|
||||
|
||||
+111
-13
@@ -12,6 +12,8 @@ use crate::{VectorcraftApp, icons, widgets};
|
||||
|
||||
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;
|
||||
|
||||
/// The Basic toolbar: (category, slots); each slot is a flyout group (first = default).
|
||||
pub const BASIC: &[(&str, &[&[&str]])] = &[
|
||||
@@ -167,21 +169,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
let held_for = resp
|
||||
.is_pointer_button_down_on()
|
||||
.then(|| ui.input(|inp| inp.pointer.press_start_time().map(|s| inp.time - s)))
|
||||
.flatten();
|
||||
if slot.len() > 1
|
||||
&& let Some(seconds) = held_for
|
||||
&& seconds < 0.35
|
||||
{
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds));
|
||||
}
|
||||
let long_press = held_for.is_some_and(|seconds| seconds >= 0.35);
|
||||
let press = if slot.len() > 1 { flyout_press(ui, &resp, rect) } else { None };
|
||||
let alt = ui.input(|inp| inp.modifiers.alt);
|
||||
if (resp.secondary_clicked() || long_press) && slot.len() > 1 {
|
||||
if press.is_some() {
|
||||
open_flyout = Some((slot.clone(), rect));
|
||||
if long_press {
|
||||
if press == Some(FlyoutPress::Primary) {
|
||||
ui.data_mut(|d| d.insert_temp(held_id, resp.id));
|
||||
}
|
||||
} else if resp.clicked() && held == Some(resp.id) {
|
||||
@@ -214,6 +206,40 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
flyout(app, ui.ctx());
|
||||
}
|
||||
|
||||
/// How a press on a tool group's button opens its flyout.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum FlyoutPress {
|
||||
/// A right press (it opens at once, before the release).
|
||||
Secondary,
|
||||
/// A primary press on the corner triangle, or one held [`LONG_PRESS`] seconds: its release
|
||||
/// mustn't pick the button's tool.
|
||||
Primary,
|
||||
}
|
||||
|
||||
/// Whether this frame's pointer state on a tool button opens its group's flyout. A primary press
|
||||
/// still short of a long one asks for the frame where it becomes long: egui draws only on input,
|
||||
/// so a still mouse would otherwise never get there.
|
||||
fn flyout_press(ui: &Ui, resp: &egui::Response, rect: egui::Rect) -> Option<FlyoutPress> {
|
||||
if resp.hovered() && ui.input(|inp| inp.pointer.button_pressed(egui::PointerButton::Secondary)) {
|
||||
return Some(FlyoutPress::Secondary);
|
||||
}
|
||||
if !resp.is_pointer_button_down_on() || !ui.input(|inp| inp.pointer.primary_down()) {
|
||||
return None;
|
||||
}
|
||||
// The triangle's corner: the bottom-right third of the button.
|
||||
let corner = egui::Rect::from_min_max(rect.center() + vec2(6.0, 4.0), rect.max);
|
||||
let (origin, held) = ui.input(|inp| (inp.pointer.press_origin(), inp.pointer.press_start_time().map(|s| inp.time - s)));
|
||||
if origin.is_some_and(|p| corner.contains(p)) {
|
||||
return Some(FlyoutPress::Primary);
|
||||
}
|
||||
let held = held.unwrap_or(0.0);
|
||||
if held >= LONG_PRESS {
|
||||
return Some(FlyoutPress::Primary);
|
||||
}
|
||||
ui.ctx().request_repaint_after_secs((LONG_PRESS - held) as f32);
|
||||
None
|
||||
}
|
||||
|
||||
/// Open a tool's options (`tool.options`, a double-click on its button): the Gradient tool's are
|
||||
/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog, a Liquify tool's its Tool
|
||||
/// Options dialog, the Blend tool's Blend
|
||||
@@ -607,6 +633,78 @@ pub(crate) mod tests {
|
||||
frame(&mut app, &ctx, 4.05, vec![c(false)]);
|
||||
assert_eq!((app.session.tool_id(), app.ui.flyout), ("roundedRectangle", None));
|
||||
}
|
||||
|
||||
/// The Shapes group's button (Selection, Direct Selection, Lasso, then Rectangle).
|
||||
fn shapes_button(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Rect {
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
frame(app, ctx, 0.0, vec![])[3]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_press_asks_for_the_frame_that_opens_the_flyout() {
|
||||
// egui draws only on input: a still press must schedule the frame where it becomes long.
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
let at = shapes_button(&mut app, &ctx).center();
|
||||
let p = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), p]);
|
||||
// The frames egui runs after the input settle; then nothing moves.
|
||||
frame(&mut app, &ctx, 1.02, vec![]);
|
||||
let input =
|
||||
egui::RawInput { time: Some(1.04), screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))), ..Default::default() };
|
||||
let out = ctx.run_ui(input, |ui| show(&mut app, ui));
|
||||
let delay = out.viewport_output.get(&egui::ViewportId::ROOT).unwrap().repaint_delay;
|
||||
assert!(delay <= std::time::Duration::from_secs_f64(LONG_PRESS), "repaint in {delay:?}");
|
||||
assert!(app.ui.flyout.is_none(), "not open before the press is long");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_the_corner_triangle_opens_the_flyout_at_once() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
let button = shapes_button(&mut app, &ctx);
|
||||
let at = button.right_bottom() - vec2(4.0, 4.0);
|
||||
let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), p(true)]);
|
||||
assert!(app.ui.flyout.is_some(), "the press on the triangle opens it");
|
||||
frame(&mut app, &ctx, 1.05, vec![p(false)]);
|
||||
frame(&mut app, &ctx, 1.1, vec![]);
|
||||
assert_eq!((app.session.tool_id(), app.ui.flyout.is_some()), ("selection", true), "the release neither closes it nor picks the tool");
|
||||
// A plain click in the middle of the button still just picks its tool.
|
||||
let mid = button.center();
|
||||
let c = |pressed| Event::PointerButton { pos: mid, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, 2.0, vec![Event::PointerMoved(mid), c(true)]);
|
||||
frame(&mut app, &ctx, 2.05, vec![c(false)]);
|
||||
assert_eq!((app.session.tool_id(), app.ui.flyout), ("rectangle", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flyout_opens_on_the_right_press_even_when_its_tool_is_active() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
let at = shapes_button(&mut app, &ctx).center();
|
||||
app.select_tool("rectangle");
|
||||
for (k, t0) in [1.0, 3.0, 5.0].into_iter().enumerate() {
|
||||
let r = |pressed| Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, t0, vec![Event::PointerMoved(at), r(true)]);
|
||||
assert!(app.ui.flyout.is_some(), "right press #{k} opens it before the release");
|
||||
frame(&mut app, &ctx, t0 + 0.05, vec![r(false)]);
|
||||
assert!(app.ui.flyout.is_some(), "right press #{k} leaves it open");
|
||||
// A click away closes it; then try again.
|
||||
let away = egui::pos2(300.0, 1000.0);
|
||||
let c = |pressed| Event::PointerButton { pos: away, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, t0 + 1.0, vec![Event::PointerMoved(away), c(true)]);
|
||||
frame(&mut app, &ctx, t0 + 1.05, vec![c(false)]);
|
||||
assert!(app.ui.flyout.is_none());
|
||||
}
|
||||
// A long press on the active tool's button opens it too.
|
||||
let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, 8.0, vec![Event::PointerMoved(at), p(true)]);
|
||||
frame(&mut app, &ctx, 8.0 + LONG_PRESS + 0.01, vec![]);
|
||||
assert!(app.ui.flyout.is_some(), "a long press on the active tool opens it");
|
||||
assert_eq!(app.session.tool_id(), "rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_button_long_press_selects_vertical_type_in_both_layouts() {
|
||||
for advanced in [false, true] {
|
||||
|
||||
Reference in new issue
Block a user