diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 928268931..d956bae82 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -279,14 +279,8 @@ impl Tool for SelectionTool { if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) { return vec![Action::SwitchTool("type".into())]; } - // A group, a compound path or a single path, image, mesh or placed file, not - // the one already isolated. Blends, envelopes, repeats and symbol instances - // aren't: they have editing modes of their own. - use vectorcraft_doc::NodeKind as K; - let isolates = cx.doc.node(top).is_some_and(|n| { - matches!(n.kind, K::Group { .. } | K::Compound { .. } | K::Path { .. } | K::Image(_) | K::Mesh(_) | K::PlacedDocument(_)) - }); - if cx.double_click_isolate && isolates && cx.isolation != Some(top) { + // An object that [`isolates`], not the one already isolated. + if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| isolates(&n.kind)) && cx.isolation != Some(top) { return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; } } else if cx.isolation.is_some() { @@ -568,6 +562,14 @@ pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool { && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })) } +/// Does a double-click or the Control bar's Isolate Selected Object button isolate an object of +/// this kind? A group, a compound path or a single path, image, mesh or placed file does. Blends, +/// envelopes, repeats and symbol instances do not; they have editing modes of their own. +pub fn isolates(kind: &vectorcraft_doc::NodeKind) -> bool { + use vectorcraft_doc::NodeKind as K; + matches!(kind, K::Group { .. } | K::Compound { .. } | K::Path { .. } | K::Image(_) | K::Mesh(_) | K::PlacedDocument(_)) +} + /// The resize cursor for handle `h` of a box turned by `angle` (counter-clockwise degrees): the /// handle that sits where `h` appears on screen, in 45° steps. fn handle_cursor(h: Handle, angle: f64) -> Cursor { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index dc04bf9bf..8b40ae8ac 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -208,6 +208,36 @@ pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorCon } } +/// The id of the Control bar's isolation button. +const ISOLATION_BUTTON: &str = "cb-isolation"; + +/// The Control bar's isolation button. In isolation mode it is Exit Isolation Mode, except while a +/// pattern is edited (the isolation bar's Done and Cancel end that). Otherwise, with one object +/// selected that [`vectorcraft_tools::select::isolates`], it is Isolate Selected Object, which +/// also isolates a path inside a group that Direct Selection selected. An envelope's contents +/// get no button: the envelope's Edit Envelope and Edit Contents buttons show instead. +fn isolation_button(app: &mut VectorcraftApp, ui: &mut Ui) { + let Some(st) = app.session.active() else { return }; + let (icon, tip, id) = if st.isolation.is_some() { + if st.doc.pattern_edit.is_some() { + return; + } + ("chevron-left", tl!("Exit Isolation Mode"), "object.exitIsolation") + } else if let [one] = st.selection.objects[..] + && st.doc.node(one).is_some_and(|n| vectorcraft_tools::select::isolates(&n.kind)) + && vectorcraft_doc::live::envelope_of(&st.doc, one).is_none() + { + ("square-dashed", tl!("Isolate Selected Object"), "object.isolate") + } else { + return; + }; + let (rect, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); + let resp = ui.interact(rect, egui::Id::new(ISOLATION_BUTTON), Sense::click()); + if widgets::paint_icon_button(ui, resp, icon, tip, false).clicked() { + crate::menus::invoke(app, id, json!({})); + } +} + /// The room the Control bar's inline X/Y/W/H fields need; with less, only the Transform link shows. const INLINE_TRANSFORM_WIDTH: f32 = 440.0; @@ -252,6 +282,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { if anchors != AnchorControls::None { anchor_buttons(app, ui, anchors); } + isolation_button(app, ui); ui.add_space(6.0); // An image or an Image Trace object shows its own controls in place of Fill and Stroke. let image = crate::place::control_bar_details(app, ui) || crate::panels::image_trace::control_bar(app, ui); @@ -1158,6 +1189,76 @@ mod tests { assert!(title(&s).ends_with("(RGB/Preview)")); } + /// One headless frame of the Control bar; returns the isolation button's rectangle, if it shows. + fn isolation_button(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Option { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 300.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::control_bar(app, ui)); + out.textures_delta.clear(); + ctx.viewport(|vp| vp.prev_pass.widgets.get(egui::Id::new(super::ISOLATION_BUTTON)).map(|w| w.rect)) + } + + /// The Control bar's Isolate Selected Object isolates a path inside a group that Direct + /// Selection selected, and Exit Isolation Mode leaves with the path selected. Type, symbol + /// instances, blends, two objects and an envelope's contents get no isolate button, and pattern + /// editing gets no exit button. + #[test] + fn the_control_bar_isolates_the_selected_object_and_exits_isolation_mode() { + use vectorcraft_doc::NodeId; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let rect = |app: &mut crate::VectorcraftApp, x: f64| { + app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (rect(&mut app, 10.0), rect(&mut app, 50.0)); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.select_tool("directSelection"); + app.run("select.anchors", json!({"id": a, "anchors": [[0, 0]]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let click = |app: &mut crate::VectorcraftApp| { + let r = isolation_button(app, &ctx, vec![]).expect("the bar shows the isolation button"); + let press = |pressed| egui::Event::PointerButton { + pos: r.center(), + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + for e in [egui::Event::PointerMoved(r.center()), press(true), press(false)] { + isolation_button(app, &ctx, vec![e]); + } + }; + click(&mut app); + assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(a)), "Isolate Selected Object isolates the path"); + click(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.isolation, st.selection.objects.clone()), (None, vec![NodeId(a)]), "Exit Isolation Mode selects the path"); + app.select_tool("selection"); + // Type, a symbol instance, a blend and two objects. + let text = app.run("text.create", json!({"x": 10, "y": 100, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + let c = rect(&mut app, 100.0); + app.run("select.set", json!({"ids": [c]})).unwrap(); + let symbol = app.run("symbol.new", json!({"name": "Box"})).unwrap()["id"].as_u64().unwrap(); + let (d, e) = (rect(&mut app, 150.0), rect(&mut app, 250.0)); + let blend = app.run("object.blend.make", json!({"ids": [d, e], "steps": 1})).unwrap()["id"].as_u64().unwrap(); + for ids in [json!([text]), json!([symbol]), json!([blend]), json!([text, symbol])] { + app.run("select.set", json!({ "ids": ids })).unwrap(); + assert_eq!(isolation_button(&mut app, &ctx, vec![]), None, "{ids}"); + } + // An envelope's contents, while Edit Contents is on. + let g = rect(&mut app, 350.0); + app.run("select.set", json!({"ids": [g]})).unwrap(); + app.run("object.envelope.makeWithWarp", json!({})).unwrap(); + app.run("object.envelope.editContents", json!({"editing": true})).unwrap(); + assert_eq!(app.session.active().unwrap().selection.objects, vec![NodeId(g)]); + assert_eq!(isolation_button(&mut app, &ctx, vec![]), None, "the envelope's controls show instead"); + let f = rect(&mut app, 300.0); + app.run("select.set", json!({"ids": [f]})).unwrap(); + app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + assert!(app.session.active().unwrap().isolation.is_some()); + assert_eq!(isolation_button(&mut app, &ctx, vec![]), None, "the isolation bar ends pattern editing"); + } + /// The Zoom tool's hint follows Performance › Animated Zoom (#394): a drag zooms as it goes, /// or (off) zooms into the area dragged across. #[test] diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 86badfdfc..44a75eb6c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -665,6 +665,7 @@ Auto Color Automatická barva Automatic Automaticky Isolate Selected Group Izolovat vybranou skupinu + Isolate Selected Object Izolovat vybraný objekt Exit Isolation Mode Ukončit izolovaný režim Make Clipping Mask Vytvořit ořezovou masku Release Clipping Mask Uvolnit ořezovou masku diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7692adb70..ac6a1adb1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3320,6 +3320,7 @@ imageTrace Outline Kontur to set spacing and orientation legt Abstand und Ausrichtung fest : : Isolate Selected Group Ausgewählte Gruppe isolieren + Isolate Selected Object Ausgewähltes Objekt isolieren Release Clipping Mask Schnittmaske zurückwandeln Make Compound Path Zusammengesetzten Pfad erstellen Release Compound Path Zusammengesetzten Pfad zurückwandeln diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c31f03400..a8fa9ee26 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3419,6 +3419,7 @@ imageTrace Outline Contorno to set spacing and orientation para definir espaciado y orientación : : Isolate Selected Group Aislar grupo seleccionado + Isolate Selected Object Aislar objeto seleccionado Release Clipping Mask Soltar máscara de recorte Make Compound Path Crear trazado compuesto Release Compound Path Soltar trazado compuesto diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 081925708..a94c06e41 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3416,6 +3416,7 @@ imageTrace Outline Contour to set spacing and orientation pour définir l’espacement et l’orientation :  : Isolate Selected Group Isoler le groupe sélectionné + Isolate Selected Object Isoler l’objet sélectionné Release Clipping Mask Annuler le masque d’écrêtage Make Compound Path Créer un tracé transparent Release Compound Path Annuler le tracé transparent diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8ad3f6b00..661c699aa 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1746,6 +1746,7 @@ Isolate Blending Keverés elkülönítése Isolate Group Csoport elkülönítése Isolate Selected Group Kijelölt csoport elkülönítése + Isolate Selected Object Kijelölt objektum elkülönítése Italian Olasz Italic Dőlt Its graphics, colors and text styles are deleted from this machine. A grafikái, színei és szövegstílusai törlődnek erről a gépről. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a0360a01c..aa7ee5ec2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3421,6 +3421,7 @@ imageTrace Outline Contorno to set spacing and orientation per impostare spaziatura e orientamento : : Isolate Selected Group Isola gruppo selezionato + Isolate Selected Object Isola oggetto selezionato Release Clipping Mask Rilascia maschera di ritaglio Make Compound Path Crea tracciato composto Release Compound Path Rilascia tracciato composto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2c5caebf5..ca806f2ff 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -670,6 +670,7 @@ Auto Color 自動カラー Automatic 自動 Isolate Selected Group 選択したグループを分離 + Isolate Selected Object 選択したオブジェクトを分離 Exit Isolation Mode 分離モードを終了 Make Clipping Mask クリッピングマスクを作成 Release Clipping Mask クリッピングマスクを解除 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a9a69ffe4..892027449 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -668,6 +668,7 @@ Auto Color 자동 색상 Automatic 자동 Isolate Selected Group 선택한 그룹 격리 + Isolate Selected Object 선택한 개체 격리 Exit Isolation Mode 격리 모드 종료 Make Clipping Mask 클리핑 마스크 만들기 Release Clipping Mask 클리핑 마스크 해제 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9dc5940a2..99edaaf7f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -612,6 +612,7 @@ Auto Color Kolor automatyczny Automatic Automatycznie Isolate Selected Group Izoluj zaznaczoną grupę + Isolate Selected Object Izoluj zaznaczony obiekt Exit Isolation Mode Wyjdź z trybu izolacji Make Clipping Mask Utwórz maskę przycinającą Release Clipping Mask Uwolnij maskę przycinającą diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6134770d7..84adeccfb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1179,6 +1179,7 @@ Isolate Blending Isolar Mesclagem Isolate Group Isolar Grupo Isolate Selected Group Isolar Grupo Selecionado + Isolate Selected Object Isolar Objeto Selecionado Italian Italiano Join Unir Join Our Discord Junte-se ao nosso Discord diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1aac992e8..0da866fc0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3417,6 +3417,7 @@ imageTrace Outline Контур to set spacing and orientation для задания интервала и ориентации : : Isolate Selected Group Изолировать выбранную группу + Isolate Selected Object Изолировать выбранный объект Release Clipping Mask Освободить маску обрезки Make Compound Path Создать составной контур Release Compound Path Освободить составной контур diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1d51cec36..7f051faac 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3414,6 +3414,7 @@ imageTrace Outline Контур to set spacing and orientation для завдання інтервалу та орієнтації : : Isolate Selected Group Ізолювати обрану групу + Isolate Selected Object Ізолювати обраний об’єкт Release Clipping Mask Звільнити відсічну маску Make Compound Path Створити складений контур Release Compound Path Звільнити складений контур diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 12f9c97cc..84ca83fc4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1608,6 +1608,7 @@ Isolate Blending Tách biệt hòa trộn Isolate Group Tách biệt nhóm Isolate Selected Group Tách biệt nhóm đã chọn + Isolate Selected Object Tách biệt đối tượng đã chọn Isometric Bottom Đẳng góc dưới Isometric Left Đẳng góc trái Isometric Right Đẳng góc phải diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b9eb52569..2d30f84fb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3276,6 +3276,7 @@ imageTrace Outline 轮廓 to set spacing and orientation 可设置间距与方向 : : Isolate Selected Group 隔离选择的编组 + Isolate Selected Object 隔离选择的对象 Release Clipping Mask 释放剪切蒙版 Make Compound Path 制作复合路径 Release Compound Path 释放复合路径 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f435042f0..83216fe94 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3407,6 +3407,7 @@ imageTrace Outline 輪廓 to set spacing and orientation 可設定間距與方向 : : Isolate Selected Group 隔離選取的群組 + Isolate Selected Object 隔離選取的物件 Release Clipping Mask 釋放剪裁遮色片 Make Compound Path 製作複合路徑 Release Compound Path 釋放複合路徑 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6d26eea20..6ebddb89f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -429,6 +429,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off. - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. + - In isolation mode the Control bar has an Exit Isolation Mode button (`object.exitIsolation`), except while a pattern is edited. Outside isolation mode, with one group, compound path, path, image, mesh or placed file selected, it has an Isolate Selected Object button (`object.isolate`), which also isolates a path inside a group that Direct Selection selected. - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields. - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`).