From bb495bcf7008675e211d8e150fc6e6a45ddc3fd8 Mon Sep 17 00:00:00 2001 From: Nicek Date: Wed, 28 Oct 2026 13:36:12 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=E5=B1=9E=E6=80=A7=E4=B8=8E?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E5=A4=8D=E5=88=B6=E7=B2=98=E8=B4=B4=EF=BC=8C?= =?UTF-8?q?=E5=90=AB=E5=9B=BE=E7=89=87/=E5=9B=BE=E6=A1=88=E8=B7=A8?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=84=E7=90=86=E4=B8=8E=E5=A4=9A=E8=AF=AD?= =?UTF-8?q?=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); } From 88477723c5950127f6271c4616fd534b6faf938c Mon Sep 17 00:00:00 2001 From: Nicek Date: Wed, 28 Oct 2026 13:36:13 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20ArkUI=20=E7=99=BE=E5=88=86?= =?UTF-8?q?=E6=AF=94=E9=97=B4=E8=B7=9D=E8=BE=93=E5=87=BA=E4=B8=8E=E5=90=8C?= =?UTF-8?q?=E5=B8=A7=E6=8C=89=E4=B8=8B/=E6=9D=BE=E5=BC=80=E5=9D=90?= =?UTF-8?q?=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/dev/codegen.rs | 15 +++++++++++++++ crates/engine/src/dev/codegen/arkui.rs | 5 +++-- crates/ui-egui/src/canvas/input.rs | 20 ++++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/dev/codegen.rs b/crates/engine/src/dev/codegen.rs index 37dae893..964387e1 100644 --- a/crates/engine/src/dev/codegen.rs +++ b/crates/engine/src/dev/codegen.rs @@ -520,6 +520,21 @@ mod tests { assert!(ark.contains(&format!(".width({})", num(width))), "the alignment has a box: {ark}"); } + #[test] + fn arkui_percent_line_height_and_letter_spacing_are_absolute_numbers() { + // ArkUI's `letterSpacing` falls back to the default for a percent string, and `lineHeight` + // takes no percent at all: both are numbers of vp, a percent being of the font size. + let mut s = Session::new(); + let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap(); + let t = id_of(&s.execute("text.create", &json!({"text": "Hi", "x": 0, "y": 0, "parent": f})).unwrap()["id"]).unwrap(); + s.execute("node.set", &json!({"id": t, "props": {"fontSize": 20.0, "lineHeight": "150%", "letterSpacing": "10%"}})).unwrap(); + let doc = s.doc().unwrap(); + let ark = code_for(doc, f, CodeLang::ArkUi); + assert!(ark.contains(".lineHeight(30)"), "{ark}"); + assert!(ark.contains(".letterSpacing(2)"), "{ark}"); + assert!(!ark.contains('%'), "no percent strings: {ark}"); + } + #[test] fn arkui_places_a_child_that_ignores_its_auto_layout_parent() { let mut s = Session::new(); diff --git a/crates/engine/src/dev/codegen/arkui.rs b/crates/engine/src/dev/codegen/arkui.rs index 6ce54ca9..087cfee5 100644 --- a/crates/engine/src/dev/codegen/arkui.rs +++ b/crates/engine/src/dev/codegen/arkui.rs @@ -55,12 +55,13 @@ fn text_layer(ctx: &Ctx, n: &Node, t: &figcraft_doc::TextData, pad: &str, out: & } match s.line_height { LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))), - LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))), + // ArkUI takes a number of vp, not a percent string: a percent is of the font size. + LineHeight::Percent(p) => mods.push(format!(".lineHeight({})", num(s.font_size * p / 100.0))), LineHeight::Auto => {} } match s.letter_spacing { LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))), - LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))), + LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing({})", num(s.font_size * p / 100.0))), _ => {} } match t.align_h { diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index a6bc55b5..5ce6c99c 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -183,6 +183,21 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo let primary_pressed = ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); let primary_down = ctx.input(|i| i.pointer.primary_down()); let primary_released = ctx.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); + // The press and the release carry their own positions: both in one frame (a quick click or a + // slow frame) would otherwise send the press from the hover position the pointer ended at. + let (press_pos, release_pos) = ctx.input(|i| { + let (mut press, mut release) = (None, None); + for e in &i.events { + if let egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, .. } = e { + if *pressed { + press = Some(*pos); + } else { + release = Some(*pos); + } + } + } + (press, release) + }); let kind = if primary_pressed && resp.hovered() { app.canvas.dragging = true; app.ui.context_menu = None; @@ -221,13 +236,14 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo app.canvas.pressure = force; } if let Some(k) = kind { - send(app, k, pos, &mods); + let at = if matches!(k, PointerKind::Down) { press_pos.unwrap_or(pos) } else { pos }; + send(app, k, at, &mods); // The pointer also let go within this frame (a quick click, or a slow frame): the tools get // the release too, so the press's gesture ends — a quick click with a shape tool still // draws and returns to Move, and a double-click right after drawing finds the Move tool. if matches!(k, PointerKind::Down) && primary_released { app.canvas.dragging = false; - send(app, PointerKind::Up, pos, &mods); + send(app, PointerKind::Up, release_pos.unwrap_or(pos), &mods); } } true From ce93dc6ff5319cd88b144ab2d6692a721c51fad4 Mon Sep 17 00:00:00 2001 From: Nicek Date: Wed, 28 Oct 2026 13:36:13 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=E6=B7=B7=E5=90=88=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E6=82=AC=E5=81=9C=E9=A2=84=E8=A7=88=E3=80=81=E5=A4=9A?= =?UTF-8?q?=E9=80=89=E5=B1=82=E7=82=B9=E5=87=BB=E4=B8=8E=E5=9B=BE=E5=B1=82?= =?UTF-8?q?=E5=B1=9E=E6=80=A7=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/canvas/textpreview.rs | 27 ++++-- crates/engine/src/canvas/tools.rs | 3 + crates/engine/src/canvas/tools/drag.rs | 11 ++- crates/engine/src/canvas/tools/draw.rs | 9 +- crates/engine/src/cmd/text.rs | 4 +- .../engine/src/selection_behaviour_tests.rs | 14 +++ crates/ui-egui/src/blend_preview_tests.rs | 90 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/panels/right.rs | 2 + crates/ui-egui/src/panels/right/menu.rs | 16 +++- 10 files changed, 163 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/blend_preview_tests.rs diff --git a/crates/engine/src/canvas/textpreview.rs b/crates/engine/src/canvas/textpreview.rs index da101d5e..357a2424 100644 --- a/crates/engine/src/canvas/textpreview.rs +++ b/crates/engine/src/canvas/textpreview.rs @@ -1,9 +1,9 @@ -//! Live text previews: what a text command would do, shown on the canvas without an undo step — -//! while the pointer is over a font family or style, a text style in the style browser or a -//! font's named instance, or while a variable font axis slider is dragged. The document under the preview is kept and comes -//! back when the preview ends ([`end`], `text.endPreview`). Every other command ends it first, so -//! committing (running the command for real) applies to the document itself as one undo step, and -//! Escape just ends the preview. +//! Live previews: what a command would do, shown on the canvas without an undo step — while the +//! pointer is over a font family or style, a text style in the style browser, a font's named +//! instance or a blend mode, or while a variable font axis slider is dragged. The document under +//! the preview is kept and comes back when the preview ends ([`end`], `text.endPreview`). Every +//! other command ends it first, so committing (running the command for real) applies to the +//! document itself as one undo step, and Escape just ends the preview. use std::sync::Arc; @@ -14,8 +14,19 @@ use crate::{EngineError, Result, Session, bad, find_command, guard, normalize}; /// The commands that show or end a preview (they don't end the one showing). pub const COMMANDS: [&str; 2] = ["text.preview", "text.endPreview"]; -/// What can be previewed: text styles, font styles and axis values. -pub const PREVIEWABLE: [&str; 4] = ["style.apply", "library.applyStyle", "text.setVariations", "text.style"]; +/// What can be previewed: text styles, font styles and axis values, layers' properties and the +/// blend of a paint on a layer, a selection or a style. +pub const PREVIEWABLE: [&str; 9] = [ + "style.apply", + "library.applyStyle", + "text.setVariations", + "text.style", + "node.set", + "fill.set", + "stroke.set", + "selection.setPaint", + "style.update", +]; /// Show `command params` as a live preview (in place of an earlier one). pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> { diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index b1dc3eff..959dce9c 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -422,6 +422,9 @@ pub(crate) enum Gesture { /// A Shift-press on this selected layer: it leaves the selection on release, unless the /// press became a drag. deselect: Option, + /// A press on one layer of a multi-selection that keeps the selection to drag it all: a + /// release without a drag selects just this layer. + keep: Option, }, Resize { start: Point, diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index d1db8f92..82772290 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -102,13 +102,15 @@ impl Session { }; match target { Some(target) => { - // Clicking inside the current selection's box keeps it (drag them all). + // Clicking inside the current selection's box keeps it (drag them all); a click + // that doesn't become a drag narrows it to the clicked layer. let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift; // Shift on a selected layer takes it out on release if the press doesn't become a // drag; a drag moves the selection with it. Where no drag can start, it goes now. let deselect = (ev.shift && sel.contains(&target)).then_some(target); let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>(); - let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) { + let keep = (in_sel_box && !ev.cmd && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target)))).then_some(target); + let new_sel = if keep.is_some() { sel.clone() } else if ev.shift && deselect.is_none() { let mut s2 = sel.clone(); @@ -132,6 +134,9 @@ impl Session { if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { if deselect.is_some() { self.set_selection(without_target()); + } else if keep.is_some() { + // No drag can start here, so the click narrows the selection now. + self.set_selection(vec![target]); } return Ok(()); } @@ -148,7 +153,7 @@ impl Session { } return Err(e); } - self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect }; + self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect, keep }; self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; } None => { diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs index 36a12bc2..4e0a69b9 100644 --- a/crates/engine/src/canvas/tools/draw.rs +++ b/crates/engine/src/canvas/tools/draw.rs @@ -111,7 +111,7 @@ impl Session { self.tool.overlay.drop_cell = None; let drop_target = self.tool.overlay.drop_target.take(); match g { - Gesture::Move { ids, moved, duplicate, start, deselect, .. } => { + Gesture::Move { ids, moved, duplicate, start, deselect, keep, .. } => { if moved { // A free move lands in the frame under the pointer (or the page) where it was // dropped; layers in an auto layout frame already sit in their slot. @@ -156,6 +156,13 @@ impl Session { if let Some(d) = deselect { let sel = self.selection().into_iter().filter(|x| *x != d).collect(); self.set_selection(sel); + } else if let Some(k) = keep { + // A click (not a drag) on one layer of a multi-selection selects just it. + let doc = self.doc()?.clone(); + let sel = self.selection(); + if sel.len() > 1 && (sel.contains(&k) || sel.iter().any(|s| doc.is_ancestor(*s, k))) { + self.set_selection(vec![k]); + } } if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) { self.widget_click(start, ev.tol() * 2.0)?; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index e8433fa5..5f00fbe7 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -361,8 +361,8 @@ pub fn specs() -> Vec { ), spec!( "text.preview", - "Preview text change", - "{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", + "Preview a change", + "{command: style.apply|library.applyStyle|text.setVariations|text.style|node.set|fill.set|stroke.set|selection.setPaint|style.update, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style, named instance, blend mode, or a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", false, |s, p| { let c = req_string("text.preview", p, "command")?; diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index 02abbfb7..35c43a4c 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -125,6 +125,8 @@ fn clicking_selects_by_the_documented_rules() { ((90.0, 30.0), NONE, vec![k.a], vec![k.b], "with a layer selected, its siblings select at its level"), ((520.0, 20.0), SHIFT, vec![k.c], vec![k.c, k.r], "Shift-click adds to the selection"), ((30.0, 130.0), SHIFT, vec![k.c, k.r], vec![k.r], "Shift-click on a selected layer removes it"), + ((520.0, 20.0), NONE, vec![k.r, k.empty], vec![k.r], "a click on one of several selected layers selects just it"), + ((520.0, 20.0), CMD, vec![k.r, k.empty], vec![k.r, k.empty], "silent: Cmd/Ctrl-click on a selected layer keeps the whole selection (pinned)"), ((520.0, 120.0), NONE, vec![], vec![k.r2], "a locked layer can't be selected on the canvas: the click goes to what is under it"), ((520.0, 220.0), NONE, vec![k.r], vec![], "a hidden layer can't be selected on the canvas (empty canvas deselects)"), ((390.0, 290.0), NONE, vec![k.r], vec![], "the background of a top-level frame with layers is like the canvas: a click deselects"), @@ -300,6 +302,18 @@ fn locked_layers_selected_in_the_layers_panel_stay_put() { assert_eq!(handle_at(s.doc().unwrap(), &[k.a], Point::new(60.0, 60.0), 1.0), None); } +#[test] +fn dragging_one_of_several_selected_layers_moves_them_all() { + let mut s = Session::new(); + let k = scene(&mut s); + let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin(); + ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]})); + drag(&mut s, (520.0, 20.0), (540.0, 20.0), NONE); + assert_eq!(sorted(s.selection()), sorted(vec![k.r, k.empty]), "a drag on one of several selected layers keeps them all selected"); + assert_eq!(at(&s, k.r), Point::new(520.0, 0.0), "the dragged layer moves"); + assert_eq!(at(&s, k.empty), Point::new(620.0, 0.0), "and so do the others"); +} + #[test] fn shift_drag_moves_the_selection() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/blend_preview_tests.rs b/crates/ui-egui/src/blend_preview_tests.rs new file mode 100644 index 00000000..77b1e9a0 --- /dev/null +++ b/crates/ui-egui/src/blend_preview_tests.rs @@ -0,0 +1,90 @@ +//! Hovering a blend mode in the layer's or a paint's menu shows it on the canvas as a live +//! preview: no edit, no undo step, and the layer is back as it was when the pointer leaves. + +use egui::{Event, Pos2, RawInput, Rect, pos2, vec2}; +use figcraft_color::BlendMode; +use figcraft_doc::NodeId; +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()) +} + +fn scene() -> (Ui, NodeId) { + let mut s = Session::new(); + let a = id(s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20})).unwrap()); + s.execute("fill.set", &json!({"ids": [a], "index": 0, "paint": {"type": "solid", "color": "#ff0000"}})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + (Ui::new(s), a) +} + +#[test] +fn every_row_of_the_blend_menu_previews_its_own_mode_under_the_pointer() { + let ctx = egui::Context::default(); + let mut seen: Vec = Vec::new(); + let mut time = 0.0; + // Down the menu, a row at a time: each row under the pointer reports its mode. + for y in (0..1000).step_by(2) { + time += 1.0 / 60.0; + let input = RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 1000.0))), + time: Some(time), + events: vec![Event::PointerMoved(pos2(40.0, y as f32))], + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + let _ = crate::right::blend_menu(ui, true, |b| seen.push(b)); + }); + out.textures_delta.clear(); + } + seen.dedup(); + assert_eq!(seen, BlendMode::ALL.to_vec(), "rows preview their modes top to bottom"); +} + +#[test] +fn a_hovered_layer_blend_shows_on_the_canvas_and_leaves_no_trace() { + let (mut u, a) = scene(); + let ctx = u.ctx.clone(); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Normal); + let steps = u.app.session.undo_labels(100).len(); + crate::right::preview_blend(&mut u.app, &ctx, &[a], BlendMode::Multiply); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Multiply, "the canvas shows the hovered mode"); + assert!(figcraft_engine::textpreview::active(&u.app.session), "as a preview"); + // Nothing asks for it on the next frame: the layer is back as it was. + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Normal, "the preview ended"); + assert!(!figcraft_engine::textpreview::active(&u.app.session)); + assert_eq!(u.app.session.undo_labels(100).len(), steps, "a preview is no undo step"); +} + +#[test] +fn picking_after_a_preview_is_one_step_back_to_the_original() { + let (mut u, a) = scene(); + let steps = u.app.session.undo_labels(100).len(); + crate::right::preview_blend(&mut u.app, &u.ctx.clone(), &[a], BlendMode::Multiply); + // The click lands while the hovered mode shows: the pick applies to the document, not the preview. + u.app.set_props(&[a], json!({"blend": BlendMode::Screen.label()})); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Screen); + assert!(!figcraft_engine::textpreview::active(&u.app.session), "the pick ended the preview"); + assert_eq!(u.app.session.undo_labels(100).len(), steps + 1, "one undo step"); + u.app.session.undo().unwrap(); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Normal, "undo goes back to the original, not the hovered mode"); +} + +#[test] +fn a_hovered_paint_blend_shows_on_its_layer_and_leaves_no_trace() { + let (mut u, a) = scene(); + let target = crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }; + let paint = u.app.session.doc().unwrap().node(a).unwrap().fills[0].clone(); + let steps = u.app.session.undo_labels(100).len(); + crate::colorpicker::preview_blend(&mut u.app, &u.ctx.clone(), &target, &paint, BlendMode::Screen); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().fills[0].blend, BlendMode::Screen, "the fill shows the hovered mode"); + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().fills[0].blend, BlendMode::Normal, "the preview ended"); + assert_eq!(u.app.session.undo_labels(100).len(), steps, "a preview is no undo step"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4c1ed337..0cbf1a23 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -38,6 +38,8 @@ mod a11y_tests; #[cfg(test)] mod async_dialog_tests; #[cfg(test)] +mod blend_preview_tests; +#[cfg(test)] mod branch_ui_tests; #[cfg(test)] mod canvas_double_click_tests; @@ -189,6 +191,8 @@ mod shortcuts_tests; #[cfg(test)] mod style_folder_rename_tests; #[cfg(test)] +mod style_props_tests; +#[cfg(test)] mod text_enter_tests; #[cfg(test)] mod titlebar_tests; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index b0c0e995..80b0c3e0 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -31,6 +31,8 @@ use inspect::inspect; pub(crate) use inspect::{inspect_section, prop_row}; #[cfg(test)] pub(crate) use menu::menu_row_width; +#[cfg(test)] +pub(crate) use menu::preview_blend; pub use menu::{blend_menu, context_popup, menu_check, menu_item, menu_item_beside_checks, menu_item_with, menu_popup, menu_row}; pub(crate) use menu::{block, menu_style, row, row_rect}; use paints::{appearance, paints, variable_modes}; diff --git a/crates/ui-egui/src/panels/right/menu.rs b/crates/ui-egui/src/panels/right/menu.rs index fb51d857..8161d20a 100644 --- a/crates/ui-egui/src/panels/right/menu.rs +++ b/crates/ui-egui/src/panels/right/menu.rs @@ -19,11 +19,16 @@ pub(crate) fn menu_style(s: &mut egui::Style) { /// (else its 600 px default area width) and keeps the size measured; rows ([`menu_row`]) take the /// larger of that width and their content, so the menu fits its content. Chain `.width(w)` for a /// menu at least `w` wide. -/// The blend modes as menu items in groups (pass-through only for layers); the one picked. -pub fn blend_menu(ui: &mut Ui, with_pass_through: bool) -> Option { +/// The blend modes as menu items in groups (pass-through only for layers); the one picked. Each +/// row `preview`s its mode on the canvas while hovered. +pub fn blend_menu(ui: &mut Ui, with_pass_through: bool, mut preview: impl FnMut(BlendMode)) -> Option { let mut picked = None; for b in BlendMode::ALL.into_iter().filter(|b| with_pass_through || *b != BlendMode::PassThrough) { - if menu_item(ui, tr(b.label()), "") { + let resp = menu_row(ui, tr(b.label()), "", 0.0, |_, _, _| {}); + if resp.hovered() { + preview(b); + } + if resp.clicked() { picked = Some(b); ui.close(); } @@ -34,6 +39,11 @@ pub fn blend_menu(ui: &mut Ui, with_pass_through: bool) -> Option { picked } +/// Show `blend` on `ids` on the canvas while its row in a blend mode menu is hovered. +pub(crate) fn preview_blend(app: &mut FigApp, ctx: &egui::Context, ids: &[NodeId], blend: BlendMode) { + app.preview_text(ctx, "node.set", json!({"ids": ids, "props": {"blend": blend.label()}})); +} + pub fn menu_popup(anchor: &egui::Response) -> egui::Popup<'static> { egui::Popup::menu(anchor).width(anchor.rect.width().max(MENU_MIN_W)).style(menu_style) } From 7ea6a424e9284947e8adf704a5653a9f6c9d7d61 Mon Sep 17 00:00:00 2001 From: Nicek Date: Wed, 28 Oct 2026 13:36:13 +0800 Subject: [PATCH 04/10] =?UTF-8?q?chore:=20Windows=20=E6=89=93=E5=8C=85?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E3=80=81=E5=BF=BD=E7=95=A5=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E3=80=81=E8=B4=A1=E7=8C=AE=E8=80=85=E7=BD=B2?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ contributors/people.toml | 3 +++ packaging/windows/package.ps1 | 3 ++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 928d6b49..a2220288 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ plans/ /log/ .DS_Store *.profraw +.cache/ +.vscode/ diff --git a/contributors/people.toml b/contributors/people.toml index ec2847f0..89366cb6 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -89,3 +89,6 @@ sync_github_name = true [people.mateusvillain] sync_github_name = true + +[people.culiulemon] +sync_github_name = true diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index a4ba751b..ecf92345 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -98,7 +98,8 @@ Copy-Item -LiteralPath (Join-Path $Root 'NOTICE') -Destination $Stage $Exes = $Bins | ForEach-Object { "$_.exe" } foreach ($exe in $Exes) { Copy-Item (Join-Path $Bin $exe) $Stage } -& (Join-Path $PSScriptRoot 'sign.ps1') @($Exes | ForEach-Object { Join-Path $Stage $_ }) +$SignFiles = @($Exes | ForEach-Object { Join-Path $Stage $_ }) +& (Join-Path $PSScriptRoot 'sign.ps1') @SignFiles # ---- font notices for the MSI ------------------------------------------------------------------ # The portable ZIP already carries these notices; stage the complete files before MSI creation too. From f8ddb5a0a8e0295e52d04f9fb0bee5f4b9fea5e6 Mon Sep 17 00:00:00 2001 From: Nicek Date: Wed, 28 Oct 2026 13:36:22 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20=E5=A4=8D=E5=88=B6=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E5=90=8E=E6=98=BE=E7=A4=BA=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/menus.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b7785e08..34e6e69c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -188,6 +188,11 @@ pub fn invoke(app: &mut FigApp, id: &str) { "collab.join" => crate::live::open_join(app, ""), // So is the server address for publishing (it carries the server key). "collab.publish" => crate::live::open_publish(app), + "edit.copyProperties" => { + if app.run(id, json!({})).is_some() { + app.toast(tr("Properties copied")); + } + } "edit.copy" | "edit.cut" => { let ids = app.session.selection(); // For other apps: text layers' text, other layers as SVG; taken before a cut removes them. From dd93a6a2a8e692d87c85b0604aee499e1bf3b3eb Mon Sep 17 00:00:00 2001 From: Nicek Date: Wed, 28 Oct 2026 16:36:47 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20PR=20#1457=20?= =?UTF-8?q?=E7=9A=84=E9=80=89=E6=8B=A9=E4=B8=8E=E6=8B=96=E6=8B=BD=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 标签在选择边界外也参与收窄,Dev Mode 单击已选图层只选它 - 区分点击命中图层与描边空白处的拖拽回退目标 - 重排圆环按按下时的修饰键判断点击,画布外松手视为拖拽结束 - 修复 clippy unnecessary_get_then_check --- crates/engine/src/canvas/tools/drag.rs | 18 ++++++++++--- crates/engine/src/devmode_behaviour_tests.rs | 13 ++++++++++ crates/engine/src/frame_label_tests.rs | 15 +++++++++++ crates/engine/src/property_copy_tests.rs | 2 +- .../engine/src/selection_behaviour_tests.rs | 15 +++++++++++ .../ui-egui/src/canvas/selection_spacing.rs | 15 +++++++---- crates/ui-egui/src/selection_spacing_tests.rs | 26 +++++++++++++++++++ 7 files changed, 94 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index 82772290..97dfc7a5 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -94,6 +94,10 @@ impl Session { let behind_sel = |t: Option| { t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s))))) }; + // What the press hit (a label or a painted layer): a click that doesn't become a drag + // selects just it. A press that reaches nothing above the selection uses the selection as + // its drag target instead; that fallback is not a clicked layer. + let clicked = target; let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { Some(first) @@ -109,7 +113,10 @@ impl Session { // drag; a drag moves the selection with it. Where no drag can start, it goes now. let deselect = (ev.shift && sel.contains(&target)).then_some(target); let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>(); - let keep = (in_sel_box && !ev.cmd && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target)))).then_some(target); + // A release without a drag narrows the selection to the layer the press hit. A + // selected frame's name label sits outside the selection bounds, so it counts too. + let keep = clicked + .filter(|c| (label.is_some() || in_sel_box) && !ev.cmd && (sel.contains(c) || sel.iter().any(|s| doc.is_ancestor(*s, *c)))); let new_sel = if keep.is_some() { sel.clone() } else if ev.shift && deselect.is_none() { @@ -122,10 +129,13 @@ impl Session { vec![target] }; self.set_selection(new_sel.clone()); - // Dev Mode only selects (Shift-click still deselects): nothing moves. + // Dev Mode only selects (Shift-click still deselects): nothing moves. No move + // gesture starts, so a click selects the layer it hit now instead of on release. if !edits { if deselect.is_some() { self.set_selection(without_target()); + } else if let Some(k) = keep.filter(|_| sel.len() > 1) { + self.set_selection(vec![k]); } return Ok(()); } @@ -134,9 +144,9 @@ impl Session { if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { if deselect.is_some() { self.set_selection(without_target()); - } else if keep.is_some() { + } else if let Some(k) = keep { // No drag can start here, so the click narrows the selection now. - self.set_selection(vec![target]); + self.set_selection(vec![k]); } return Ok(()); } diff --git a/crates/engine/src/devmode_behaviour_tests.rs b/crates/engine/src/devmode_behaviour_tests.rs index 38d1a9fb..47c33aa2 100644 --- a/crates/engine/src/devmode_behaviour_tests.rs +++ b/crates/engine/src/devmode_behaviour_tests.rs @@ -38,6 +38,19 @@ fn status(s: &mut Session, ids: &[NodeId], st: &str) { ex(s, "node.set", json!({"ids": ids, "props": {"devStatus": st}})); } +#[test] +fn a_dev_mode_click_on_a_selected_layer_selects_just_it() { + let mut s = Session::new(); + let a = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))); + let b = idv(&ex(&mut s, "shape.rectangle", json!({"x": 80, "y": 0, "width": 40, "height": 40}))); + s.tool.dev_mode = true; + ex(&mut s, "select.set", json!({"ids": [a, b]})); + // Dev Mode starts no move gesture, so the click narrows the selection at the press itself. + s.pointer(&ev(PointerKind::Down, 20.0, 20.0)).unwrap(); + s.pointer(&ev(PointerKind::Up, 20.0, 20.0)).unwrap(); + assert_eq!(s.selection(), vec![a], "a click on one of several selected layers selects it"); +} + #[test] fn the_dev_mode_canvas_selects_and_pans_but_never_edits() { let mut s = Session::new(); diff --git a/crates/engine/src/frame_label_tests.rs b/crates/engine/src/frame_label_tests.rs index 3da9522f..9a4143ad 100644 --- a/crates/engine/src/frame_label_tests.rs +++ b/crates/engine/src/frame_label_tests.rs @@ -62,6 +62,21 @@ fn pressing_a_rotated_frames_label_selects_and_drags_the_frame() { assert!(s.selection().is_empty()); } +#[test] +fn a_click_on_a_selected_frames_label_selects_only_it() { + let (mut s, f, q) = setup(); + let other = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 500, "y": 500, "width": 40, "height": 40}))["id"]).unwrap(); + ex(&mut s, "select.set", json!({"ids": [f, other]})); + // A point on the label's left edge, clear of the selection box's resize handles, lies outside + // the selection's bounds (the strip starts left of the frame's left edge there). + let p = q[0] + (q[3] - q[0]) * 0.2 + (q[1] - q[0]) * 0.02; + assert!(s.doc().unwrap().bounds_of(&[f, other]).is_some_and(|b| !b.contains(p)), "the label point is outside the selection bounds"); + assert_eq!(label_at(s.doc().unwrap(), s.page().unwrap(), &s.tool.labels, p), Some(f), "the point is on the label"); + s.pointer(&ev(PointerKind::Down, p)).unwrap(); + s.pointer(&ev(PointerKind::Up, p)).unwrap(); + assert_eq!(s.selection(), vec![f], "the label outside the bounds still narrows the selection to its frame"); +} + #[test] fn labels_of_frames_that_cant_be_picked_do_nothing() { let (mut s, f, q) = setup(); diff --git a/crates/engine/src/property_copy_tests.rs b/crates/engine/src/property_copy_tests.rs index 40f2c4b2..2064fdb7 100644 --- a/crates/engine/src/property_copy_tests.rs +++ b/crates/engine/src/property_copy_tests.rs @@ -114,7 +114,7 @@ fn a_copied_image_fill_brings_its_image_to_another_file() { // 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()); + assert!(!s.doc().unwrap().images.contains_key("img-key")); 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(_))); diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index 35c43a4c..c5852728 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -456,6 +456,21 @@ fn a_layer_stacked_below_the_selection_doesnt_take_a_drag_from_inside_it() { assert_eq!(at(&s, gap), Point::new(home.x + 10.0, home.y + 10.0)); } +#[test] +fn a_click_where_nothing_is_hit_inside_a_multiple_selection_keeps_it() { + let mut s = Session::new(); + let a = rect(&mut s, 100.0, 300.0, None); + let b = rect(&mut s, 220.0, 300.0, None); + for i in [a, b] { + ex(&mut s, "node.set", json!({"ids": [i], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}})); + } + ex(&mut s, "select.set", json!({"ids": [a, b]})); + // The press is inside `a` but its unfilled inside hits nothing: `a` is only the drag fallback, + // not a clicked layer, so a click keeps the selection. + click(&mut s, 120.0, 320.0, NONE); + assert_eq!(sorted(s.selection()), sorted(vec![a, b])); +} + #[test] fn alt_shift_drag_on_a_selected_layer_duplicates_it() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/canvas/selection_spacing.rs b/crates/ui-egui/src/canvas/selection_spacing.rs index 94439931..8dc9db62 100644 --- a/crates/ui-egui/src/canvas/selection_spacing.rs +++ b/crates/ui-egui/src/canvas/selection_spacing.rs @@ -88,6 +88,9 @@ struct Drag { uid: u64, page: NodeId, moved: bool, + /// The modifiers at the press: a release is a click only if it was a plain press, however the + /// modifiers stand when the button comes up. + plain: bool, } fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec, p: Pos2, action: Action) { @@ -95,10 +98,11 @@ fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec, p: Pos2, Action::Spacing { .. } => tr("Spacing"), Action::Reorder { .. } => tr("Reorder selection"), }; + let plain = ctx.input(|i| !(i.modifiers.shift || i.modifiers.alt || i.modifiers.command || i.modifiers.ctrl)); if app.session.begin_interaction(label).is_ok() && let Ok(ds) = app.session.ds() { - let drag = Drag { ids, action, start: p, uid: ds.uid, page: ds.page, moved: false }; + let drag = Drag { ids, action, start: p, uid: ds.uid, page: ds.page, moved: false, plain }; app.ui.layers_focus = false; app.ui.pages_focus = None; app.ui.key_box = None; @@ -281,11 +285,12 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: }); 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); + // the selection narrows to it, as a click on the canvas would. The press's modifiers + // decide (letting Shift go before the release doesn't make it a plain click), and a + // release away from where it pressed (outside the canvas, say) ends a drag, not a click. + let released = pos.is_some_and(|p| app.canvas.rect.contains(p) && p.distance(drag.start) < 3.0); let clicked = match &drag.action { - Action::Reorder { source, .. } if plain && !drag.moved => Some(*source), + Action::Reorder { source, .. } if drag.plain && !drag.moved && released => Some(*source), _ => None, }; let _ = app.session.commit_interaction(); diff --git a/crates/ui-egui/src/selection_spacing_tests.rs b/crates/ui-egui/src/selection_spacing_tests.rs index 5de2be94..d23f3436 100644 --- a/crates/ui-egui/src/selection_spacing_tests.rs +++ b/crates/ui-egui/src/selection_spacing_tests.rs @@ -241,6 +241,32 @@ fn a_click_on_one_of_several_selected_layers_selects_it() { assert_eq!(u.app.session.selection(), vec![ids[0]], "the click selects the layer whose ring it hit"); } +#[test] +fn the_press_modifiers_decide_whether_a_ring_release_is_a_click() { + let (mut u, ids) = scene(false); + let p = u.app.to_screen(125., 130.); + let shift = Modifiers::SHIFT; + // Shift is down at the press and let go before the release: the ring keeps its Shift meaning. + u.frame_with(vec![Event::PointerMoved(p)], shift); + u.frame_with(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: shift }], shift); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }]); + u.frame(vec![]); + assert_eq!(u.app.session.selection(), ids, "letting Shift go before the release doesn't make it a plain click"); +} + +#[test] +fn releasing_a_ring_outside_the_canvas_ends_a_drag_even_without_motion() { + let (mut u, ids) = scene(false); + let p = u.app.to_screen(125., 130.); + u.frame(vec![Event::PointerMoved(p)]); + u.button(p, true); + // The pointer leaves before any in-canvas motion is seen, so `moved` stays false. + u.frame(vec![Event::PointerGone]); + u.button(Pos2::new(-1., -1.), false); + u.frame(vec![]); + assert_eq!(u.app.session.selection(), ids, "a release away from the press ends a drag, not a click"); +} + #[test] fn reorder_ring_escape_click_and_returning_home_make_no_undo_step() { for action in [0, 1, 2] { From c6ba81b6e63a324d0657a11bbefd6522f10194fe Mon Sep 17 00:00:00 2001 From: Nicek Date: Wed, 28 Oct 2026 18:39:09 +0800 Subject: [PATCH 07/10] =?UTF-8?q?test:=20=E5=90=8C=E5=B8=A7=E6=8C=89?= =?UTF-8?q?=E4=B8=8B/=E6=9D=BE=E5=BC=80=E7=BB=98=E5=88=B6=E4=BB=8E?= =?UTF-8?q?=E6=8C=89=E4=B8=8B=E7=82=B9=E5=BC=80=E5=A7=8B=E7=9A=84=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/canvas_fixes_tests.rs | 25 +++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index dc3f7e94..d3f0d8d7 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; -use egui::{Pos2, Shape}; +use egui::{Event, Modifiers, PointerButton, Pos2, Shape}; use figcraft_engine::Session; use serde_json::json; @@ -25,6 +25,29 @@ fn dashes_along(shapes: &[Shape], a: Pos2, b: Pos2) -> usize { shapes.iter().filter(|s| matches!(s, Shape::LineSegment { points: [p, q], .. } if on(*p) && on(*q))).count() } +#[test] +fn a_press_and_release_in_one_frame_draw_from_the_press() { + // A quick drag (or a slow frame) puts both in one frame, where the hover position is the end: + // the press must still use its own position, or the shape starts where the pointer let go. + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "rectangle"})); + u.frame(vec![]); + let press = u.app.to_screen(100.0, 100.0); + let release = u.app.to_screen(260.0, 220.0); + u.frame(vec![ + Event::PointerMoved(release), + Event::PointerButton { pos: press, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos: release, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + for _ in 0..3 { + u.frame(vec![]); + } + let doc = u.app.session.doc().unwrap(); + let drawn = u.app.session.selection().first().copied().expect("the press drew a shape"); + let b = doc.world_bounds(drawn).expect("the shape has bounds"); + assert!((b.x0 - 100.0).abs() < 1.0 && (b.y0 - 100.0).abs() < 1.0, "drawn from the press, not the release: {b:?}"); +} + #[test] fn frame_outlines_and_slices_draw_when_shown() { let mut s = Session::new(); From 4c0eb71a4c57842e36a7f62f2e36104405fc3efa Mon Sep 17 00:00:00 2001 From: Nicek Date: Wed, 28 Oct 2026 23:01:22 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20=E7=B2=98=E8=B4=B4=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=A1=AB=E5=85=85=E6=97=B6=E9=81=BF=E5=BC=80=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E6=96=87=E4=BB=B6=E5=B7=B2=E5=8D=A0=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目标文件已有同名键但数据不同时,粘贴的 paint 改用复制数据的内容哈希键,避免渲染成目标文件里的图片;目标文件原图片保留给已有图层。 --- crates/engine/src/cmd/edit/props.rs | 15 ++++++++++----- crates/engine/src/property_copy_tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs index c1479aab..845679de 100644 --- a/crates/engine/src/cmd/edit/props.rs +++ b/crates/engine/src/cmd/edit/props.rs @@ -365,7 +365,7 @@ pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result Result { let cmd = if fills { "fill.paste" } else { "stroke.paste" }; - let Some((copied_fills, paint)) = s.clipboard.paint.clone() else { + let Some((copied_fills, mut paint)) = s.clipboard.paint.clone() else { return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into())); }; if copied_fills != fills { @@ -386,10 +386,15 @@ pub(super) fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result Date: Wed, 28 Oct 2026 23:55:05 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=E7=B2=98=E8=B4=B4=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E7=9A=84=E5=86=85=E5=AE=B9=E5=93=88=E5=B8=8C=E9=94=AE?= =?UTF-8?q?=E8=A2=AB=E5=8D=A0=E7=94=A8=E6=97=B6=E6=94=B9=E7=94=A8=E7=A9=BA?= =?UTF-8?q?=E9=97=B2=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/cmd/edit/props.rs | 15 +++++++++++++-- crates/engine/src/property_copy_tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs index 845679de..8724f267 100644 --- a/crates/engine/src/cmd/edit/props.rs +++ b/crates/engine/src/cmd/edit/props.rs @@ -391,8 +391,19 @@ pub(super) fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result 999 { + return Err(bad(cmd, "no free image key for the copied image")); + } + key = format!("{base}-{n}"); + } doc.images.entry(key.clone()).or_insert(d); img.image = key; } diff --git a/crates/engine/src/property_copy_tests.rs b/crates/engine/src/property_copy_tests.rs index 676158f2..0d85901f 100644 --- a/crates/engine/src/property_copy_tests.rs +++ b/crates/engine/src/property_copy_tests.rs @@ -143,6 +143,30 @@ fn a_copied_image_key_taken_in_another_file_gets_the_copied_data() { assert_eq!(doc.images.get("img-key"), Some(&taken), "the destination's image stays"); } +#[test] +fn a_copied_image_whose_hash_key_is_also_taken_gets_a_free_key() { + let mut s = Session::new(); + let a = rect(&mut s); + let bytes = vec![1, 2, 3]; + let copied = figcraft_doc::ImageData { bytes: std::sync::Arc::new(bytes.clone()), width: 2, height: 2 }; + let taken = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![9, 9, 9]), width: 3, height: 3 }; + // Images are usually keyed by their content hash: here the copied key is that hash, and the + // other file already uses it for other data, so the replacement key is taken as well. + let hash = figcraft_format::image_hash(&bytes); + ex(&mut s, "fill.add", json!({"ids": [a], "paint": {"type": "image", "image": hash}})); + s.doc_mut().unwrap().images.insert(hash.clone(), copied.clone()); + ex(&mut s, "fill.copy", json!({"id": a, "index": 1})); + ex(&mut s, "file.new", json!({})); + let b = rect(&mut s); + s.doc_mut().unwrap().images.insert(hash.clone(), taken.clone()); + ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0})); + let doc = s.doc().unwrap(); + let figcraft_color::PaintKind::Image(img) = &doc.node(b).unwrap().fills[0].kind else { panic!("the pasted fill is an image") }; + assert_ne!(img.image, hash, "the taken hash is not reused"); + assert_eq!(doc.images.get(&img.image), Some(&copied), "the pasted paint points at the copied data"); + assert_eq!(doc.images.get(&hash), Some(&taken), "the destination's image stays"); +} + #[test] fn a_copied_pattern_does_not_paste_into_another_file() { let mut s = Session::new(); From 1c253ef30d1a8f17a8a7b8f4152d54ccc498fa98 Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 29 Oct 2026 09:46:43 +0800 Subject: [PATCH 10/10] fix: pasted image keys stay archive-valid when the content hash is taken The blend menu and its hover preview now live beside upstream's split menu module. --- crates/engine/src/cmd/edit/props.rs | 5 +++-- crates/engine/src/property_copy_tests.rs | 16 ++++++++++++++++ crates/ui-egui/src/panels/right/menu.rs | 12 ++++++------ 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs index 8724f267..8156d072 100644 --- a/crates/engine/src/cmd/edit/props.rs +++ b/crates/engine/src/cmd/edit/props.rs @@ -393,7 +393,8 @@ pub(super) fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result Result 999 { return Err(bad(cmd, "no free image key for the copied image")); } - key = format!("{base}-{n}"); + key = format!("{base}{n}"); } doc.images.entry(key.clone()).or_insert(d); img.image = key; diff --git a/crates/engine/src/property_copy_tests.rs b/crates/engine/src/property_copy_tests.rs index 0d85901f..160dfaaf 100644 --- a/crates/engine/src/property_copy_tests.rs +++ b/crates/engine/src/property_copy_tests.rs @@ -165,6 +165,22 @@ fn a_copied_image_whose_hash_key_is_also_taken_gets_a_free_key() { assert_ne!(img.image, hash, "the taken hash is not reused"); assert_eq!(doc.images.get(&img.image), Some(&copied), "the pasted paint points at the copied data"); assert_eq!(doc.images.get(&hash), Some(&taken), "the destination's image stays"); + let key = img.image.clone(); + // An image key names an archive member, and only letters and digits are read back: the + // numbered key must bring the copied image through a save and reopen. + assert!(key.bytes().all(|b| b.is_ascii_alphanumeric()), "the key is archive-valid: {key}"); + let dir = std::env::temp_dir().join(format!("figcraft-image-key-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("pasted.figcraft").to_string_lossy().to_string(); + ex(&mut s, "file.save", json!({"path": path})); + let mut t = Session::empty(); + ex(&mut t, "file.open", json!({"path": path})); + let saved = t.doc().unwrap(); + let figcraft_color::PaintKind::Image(img) = &saved.node(b).unwrap().fills[0].kind else { panic!("the pasted fill is an image") }; + assert_eq!(img.image, key, "the reloaded paint keeps its key"); + // A reopened image's size is read from its pixels on demand, so only its bytes are compared. + assert_eq!(saved.images.get(&key).map(|i| i.bytes.as_slice()), Some(copied.bytes.as_slice()), "the copied image survives the reload"); + let _ = std::fs::remove_dir_all(&dir); } #[test] diff --git a/crates/ui-egui/src/panels/right/menu.rs b/crates/ui-egui/src/panels/right/menu.rs index 8161d20a..db3820c5 100644 --- a/crates/ui-egui/src/panels/right/menu.rs +++ b/crates/ui-egui/src/panels/right/menu.rs @@ -1,4 +1,4 @@ -use super::{BlendMode, Color32, CornerRadius, FIELD_H, Id, PAD, Rect, Sense, Tokens, Ui, a11y, pos2, regular, tr, vec2}; +use super::{BlendMode, Color32, CornerRadius, FIELD_H, FigApp, Id, NodeId, PAD, Rect, Sense, Tokens, Ui, a11y, json, pos2, regular, tr, vec2}; /// The smallest width a popup menu is measured in (see [`menu_popup`]). const MENU_MIN_W: f32 = 48.0; @@ -14,11 +14,6 @@ pub(crate) fn menu_style(s: &mut egui::Style) { s.wrap_mode = Some(egui::TextWrapMode::Extend); } -/// The menu a dropdown or button opens: as wide as its widest row and at least as wide as -/// `anchor`. egui measures a popup in a first, invisible pass as wide as the width it is given -/// (else its 600 px default area width) and keeps the size measured; rows ([`menu_row`]) take the -/// larger of that width and their content, so the menu fits its content. Chain `.width(w)` for a -/// menu at least `w` wide. /// The blend modes as menu items in groups (pass-through only for layers); the one picked. Each /// row `preview`s its mode on the canvas while hovered. pub fn blend_menu(ui: &mut Ui, with_pass_through: bool, mut preview: impl FnMut(BlendMode)) -> Option { @@ -44,6 +39,11 @@ pub(crate) fn preview_blend(app: &mut FigApp, ctx: &egui::Context, ids: &[NodeId app.preview_text(ctx, "node.set", json!({"ids": ids, "props": {"blend": blend.label()}})); } +/// The menu a dropdown or button opens: as wide as its widest row and at least as wide as +/// `anchor`. egui measures a popup in a first, invisible pass as wide as the width it is given +/// (else its 600 px default area width) and keeps the size measured; rows ([`menu_row`]) take the +/// larger of that width and their content, so the menu fits its content. Chain `.width(w)` for a +/// menu at least `w` wide. pub fn menu_popup(anchor: &egui::Response) -> egui::Popup<'static> { egui::Popup::menu(anchor).width(anchor.rect.width().max(MENU_MIN_W)).style(menu_style) }