Merge pull request #3430 from addibble/feat/properties-clip-group-quick-actions

G1: Properties: Edit Clipping Path and Edit Contents for one clip group
This commit is contained in:
BFlat
2026-10-22 00:26:05 +07:00
committed by GitHub
4 changed files with 93 additions and 4 deletions
+84
View File
@@ -11,6 +11,8 @@ use crate::theme::Tokens;
use crate::widgets::{self, dim_label, divider, section_header};
use crate::{VectorcraftApp, icons};
/// The panel for what is selected: the selection's kind as its heading, then its sections (Edit
/// Artboards' with the Artboard tool, the document's with nothing selected).
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else {
@@ -39,6 +41,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
document_sections(app, ui);
return;
}
if n_sel == 1
&& let Some(NodeKind::Group { clip: true, children }) = first.as_ref().map(|n| &n.kind)
{
clip_group_buttons(app, ui, children.len() > 1);
divider(ui);
}
transform_section(app, ui);
divider(ui);
// The Gradient tool: the gradient's type and, for a freeform one, how clicks add points (#921).
@@ -139,6 +147,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// The one selected clip group's Edit Clipping Path and Edit Contents, at the top of the panel as in
/// the reference app ("With the clip group selected, click the Edit Clipping Path button at the top
/// of the Properties panel to select and edit the mask, or click the Edit Contents button to select
/// and edit the masked content", <https://www.adobe.com/learn/illustrator/web/crop-using-masks>):
/// they select its clipping path or its clipped art for the selection tools, as Object › Clipping
/// Mask does. Edit Contents only when the group clips something.
fn clip_group_buttons(app: &mut VectorcraftApp, ui: &mut Ui, has_contents: bool) {
let w = (ui.available_width() - 6.0) / 2.0;
ui.horizontal(|ui| {
if widgets::flat_button(ui, tl!("Edit Clipping Path"), w).clicked() {
crate::menus::invoke(app, "object.clippingMask.editMask", json!({}));
}
if has_contents && widgets::flat_button(ui, tl!("Edit Contents"), w).clicked() {
crate::menus::invoke(app, "object.clippingMask.editContents", json!({}));
}
});
}
/// The one selected image: its file (or Embedded), colour mode and resolution, and the Links
/// panel's actions for it.
fn image_section(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -645,6 +671,7 @@ mod tests {
panel_frame(app, ctx, events, show)
}
/// One frame of `draw` with `events` → the texts painted, with their rects.
fn panel_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>, draw: fn(&mut VectorcraftApp, &mut Ui)) -> 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| draw(app, ui));
@@ -661,6 +688,7 @@ mod tests {
.collect()
}
/// Clicks the text `label` where the last frame painted it, over two frames (press, release).
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() };
@@ -668,6 +696,62 @@ mod tests {
frame(app, ctx, vec![b(false)]);
}
/// One selected clip group has Edit Clipping Path and Edit Contents at the top of the panel,
/// under its Clip Group heading and before every section, and they select its clipping path or
/// its clipped art; one that clips nothing has Edit Clipping Path alone. A plain group, or a clip
/// group selected with another object, has neither.
#[test]
fn one_clip_group_has_edit_clipping_path_and_edit_contents_at_the_top() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let art = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 140})).unwrap()["id"].clone();
let mask = app.run("shape.ellipse", json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [art, mask]})).unwrap();
let group = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [group]})).unwrap();
let ctx = egui::Context::default();
let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.iter().map(|n| json!(n.0)).collect::<Vec<_>>();
let labels = |texts: &[(String, Rect)]| texts.iter().map(|(t, _)| t.clone()).collect::<Vec<_>>();
let texts = frame(&mut app, &ctx, vec![]);
let shown = labels(&texts);
let at = |label: &str| shown.iter().position(|t| t == label).unwrap_or_else(|| panic!("{label} in {shown:?}"));
assert_eq!(at("Clip Group"), 0, "{shown:?}");
assert!(at("Edit Clipping Path") == 1 && at("Edit Contents") == 2, "{shown:?}");
click(&mut app, &ctx, &texts, "Edit Contents");
assert_eq!(selected(&app), std::slice::from_ref(&art), "Edit Contents selects the clipped art");
app.run("select.set", json!({"ids": [group]})).unwrap();
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Edit Clipping Path");
assert_eq!(selected(&app), std::slice::from_ref(&mask), "Edit Clipping Path selects the clipping path");
let neither = |app: &mut VectorcraftApp| {
let shown = labels(&frame(app, &ctx, vec![]));
!shown.iter().any(|t| t == "Edit Clipping Path" || t == "Edit Contents")
};
// The clip group with another object, whichever is selected first.
let other = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [group, other]})).unwrap();
assert!(neither(&mut app), "a clip group and another object");
app.run("select.set", json!({"ids": [other, group]})).unwrap();
assert!(neither(&mut app), "another object and a clip group");
// A plain group.
let b = app.run("shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [other, b]})).unwrap();
let plain = app.run("object.group", json!({})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [plain]})).unwrap();
assert!(labels(&frame(&mut app, &ctx, vec![])).iter().any(|t| t == "Ungroup"));
assert!(neither(&mut app), "a plain group");
// A clip group whose clipped art is deleted: Edit Clipping Path alone.
app.run("select.set", json!({"ids": [art]})).unwrap();
app.run("edit.clear", json!({})).unwrap();
app.run("select.set", json!({"ids": [group]})).unwrap();
let texts = frame(&mut app, &ctx, vec![]);
let shown = labels(&texts);
assert_eq!(shown.iter().position(|t| t == "Edit Clipping Path"), Some(1), "{shown:?}");
assert!(!shown.iter().any(|t| t == "Edit Contents"), "nothing to edit: {shown:?}");
click(&mut app, &ctx, &texts, "Edit Clipping Path");
assert_eq!(selected(&app), std::slice::from_ref(&mask), "Edit Clipping Path selects the clipping path");
}
/// The Snap Options and Scale Strokes & Effects checkboxes keep a visible box at rest in every
/// theme, as the Preferences ones do (#1080), and toggle when clicked.
#[test]
+3 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long