From bb495bcf7008675e211d8e150fc6e6a45ddc3fd8 Mon Sep 17 00:00:00 2001 From: Nicek Date: Wed, 28 Oct 2026 13:36:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B1=9E=E6=80=A7=E4=B8=8E=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E5=A4=8D=E5=88=B6=E7=B2=98=E8=B4=B4=EF=BC=8C=E5=90=AB?= =?UTF-8?q?=E5=9B=BE=E7=89=87/=E5=9B=BE=E6=A1=88=E8=B7=A8=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=84=E7=90=86=E4=B8=8E=E5=A4=9A=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/cmd/edit/clipboard.rs | 17 ++- crates/engine/src/cmd/edit/props.rs | 23 +++- crates/engine/src/prefs.rs | 4 + crates/engine/src/property_copy_tests.rs | 30 +++++ crates/ui-egui/locales/bn.toml | 1 + crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ka.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + .../ui-egui/src/canvas/selection_spacing.rs | 11 ++ crates/ui-egui/src/panels/right/paints.rs | 5 +- crates/ui-egui/src/selection_spacing_tests.rs | 20 ++- crates/ui-egui/src/shortcuts.rs | 7 +- crates/ui-egui/src/style_props_tests.rs | 119 ++++++++++++++++++ crates/ui-egui/src/widgets/colorpicker.rs | 83 ++++++------ 28 files changed, 295 insertions(+), 42 deletions(-) create mode 100644 crates/ui-egui/src/style_props_tests.rs diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 0df97532..9690a1cd 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -112,6 +112,8 @@ pub(super) fn paste_design_layers(s: &mut Session, p: &Value) -> Result { let props = s.clipboard.props.take(); let paint = s.clipboard.paint.take(); let stroke = s.clipboard.stroke.take(); + let paint_images = std::mem::take(&mut s.clipboard.paint_images); + let paint_source_doc = s.clipboard.paint_source_doc; let effect = s.clipboard.effect.take(); // No open file has id 0. So paste treats this copy as a copy from a different file. let layers_generation = s.clipboard.layers_generation.wrapping_add(1); @@ -121,6 +123,8 @@ pub(super) fn paste_design_layers(s: &mut Session, p: &Value) -> Result { props, paint, stroke, + paint_images, + paint_source_doc, effect, bounds, source_doc: 0, @@ -294,9 +298,18 @@ pub(super) fn copy_props_of(doc: &Document, id: NodeId) -> Value { let Some(n) = doc.node(id) else { return Value::Null }; let mut v = json!({ "fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align, - "effects": n.effects, "opacity": n.opacity, "blend": n.blend, "cornerRadius": n.corners.radii, - "cornerSmoothing": n.corners.smoothing, + "strokeSides": n.stroke.sides, "startCap": n.stroke.cap_start, "endCap": n.stroke.cap_end, + "strokeJoin": n.stroke.join, "miterLimit": n.stroke.miter_limit, "dashes": n.stroke.dashes, + "widthProfile": super::super::props::width_points_json(n.stroke.width_profile.as_ref()), "brush": n.stroke.brush, + "effects": n.effects, "opacity": n.opacity, "blend": n.blend, }); + // A vector rounds each anchor on its own (`vector.edit.cornerRadius` keeps the radii in the + // network's vertices, its layer corners stay empty): there is no style radius to carry, and + // pasting the empty one would flatten every anchor's radius. + if !matches!(n.kind, NodeKind::Vector { .. }) { + v["cornerRadius"] = json!(n.corners.radii); + v["cornerSmoothing"] = json!(n.corners.smoothing); + } if let NodeKind::Text(t) = &n.kind { v["fontFamily"] = json!(t.style.font_family); v["fontStyle"] = json!(t.style.font_style); diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs index 91855c2e..c1479aab 100644 --- a/crates/engine/src/cmd/edit/props.rs +++ b/crates/engine/src/cmd/edit/props.rs @@ -346,6 +346,16 @@ pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result Result { diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index ced1793f..bfbcb95a 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -164,6 +164,10 @@ pub struct Clipboard { pub paint: Option<(bool, figcraft_color::Paint)>, /// The stroke settings (`weight`, alignment, sides…) copied with a stroke's paint. pub stroke: Option, + /// The image data the copied paint names, carried so a paste into another file has it. + pub paint_images: std::collections::BTreeMap, + /// The file the copied paint came from: a pattern's node id only means anything there. + pub paint_source_doc: u64, /// A copied effect row (`effect.copy`). pub effect: Option, /// The page-space bounds of the copied selection. diff --git a/crates/engine/src/property_copy_tests.rs b/crates/engine/src/property_copy_tests.rs index df639034..40f2c4b2 100644 --- a/crates/engine/src/property_copy_tests.rs +++ b/crates/engine/src/property_copy_tests.rs @@ -103,6 +103,36 @@ fn pasting_design_layers_keeps_a_copied_row() { ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0})); } +#[test] +fn a_copied_image_fill_brings_its_image_to_another_file() { + let mut s = Session::new(); + let a = rect(&mut s); + ex(&mut s, "fill.add", json!({"ids": [a], "paint": {"type": "image", "image": "img-key"}})); + let data = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![1, 2, 3]), width: 2, height: 2 }; + s.doc_mut().unwrap().images.insert("img-key".into(), data.clone()); + ex(&mut s, "fill.copy", json!({"id": a, "index": 1})); + // Another file has no image under that key: the copy brings the data with it. + ex(&mut s, "file.new", json!({})); + let b = rect(&mut s); + assert!(s.doc().unwrap().images.get("img-key").is_none()); + ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0})); + assert_eq!(s.doc().unwrap().images.get("img-key"), Some(&data)); + assert!(matches!(node(&s, b).fills[0].kind, figcraft_color::PaintKind::Image(_))); +} + +#[test] +fn a_copied_pattern_does_not_paste_into_another_file() { + let mut s = Session::new(); + let source = rect(&mut s); + let patterned = rect(&mut s); + ex(&mut s, "fill.pattern", json!({"ids": [patterned], "source": source})); + ex(&mut s, "fill.copy", json!({"id": patterned})); + // Its node id means nothing in another file, where it may name an unrelated layer. + ex(&mut s, "file.new", json!({})); + let b = rect(&mut s); + assert!(s.execute("fill.paste", &json!({"ids": [b], "index": 0})).is_err(), "a pattern stays in its file"); +} + #[test] fn a_copied_pattern_cannot_paste_into_a_layer_it_draws() { let mut s = Session::new(); diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index bfdcd9dd..f9bdc180 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -1063,6 +1063,7 @@ status = "draft — needs native review" "Previous ready for dev" = "ডেভের জন্য পূর্ববর্তী প্রস্তুত" "Progressive" = "প্রোগ্রেসিভ" "Properties" = "প্রপার্টিজ" +"Properties copied" = "প্রপার্টিজ কপি করা হয়েছে" "Prototype" = "প্রোটোটাইপ" "Prototype preview" = "প্রোটোটাইপ প্রিভিউ" "Prototype settings" = "প্রোটোটাইপ সেটিংস" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index c9fdc70f..f8f86df9 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1042,6 +1042,7 @@ status = "draft — needs native review" "Previous ready for dev" = "Předchozí připravené pro vývoj" "Progressive" = "Postupné" "Properties" = "Vlastnosti" +"Properties copied" = "Vlastnosti zkopírovány" "Prototype" = "Prototyp" "Prototype preview" = "Náhled prototypu" "Prototype settings" = "Nastavení prototypu" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index aca9973e..127900c9 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1011,6 +1011,7 @@ status = "draft — needs native review" "Previous ready for dev" = "Vorheriges bereit für Entwicklung" "Progressive" = "Progressiv" "Properties" = "Eigenschaften" +"Properties copied" = "Eigenschaften kopiert" "Prototype" = "Prototyp" "Prototype preview" = "Prototyp-Vorschau" "Prototype settings" = "Prototyp-Einstellungen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index b0afcdf7..7ff560e0 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1095,6 +1095,7 @@ name = "English" "Previous ready for dev" = "Previous ready for dev" "Progressive" = "Progressive" "Properties" = "Properties" +"Properties copied" = "Properties copied" "Prototype" = "Prototype" "Prototype preview" = "Prototype preview" "Prototype settings" = "Prototype settings" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index b8f21763..ddbdfd8d 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1092,6 +1092,7 @@ status = "draft — needs native review" "Previous ready for dev" = "Anterior listo para desarrollo" "Progressive" = "Progresivo" "Properties" = "Propiedades" +"Properties copied" = "Propiedades copiadas" "Prototype" = "Prototipo" "Prototype preview" = "Vista previa del prototipo" "Prototype settings" = "Ajustes del prototipo" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 0e6975cc..f1a3bbc0 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1067,6 +1067,7 @@ status = "draft — needs native review" "Previous ready for dev" = "Edellinen kehitysvalmis" "Progressive" = "Asteittainen" "Properties" = "Ominaisuudet" +"Properties copied" = "Ominaisuudet kopioitu" "Prototype" = "Prototyyppi" "Prototype preview" = "Prototyypin esikatselu" "Prototype settings" = "Prototyypin asetukset" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 64a4b208..7c80a3e3 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1066,6 +1066,7 @@ status = "draft — needs native review" "Previous ready for dev" = "Prêt pour le dev précédent" "Progressive" = "Progressif" "Properties" = "Propriétés" +"Properties copied" = "Propriétés copiées" "Prototype" = "Prototype" "Prototype preview" = "Aperçu du prototype" "Prototype settings" = "Réglages du prototype" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index b7d15479..b1755027 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1049,6 +1049,7 @@ status = "draft — needs native review" "Previous ready for dev" = "Versi siap dev sebelumnya" "Progressive" = "Progresif" "Properties" = "Properti" +"Properties copied" = "Properti disalin" "Prototype" = "Prototype" "Prototype preview" = "Pratinjau prototype" "Prototype settings" = "Pengaturan prototype" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 29f16e11..ec117721 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1054,6 +1054,7 @@ status = "draft — needs native review" "Previous ready for dev" = "前の開発準備完了" "Progressive" = "プログレッシブ" "Properties" = "プロパティ" +"Properties copied" = "プロパティをコピーしました" "Prototype" = "プロトタイプ" "Prototype preview" = "プロトタイプのプレビュー" "Prototype settings" = "プロトタイプの設定" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index bf4d51c4..0b685d75 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -1065,6 +1065,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Previous ready for dev" = "წინა მზად დეველოპერისთვის" "Progressive" = "პროგრესული" "Properties" = "თვისებები" +"Properties copied" = "თვისებები დაკოპირდა" "Prototype" = "პროტოტიპი" "Prototype preview" = "პროტოტიპის წინასწარი ნახვა" "Prototype settings" = "პროტოტიპის პარამეტრები" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index b55ff444..397d9507 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1005,6 +1005,7 @@ status = "draft — needs native review" "Previous ready for dev" = "이전 개발 준비 완료" "Progressive" = "점진적" "Properties" = "속성" +"Properties copied" = "속성 복사됨" "Prototype" = "프로토타입" "Prototype preview" = "프로토타입 미리 보기" "Prototype settings" = "프로토타입 설정" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 4bcc9705..d9905f2c 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -1067,6 +1067,7 @@ status = "draft — needs native review" "Previous ready for dev" = "Vorige klaar voor developers" "Progressive" = "Progressive" "Properties" = "Eigenschappen" +"Properties copied" = "Eigenschappen gekopieerd" "Prototype" = "Prototype" "Prototype preview" = "Prototypevoorvertoning" "Prototype settings" = "Prototype-instellingen" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index ac7ccfe2..c935fca1 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1069,6 +1069,7 @@ name = "Polski" "Previous ready for dev" = "Poprzedni gotowy do wdrożenia" "Progressive" = "Progresywny" "Properties" = "Właściwości" +"Properties copied" = "Właściwości skopiowane" "Prototype" = "Prototyp" "Prototype preview" = "Podgląd prototypu" "Prototype settings" = "Ustawienia prototypu" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 17ee60d9..16d6eab7 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1004,6 +1004,7 @@ status = "draft — needs native review" "Previous ready for dev" = "Anterior pronto para dev" "Progressive" = "Progressivo" "Properties" = "Propriedades" +"Properties copied" = "Propriedades copiadas" "Prototype" = "Protótipo" "Prototype preview" = "Visualização do protótipo" "Prototype settings" = "Configurações do protótipo" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 2d7a9cf0..d25f7d14 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1069,6 +1069,7 @@ name = "Русский" "Previous ready for dev" = "Предыдущий готовый к разработке" "Progressive" = "Прогрессивный" "Properties" = "Свойства" +"Properties copied" = "Свойства скопированы" "Prototype" = "Прототип" "Prototype preview" = "Предпросмотр прототипа" "Prototype settings" = "Настройки прототипа" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index aeac2327..d9a50a55 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1072,6 +1072,7 @@ name = "Svenska" "Previous ready for dev" = "Föregående redo för utveckling" "Progressive" = "Progressiv" "Properties" = "Egenskaper" +"Properties copied" = "Egenskaper kopierade" "Prototype" = "Prototyp" "Prototype preview" = "Förhandsgranskning av prototyp" "Prototype settings" = "Prototypinställningar" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 3401b020..a36e21d2 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1046,6 +1046,7 @@ name = "Türkçe" "Previous ready for dev" = "Önceki geliştirmeye hazır" "Progressive" = "Aşamalı" "Properties" = "Özellikler" +"Properties copied" = "Özellikler kopyalandı" "Prototype" = "Prototip" "Prototype preview" = "Prototip önizlemesi" "Prototype settings" = "Prototip ayarları" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a4a9085f..1081afad 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1013,6 +1013,7 @@ status = "draft — needs native review" "Previous ready for dev" = "上一个可供开发" "Progressive" = "渐进" "Properties" = "属性" +"Properties copied" = "属性已复制" "Prototype" = "原型" "Prototype preview" = "原型预览" "Prototype settings" = "原型设置" diff --git a/crates/ui-egui/src/canvas/selection_spacing.rs b/crates/ui-egui/src/canvas/selection_spacing.rs index 414348bb..94439931 100644 --- a/crates/ui-egui/src/canvas/selection_spacing.rs +++ b/crates/ui-egui/src/canvas/selection_spacing.rs @@ -280,8 +280,19 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: Action::Reorder { .. } => CursorIcon::Grabbing, }); if !down { + // A plain press on a reorder ring that didn't become a drag is a click on that layer: + // the selection narrows to it, as a click on the canvas would. With a modifier held + // the ring keeps Figma's meaning (Shift/Alt/Cmd on a selected layer), so it does nothing. + let plain = !ctx.input(|i| i.modifiers.shift || i.modifiers.alt || i.modifiers.command || i.modifiers.ctrl); + let clicked = match &drag.action { + Action::Reorder { source, .. } if plain && !drag.moved => Some(*source), + _ => None, + }; let _ = app.session.commit_interaction(); ctx.data_mut(|d| d.remove::(drag_id())); + if let Some(id) = clicked { + app.session.set_selection(vec![id]); + } } else { ctx.data_mut(|d| d.insert_temp(drag_id(), drag)); } diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index b947e296..cb57d3c7 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -1,4 +1,4 @@ -use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect}; +use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, preview_blend, row, row_context_menu, row_rect}; use super::stroke::stroke_settings; use super::{ Align2, BlendMode, CornerRadius, Document, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, ListDrag, MaskType, Node, NodeId, NodeKind, PAD, Paint, @@ -132,7 +132,8 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N let label = blend.map(|b| tr(b.label())).unwrap_or(tr("Mixed")); let resp = widgets::dropdown(ui, label, col * 2.0 + 8.0, Some(Icon::Blend)); menu_popup(&resp).width(200.0).show(|ui| { - if let Some(b) = blend_menu(ui, true) { + let ctx = ui.ctx().clone(); + if let Some(b) = blend_menu(ui, true, |b| preview_blend(app, &ctx, &ids, b)) { app.set_props(&ids, json!({"blend": b.label()})); } }); diff --git a/crates/ui-egui/src/selection_spacing_tests.rs b/crates/ui-egui/src/selection_spacing_tests.rs index b4b384d0..5de2be94 100644 --- a/crates/ui-egui/src/selection_spacing_tests.rs +++ b/crates/ui-egui/src/selection_spacing_tests.rs @@ -228,6 +228,19 @@ fn reorder_ring_drag_inserts_across_grid_rows_as_one_undo_step() { assert_eq!(xy(&u, ids[0]), (180., 200.)); } +#[test] +fn a_click_on_one_of_several_selected_layers_selects_it() { + let (mut u, ids) = scene(false); + // The reorder ring sits at the layer's centre and takes the press; a click (no drag) on it is + // a click on the layer, which then has the selection to itself. + let p = u.app.to_screen(125., 130.); + u.frame(vec![Event::PointerMoved(p)]); + u.button(p, true); + u.button(p, false); + u.frame(vec![]); + assert_eq!(u.app.session.selection(), vec![ids[0]], "the click selects the layer whose ring it hit"); +} + #[test] fn reorder_ring_escape_click_and_returning_home_make_no_undo_step() { for action in [0, 1, 2] { @@ -252,7 +265,12 @@ fn reorder_ring_escape_click_and_returning_home_make_no_undo_step() { u.frame(vec![]); assert_eq!(u.app.session.doc().unwrap(), &before); assert_eq!(u.app.session.undo_depth().0, history); - assert_eq!(u.app.session.selection(), ids); + if action == 0 { + // The press on a ring that doesn't drag is a click on that layer. + assert_eq!(u.app.session.selection(), vec![ids[0]], "a click on a ring selects its layer"); + } else { + assert_eq!(u.app.session.selection(), ids); + } assert!(u.app.session.ds().unwrap().interaction.is_none()); } } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 94285cb5..e8832802 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -179,7 +179,12 @@ pub(crate) fn handle_input( let now = ctx.input(|i| i.time); for ev in &events { match ev { - Event::Copy => crate::menus::invoke(app, "edit.copy"), + // egui-winit sends Cmd/Ctrl+C as a copy event whatever Alt does, so Copy properties + // (Alt+Cmd/Ctrl+C) arrives here and never as a key. + Event::Copy => { + let props = ctx.input(|i| i.modifiers.alt); + crate::menus::invoke(app, if props { "edit.copyProperties" } else { "edit.copy" }); + } Event::Cut => cut(app), Event::Paste(text) if !swallowed.drop_text => crate::menus::paste(app, Some(text), None), Event::Key { key, physical_key, pressed: true, modifiers, repeat } diff --git a/crates/ui-egui/src/style_props_tests.rs b/crates/ui-egui/src/style_props_tests.rs new file mode 100644 index 00000000..0e238ea5 --- /dev/null +++ b/crates/ui-egui/src/style_props_tests.rs @@ -0,0 +1,119 @@ +//! Copying a layer's style with Cmd+Alt+C / Ctrl+Alt+C and pasting it onto other layers with +//! Cmd+Alt+V / Ctrl+Alt+V: fills, strokes, corners, effects, opacity and blend mode. + +use egui::{Event, Key, Modifiers}; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// The copy and paste keys of the platform the tests run on. +fn chord() -> Modifiers { + if cfg!(target_os = "macos") { + Modifiers { alt: true, command: true, ..Default::default() } + } else { + Modifiers { alt: true, ctrl: true, command: true, ..Default::default() } + } +} + +/// Cmd/Ctrl+Alt+V as the desktop shell delivers it: the press is swallowed, the release arrives. +fn release_v(u: &mut Ui) { + let m = chord(); + u.frame_with(vec![Event::Key { key: Key::V, physical_key: Some(Key::V), pressed: false, repeat: false, modifiers: m }], m); +} + +/// Two rectangles, the first with a full style and selected. +fn scene() -> (Ui, NodeId, NodeId) { + let mut s = Session::new(); + let styled = id(s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20})).unwrap()); + let plain = id(s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 0, "width": 40, "height": 20})).unwrap()); + s.execute( + "node.set", + &json!({"id": styled, "props": { + "fills": [{"type": "solid", "color": "#ff0000"}], + "strokes": [{"type": "solid", "color": "#0000ff"}], + "strokeWeight": 3, + "strokeAlign": "outside", + "strokeSides": [1, 2, 3, 4], + "startCap": "round", + "endCap": "square", + "strokeJoin": "bevel", + "miterLimit": 7, + "dashes": [4, 2], + "widthProfile": [[0, 0.5], [1, 1]], + "brush": "taper", + "cornerRadius": 6, + "cornerSmoothing": 0.6, + "effects": [{"type": "dropShadow", "offset": [2, 4], "radius": 5, "spread": 1}], + "opacity": 0.5, + "blend": "multiply", + }}), + ) + .unwrap(); + s.execute("select.set", &json!({"ids": [styled]})).unwrap(); + (Ui::new(s), styled, plain) +} + +#[test] +fn the_copy_chord_arrives_as_a_copy_event_and_takes_properties_not_layers() { + let (mut u, _styled, _plain) = scene(); + // egui-winit sends Cmd/Ctrl+C as a copy event whatever Alt does: the key never reaches the + // keymap, and Alt is what tells Copy properties from Copy. + u.frame_with(vec![Event::Copy], chord()); + assert!(u.app.session.clipboard.props.is_some(), "Copy properties ran from the copy event"); + assert!(u.app.session.clipboard.nodes.is_empty(), "the layers were not copied"); + assert!(u.app.ui.toast.is_some(), "the copy shows a confirmation"); +} + +#[test] +fn the_style_keys_copy_and_paste_every_style_property() { + let (mut u, styled, plain) = scene(); + u.frame_with(vec![Event::Copy], chord()); + u.app.session.execute("select.set", &json!({"ids": [plain]})).unwrap(); + release_v(&mut u); + let doc = u.app.session.doc().unwrap(); + let (a, b) = (doc.node(styled).unwrap(), doc.node(plain).unwrap()); + assert_eq!(b.fills, a.fills, "fills"); + assert_eq!(b.stroke, a.stroke, "strokes: paints, weight, align, sides, caps, join, dashes, width profile, brush"); + assert_eq!(b.corners, a.corners, "corner radii and smoothing"); + assert_eq!(b.effects, a.effects, "effects"); + assert_eq!(b.opacity, a.opacity, "layer opacity"); + assert_eq!(b.blend, a.blend, "blend mode"); +} + +#[test] +fn a_vectors_anchor_radii_are_not_part_of_the_copied_style() { + // Radii set on anchors in edit mode live in the network's vertices, not in the layer's corners: + // copying such a vector's style and pasting it back must leave every anchor's radius as it was. + let mut s = Session::new(); + let v = id(s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 40})).unwrap()); + s.execute("vector.edit.begin", &json!({"id": v})).unwrap(); + s.execute("vector.edit.cornerRadius", &json!({"radius": 6, "vertices": [0, 1]})).unwrap(); + s.execute("vector.edit.end", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [v]})).unwrap(); + let radii = |s: &Session| match &s.doc().unwrap().node(v).unwrap().kind { + NodeKind::Vector { network } => network.vertices.iter().map(|p| p.radius).collect::>(), + _ => panic!("the layer was converted to a vector"), + }; + let before = radii(&s); + assert_eq!(&before[..2], &[6.0, 6.0], "two anchors are rounded"); + let mut u = Ui::new(s); + u.frame_with(vec![Event::Copy], chord()); + release_v(&mut u); + assert_eq!(radii(&u.app.session), before, "the paste left every anchor's radius alone"); +} + +#[test] +fn pasting_a_style_leaves_the_geometry_alone() { + let (mut u, _styled, plain) = scene(); + u.frame_with(vec![Event::Copy], chord()); + u.app.session.execute("select.set", &json!({"ids": [plain]})).unwrap(); + release_v(&mut u); + let b = u.app.session.doc().unwrap().world_bounds(plain).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (100.0, 0.0, 40.0, 20.0), "the layer keeps its place and size"); +} diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index 0a3740e1..2f236a93 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -3,7 +3,7 @@ use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_color::{ - Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint, + BlendMode, Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint, PatternTile, }; use figcraft_doc::{NodeId, NodeKind}; @@ -118,6 +118,40 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option { } } +/// The command and its params that write `paint` to `target`, when the target can take it. +fn paint_command(app: &FigApp, target: &PickerTarget, paint: &Paint) -> Option<(&'static str, serde_json::Value)> { + Some(match target { + PickerTarget::PluginField { .. } => return None, + PickerTarget::SelectionColor { targets, .. } => ("selection.setPaint", json!({"targets": targets, "paint": paint})), + PickerTarget::PageBackground => { + let c = paint.solid_color().unwrap_or(Color::DEFAULT_PAGE); + ("page.setBackground", json!({"color": c.to_css_hex()})) + } + PickerTarget::Paint { fills, index, ids } => { + (if *fills { "fill.set" } else { "stroke.set" }, json!({"ids": ids, "index": index, "paint": paint})) + } + PickerTarget::Effect { ids, index, secondary } => { + let mut e = ids.first().and_then(|i| app.session.doc().ok()?.node(*i)?.effects.get(*index).cloned())?; + if let Some(c) = e.color_mut(*secondary) { + *c = paint.solid_color().unwrap_or(Color::BLACK); + } + ("effect.set", json!({"ids": ids, "index": index, "effect": e})) + } + PickerTarget::TextRange { id, start, end } => ("text.style", json!({"id": id, "start": start, "end": end, "textFills": [paint]})), + PickerTarget::Variable { id, mode } => { + let c = paint.solid_color().unwrap_or(Color::BLACK); + ("variable.set", json!({"variable": id, "mode": mode, "value": c.to_css_hex()})) + } + PickerTarget::Style { id, index } => { + let mut paints = app.session.doc().ok().and_then(|d| style_paints(d, id))?; + if let Some(slot) = paints.get_mut(*index) { + *slot = paint.clone(); + } + ("style.update", json!({"style": id, "value": paints})) + } + }) +} + /// Write `paint` to the target. `live` edits are part of an ongoing drag. fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { if let PickerTarget::PluginField { plugin, control, .. } = target { @@ -132,39 +166,8 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { if live { let _ = app.session.begin_interaction("Color"); } - let r = match target { - PickerTarget::SelectionColor { targets, .. } => app.session.execute("selection.setPaint", &json!({"targets": targets, "paint": paint})), - PickerTarget::PageBackground => { - let c = paint.solid_color().unwrap_or(Color::DEFAULT_PAGE); - app.session.execute("page.setBackground", &json!({"color": c.to_css_hex()})) - } - PickerTarget::Paint { fills, index, ids } => { - app.session.execute(if *fills { "fill.set" } else { "stroke.set" }, &json!({"ids": ids, "index": index, "paint": paint})) - } - PickerTarget::Effect { ids, index, secondary } => { - let Some(mut e) = ids.first().and_then(|i| app.session.doc().ok()?.node(*i)?.effects.get(*index).cloned()) else { return }; - if let Some(c) = e.color_mut(*secondary) { - *c = paint.solid_color().unwrap_or(Color::BLACK); - } - app.session.execute("effect.set", &json!({"ids": ids, "index": index, "effect": e})) - } - PickerTarget::TextRange { id, start, end } => { - app.session.execute("text.style", &json!({"id": id, "start": start, "end": end, "textFills": [paint]})) - } - PickerTarget::Variable { id, mode } => { - let c = paint.solid_color().unwrap_or(Color::BLACK); - app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.to_css_hex()})) - } - PickerTarget::PluginField { .. } => return, - PickerTarget::Style { id, index } => { - let Some(mut paints) = app.session.doc().ok().and_then(|d| style_paints(d, id)) else { return }; - if let Some(slot) = paints.get_mut(*index) { - *slot = paint.clone(); - } - app.session.execute("style.update", &json!({"style": id, "value": paints})) - } - }; - if let Err(e) = r { + let Some((cmd, params)) = paint_command(app, target, paint) else { return }; + if let Err(e) = app.session.execute(cmd, ¶ms) { app.toast(e.to_string()); } // Drag previews (`live`) are not choices: a drag records only the colour it ends on. Not "an @@ -174,6 +177,15 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { } } +/// Show the target's paint with `blend` on the canvas while its blend mode row is hovered. +pub(crate) fn preview_blend(app: &mut FigApp, ctx: &egui::Context, target: &PickerTarget, paint: &Paint, blend: BlendMode) { + let mut p = paint.clone(); + p.blend = blend; + if let Some((cmd, params)) = paint_command(app, target, &p) { + app.preview_text(ctx, cmd, params); + } +} + fn remember_recent(app: &mut FigApp, c: Color) { // By 8-bit value: a document stores colours rounded to 8 bits, so the colour picked and the one read // back differ by a fraction and would show as two identical swatches. @@ -610,7 +622,8 @@ fn blend_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: &mut P ui.add_space(16.0); let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode")); crate::right::menu_popup(&resp).width(200.0).show(|ui| { - if let Some(b) = crate::right::blend_menu(ui, false) { + let ctx = ui.ctx().clone(); + if let Some(b) = crate::right::blend_menu(ui, false, |b| preview_blend(app, &ctx, target, paint, b)) { paint.blend = b; write(app, target, paint, false); }