Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ccafe1187 | ||
|
|
f3b90fb93f | ||
|
|
fc052cb001 | ||
|
|
896f8efea8 | ||
|
|
921e60f4fc | ||
|
|
9dec49ed03 | ||
|
|
dc157c6c5b | ||
|
|
aa125bfff1 | ||
|
|
22008b4e68 | ||
|
|
2497713779 | ||
|
|
1d013959cb | ||
|
|
b1f7d5ad7d |
No files matched your search
+1
-1
@@ -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-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), 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), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), 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), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ enum Drag {
|
||||
/// A Selection tool move dragged off the canvas: the panels get the art
|
||||
/// ([`widgets::PanelDrag::Art`]).
|
||||
Art,
|
||||
/// Fingers making a touch gesture ([`crate::touch`]): the first one's press does nothing.
|
||||
Gesture,
|
||||
}
|
||||
|
||||
fn drag_id() -> egui::Id {
|
||||
@@ -384,6 +386,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 {
|
||||
task_bar(app, ui, &xf);
|
||||
}
|
||||
if app.ui.screen_mode < 3 {
|
||||
crate::free_transform::show(app, ui, xf.rect);
|
||||
}
|
||||
// Cursor.
|
||||
if resp.hovered() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
@@ -474,6 +479,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p)));
|
||||
app.hover_doc = hover.map(|p| xf.to_doc(p));
|
||||
let view = app.view_info();
|
||||
touch_gestures(app, ui, rect, view);
|
||||
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
|
||||
// A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows
|
||||
// without moving the mouse (Alt switches the Shape Builder to erase mode).
|
||||
@@ -565,7 +571,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
vm.rotation = vectorcraft_geom::normalize_deg(deg);
|
||||
}
|
||||
}
|
||||
Drag::ZoomBox { .. } | Drag::Art => {}
|
||||
Drag::ZoomBox { .. } | Drag::Art | Drag::Gesture => {}
|
||||
Drag::Tool if drag_art_out(app, ui, resp, p, view) => {
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art));
|
||||
}
|
||||
@@ -609,7 +615,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
|
||||
dispatch(app, &ev, view);
|
||||
}
|
||||
Drag::Art => {}
|
||||
Drag::Art | Drag::Gesture => {}
|
||||
Drag::Pan { .. } | Drag::RotateView { .. } => {}
|
||||
}
|
||||
}
|
||||
@@ -688,6 +694,50 @@ fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn
|
||||
(zoom, scroll)
|
||||
}
|
||||
|
||||
/// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second
|
||||
/// finger drops the press the first one began (its tool interaction rolled back; the rest of the
|
||||
/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes.
|
||||
fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo) {
|
||||
if !app.session.prefs.touch_gestures {
|
||||
return;
|
||||
}
|
||||
let (touches, now): (Vec<_>, f64) = ui.input(|i| {
|
||||
let t = i.events.iter().filter_map(|e| match e {
|
||||
egui::Event::Touch { id, phase, pos, .. } => Some((*id, *phase, *pos)),
|
||||
_ => None,
|
||||
});
|
||||
(t.collect(), i.time)
|
||||
});
|
||||
if touches.is_empty() {
|
||||
return;
|
||||
}
|
||||
let key = egui::Id::new("canvas-touch");
|
||||
let mut taps: crate::touch::Taps = ui.data(|d| d.get_temp(key)).unwrap_or_default();
|
||||
let mut tap = None;
|
||||
for (id, phase, pos) in touches {
|
||||
// A gesture begins on the canvas.
|
||||
if phase == egui::TouchPhase::Start && !taps.is_down() && !rect.contains(pos) {
|
||||
continue;
|
||||
}
|
||||
match taps.feed(id, phase, pos, now) {
|
||||
Some(crate::touch::Touch::Fingers) => {
|
||||
if ui.data(|d| d.get_temp::<Drag>(drag_id())) == Some(Drag::Tool) {
|
||||
// Nothing it began is kept; the tool's mouse-up returns it to rest.
|
||||
let _ = app.session.cancel_interaction();
|
||||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture));
|
||||
}
|
||||
Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(key, taps));
|
||||
if let Some(cmd) = tap {
|
||||
crate::menus::invoke(app, cmd, serde_json::json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// A Selection tool move dragged off the canvas (to `p`, over a panel) turns into a panel drag of
|
||||
/// the selected art ([`widgets::PanelDrag::Art`]): the move is dropped, so the art stays where it
|
||||
/// was, and the panel it is released on takes it (the Graphic Styles panel makes a style of it).
|
||||
@@ -1865,6 +1915,40 @@ mod tests {
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the
|
||||
/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing.
|
||||
#[test]
|
||||
fn two_finger_tap_undoes_and_three_finger_tap_redoes() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap();
|
||||
app.select_tool("rectangle");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len();
|
||||
let c = app.canvas_rect.unwrap().center();
|
||||
let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None };
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
// `fingers` fingers tap at the centre, the first also pressing as the pointer and moving a
|
||||
// little (as a rectangle drag would begin).
|
||||
let tap = |app: &mut VectorcraftApp, fingers: u64| {
|
||||
let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0);
|
||||
frame(app, &ctx, vec![touch(0, egui::TouchPhase::Start, at(0)), egui::Event::PointerMoved(at(0)), button(at(0), true)]);
|
||||
frame(app, &ctx, vec![touch(0, egui::TouchPhase::Move, at(0) + vec2(6.0, 6.0)), egui::Event::PointerMoved(at(0) + vec2(6.0, 6.0))]);
|
||||
frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::Start, at(n))).collect());
|
||||
frame(app, &ctx, vec![touch(0, egui::TouchPhase::End, at(0)), button(at(0) + vec2(6.0, 6.0), false), egui::Event::PointerGone]);
|
||||
frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::End, at(n))).collect());
|
||||
frame(app, &ctx, vec![]);
|
||||
};
|
||||
tap(&mut app, 2);
|
||||
assert_eq!(count(&app), 0, "the rectangle undone, none drawn");
|
||||
tap(&mut app, 3);
|
||||
assert_eq!(count(&app), 1, "redone");
|
||||
app.session.prefs.touch_gestures = false;
|
||||
tap(&mut app, 2);
|
||||
assert_eq!(count(&app), 2, "off: the first finger draws as the pointer, and nothing is undone");
|
||||
}
|
||||
|
||||
/// The Artboard tool's label holds the artboard's name (never translated); the tools' own
|
||||
/// labels are interface text.
|
||||
#[test]
|
||||
|
||||
@@ -84,6 +84,8 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
|
||||
"pinned": app.ui.task_bar_place.pinned,
|
||||
"rect": crate::canvas::task_bar_rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
|
||||
},
|
||||
// The Free Transform tool's widget, where it shows.
|
||||
"freeTransformWidget": crate::free_transform::rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
|
||||
"view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()),
|
||||
"canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
|
||||
"window": [r.width(), r.height()],
|
||||
|
||||
@@ -134,6 +134,10 @@ struct MenuState {
|
||||
/// The pass the list was last drawn in, and the pass it opened in.
|
||||
last: u64,
|
||||
opened: u64,
|
||||
/// The family the text had as the menu opened. A preview shows another one in the document
|
||||
/// (and the menu's caller), but this one stays the current family: hovering it ends the
|
||||
/// preview (#563).
|
||||
current: String,
|
||||
/// The highlighted row and the query and filters it belongs to.
|
||||
highlight: Option<usize>,
|
||||
query: String,
|
||||
@@ -194,6 +198,7 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
|
||||
st.opened = pass;
|
||||
st.highlight = None;
|
||||
st.query.clear();
|
||||
st.current = current.to_string();
|
||||
}
|
||||
st.last = pass;
|
||||
// For [`end_stale_preview`]: the menu is open, in this layer.
|
||||
@@ -243,8 +248,9 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
|
||||
});
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック).
|
||||
let current = db.canonical(current, "").0;
|
||||
// The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック),
|
||||
// as it was before any preview.
|
||||
let current = db.canonical(&st.current, "").0;
|
||||
let current = current.as_str();
|
||||
let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f));
|
||||
// The generation is read first: fonts that load meanwhile make the next frame list them.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! The Free Transform widget (#598): while the Free Transform tool is active, a small floating box
|
||||
//! at the canvas's top left with Constrain (as holding Shift: proportional scaling, moves and
|
||||
//! rotations by 45°) over the tool's three modes, Free Transform, Perspective Distort and Free
|
||||
//! Distort. It sets the tool's options through `tool.setOption` (`constrain`, `mode`), as agents
|
||||
//! do; the modifier keys keep working while dragging.
|
||||
|
||||
use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The tool the widget belongs to.
|
||||
const TOOL: &str = "freeTransform";
|
||||
/// A button's side.
|
||||
const BUTTON: f32 = 28.0;
|
||||
|
||||
/// What a button shows.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Glyph {
|
||||
/// An icon of the set.
|
||||
Icon(&'static str),
|
||||
/// A box narrower at the top than at the bottom, its corners marked.
|
||||
Perspective,
|
||||
/// A box with corners pulled every which way, its corners marked.
|
||||
Distort,
|
||||
}
|
||||
|
||||
/// The tool's modes as its `mode` option names them, with their buttons.
|
||||
fn modes() -> [(&'static str, Glyph, &'static str); 3] {
|
||||
[
|
||||
("free", Glyph::Icon("dc-free-transform"), tl!("Free Transform")),
|
||||
("perspective", Glyph::Perspective, tl!("Perspective Distort")),
|
||||
("distort", Glyph::Distort, tl!("Free Distort")),
|
||||
]
|
||||
}
|
||||
|
||||
/// Where the widget was last drawn (none while it isn't shown).
|
||||
pub(crate) fn rect(ctx: &egui::Context) -> Option<Rect> {
|
||||
let id = egui::Id::new(TOOL);
|
||||
ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten())
|
||||
}
|
||||
|
||||
/// Show the widget in `canvas` (the canvas's area inside the rulers) while the Free Transform
|
||||
/// tool is active.
|
||||
pub(crate) fn show(app: &mut VectorcraftApp, ui: &Ui, canvas: Rect) {
|
||||
if app.session.tool_id() != TOOL {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let opts = app.session.tool_options();
|
||||
let mode = opts["mode"].as_str().unwrap_or("free").to_string();
|
||||
let constrain = opts["constrain"].as_bool().unwrap_or(false);
|
||||
let mut set: Option<(&str, Value)> = None;
|
||||
egui::Area::new(egui::Id::new(TOOL)).order(egui::Order::Middle).fixed_pos(canvas.min + vec2(10.0, 10.0)).show(ui.ctx(), |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.panel)
|
||||
.stroke(Stroke::new(1.0, t.button_border))
|
||||
.corner_radius(CornerRadius::same(4))
|
||||
.inner_margin(egui::Margin::same(3))
|
||||
.shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(60) })
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
// Constrain shapes only what the Free Transform mode does.
|
||||
if button(ui, Glyph::Icon("link"), tl!("Constrain"), constrain, mode == "free") {
|
||||
set = Some(("constrain", json!(!constrain)));
|
||||
}
|
||||
let (line, _) = ui.allocate_exact_size(vec2(BUTTON, 3.0), Sense::hover());
|
||||
ui.painter().hline(line.x_range().shrink(4.0), line.center().y, Stroke::new(1.0, t.button_border));
|
||||
for (name, glyph, tip) in modes() {
|
||||
if button(ui, glyph, tip, mode == name, true) && mode != name {
|
||||
set = Some(("mode", json!(name)));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some((key, value)) = set
|
||||
&& let Err(e) = app.run("tool.setOption", json!({ "key": key, "value": value }))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// A square button showing `glyph`, highlighted when `on`, greyed and inert unless `enabled`.
|
||||
/// Returns whether it was clicked.
|
||||
fn button(ui: &mut Ui, glyph: Glyph, tip: &str, on: bool, enabled: bool) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(BUTTON, BUTTON), if enabled { Sense::click() } else { Sense::hover() });
|
||||
let bg = if on && enabled {
|
||||
t.tool_active
|
||||
} else if enabled && resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(3), bg);
|
||||
let color = match (enabled, on) {
|
||||
(false, _) => t.text_dim,
|
||||
(true, true) => t.text,
|
||||
(true, false) => t.icon,
|
||||
};
|
||||
paint(ui, glyph, rect.shrink(6.0), color);
|
||||
resp.on_hover_text(tip).clicked() && enabled
|
||||
}
|
||||
|
||||
/// Draw `glyph` in `r`.
|
||||
fn paint(ui: &Ui, glyph: Glyph, r: Rect, color: Color32) {
|
||||
let at = |x: f32, y: f32| pos2(r.left() + x * r.width(), r.top() + y * r.height());
|
||||
let quad: [Pos2; 4] = match glyph {
|
||||
Glyph::Icon(name) => return crate::icons::paint(ui, name, r, color),
|
||||
Glyph::Perspective => [at(0.3, 0.12), at(0.7, 0.12), at(0.95, 0.88), at(0.05, 0.88)],
|
||||
Glyph::Distort => [at(0.08, 0.2), at(0.78, 0.06), at(0.94, 0.9), at(0.2, 0.76)],
|
||||
};
|
||||
let (p, fill) = (ui.painter(), Tokens::get(ui.ctx()).panel);
|
||||
p.add(egui::Shape::closed_line(quad.to_vec(), Stroke::new(1.3, color)));
|
||||
for c in quad {
|
||||
let mark = Rect::from_center_size(c, vec2(3.5, 3.5));
|
||||
p.rect_filled(mark, 0.0, fill);
|
||||
p.rect_stroke(mark, 0.0, Stroke::new(1.0, color), StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::*;
|
||||
use crate::canvas::Xf;
|
||||
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||||
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||||
ctx.run_ui(raw, |ui| crate::canvas::show(app, ui)).textures_delta.clear();
|
||||
}
|
||||
|
||||
/// A press and release at `at`, then a frame to settle.
|
||||
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) {
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, ctx, vec![egui::Event::PointerMoved(at), button(true)]);
|
||||
frame(app, ctx, vec![button(false)]);
|
||||
frame(app, ctx, vec![]);
|
||||
}
|
||||
|
||||
fn options(app: &VectorcraftApp) -> (String, bool) {
|
||||
let o = app.session.tool_options();
|
||||
(o["mode"].as_str().unwrap_or_default().to_string(), o["constrain"].as_bool().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The widget shows with the Free Transform tool only; its buttons set the mode and Constrain
|
||||
/// (inert in the distort modes); a corner dragged in Free Distort moves that corner alone.
|
||||
#[test]
|
||||
fn the_widget_sets_the_mode_and_a_corner_drag_then_distorts() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap()["id"].clone();
|
||||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
app.select_tool("selection");
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert!(rect(&ctx).is_none(), "not with the Selection tool");
|
||||
app.select_tool(TOOL);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let w = rect(&ctx).expect("the widget shows");
|
||||
let canvas = app.canvas_rect.unwrap();
|
||||
assert!(canvas.contains(w.min) && w.left() - canvas.left() < 20.0 && w.top() - canvas.top() < 20.0, "{w:?} in {canvas:?}");
|
||||
// Constrain, a rule, then Free Transform, Perspective Distort and Free Distort.
|
||||
let at = |i: usize| {
|
||||
let rule = if i > 0 { 3.0 + 2.0 } else { 0.0 };
|
||||
pos2(w.center().x, w.top() + 4.0 + BUTTON / 2.0 + i as f32 * (BUTTON + 2.0) + rule)
|
||||
};
|
||||
assert_eq!(options(&app), ("free".into(), false));
|
||||
click(&mut app, &ctx, at(0));
|
||||
assert_eq!(options(&app), ("free".into(), true), "Constrain on");
|
||||
click(&mut app, &ctx, at(3));
|
||||
assert_eq!(options(&app), ("distort".into(), true));
|
||||
click(&mut app, &ctx, at(0));
|
||||
assert_eq!(options(&app), ("distort".into(), true), "Constrain is inert in Free Distort");
|
||||
click(&mut app, &ctx, at(2));
|
||||
assert_eq!(options(&app).0, "perspective");
|
||||
click(&mut app, &ctx, at(3));
|
||||
// Free Distort: the top-left corner dragged alone.
|
||||
let xf = Xf::new(canvas, app.view().unwrap());
|
||||
let (from, to) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(80.0, 90.0)));
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + (to - from) / 2.0)]);
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let st = app.session.active().unwrap();
|
||||
let node = st.doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap();
|
||||
let pts: Vec<Point> = node.path_data().unwrap().anchors().map(|(.., a)| a.p).collect();
|
||||
let near = |q: Point| pts.iter().any(|p| p.distance(q) < 0.5);
|
||||
assert!(
|
||||
near(Point::new(80.0, 90.0)) && near(Point::new(200.0, 100.0)) && near(Point::new(200.0, 160.0)) && near(Point::new(100.0, 160.0)),
|
||||
"{pts:?}"
|
||||
);
|
||||
assert_eq!(app.session.tool_id(), TOOL);
|
||||
}
|
||||
}
|
||||
@@ -639,3 +639,6 @@
|
||||
Expand Shaper Group Rozšířit skupinu Shaper
|
||||
Release Shaper Group Uvolnit skupinu Shaper
|
||||
Select Shaper Face Vybrat oblast Shaper
|
||||
Constrain Omezit
|
||||
Free Transform Volná transformace
|
||||
Perspective Distort Perspektivní deformace
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1492,6 +1492,8 @@
|
||||
New Art Brush from Selection Nuevo pincel de arte a partir de la selección
|
||||
New Art Has Basic Appearance La nueva ilustración tiene apariencia básica
|
||||
New Artboard Nueva mesa de trabajo
|
||||
Artboard Mesa de trabajo
|
||||
Exit Salir
|
||||
New Bristle Brush Nuevo pincel de cerdas
|
||||
New Brush… Nuevo pincel…
|
||||
New Calligraphic Brush Nuevo pincel caligráfico
|
||||
@@ -3603,3 +3605,6 @@ axis Both Ambos
|
||||
Expand Shaper Group Expandir grupo Shaper
|
||||
Release Shaper Group Soltar grupo Shaper
|
||||
Select Shaper Face Seleccionar cara Shaper
|
||||
Constrain Restringir
|
||||
Free Transform Transformación libre
|
||||
Perspective Distort Distorsión de perspectiva
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1492,6 +1492,8 @@
|
||||
New Art Brush from Selection Nuovo pennello artistico dalla selezione
|
||||
New Art Has Basic Appearance La nuova grafica ha aspetto di base
|
||||
New Artboard Nuova tavola da disegno
|
||||
Artboard Tavola da disegno
|
||||
Exit Esci
|
||||
New Bristle Brush Nuovo pennello a setole
|
||||
New Brush… Nuovo pennello…
|
||||
New Calligraphic Brush Nuovo pennello calligrafico
|
||||
@@ -3603,3 +3605,6 @@ axis Both Entrambi
|
||||
Expand Shaper Group Espandi gruppo Shaper
|
||||
Release Shaper Group Rilascia gruppo Shaper
|
||||
Select Shaper Face Seleziona faccia Shaper
|
||||
Constrain Vincola
|
||||
Free Transform Trasformazione libera
|
||||
Perspective Distort Distorsione prospettica
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1635,6 +1635,8 @@
|
||||
New Art Brush from Selection 選択範囲から新規アートブラシ
|
||||
New Art Has Basic Appearance 新規アートに基本アピアランスを使用
|
||||
New Artboard 新規アートボード
|
||||
Artboard アートボード
|
||||
Exit 終了
|
||||
New Bristle Brush 新規剛毛ブラシ
|
||||
New Brush… 新規ブラシ…
|
||||
New Calligraphic Brush 新規絵筆ブラシ
|
||||
@@ -3065,3 +3067,6 @@ theme Appearance 外観
|
||||
Expand Shaper Group Shaper グループを拡張
|
||||
Release Shaper Group Shaper グループを解除
|
||||
Select Shaper Face Shaper の面を選択
|
||||
Constrain 制限
|
||||
Free Transform 自由変形
|
||||
Perspective Distort 遠近変形
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2653,3 +2653,6 @@
|
||||
Expand Shaper Group Expandir Grupo Formador
|
||||
Release Shaper Group Liberar Grupo Formador
|
||||
Select Shaper Face Selecionar Face do Formador
|
||||
Constrain Restringir
|
||||
Free Transform Transformação Livre
|
||||
Perspective Distort Distorção de Perspectiva
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3601,3 +3601,8 @@ axis Both Оба
|
||||
Expand Shaper Group Разобрать группу Shaper
|
||||
Release Shaper Group Освободить группу Shaper
|
||||
Select Shaper Face Выбрать область Shaper
|
||||
Constrain Ограничить
|
||||
Free Transform Свободное трансформирование
|
||||
Perspective Distort Искажение перспективы
|
||||
Artboard Монтажная область
|
||||
Exit Выйти
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1469,6 +1469,7 @@
|
||||
New Art Brush from Selection 从选区新建艺术画笔
|
||||
New Art Has Basic Appearance 新建图稿使用基本外观
|
||||
New Artboard 新建画板
|
||||
Exit 退出
|
||||
New Bristle Brush 新建鬃毛画笔
|
||||
New Brush… 新建画笔…
|
||||
New Calligraphic Brush 新建书法画笔
|
||||
@@ -3319,3 +3320,5 @@ axis Both 两者
|
||||
Expand Shaper Group 扩展 Shaper 组
|
||||
Release Shaper Group 释放 Shaper 组
|
||||
Select Shaper Face 选择 Shaper 区域
|
||||
Constrain 约束
|
||||
Perspective Distort 透视扭曲
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1509,6 +1509,8 @@
|
||||
New Art Brush from Selection 從選取範圍新增藝術筆刷
|
||||
New Art Has Basic Appearance 新圖稿具有基本外觀
|
||||
New Artboard 新增工作區域
|
||||
Artboard 工作區域
|
||||
Exit 結束
|
||||
New Bristle Brush 新增鬃毛筆刷
|
||||
New Brush… 新增筆刷…
|
||||
New Calligraphic Brush 新增書法筆刷
|
||||
@@ -3095,3 +3097,6 @@ axis Both 兩者
|
||||
Expand Shaper Group 展開 Shaper 群組
|
||||
Release Shaper Group 釋放 Shaper 群組
|
||||
Select Shaper Face 選取 Shaper 區域
|
||||
Constrain 強制
|
||||
Free Transform 任意變形
|
||||
Perspective Distort 透視扭曲
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -27,6 +27,7 @@ pub mod dock;
|
||||
pub mod find_font;
|
||||
pub mod floating;
|
||||
pub mod font_menu;
|
||||
mod free_transform;
|
||||
pub mod graphics;
|
||||
pub mod i18n;
|
||||
pub mod icon_data;
|
||||
@@ -50,6 +51,7 @@ pub mod sysclip;
|
||||
pub mod theme;
|
||||
pub mod titlebar;
|
||||
pub mod toolbar;
|
||||
mod touch;
|
||||
mod ui_fonts;
|
||||
pub mod unsaved;
|
||||
pub mod widgets;
|
||||
|
||||
@@ -13,13 +13,13 @@ use crate::widgets::{self, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
||||
/// The active artboard (of `n`).
|
||||
fn selected(app: &VectorcraftApp, n: usize) -> usize {
|
||||
pub(crate) fn selected(app: &VectorcraftApp, n: usize) -> usize {
|
||||
app.view().map_or(0, |v| v.artboard).min(n.saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Make artboard `i` the active one, leaving the view where it is: the navigator's and, while it
|
||||
/// is the tool, the Artboard tool's.
|
||||
fn select(app: &mut VectorcraftApp, i: usize) {
|
||||
pub(crate) fn select(app: &mut VectorcraftApp, i: usize) {
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.artboard = i;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let n_sel = st.selection.len();
|
||||
let anchors = crate::chrome::anchor_controls(app);
|
||||
let first = first_selected(app);
|
||||
// Edit Artboards (the Artboard tool): the active artboard, whatever is selected.
|
||||
let editing_artboards = app.session.tool_id() == "artboard";
|
||||
let label = match (&first, n_sel) {
|
||||
_ if editing_artboards => tl!("Artboard").to_string(),
|
||||
(None, _) => tl!("Document").to_string(),
|
||||
(_, n) if n > 1 => crate::i18n::tn(n as u64, "{n} Object", "{n} Objects"),
|
||||
(Some(n), _) if crate::panels::image_trace::is_trace(n) => tl!("Image Tracing").to_string(),
|
||||
@@ -26,6 +29,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
};
|
||||
ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim));
|
||||
ui.add_space(4.0);
|
||||
if editing_artboards {
|
||||
artboard_sections(app, ui);
|
||||
return;
|
||||
}
|
||||
if first.is_none() {
|
||||
document_sections(app, ui);
|
||||
return;
|
||||
@@ -188,6 +195,48 @@ fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit Artboards (the Artboard tool): the active artboard's name, position and size, New Artboard
|
||||
/// and Delete Artboard, and Exit back to the Selection tool (#530).
|
||||
fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let units = app.session.general_unit();
|
||||
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
|
||||
let i = super::artboards::selected(app, n);
|
||||
if let Some(ab) = app.session.active().and_then(|d| d.doc.artboards.get(i)).cloned() {
|
||||
// Its name as it is (names are never translated).
|
||||
ui.label(egui::RichText::new(&ab.name).size(13.0).color(Tokens::get(ui.ctx()).text));
|
||||
let fw = ((ui.available_width() - 50.0) / 2.0).clamp(60.0, 110.0);
|
||||
egui::Grid::new("ab-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
||||
let r = ab.rect;
|
||||
for (row, [(l1, k1, v1), (l2, k2, v2)]) in
|
||||
[[("X:", "x", r.x0), ("W:", "width", r.width())], [("Y:", "y", r.y0), ("H:", "height", r.height())]].into_iter().enumerate()
|
||||
{
|
||||
for (label, key, v) in [(l1, k1, v1), (l2, k2, v2)] {
|
||||
dim_label(ui, label);
|
||||
if let Some(v) = widgets::num_field(ui, ("ab", key, row), Some(v), units, fw) {
|
||||
app.run("artboard.setProps", json!({"index": i, key: v})).ok();
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let w = (ui.available_width() - 6.0) / 2.0;
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::flat_button(ui, tl!("New Artboard"), w).clicked() && app.run("artboard.new", json!({})).is_ok() {
|
||||
super::artboards::select(app, n);
|
||||
}
|
||||
if ui.add_enabled_ui(n > 1, |ui| widgets::flat_button(ui, tl!("Delete Artboard"), w)).inner.clicked() {
|
||||
app.run("artboard.delete", json!({"index": i})).ok();
|
||||
}
|
||||
});
|
||||
divider(ui);
|
||||
section_header(ui, tl!("Quick Actions"));
|
||||
if widgets::flat_button(ui, tl!("Exit"), w).clicked() {
|
||||
app.select_tool("selection");
|
||||
}
|
||||
}
|
||||
|
||||
fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let units = app.session.general_unit();
|
||||
section_header(ui, tl!("Document"));
|
||||
@@ -416,3 +465,58 @@ pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::{Event, PointerButton, Pos2, Rect, vec2};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// One frame of the panel with `events` → the texts painted, with their rects.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
|
||||
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
if let egui::Shape::Text(t) = &s.shape {
|
||||
Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size())))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, texts: &[(String, Rect)], label: &str) {
|
||||
let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}"));
|
||||
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, ctx, vec![Event::PointerMoved(at), b(true)]);
|
||||
frame(app, ctx, vec![b(false)]);
|
||||
}
|
||||
|
||||
/// #530: Edit Artboards shows the active artboard, with New Artboard, Delete Artboard and Exit.
|
||||
#[test]
|
||||
fn edit_artboards_adds_artboards_and_exits() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
click(&mut app, &ctx, &texts, "Edit Artboards");
|
||||
assert_eq!(app.session.tool_id(), "artboard");
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
assert!(texts.iter().any(|(t, _)| t == "Artboard 1") && texts.iter().any(|(t, _)| t == "200 pt"), "{texts:?}");
|
||||
click(&mut app, &ctx, &texts, "New Artboard");
|
||||
let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.len();
|
||||
assert_eq!(boards(&app), 2);
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
assert!(texts.iter().any(|(t, _)| t == "Artboard 2"), "the new artboard is the active one: {texts:?}");
|
||||
click(&mut app, &ctx, &texts, "Delete Artboard");
|
||||
assert_eq!(boards(&app), 1);
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
click(&mut app, &ctx, &texts, "Exit");
|
||||
assert_eq!(app.session.tool_id(), "selection");
|
||||
}
|
||||
}
|
||||
@@ -318,3 +318,61 @@ fn the_search_field_stays_put_while_the_list_scrolls() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #563: a preview stays on the text while the menu stays on its family. The menu's caller passes
|
||||
/// the text's font, which the preview changed; the menu keeps the font from before the preview as
|
||||
/// the current one, so it doesn't take the previewed family for it and end the preview, then
|
||||
/// preview it again, every frame (the text flickered).
|
||||
#[test]
|
||||
fn a_preview_stays_put_while_the_menu_is_still() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Gagaku", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let font = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.first_style().font_family,
|
||||
_ => String::new(),
|
||||
};
|
||||
let revision = |app: &VectorcraftApp| app.session.active().unwrap().revision;
|
||||
// A panel's frame: the menu shows the text's font, and what it picks is applied.
|
||||
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let input =
|
||||
egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() };
|
||||
let current = font(app);
|
||||
let (mut at, mut picked) = (egui::Pos2::ZERO, None);
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
crate::font_menu::end_stale_preview(app, ui.ctx());
|
||||
at = ui.next_widget_position();
|
||||
picked = font_menu(ui, "test-steady", ¤t, 260.0, None, MenuLook::default());
|
||||
if let Some(p) = picked.clone() {
|
||||
crate::font_menu::apply(app, ui.ctx(), p);
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
(at, picked)
|
||||
};
|
||||
let (at, _) = frame(&mut app, vec![]);
|
||||
let click = at + egui::vec2(40.0, 10.0);
|
||||
let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(click), button(true)]);
|
||||
frame(&mut app, vec![button(false)]);
|
||||
for _ in 0..5 {
|
||||
frame(&mut app, vec![]);
|
||||
}
|
||||
let (_, picked) = frame(&mut app, key(egui::Key::ArrowDown));
|
||||
assert!(matches!(picked, Some(FontPick::Preview(..))), "{picked:?}");
|
||||
let previewed = font(&app);
|
||||
assert_ne!(previewed, "Inter");
|
||||
let rev = revision(&app);
|
||||
for _ in 0..6 {
|
||||
let (_, picked) = frame(&mut app, vec![]);
|
||||
assert_eq!(picked, None, "nothing more to apply");
|
||||
}
|
||||
assert_eq!((font(&app), revision(&app)), (previewed, rev), "the preview stays, untouched");
|
||||
// Back on the text's own family, the preview ends.
|
||||
let (_, picked) = frame(&mut app, key(egui::Key::ArrowUp));
|
||||
assert_eq!(picked, Some(FontPick::EndPreview));
|
||||
assert_eq!(font(&app), "Inter");
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Touch gestures on the canvas (Preferences › Devices › Enable Touch Gestures, #585): a tap with
|
||||
//! two fingers undoes, one with three redoes.
|
||||
//!
|
||||
//! egui hands every finger's contact over as [`egui::Event::Touch`] (winit reads them from the
|
||||
//! system: `WM_POINTER` on Windows), and makes the first finger the mouse pointer too. So the first
|
||||
//! finger has pressed on the canvas by the time a second lands: that press is dropped then
|
||||
//! ([`Taps::feed`] says [`Touch::Fingers`]), and the tap undoes what came before it.
|
||||
|
||||
use egui::{Pos2, TouchId, TouchPhase};
|
||||
|
||||
/// How long the fingers of a tap may stay down, in seconds.
|
||||
const TAP_TIME: f64 = 0.5;
|
||||
/// How far (points) a finger of a tap may move: further, it is a pinch or a pan.
|
||||
const TAP_SLOP: f32 = 16.0;
|
||||
|
||||
/// What a touch event made of the gesture.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Touch {
|
||||
/// A second finger landed: the gesture isn't the first finger's press.
|
||||
Fingers,
|
||||
/// All fingers lifted from a tap of this many fingers (two or more).
|
||||
Tap(usize),
|
||||
}
|
||||
|
||||
/// The fingers down and how the gesture they make has gone so far.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Taps {
|
||||
/// Each finger down and where it landed.
|
||||
down: Vec<(TouchId, Pos2)>,
|
||||
/// The most fingers down at once in this gesture.
|
||||
most: usize,
|
||||
/// When its first finger landed.
|
||||
start: f64,
|
||||
/// A finger moved too far or stayed too long: not a tap.
|
||||
spoilt: bool,
|
||||
}
|
||||
|
||||
impl Taps {
|
||||
/// Follow a finger's `phase` at `pos` (touch `id`) at time `now` (seconds).
|
||||
pub(crate) fn feed(&mut self, id: TouchId, phase: TouchPhase, pos: Pos2, now: f64) -> Option<Touch> {
|
||||
match phase {
|
||||
TouchPhase::Start => {
|
||||
if self.down.is_empty() {
|
||||
*self = Self { start: now, ..Self::default() };
|
||||
}
|
||||
if !self.down.iter().any(|(d, _)| *d == id) {
|
||||
self.down.push((id, pos));
|
||||
}
|
||||
let before = self.most;
|
||||
self.most = self.most.max(self.down.len());
|
||||
(before < 2 && self.most >= 2).then_some(Touch::Fingers)
|
||||
}
|
||||
TouchPhase::Move => {
|
||||
if self.down.iter().any(|(d, at)| *d == id && at.distance(pos) > TAP_SLOP) {
|
||||
self.spoilt = true;
|
||||
}
|
||||
None
|
||||
}
|
||||
TouchPhase::End | TouchPhase::Cancel => {
|
||||
let known = self.down.len();
|
||||
self.down.retain(|(d, _)| *d != id);
|
||||
if self.down.len() == known {
|
||||
return None;
|
||||
}
|
||||
self.spoilt |= phase == TouchPhase::Cancel || now - self.start > TAP_TIME;
|
||||
(self.down.is_empty() && !self.spoilt && self.most >= 2).then_some(Touch::Tap(self.most))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A finger is down.
|
||||
pub(crate) fn is_down(&self) -> bool {
|
||||
!self.down.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The command a tap of `fingers` runs: two undo, three redo.
|
||||
pub(crate) fn tap_command(fingers: usize) -> Option<&'static str> {
|
||||
match fingers {
|
||||
2 => Some("edit.undo"),
|
||||
3 => Some("edit.redo"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn id(n: u64) -> TouchId {
|
||||
TouchId(n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_and_three_finger_taps() {
|
||||
let mut t = Taps::default();
|
||||
let p = Pos2::new(100.0, 100.0);
|
||||
assert_eq!(t.feed(id(1), TouchPhase::Start, p, 0.0), None);
|
||||
assert_eq!(t.feed(id(2), TouchPhase::Start, p + egui::vec2(60.0, 0.0), 0.05), Some(Touch::Fingers));
|
||||
assert!(t.is_down());
|
||||
assert_eq!(t.feed(id(1), TouchPhase::Move, p + egui::vec2(3.0, 2.0), 0.1), None);
|
||||
assert_eq!(t.feed(id(1), TouchPhase::End, p, 0.15), None, "one finger still down");
|
||||
assert_eq!(t.feed(id(2), TouchPhase::End, p, 0.2), Some(Touch::Tap(2)));
|
||||
assert!(!t.is_down());
|
||||
// Three fingers.
|
||||
for (n, at) in [(4, 0.0), (5, 0.02), (6, 0.04)] {
|
||||
t.feed(id(n), TouchPhase::Start, p, 1.0 + at);
|
||||
}
|
||||
let ends: Vec<_> = [4, 5, 6].into_iter().map(|n| t.feed(id(n), TouchPhase::End, p, 1.2)).collect();
|
||||
assert_eq!(ends, [None, None, Some(Touch::Tap(3))]);
|
||||
assert_eq!((tap_command(2), tap_command(3), tap_command(1)), (Some("edit.undo"), Some("edit.redo"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pinch_a_long_press_a_cancel_or_one_finger_is_no_tap() {
|
||||
let p = Pos2::new(100.0, 100.0);
|
||||
let gesture = |moved: f32, held: f64, last: TouchPhase, fingers: u64| {
|
||||
let mut t = Taps::default();
|
||||
for n in 0..fingers {
|
||||
t.feed(id(n), TouchPhase::Start, p, 0.0);
|
||||
}
|
||||
t.feed(id(0), TouchPhase::Move, p + egui::vec2(moved, 0.0), held / 2.0);
|
||||
(0..fingers).filter_map(|n| t.feed(id(n), if n + 1 == fingers { last } else { TouchPhase::End }, p, held)).last()
|
||||
};
|
||||
assert_eq!(gesture(0.0, 0.2, TouchPhase::End, 2), Some(Touch::Tap(2)));
|
||||
assert_eq!(gesture(40.0, 0.2, TouchPhase::End, 2), None, "a pinch");
|
||||
assert_eq!(gesture(0.0, 0.9, TouchPhase::End, 2), None, "held too long");
|
||||
assert_eq!(gesture(0.0, 0.2, TouchPhase::Cancel, 2), None, "cancelled");
|
||||
assert_eq!(gesture(0.0, 0.2, TouchPhase::End, 1), None, "a pen or one finger");
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser
|
||||
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) |
|
||||
| `engine.commands` | | every command with label, shortcut, params doc, enablement |
|
||||
| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does |
|
||||
| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way) |
|
||||
| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way) |
|
||||
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
|
||||
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected |
|
||||
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
|
||||
@@ -214,6 +214,13 @@ Bar Position (`window.taskBar.reset`), which unpins it and puts it back under th
|
||||
Panel. As in Illustrator, neither the position nor the pin is saved: each launch starts with the bar under the
|
||||
selection.
|
||||
|
||||
The Free Transform widget: while the Free Transform tool (E) is active, a small box at the canvas's top left holds
|
||||
Constrain over the tool's three modes, Free Transform, Perspective Distort and Free Distort. The buttons set the tool's
|
||||
options, as `tool.setOption {key: "mode", value: "free"|"perspective"|"distort"}` and `{key: "constrain", value}` do:
|
||||
Constrain acts as Shift held (proportional scaling, moves and rotations by 45°) and does nothing in the distort modes,
|
||||
where it is greyed. The modifier keys still work while dragging (Cmd on a corner distorts it freely, Cmd+Alt+Shift in
|
||||
perspective, Cmd on a side shears).
|
||||
|
||||
Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title out of the dock floats that panel
|
||||
inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A
|
||||
floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own;
|
||||
|
||||
Reference in new issue
Block a user