feat: 为填充、描边和效果添加右键复制粘贴功能

实现了在属性行和分区标题上右键菜单,支持复制粘贴单个填充、描边和效果。新增了相关的命令、测试和国际化文本,完善了剪贴板数据结构以支持这些新功能。
This commit is contained in:
Nicek
2026-10-21 22:18:30 +08:00
parent 35da7b7cff
commit 12e0cdc147
12 changed files with 425 additions and 10 deletions
+102 -1
View File
@@ -229,6 +229,22 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)),
spec!("fill.copy", "Copy fill", "{id?, index? (default the top fill)}", false, |s, p| paint_copy(s, p, true)),
spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one)}", true, |s, p| paint_paste(s, p, true)),
spec!(
"stroke.copy",
"Copy stroke",
"{id?, index? (default the top stroke)} its paint and the stroke's weight, alignment, sides, caps, joins and dashes",
false,
|s, p| paint_copy(s, p, false)
),
spec!(
"stroke.paste",
"Paste stroke",
"{ids?, index? (replace the stroke there; without: add one)} the copied stroke's settings come with its paint",
true,
|s, p| paint_paste(s, p, false)
),
spec!(
"fill.pattern",
"Pattern fill",
@@ -276,6 +292,8 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!("effect.copy", "Copy effect", "{id?, index? (default the top effect)}", false, effect_copy),
spec!("effect.paste", "Paste effect", "{ids?, index? (replace the effect there; without: add one)}", true, effect_paste),
spec!("grid.add", "Add layout grid", "{ids?, grid? {kind: grid|columns|rows, ..}}", true, |s, p| {
let g: LayoutGrid = parse("grid.add", p, "grid")?.unwrap_or_default();
for id in req_targets("grid.add", s, p)? {
@@ -608,7 +626,7 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
let props = s.clipboard.props.take();
// No open file has id 0. So paste treats this copy as a copy from a different file.
s.clipboard =
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() };
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, ..crate::Clipboard::default() };
let mut out = paste(s, p)?;
if !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
@@ -1386,6 +1404,53 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
ok()
}
/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke
/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along.
fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
let cmd = if fills { "fill.copy" } else { "stroke.copy" };
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
let at = opt_index(cmd, p, "index")?;
let (paint, stroke) = {
let doc = s.doc()?;
let n = doc.get(id)?;
let list = if fills { &n.fills } else { &n.strokes };
let at = at.unwrap_or(list.len().saturating_sub(1));
let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?;
(paint, (!fills).then(|| n.stroke.clone()))
};
s.clipboard.paint = Some((fills, paint.clone()));
s.clipboard.stroke = stroke;
serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string()))
}
/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A
/// stroke's copied settings go onto each target too.
fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
let cmd = if fills { "fill.paste" } else { "stroke.paste" };
let Some((copied_fills, paint)) = s.clipboard.paint.clone() else {
return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into()));
};
if copied_fills != fills {
let what = if fills { "stroke" } else { "fill" };
return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}")));
}
let ids = req_targets(cmd, s, p)?;
let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?});
let out = match opt_index(cmd, p, "index")? {
Some(at) => {
q["index"] = json!(at);
paint_set(s, &q, fills)?
}
None => paint_list(s, &q, fills, true)?,
};
if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) {
for id in ids {
s.doc_mut()?.get_mut(id)?.stroke = stroke.clone();
}
}
Ok(out)
}
fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "fill.pattern";
let src = req_id(CMD, p, "source")?;
@@ -1422,3 +1487,39 @@ fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
}
ok()
}
/// Copy effect `index` (the top one by default) of the first target into the clipboard.
fn effect_copy(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "effect.copy";
let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?;
let at = opt_index(CMD, p, "index")?;
let e = {
let doc = s.doc()?;
let list = &doc.get(id)?.effects;
let at = at.unwrap_or(list.len().saturating_sub(1));
list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))?
};
s.clipboard.effect = Some(e.clone());
serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string()))
}
/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one.
fn effect_paste(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "effect.paste";
let Some(e) = s.clipboard.effect.clone() else {
return Err(EngineError::Disabled(CMD.into(), "no copied effect".into()));
};
let at = opt_index(CMD, p, "index")?;
for id in req_targets(CMD, s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
match at {
Some(i) => {
if let Some(slot) = n.effects.get_mut(i) {
*slot = e.clone();
}
}
None => n.effects.push(e.clone()),
}
}
ok()
}
+8
View File
@@ -759,6 +759,12 @@ pub struct Clipboard {
pub images: std::collections::BTreeMap<String, figcraft_doc::ImageData>,
/// Copied properties (Copy properties / Paste properties).
pub props: Option<Value>,
/// A copied fill or stroke row (`fill.copy`, `stroke.copy`): whether it's a fill, and the paint.
pub paint: Option<(bool, figcraft_color::Paint)>,
/// The stroke settings (`weight`, alignment, sides…) copied with a stroke's paint.
pub stroke: Option<figcraft_doc::StrokeProps>,
/// A copied effect row (`effect.copy`).
pub effect: Option<figcraft_doc::Effect>,
/// The page-space bounds of the copied selection.
pub bounds: Option<figcraft_geom::Rect>,
pub source_doc: u64,
@@ -1787,6 +1793,8 @@ pub(crate) mod plugins_tests;
#[cfg(test)]
mod progressive_blur_tests;
#[cfg(test)]
mod property_copy_tests;
#[cfg(test)]
mod proto_behaviour_tests;
#[cfg(test)]
mod proto_conditional_tests;
+90
View File
@@ -0,0 +1,90 @@
//! Copying one fill, stroke or effect row (`fill.copy`, `stroke.copy`, `effect.copy`) and pasting
//! it onto other layers (`fill.paste`, `stroke.paste`, `effect.paste`): the Design panel's row
//! menus.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Effect, EffectKind, NodeId};
use serde_json::{Value, json};
use crate::Session;
fn id(v: Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn rect(s: &mut Session) -> NodeId {
id(ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})))
}
fn node(s: &Session, id: NodeId) -> figcraft_doc::Node {
s.doc().unwrap().node(id).unwrap().clone()
}
#[test]
fn a_copied_fill_replaces_a_fill_row_or_adds_one() {
let mut s = Session::new();
let a = rect(&mut s);
let b = rect(&mut s);
let red = Paint::solid(Color::from_hex("#FF0000").unwrap());
ex(&mut s, "fill.add", json!({"ids": [a], "paint": red}));
let copied = ex(&mut s, "fill.copy", json!({"id": a, "index": 1}));
assert_eq!(copied["type"], "solid", "the copy reports the paint it took");
// Onto b's fill row: it replaces that paint.
ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
assert_eq!(node(&s, b).fills, vec![red.clone()]);
// With no row: it adds one on top.
ex(&mut s, "fill.paste", json!({"ids": [b]}));
assert_eq!(node(&s, b).fills, [red.clone(), red]);
// Each paste is one undo step.
ex(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, b).fills.len(), 1);
}
#[test]
fn a_copied_stroke_pastes_as_a_stroke_only() {
let mut s = Session::new();
let a = rect(&mut s);
let b = rect(&mut s);
let blue = Paint::solid(Color::from_hex("#0000FF").unwrap());
ex(&mut s, "stroke.add", json!({"ids": [a], "paint": blue}));
ex(&mut s, "node.set", json!({"ids": [a], "props": {"strokeWeight": 3.0, "strokeAlign": "outside"}}));
ex(&mut s, "stroke.copy", json!({"id": a, "index": 0}));
ex(&mut s, "stroke.add", json!({"ids": [b]}));
ex(&mut s, "stroke.paste", json!({"ids": [b], "index": 0}));
assert_eq!(node(&s, b).strokes, [blue]);
assert_eq!(node(&s, b).stroke, node(&s, a).stroke, "the weight and alignment come with the paint");
assert_eq!((node(&s, b).stroke.weight, node(&s, b).stroke.align), (3.0, figcraft_doc::StrokeAlign::Outside));
// A copied fill doesn't paste as a stroke, and the target keeps its stroke.
ex(&mut s, "fill.copy", json!({"id": a}));
assert!(s.execute("stroke.paste", &json!({"ids": [b]})).is_err());
assert_eq!(node(&s, b).strokes.len(), 1);
// A layer with no stroke has nothing to copy.
assert!(s.execute("stroke.copy", &json!({"id": b, "index": 1})).is_err());
}
#[test]
fn a_copied_effect_replaces_an_effect_row_or_adds_one() {
let mut s = Session::new();
let a = rect(&mut s);
let b = rect(&mut s);
ex(&mut s, "effect.add", json!({"ids": [a], "effect": {"type": "dropShadow", "radius": 7, "offset": [2, 3]}}));
ex(&mut s, "effect.copy", json!({"id": a, "index": 0}));
let e = node(&s, a).effects[0].clone();
// Onto b's effect row: it replaces that effect.
ex(&mut s, "effect.add", json!({"ids": [b], "type": "layerBlur"}));
ex(&mut s, "effect.paste", json!({"ids": [b], "index": 0}));
assert_eq!(node(&s, b).effects, vec![e.clone()]);
// With no row: it adds one on top.
ex(&mut s, "effect.paste", json!({"ids": [b]}));
assert_eq!(node(&s, b).effects, [e.clone(), e]);
ex(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, b).effects.len(), 1);
let Effect { kind: EffectKind::DropShadow(shadow), visible } = node(&s, b).effects[0].clone() else {
panic!("the pasted effect is a drop shadow")
};
assert_eq!((shadow.radius, shadow.offset, visible), (7.0, (2.0, 3.0), true));
}
+6
View File
@@ -313,7 +313,10 @@ name = "English"
"Copy as PNG" = "Copy as PNG"
"Copy as SVG" = "Copy as SVG"
"Copy code" = "Copy code"
"Copy effect" = "Copy effect"
"Copy fill" = "Copy fill"
"Copy link" = "Copy link"
"Copy stroke" = "Copy stroke"
"Copy the {label}" = "Copy the {label}"
"Copy {value}" = "Copy {value}"
"Corner radius" = "Corner radius"
@@ -971,7 +974,10 @@ name = "English"
"Pass through" = "Pass through"
"Paste a link (https://…)" = "Paste a link (https://…)"
"Paste a team library link (a view link)" = "Paste a team library link (a view link)"
"Paste effect" = "Paste effect"
"Paste fill" = "Paste fill"
"Paste here" = "Paste here"
"Paste stroke" = "Paste stroke"
"Paste to replace" = "Paste to replace"
"Pasted design layers; {n} kinds of content were left out" = { one = "Pasted design layers; {n} kind of content was left out", other = "Pasted design layers; {n} kinds of content were left out" }
"Pattern" = "Pattern"
+12
View File
@@ -315,7 +315,10 @@ status = "draft — needs native review"
"Copy as PNG" = "Copiar como PNG"
"Copy as SVG" = "Copiar como SVG"
"Copy code" = "Copiar código"
"Copy effect" = "Copiar efecto"
"Copy fill" = "Copiar relleno"
"Copy link" = "Copiar enlace"
"Copy stroke" = "Copiar trazo"
"Copy the {label}" = "Copiar {label}"
"Copy {value}" = "Copiar {value}"
"Corner radius" = "Radio de esquina"
@@ -972,7 +975,10 @@ status = "draft — needs native review"
"Pass through" = "Atravesar"
"Paste a link (https://…)" = "Pega un enlace (https://…)"
"Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)"
"Paste effect" = "Pegar efecto"
"Paste fill" = "Pegar relleno"
"Paste here" = "Pegar aquí"
"Paste stroke" = "Pegar trazo"
"Paste to replace" = "Pegar para reemplazar"
"Pasted design layers; {n} kinds of content were left out" = { one = "Capas de diseño pegadas; se omitió {n} tipo de contenido", other = "Capas de diseño pegadas; se omitieron {n} tipos de contenido" }
"Pattern" = "Patrón"
@@ -1894,7 +1900,9 @@ status = "draft — needs native review"
"edit.setDefaultProperties" = "Establecer propiedades predeterminadas"
"edit.undo" = "Deshacer"
"effect.add" = "Añadir efecto"
"effect.copy" = "Copiar efecto"
"effect.move" = "Mover efecto"
"effect.paste" = "Pegar efecto"
"effect.remove" = "Quitar efecto"
"effect.set" = "Establecer efecto"
"export.add" = "Añadir ajuste de exportación"
@@ -1922,7 +1930,9 @@ status = "draft — needs native review"
"file.saveVersion" = "Guardar en el historial de versiones"
"file.versionHistory" = "Mostrar historial de versiones"
"fill.add" = "Añadir relleno"
"fill.copy" = "Copiar relleno"
"fill.move" = "Mover relleno"
"fill.paste" = "Pegar relleno"
"fill.pattern" = "Relleno de patrón"
"fill.remove" = "Quitar relleno"
"fill.set" = "Establecer relleno"
@@ -2112,7 +2122,9 @@ status = "draft — needs native review"
"shapeBuilder.remove" = "Quitar regiones"
"stroke.add" = "Añadir trazo"
"stroke.brush" = "Pincel"
"stroke.copy" = "Copiar trazo"
"stroke.move" = "Mover trazo"
"stroke.paste" = "Pegar trazo"
"stroke.remove" = "Quitar trazo"
"stroke.set" = "Establecer trazo"
"stroke.widthProfile" = "Perfil de ancho"
+12
View File
@@ -300,7 +300,10 @@ status = "draft — needs native review"
"Copy as PNG" = "复制为 PNG"
"Copy as SVG" = "复制为 SVG"
"Copy code" = "复制代码"
"Copy effect" = "复制效果"
"Copy fill" = "复制填充"
"Copy link" = "复制链接"
"Copy stroke" = "复制描边"
"Copy the {label}" = "复制{label}"
"Copy {value}" = "复制 {value}"
"Corner radius" = "圆角半径"
@@ -910,7 +913,10 @@ status = "draft — needs native review"
"Pass through" = "穿透"
"Paste a link (https://…)" = "粘贴链接(https://…)"
"Paste a team library link (a view link)" = "粘贴团队库链接(查看链接)"
"Paste effect" = "粘贴效果"
"Paste fill" = "粘贴填充"
"Paste here" = "粘贴到此处"
"Paste stroke" = "粘贴描边"
"Paste to replace" = "粘贴以替换"
"Pasted design layers; {n} kinds of content were left out" = { other = "已粘贴设计图层;有 {n} 类内容未包含" }
"Pattern" = "图案"
@@ -1777,7 +1783,9 @@ status = "draft — needs native review"
"edit.setDefaultProperties" = "设为默认属性"
"edit.undo" = "撤销"
"effect.add" = "添加效果"
"effect.copy" = "复制效果"
"effect.move" = "移动效果"
"effect.paste" = "粘贴效果"
"effect.remove" = "移除效果"
"effect.set" = "设置效果"
"export.add" = "添加导出设置"
@@ -1804,7 +1812,9 @@ status = "draft — needs native review"
"file.saveVersion" = "保存到版本历史"
"file.versionHistory" = "显示版本历史"
"fill.add" = "添加填充"
"fill.copy" = "复制填充"
"fill.move" = "移动填充"
"fill.paste" = "粘贴填充"
"fill.pattern" = "图案填充"
"fill.remove" = "移除填充"
"fill.set" = "设置填充"
@@ -1989,7 +1999,9 @@ status = "draft — needs native review"
"shapeBuilder.remove" = "移除区域"
"stroke.add" = "添加描边"
"stroke.brush" = "画笔"
"stroke.copy" = "复制描边"
"stroke.move" = "移动描边"
"stroke.paste" = "粘贴描边"
"stroke.remove" = "移除描边"
"stroke.set" = "设置描边"
"stroke.widthProfile" = "宽度配置"
+2
View File
@@ -187,6 +187,8 @@ mod present_tests;
#[cfg(test)]
mod present_view_tests;
#[cfg(test)]
mod property_menu_tests;
#[cfg(test)]
mod proto_editor_tests;
#[cfg(test)]
mod proto_reorder_tests;
+19
View File
@@ -10,6 +10,7 @@ use serde_json::json;
use crate::colorpicker::PickerTarget;
use crate::layers_drag_tests::Ui;
use crate::list_drag::grip_id;
use crate::live_tests::{flat, text_center};
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
@@ -151,6 +152,24 @@ fn adding_a_fill_or_stroke_opens_the_color_picker() {
assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: false, index: 0, ids: vec![r] }));
}
#[test]
fn an_empty_sections_header_adds_its_item_but_a_full_one_only_adds_from_the_plus() {
let mut s = Session::new();
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
// Nothing is stroked yet: clicking anywhere on the Stroke header adds one.
let shapes = flat(&u.frame_output(vec![]).shapes);
click(&mut u, text_center(&shapes, "Stroke"));
assert_eq!(u.app.session.doc().unwrap().node(r).unwrap().strokes.len(), 1);
// It has a stroke now: the header is no longer a button of its own.
u.app.ui.picker = None;
u.frame(vec![]);
let shapes = flat(&u.frame_output(vec![]).shapes);
click(&mut u, text_center(&shapes, "Stroke"));
assert_eq!(u.app.session.doc().unwrap().node(r).unwrap().strokes.len(), 1);
}
#[test]
fn adding_a_fill_to_mixed_fills_opens_no_picker() {
let mut s = Session::new();
+101
View File
@@ -0,0 +1,101 @@
//! The Design panel's property row menus: a secondary click on a fill, stroke or effect row
//! copies that one paint or effect, and on another layer's row pastes it there.
use egui::{Event, Modifiers, PointerButton, Pos2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
use crate::live_tests::{flat, text_center};
fn id(v: &serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session) -> NodeId {
id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap())
}
/// A click with `button` at `p`, and the frames that follow; what the last frame drew.
fn click(u: &mut Ui, p: Pos2, button: PointerButton) -> Vec<egui::Shape> {
for _ in 0..5 {
u.frame(vec![Event::PointerMoved(p)]);
}
let mut shapes = Vec::new();
for pressed in [Some(true), Some(false)].into_iter().chain(std::iter::repeat_n(None, 20)) {
let events = pressed.map(|pressed| Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE });
shapes = flat(&u.frame_output(events.into_iter().collect()).shapes);
}
shapes
}
/// The text a row menu offers, clicked.
fn pick(u: &mut Ui, menu: &[egui::Shape], label: &str) {
click(u, text_center(menu, label), PointerButton::Primary);
}
#[test]
fn a_fill_row_copies_and_pastes_a_fill() {
let mut s = Session::new();
let a = rect(&mut s);
let b = rect(&mut s);
s.execute("fill.set", &json!({"ids": [a], "color": "#FF0000"})).unwrap();
s.execute("fill.set", &json!({"ids": [b], "color": "#0000FF"})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut u = Ui::new(s);
let shapes = flat(&u.frame_output(vec![]).shapes);
let menu = click(&mut u, text_center(&shapes, "FF0000"), PointerButton::Secondary);
pick(&mut u, &menu, crate::i18n::tr("Copy fill"));
let copied = u.app.session.clipboard.paint.clone().expect("the fill is on the clipboard");
assert_eq!(copied.1, u.app.session.doc().unwrap().node(a).unwrap().fills[0]);
// Onto b's fill row: it replaces that fill.
u.app.run("select.set", json!({"ids": [b]}));
let shapes = flat(&u.frame_output(vec![]).shapes);
let menu = click(&mut u, text_center(&shapes, "0000FF"), PointerButton::Secondary);
pick(&mut u, &menu, crate::i18n::tr("Paste fill"));
assert_eq!(u.app.session.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000");
}
#[test]
fn a_stroke_row_copies_and_pastes_a_stroke() {
let mut s = Session::new();
let a = rect(&mut s);
let b = rect(&mut s);
s.execute("stroke.add", &json!({"ids": [a], "paint": {"type": "solid", "color": "#FF0000"}})).unwrap();
s.execute("node.set", &json!({"ids": [a], "props": {"strokeWeight": 3.0}})).unwrap();
s.execute("stroke.add", &json!({"ids": [b]})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut u = Ui::new(s);
let shapes = flat(&u.frame_output(vec![]).shapes);
let menu = click(&mut u, text_center(&shapes, "FF0000"), PointerButton::Secondary);
pick(&mut u, &menu, crate::i18n::tr("Copy stroke"));
u.app.run("select.set", json!({"ids": [b]}));
let shapes = flat(&u.frame_output(vec![]).shapes);
let menu = click(&mut u, text_center(&shapes, "000000"), PointerButton::Secondary);
pick(&mut u, &menu, crate::i18n::tr("Paste stroke"));
let strokes = u.app.session.doc().unwrap().node(b).unwrap().strokes.clone();
assert_eq!(strokes.len(), 1, "it replaced b's stroke");
assert_eq!(strokes[0].solid_color().unwrap().to_hex(), "FF0000");
assert_eq!(u.app.session.doc().unwrap().node(b).unwrap().stroke.weight, 3.0, "the weight came with it");
}
#[test]
fn an_effect_row_copies_and_pastes_an_effect() {
let mut s = Session::new();
let a = rect(&mut s);
let b = rect(&mut s);
s.execute("effect.add", &json!({"ids": [a], "type": "layerBlur"})).unwrap();
s.execute("effect.add", &json!({"ids": [b], "type": "dropShadow"})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut u = Ui::new(s);
let shapes = flat(&u.frame_output(vec![]).shapes);
let menu = click(&mut u, text_center(&shapes, "Layer blur"), PointerButton::Secondary);
pick(&mut u, &menu, crate::i18n::tr("Copy effect"));
u.app.run("select.set", json!({"ids": [b]}));
let shapes = flat(&u.frame_output(vec![]).shapes);
let menu = click(&mut u, text_center(&shapes, "Drop shadow"), PointerButton::Secondary);
pick(&mut u, &menu, crate::i18n::tr("Paste effect"));
let effects = u.app.session.doc().unwrap().node(b).unwrap().effects.clone();
assert_eq!(effects, u.app.session.doc().unwrap().node(a).unwrap().effects);
}
+55 -5
View File
@@ -253,6 +253,15 @@ pub fn context_popup(resp: &egui::Response) -> egui::Popup<'static> {
egui::Popup::context_menu(resp).width(MENU_MIN_W).style(menu_style)
}
/// A context menu for a property row: a secondary click anywhere over `rect` opens it, even over
/// the row's fields (which would otherwise take the click), like the incumbent's row menus.
fn row_context_menu(ui: &Ui, rect: Rect, id: Id) -> egui::Popup<'static> {
let resp = ui.interact(rect, id, Sense::hover());
let open = ui.rect_contains_pointer(rect) && ui.input(|i| i.pointer.secondary_clicked());
let at_pointer = egui::Popup::menu(&resp).width(MENU_MIN_W).style(menu_style).at_pointer_fixed();
at_pointer.open_memory(open.then_some(egui::SetOpenCommand::Bool(true)))
}
pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
menu_row(ui, label, shortcut, 0.0, |_, _, _| {}).clicked()
}
@@ -1098,12 +1107,15 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:
let title = if fills { tr("Fill") } else { tr("Stroke") };
let empty = list.as_ref().is_none_or(|l| l.is_empty());
let slot = if fills { StyleSlot::Fill } else { StyleSlot::Stroke };
if let Some(i) = widgets::section_header(
// The section's right-click menu adds the copied paint (`fill.paste` / `stroke.paste`).
let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") };
let (clicked, header) = widgets::section_header_resp(
ui,
title,
&[(Icon::Styles, tr("Styles")), (Icon::Plus, if fills { tr("Add fill") } else { tr("Add stroke") })],
empty,
) {
);
if let Some(i) = clicked {
if i == 1 {
// The new paint goes on top; open its picker. A mixed selection has no one index to edit.
if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some()
@@ -1115,6 +1127,12 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:
style_picker::open(app, ui, &ids, slot);
}
}
context_popup(&header).show(|ui| {
if app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills) && menu_item(ui, paste, "") {
app.run(if fills { "fill.paste" } else { "stroke.paste" }, json!({"ids": ids}));
ui.close();
}
});
style_picker::chip(app, ui, doc, &ids, slot);
match &list {
None => widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary),
@@ -1536,7 +1554,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
let t = Tokens::get(ui.ctx());
let ids = ids.to_vec();
let cmd = if fills { "fill.set" } else { "stroke.set" };
row_rect(ui, 32.0, |ui| {
let r = row_rect(ui, 32.0, |ui| {
let w = ui.available_width() - 56.0;
let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
@@ -1619,7 +1637,21 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
None => app.ui.picker = None,
}
}
})
});
let copy = if fills { tr("Copy fill") } else { tr("Copy stroke") };
let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") };
let can_paste = app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills);
row_context_menu(ui, r, Id::new(("paint_menu", fills, i))).show(|ui| {
if menu_item(ui, copy, "") {
app.run(if fills { "fill.copy" } else { "stroke.copy" }, json!({"ids": ids, "index": i}));
ui.close();
}
if can_paste && menu_item(ui, paste, "") {
app.run(if fills { "fill.paste" } else { "stroke.paste" }, json!({"ids": ids, "index": i}));
ui.close();
}
});
r
}
/// A paint row's label (also what a drag of it carries): its colour, gradient kind or "Image".
@@ -1637,13 +1669,20 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let ids = sel.to_vec();
let list = common(doc, sel, |n| n.effects.clone());
let empty = list.as_ref().is_none_or(|l| l.is_empty());
if let Some(i) = widgets::section_header(ui, tr("Effects"), &[(Icon::Styles, tr("Styles")), (Icon::Plus, tr("Add effect"))], empty) {
let (clicked, header) = widgets::section_header_resp(ui, tr("Effects"), &[(Icon::Styles, tr("Styles")), (Icon::Plus, tr("Add effect"))], empty);
if let Some(i) = clicked {
if i == 1 {
app.run("effect.add", json!({"ids": ids, "type": "dropShadow"}));
} else {
style_picker::open(app, ui, &ids, StyleSlot::Effect);
}
}
context_popup(&header).show(|ui| {
if app.session.clipboard.effect.is_some() && menu_item(ui, tr("Paste effect"), "") {
app.run("effect.paste", json!({"ids": ids}));
ui.close();
}
});
style_picker::chip(app, ui, doc, &ids, StyleSlot::Effect);
let Some(list) = list else { return };
let mut drag = ListDrag::new(ui, "effects");
@@ -1678,6 +1717,17 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
removed = Some(i);
}
});
let can_paste = app.session.clipboard.effect.is_some();
row_context_menu(ui, header, Id::new(("effect_menu", i))).show(|ui| {
if menu_item(ui, tr("Copy effect"), "") {
app.run("effect.copy", json!({"ids": ids, "index": i}));
ui.close();
}
if can_paste && menu_item(ui, tr("Paste effect"), "") {
app.run("effect.paste", json!({"ids": ids, "index": i}));
ui.close();
}
});
if open {
effect_settings(app, ui, &ids, i, e);
}
+16 -2
View File
@@ -826,9 +826,19 @@ pub fn disclosure(ui: &mut Ui, label: &str, open: bool) -> bool {
/// A section header ("Fill", "Stroke"…) with optional right-side icon buttons; returns which
/// button was clicked. `dim` greys the title (empty sections).
pub fn section_header(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: bool) -> Option<usize> {
section_header_resp(ui, title, buttons, dim).0
}
/// [`section_header`], with the header's own response for a right-click menu.
pub fn section_header_resp(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: bool) -> (Option<usize>, egui::Response) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (rect, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
let (rect, resp) = ui.allocate_exact_size(vec2(w, 40.0), Sense::click());
// Only an empty section's header acts as its `+` button; elsewhere it is a right-click target
// alone, so it keeps the normal cursor.
if resp.hovered() && !dim {
ui.ctx().set_cursor_icon(egui::CursorIcon::Default);
}
ui.painter().text(
pos2(rect.min.x + PAD, rect.center().y),
Align2::LEFT_CENTER,
@@ -850,7 +860,11 @@ pub fn section_header(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: b
}
x -= 24.0;
}
out
// An empty section's whole header adds its item, not only its `+` button.
if out.is_none() && dim && resp.clicked() {
out = buttons.iter().position(|(icon, _)| *icon == Icon::Plus);
}
(out, resp)
}
/// An icon button at `rect` (hover highlight, tooltip); true when clicked.
+2 -2
View File
@@ -1,8 +1,8 @@
# Parity checklist
> **Generated:** 2026-10-17 by `cargo xtask parity`. Do not edit by hand.
> **Generated:** 2026-10-21 by `cargo xtask parity`. Do not edit by hand.
FigCraft commands registered for the incumbent's main menu and toolbar (561 commands in the engine).
FigCraft commands registered for the incumbent's main menu and toolbar (575 commands in the engine).
**212 / 212 menu items (100.0%)**