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 <noreply@anthropic.com>
This commit is contained in:
Victor
2026-10-18 01:11:39 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 039a467dcb
commit f7382b2844
26 changed files with 256 additions and 28 deletions
+66 -28
View File
@@ -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 (<https://community.adobe.com/questions-652/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580>),
// and to rearrange them (<https://creativepro.com/managing-artboards-in-illustrator/>).
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 (<https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580>).
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 (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>).
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::<f32>();
let est_w = 118.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::<f32>();
// 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");
+1
View File
@@ -628,6 +628,7 @@
Created: Създадено:
Crop Изрязване
Crop Image Изрязване на изображение
Edit Path Редактиране на контура
Mask Маска
Crop Marks Знаци за изрязване
Crop To Изрязване до
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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íť…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -602,6 +602,7 @@
Created: Δημιουργήθηκε:
Crop Περικοπή
Crop Image Περικοπή εικόνας
Edit Path Επεξεργασία διαδρομής
Mask Μάσκα
Crop Marks Σημάδια ξακρίσματος
Crop To Περικοπή ως προς
1 # Ελληνικά (el) — VectorCraft
602
603
604
605
606
607
608
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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…
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
173
174
175
176
177
178
179
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -188,6 +188,7 @@
Expand… 分割・拡張…
Expand Appearance アピアランスを分割
Crop Image 画像の切り抜き
Edit Path パスを編集
Mask マスク
Rasterize… ラスタライズ…
Create Gradient Mesh… グラデーションメッシュを作成…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -184,6 +184,7 @@
Expand… 확장…
Expand Appearance 모양새 확장
Crop Image 이미지 자르기
Edit Path 패스 편집
Mask 마스크
Rasterize… 래스터화…
Create Gradient Mesh… 그레이디언트 메시 만들기…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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ą…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -640,6 +640,7 @@
Created: Создано:
Crop Обрезать
Crop Image Обрезать изображение
Edit Path Редактировать контур
Mask Маска
Crop Marks Метки обрезки
Crop To Обрезать до
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -636,6 +636,7 @@
Created: Створено:
Crop Обрізати
Crop Image Обрізати зображення
Edit Path Редагувати контур
Mask Маска
Crop Marks Мітки обрізання
Crop To Обрізати до
1 # Ukrainian (uk) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation.
636
637
638
639
640
641
642
+1
View File
@@ -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
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
741
742
743
744
745
746
747
+1
View File
@@ -596,6 +596,7 @@
Created: 创建时间:
Crop 裁剪
Crop Image 裁剪图像
Edit Path 编辑路径
Mask 蒙版
Crop Marks 裁切标记
Crop To 裁剪到
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -611,6 +611,7 @@
Created: 建立時間:
Crop 裁切
Crop Image 裁切影像
Edit Path 編輯路徑
Mask 遮色片
Crop Marks 裁切標記
Crop To 裁切至
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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;
+165
View File
@@ -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<Event>) -> 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::<Vec<_>>())
}
/// 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));
}
+1
View File
@@ -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 (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>): 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.