From f7382b2844a49c7cb242d1de2a225ed4eaad95f6 Mon Sep 17 00:00:00 2001 From: Victor <124060164+VL420@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:28:05 -0300 Subject: [PATCH] G1: Contextual Task Bar: Join, Edit Path, Release and type conversion by selection The task bar offers more next steps for each kind of selection: - an open path: Join (closes it) and Edit Path (the Direct Selection tool); a closed path: Edit Path; - a compound path: Edit Path and Release Compound Path; - a clipping group: Edit Clipping Path and Release Clipping Mask instead of Ungroup and Isolate; - an image selected with vector art: Make Clipping Mask instead of Unite; - point or area type: Convert To Area Type / Convert To Point Type beside Create Outlines. Buttons may now carry command parameters. "Edit Path" is translated in every catalog; the other labels reuse the menu's. cargo xtask ci passes all 9 steps. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 94 +++++++++++----- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tests_taskbar.rs | 165 ++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 26 files changed, 256 insertions(+), 28 deletions(-) create mode 100644 crates/ui-egui/src/tests_taskbar.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 66edf39bf..532ec6a89 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3,6 +3,7 @@ use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; use serde_json::json; +use vectorcraft_doc::text::TextKind; use vectorcraft_doc::{Node, NodeKind, SymbolBoxes, Unit}; use vectorcraft_geom::{Affine, BezPath, PathEl, Point, Rect}; use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind}; @@ -2854,30 +2855,44 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { let n = st.selection.len(); let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned(); let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. })); - let is_text = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Text(_))); let is_image = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Image(_))); + let is_clip_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { clip: true, .. })); + let is_compound = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Compound { .. })); + let open_path = first.as_ref().is_some_and(|f| matches!(&f.kind, NodeKind::Path { path, .. } if !path.is_closed())); + let text = first.as_ref().and_then(|f| match &f.kind { + NodeKind::Text(t) => Some(&t.kind), + _ => None, + }); + // Images and vector art selected together: a clipping mask is the likely next step. + let mixed = n > 1 && { + let images = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).filter(|o| matches!(o.kind, NodeKind::Image(_))).count(); + images > 0 && images < n + }; // An Image Trace object: the group the engine names "Image Trace", its source image first. let is_trace = is_group && first.as_ref().is_some_and(|f| { f.name.as_deref() == Some("Image Trace") && f.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) }); - let mut items: Vec<(&str, &str, &str)> = vec![]; // (label, icon, command) + let mut items: Vec<(&str, &str, &str, serde_json::Value)> = vec![]; // (label, icon, command, params) + let none = || json!({}); if on_artboards { // As in the reference app, the Artboard tool offers to resize, duplicate and export the // artboards (), // and to rearrange them (). - items.push((tl!("Resize"), "move-diagonal-2", TASK_BAR_ARTBOARD_OPTIONS)); - items.push((tl!("Duplicate"), "copy", TASK_BAR_DUPLICATE_ARTBOARDS)); + items.push((tl!("Resize"), "move-diagonal-2", TASK_BAR_ARTBOARD_OPTIONS, none())); + items.push((tl!("Duplicate"), "copy", TASK_BAR_DUPLICATE_ARTBOARDS, none())); if st.doc.artboards.len() > 1 { - items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS)); + items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none())); } - items.push((tl!("Export"), "share-2", "file.exportForScreens")); + items.push((tl!("Export"), "share-2", "file.exportForScreens", none())); } else { + // Edit Path: the Direct Selection tool, to edit the path's anchors. + let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"})); // As in the reference app, an image offers Image Trace and Crop Image, and traced art offers // Expand (). if n == 1 && is_image { - items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE)); - items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage")); + items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE, none())); + items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage", none())); } // Direct-selected anchors take the place of a path's Offset Path and Simplify. For them the bar // offers Remove, Connect, Cut and Convert to corner or smooth, as the reference app does @@ -2885,29 +2900,52 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { // These run the same commands as the Control bar's anchor buttons. let anchors = !st.selection.anchors.is_empty(); if anchors { - items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors")); - items.push((tl!("Connect"), "dc-join", "path.join")); - items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors")); - items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER)); - items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH)); + items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors", none())); + items.push((tl!("Connect"), "dc-join", "path.join", none())); + items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors", none())); + items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); + items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); } + // As in the reference app, open paths offer Join and Edit Path, compound paths and clipping + // masks their Release, images selected with vector art Make Clipping Mask, and type converts + // between point and area type (). if n > 1 { - items.push((tl!("Group"), "group", "object.group")); - items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite")); + items.push((tl!("Group"), "group", "object.group", none())); + if mixed { + items.push((tl!("Make Clipping Mask"), "dc-pf-crop", "object.clippingMask.make", none())); + } else { + items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite", none())); + } } else if is_trace { - items.push((tl!("Expand"), "maximize-2", "imageTrace.expand")); + items.push((tl!("Expand"), "maximize-2", "imageTrace.expand", none())); + } else if is_clip_group { + items.push((tl!("Edit Clipping Path"), "dc-direct", "object.clippingMask.editMask", none())); + items.push((tl!("Release Clipping Mask"), "ungroup", "object.clippingMask.release", none())); } else if is_group { - items.push((tl!("Ungroup"), "ungroup", "object.ungroup")); - items.push((tl!("Isolate"), "square-dashed", "object.isolate")); - } else if is_text { - items.push((tl!("Create Outlines"), "type", "type.createOutlines")); + items.push((tl!("Ungroup"), "ungroup", "object.ungroup", none())); + items.push((tl!("Isolate"), "square-dashed", "object.isolate", none())); + } else if is_compound { + items.push(edit_path()); + items.push((tl!("Release Compound Path"), "ungroup", "object.compoundPath.release", none())); + } else if let Some(kind) = text { + items.push((tl!("Create Outlines"), "type", "type.createOutlines", none())); + // Point type converts to area type and back; type on a path doesn't. + match kind { + TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())), + TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())), + TextKind::OnPath { .. } => {} + } } else if is_image { // Offset Path and Simplify don't apply to an image. } else if !anchors { - items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath")); - items.push((tl!("Simplify"), "spline", "object.path.simplify")); + if open_path { + items.push((tl!("Join"), "dc-join", "path.join", none())); + } + items.push(edit_path()); + items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath", none())); + items.push((tl!("Simplify"), "spline", "object.path.simplify", none())); } - items.push((tl!("Duplicate"), "copy", "edit.duplicate")); + items.push((tl!("Duplicate"), "copy", "edit.duplicate", none())); } let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default(); let colored = has_colors(&st.doc, &st.selection.objects); @@ -2916,7 +2954,7 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { // rotated). let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); let anchor = pos2(xf.to_screen(b.center()).x, lowest); - let est_w = 118.0 + items.iter().map(|(l, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); + let est_w = 118.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); // Its place under the selection, then where the handle moved it, kept on the canvas (with the // bar's size last frame, the estimate before it first shows). let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0); @@ -2962,7 +3000,7 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } else if grip.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } - for (label, icon, cmd) in &items { + for (label, icon, cmd, params) in &items { let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong); let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click()); if resp.hovered() { @@ -2972,7 +3010,7 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon); ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong); if resp.clicked() { - run = Some((*cmd, json!({}))); + run = Some((*cmd, params.clone())); } } // The selected objects' Fill, Recolor Artwork and Lock, left out with the Artboard tool. @@ -6573,7 +6611,7 @@ mod tests { // A selected object with the Selection tool: its own actions, none of the artboards'. app.select_tool("selection"); app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); - assert_eq!(names(&mut app), ["Offset Path", "Simplify", "Duplicate"]); + assert_eq!(names(&mut app), ["Edit Path", "Offset Path", "Simplify", "Duplicate"]); // One artboard: nothing to rearrange. app.session.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); @@ -6675,7 +6713,7 @@ mod tests { app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); let mut square = |x: f64, y: f64| app.session.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 40, "height": 40})).unwrap()["id"].clone(); - let (a, b) = (square(120.0, 40.0), square(220.0, 150.0)); + let (a, b) = (square(120.0, 40.0), square(160.0, 150.0)); app.select_tool("selection"); let ctx = egui::Context::default(); let bar = || task_bar_rect(&ctx).expect("the task bar shows"); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 77a8bc79d..5f0e8c1c7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -628,6 +628,7 @@ Created: Създадено: Crop Изрязване Crop Image Изрязване на изображение + Edit Path Редактиране на контура Mask Маска Crop Marks Знаци за изрязване Crop To Изрязване до diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ad8455284..4a6de6865 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -188,6 +188,7 @@ Expand… Rozložit… Expand Appearance Rozložit vzhled Crop Image Oříznout obraz + Edit Path Upravit cestu Mask Maska Rasterize… Rastrovat… Create Gradient Mesh… Vytvořit přechodovou síť… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 58282f75b..d2de88ef3 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -173,6 +173,7 @@ Expand… Udvid… Expand Appearance Udvid udseende Crop Image Beskær billede + Edit Path Redigér kurve Mask Maskér Rasterize… Rasterisér… Create Gradient Mesh… Opret gradientnet… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b9f80b3fc..adf00a22b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -628,6 +628,7 @@ Created: Erstellt: Crop Schnittmengenfläche Crop Image Bild beschneiden + Edit Path Pfad bearbeiten Mask Maske Crop Marks Schnittmarken Crop To Beschneiden auf diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ec05156ac..33f4e4d99 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -602,6 +602,7 @@ Created: Δημιουργήθηκε: Crop Περικοπή Crop Image Περικοπή εικόνας + Edit Path Επεξεργασία διαδρομής Mask Μάσκα Crop Marks Σημάδια ξακρίσματος Crop To Περικοπή ως προς diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 976185794..002996bc1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -642,6 +642,7 @@ Created: Creado: Crop Recortar Crop Image Recortar imagen + Edit Path Editar trazado Mask Máscara Crop Marks Marcas de corte Crop To Recortar a diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index bee2148d1..34db46493 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -195,6 +195,7 @@ Expand… Laajenna… Expand Appearance Laajenna ulkoasu Crop Image Rajaa kuva + Edit Path Muokkaa polkua Mask Maski Rasterize… Rasteroi… Create Gradient Mesh… Luo liukuväriverkko… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 44dde6d46..3073944c3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -644,6 +644,7 @@ Created: Création : Crop Recadrer Crop Image Recadrer l’image + Edit Path Modifier le tracé Mask Masque Crop Marks Traits de coupe Crop To Recadrer selon diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 27050a4a8..79dd02cb9 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -840,6 +840,7 @@ CRLF (Windows) CRLF (Windows) Crop Körülvágás Crop Image Kép körülvágása + Edit Path Útvonal szerkesztése Crop Marks Vágójelek Crop To Körülvágás Cross Kereszt diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3af3c3c0c..a2a0f5c18 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -173,6 +173,7 @@ Expand… Perluas… Expand Appearance Perluas tampilan Crop Image Pangkas gambar + Edit Path Edit jalur Mask Masker Rasterize… Rasterisasi… Create Gradient Mesh… Buat mesh gradien… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a9651aa46..298937b58 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -642,6 +642,7 @@ Created: Creato: Crop Ritaglia Crop Image Ritaglia immagine + Edit Path Modifica tracciato Mask Maschera Crop Marks Segni di ritaglio Crop To Ritaglia in base a diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b052fad83..e4d632d27 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -188,6 +188,7 @@ Expand… 分割・拡張… Expand Appearance アピアランスを分割 Crop Image 画像の切り抜き + Edit Path パスを編集 Mask マスク Rasterize… ラスタライズ… Create Gradient Mesh… グラデーションメッシュを作成… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e6fd6add2..346217932 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -184,6 +184,7 @@ Expand… 확장… Expand Appearance 모양새 확장 Crop Image 이미지 자르기 + Edit Path 패스 편집 Mask 마스크 Rasterize… 래스터화… Create Gradient Mesh… 그레이디언트 메시 만들기… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 131080f75..7cd6fbaf6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -187,6 +187,7 @@ Expand… Uitbreiden… Expand Appearance Uiterlijk uitbreiden Crop Image Afbeelding bijsnijden + Edit Path Pad bewerken Mask Masker Rasterize… Rasteriseren… Create Gradient Mesh… Verloopmesh maken… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4b3b5a13b..8e5816470 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -153,6 +153,7 @@ Expand… Rozszerz… Expand Appearance Rozszerz wygląd Crop Image Kadruj obraz + Edit Path Edytuj ścieżkę Mask Maska Rasterize… Rasteryzuj… Create Gradient Mesh… Utwórz siatkę gradientową… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a7f5f9568..33e943ec8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -575,6 +575,7 @@ Created: Criado: Crop Recortar Crop Image Recortar Imagem + Edit Path Editar caminho Mask Máscara Crop Marks Marcas de Corte Crop To Recortar Para diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ef24524fe..66a34df37 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -640,6 +640,7 @@ Created: Создано: Crop Обрезать Crop Image Обрезать изображение + Edit Path Редактировать контур Mask Маска Crop Marks Метки обрезки Crop To Обрезать до diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 7c4f86d93..2916f2401 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -630,6 +630,7 @@ Created: Skapad: Crop Beskär Crop Image Beskär bild + Edit Path Redigera bana Mask Maskera Crop Marks Skärmärken Crop To Beskär till diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a93f1a6e5..2296adc25 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -636,6 +636,7 @@ Created: Створено: Crop Обрізати Crop Image Обрізати зображення + Edit Path Редагувати контур Mask Маска Crop Marks Мітки обрізання Crop To Обрізати до diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9e5862b28..0c2cb2e2f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -741,6 +741,7 @@ Created: Ngày tạo: Crop Cắt xén Crop Image Cắt xén ảnh + Edit Path Sửa đường dẫn Crop Marks Dấu cắt xén Crop To Cắt xén đến Crosshatch Lưới đan diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a2f826b41..6a3ed6437 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -596,6 +596,7 @@ Created: 创建时间: Crop 裁剪 Crop Image 裁剪图像 + Edit Path 编辑路径 Mask 蒙版 Crop Marks 裁切标记 Crop To 裁剪到 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a80e4065e..40b0c3c0d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -611,6 +611,7 @@ Created: 建立時間: Crop 裁切 Crop Image 裁切影像 + Edit Path 編輯路徑 Mask 遮色片 Crop Marks 裁切標記 Crop To 裁切至 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a9ba3ffec..9e1f21344 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -155,6 +155,8 @@ mod tests_sysclip_emf; #[cfg(test)] mod tests_sysclip_probe; #[cfg(test)] +mod tests_taskbar; +#[cfg(test)] mod tests_transparencygrid; #[cfg(test)] mod tests_widthtool; diff --git a/crates/ui-egui/src/tests_taskbar.rs b/crates/ui-egui/src/tests_taskbar.rs new file mode 100644 index 000000000..e1563c339 --- /dev/null +++ b/crates/ui-egui/src/tests_taskbar.rs @@ -0,0 +1,165 @@ +//! The Contextual Task Bar's actions for each kind of selection: open paths offer Join and Edit +//! Path, compound paths and clipping masks their Release, images selected with vector art Make +//! Clipping Mask, and type converts between point and area type. + +use egui::{Event, PointerButton, Pos2, Rect, vec2}; +use serde_json::json; +use vectorcraft_doc::text::TextKind; +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_engine::Session; + +use crate::tests_removeanchors::{at, has, shapes_text}; +use crate::{VectorcraftApp, canvas}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app +} + +fn id(v: serde_json::Value) -> u64 { + v["id"].as_u64().unwrap() +} + +fn kind(app: &VectorcraftApp, id: u64) -> NodeKind { + app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind.clone() +} + +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) +} + +/// The task bar's labels for the current selection (its area settles on the second frame). +fn bar(app: &mut VectorcraftApp) -> (egui::Context, Vec<(String, Rect)>) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(app, &ctx, vec![]); + let texts = frame(app, &ctx, vec![]); + (ctx, texts) +} + +/// Click the task bar's `label`. +fn click(app: &mut VectorcraftApp, label: &str) { + let (ctx, texts) = bar(app); + let p = at(&texts, label); + let press = |pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, &ctx, vec![Event::PointerMoved(p)]); + frame(app, &ctx, vec![press(true)]); + frame(app, &ctx, vec![press(false)]); + frame(app, &ctx, vec![]); +} + +fn select(app: &mut VectorcraftApp, ids: &[u64]) { + app.run("select.set", json!({"ids": ids})).unwrap(); +} + +#[test] +fn an_open_path_offers_join_and_edit_path() { + let mut app = app(); + let line = id(app.run("shape.line", json!({"x1": 50, "y1": 50, "x2": 200, "y2": 120})).unwrap()); + select(&mut app, &[line]); + let (_, texts) = bar(&mut app); + for label in ["Join", "Edit Path", "Offset Path", "Simplify", "Duplicate"] { + assert!(has(&texts, label), "{label}: {texts:?}"); + } + // Join closes the single open path. + click(&mut app, "Join"); + assert!(matches!(kind(&app, line), NodeKind::Path { path, .. } if path.is_closed())); + // A closed path offers Edit Path but not Join. + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Edit Path") && !has(&texts, "Join"), "{texts:?}"); + // Edit Path switches to the Direct Selection tool, keeping the selection. + click(&mut app, "Edit Path"); + assert_eq!(app.session.tool_id(), "directSelection"); + assert_eq!(app.session.active().unwrap().selection.objects, [NodeId(line)]); +} + +#[test] +fn a_compound_path_offers_edit_path_and_release() { + let mut app = app(); + let a = id(app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 120, "height": 100})).unwrap()); + let b = id(app.run("shape.ellipse", json!({"x": 70, "y": 60, "width": 50, "height": 50})).unwrap()); + select(&mut app, &[a, b]); + let c = id(app.run("object.compoundPath.make", json!({})).unwrap()); + assert!(matches!(kind(&app, c), NodeKind::Compound { .. })); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Edit Path") && has(&texts, "Release Compound Path"), "{texts:?}"); + assert!(!has(&texts, "Offset Path"), "{texts:?}"); + click(&mut app, "Release Compound Path"); + let st = app.session.active().unwrap(); + assert_eq!(st.selection.objects.len(), 2, "the paths come apart, selected"); + assert!(st.selection.objects.iter().all(|i| matches!(st.doc.node(*i).unwrap().kind, NodeKind::Path { .. }))); +} + +#[test] +fn a_clipping_mask_offers_edit_clipping_path_and_release() { + let mut app = app(); + let art = id(app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 160, "height": 120})).unwrap()); + let mask = id(app.run("shape.ellipse", json!({"x": 60, "y": 50, "width": 100, "height": 90})).unwrap()); + select(&mut app, &[art, mask]); + let g = id(app.run("object.clippingMask.make", json!({})).unwrap()); + assert!(matches!(kind(&app, g), NodeKind::Group { clip: true, .. })); + select(&mut app, &[g]); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Edit Clipping Path") && has(&texts, "Release Clipping Mask"), "{texts:?}"); + assert!(!has(&texts, "Ungroup"), "a clip group isn't offered Ungroup: {texts:?}"); + click(&mut app, "Release Clipping Mask"); + assert!(!matches!(kind(&app, g), NodeKind::Group { clip: true, .. }), "no longer clipped"); + // Undo puts the mask back. + app.run("edit.undo", json!({})).unwrap(); + assert!(matches!(kind(&app, g), NodeKind::Group { clip: true, .. })); +} + +#[test] +fn an_image_with_vector_art_offers_make_clipping_mask() { + let mut app = app(); + let mut png = vec![]; + image::RgbaImage::from_pixel(20, 20, image::Rgba([0, 128, 255, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let dir = std::env::temp_dir().join(format!("vectorcraft-ui-taskbar-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("blue.png"); + std::fs::write(&path, &png).unwrap(); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.run("file.place", json!({"path": path.to_string_lossy(), "link": false})).unwrap(); + let img = app.session.active().unwrap().selection.objects[0].0; + assert!(matches!(kind(&app, img), NodeKind::Image(_))); + let shape = id(app.run("shape.ellipse", json!({"x": 150, "y": 100, "width": 60, "height": 60})).unwrap()); + select(&mut app, &[img, shape]); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Make Clipping Mask") && has(&texts, "Group"), "{texts:?}"); + assert!(!has(&texts, "Unite"), "{texts:?}"); + click(&mut app, "Make Clipping Mask"); + let st = app.session.active().unwrap(); + let g = st.selection.objects[0]; + assert!(matches!(st.doc.node(g).unwrap().kind, NodeKind::Group { clip: true, .. })); + // Vector art alone still offers Unite. + let a = id(app.run("shape.rectangle", json!({"x": 10, "y": 200, "width": 40, "height": 40})).unwrap()); + let b = id(app.run("shape.rectangle", json!({"x": 30, "y": 210, "width": 40, "height": 40})).unwrap()); + select(&mut app, &[a, b]); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Unite") && !has(&texts, "Make Clipping Mask"), "{texts:?}"); +} + +#[test] +fn type_converts_between_point_and_area_type() { + let mut app = app(); + let t = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Hello"})).unwrap()); + select(&mut app, &[t]); + let text_kind = |app: &VectorcraftApp| match kind(app, t) { + NodeKind::Text(t) => t.kind.clone(), + other => panic!("{other:?}"), + }; + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Create Outlines") && has(&texts, "Convert To Area Type"), "{texts:?}"); + click(&mut app, "Convert To Area Type"); + assert!(matches!(text_kind(&app), TextKind::Area { .. })); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Convert To Point Type") && !has(&texts, "Convert To Area Type"), "{texts:?}"); + click(&mut app, "Convert To Point Type"); + assert!(matches!(text_kind(&app), TextKind::Point)); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 65c6a4d4c..9344290de 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -448,6 +448,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's variants for other tools (only the selection tools and the Artboard tool show it). - With one image selected, the contextual task bar offers Image Trace, which traces the image at once with the Default preset (the Image Trace panel is left as it was), and Crop Image; with an Image Trace object it offers Expand, which keeps only the traced paths. As in Illustrator ("Selecting images displays options like Image Trace, Retype, and Crop Image"; "You can also quickly expand traced artwork from the bar": [Contextual Task Bar announcement](https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580)). Offset Path and Simplify, which don't apply to an image, aren't offered for one. Not done: Retype, which the app doesn't have. - With the Artboard tool, the contextual task bar sits under the selected artboards and offers Resize (Artboard Options for the active artboard), Duplicate (each selected artboard, in one undo step, the copies then selected), Rearrange All (with two or more artboards) and Export (Export for Screens with the selected artboards checked). As in Illustrator ("the Contextual Task Bar reveals options to resize, duplicate, and export artboards": the [Contextual Task Bar announcement](https://community.adobe.com/questions-652/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580); Rearrange and Export on the bar: [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/)). + - The contextual task bar offers more next steps by selection (): an open path Join and Edit Path (the Direct Selection tool), a closed path Edit Path, a compound path Edit Path and Release Compound Path, a clipping group Edit Clipping Path and Release Clipping Mask instead of Ungroup and Isolate, an image selected with vector art Make Clipping Mask instead of Unite, and point or area type Convert To Area Type or Convert To Point Type beside Create Outlines. Evidence: `crates/ui-egui/src/tests_taskbar.rs`. - With anchor points selected with Direct Selection, the contextual task bar offers Remove Anchor Points, Connect (`path.join`), Cut Path, Corner and Smooth (`path.convertAnchors` with `to` set to `corner` or `smooth`), the commands of the Control bar's anchor buttons. As in Illustrator ("Remove selected anchor points, Connect selected end points, Cut path at selected anchor points, Convert selected anchor points to corner, and Convert selected anchor points to smooth": [Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Not done: Simplify, which the bar offers only while no anchors are selected, and the bar's Smooth item that adjusts a path's curvature, a command the app doesn't have (Object › Path › Smooth). - 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. - Screen readers get a name for the Tools panel's tool buttons, flyout rows, floating strips and tear-off bars, and for the icon buttons, text buttons, toggles, checkboxes, radio buttons, tabs, chips, sliders, angle dials and combo boxes that panels and dialogs draw with `crates/ui-egui/src/widgets.rs`: the text, label or tool tip each one shows, or a name of its own where it shows none (the angle dial, the spinner, the preset arrow, the color wheel and the Reference Point's squares). The tool buttons, toggles, checkboxes, radio buttons, tabs and the Reference Point's nine squares also report whether they are on, an icon button that is lit, such as the chosen Align To button, reports that it is pressed, and the angle dial reports its angle. The status bar's First, Previous, Next and Last Artboard buttons have tool tips.