From 12e0cdc147fd646e1c4eea550323de0bf8640494 Mon Sep 17 00:00:00 2001 From: Nicek Date: Wed, 21 Oct 2026 22:18:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BA=E5=A1=AB=E5=85=85=E3=80=81?= =?UTF-8?q?=E6=8F=8F=E8=BE=B9=E5=92=8C=E6=95=88=E6=9E=9C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E5=A4=8D=E5=88=B6=E7=B2=98=E8=B4=B4=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现了在属性行和分区标题上右键菜单,支持复制粘贴单个填充、描边和效果。新增了相关的命令、测试和国际化文本,完善了剪贴板数据结构以支持这些新功能。 --- crates/engine/src/cmd/edit.rs | 103 +++++++++++++++++++++- crates/engine/src/lib.rs | 8 ++ crates/engine/src/property_copy_tests.rs | 90 +++++++++++++++++++ crates/ui-egui/locales/en.toml | 6 ++ crates/ui-egui/locales/es.toml | 12 +++ crates/ui-egui/locales/zh-Hans.toml | 12 +++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panel_remove_tests.rs | 19 ++++ crates/ui-egui/src/property_menu_tests.rs | 101 +++++++++++++++++++++ crates/ui-egui/src/right.rs | 60 +++++++++++-- crates/ui-egui/src/widgets.rs | 18 +++- docs/parity-checklist.md | 4 +- 12 files changed, 425 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/property_copy_tests.rs create mode 100644 crates/ui-egui/src/property_menu_tests.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..0791f7e3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -229,6 +229,22 @@ pub fn specs() -> Vec { 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 { } 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 { 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 { 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 { + 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 { + 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 { 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 { } 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 { + 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 { + 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() +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..3cb7de22 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -759,6 +759,12 @@ pub struct Clipboard { pub images: std::collections::BTreeMap, /// Copied properties (Copy properties / Paste properties). pub props: Option, + /// 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, + /// A copied effect row (`effect.copy`). + pub effect: Option, /// The page-space bounds of the copied selection. pub bounds: Option, 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; diff --git a/crates/engine/src/property_copy_tests.rs b/crates/engine/src/property_copy_tests.rs new file mode 100644 index 00000000..f6cc70d7 --- /dev/null +++ b/crates/engine/src/property_copy_tests.rs @@ -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)); +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..aa9f97b3 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -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" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..f47ab965 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -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" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 94c91942..befa9685 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -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" = "宽度配置" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..6dac06a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index 3805dca3..bb74ee72 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -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(); diff --git a/crates/ui-egui/src/property_menu_tests.rs b/crates/ui-egui/src/property_menu_tests.rs new file mode 100644 index 00000000..177c7720 --- /dev/null +++ b/crates/ui-egui/src/property_menu_tests.rs @@ -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 { + 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); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index afa884a8..b2babc56 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -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); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 845dca9d..db83cbe9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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 { + 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, 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. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 775a4fcb..13fda27c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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%)**