From 2a3c5b46892a6c930b7947f5bf91d83173826517 Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 00:16:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B9=E8=BF=9B=E5=8F=8C=E5=87=BB?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=EF=BC=8C=E7=BC=96=E8=BE=91=E5=BD=A2=E7=8A=B6?= =?UTF-8?q?=E7=82=B9=E6=97=B6=E6=9B=B4=E7=AC=A6=E5=90=88=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E4=B9=A0=E6=83=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/rules/git-commit-message.md | 6 ++ .../engine/src/selection_behaviour_tests.rs | 24 +++++ crates/engine/src/tools.rs | 19 +++- crates/ui-egui/src/canvas.rs | 13 ++- .../ui-egui/src/canvas_double_click_tests.rs | 96 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panel_width_tests.rs | 5 +- 7 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 .trae/rules/git-commit-message.md diff --git a/.trae/rules/git-commit-message.md b/.trae/rules/git-commit-message.md new file mode 100644 index 00000000..99f24731 --- /dev/null +++ b/.trae/rules/git-commit-message.md @@ -0,0 +1,6 @@ +--- +alwaysApply: true +scene: git_message +--- + +在此处编写规则,自定义 AI 生成提交信息的风格。 diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index bc961a4a..a54daf23 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -175,6 +175,30 @@ fn double_click_enters_one_level() { assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(k.r), "double-click on a shape edits its points"); } +#[test] +fn a_double_click_anywhere_on_the_selected_shape_edits_its_points() { + let mut s = Session::new(); + let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}})); + ex(&mut s, "stroke.add", json!({"ids": [r]})); + // Selected by its outline; the empty middle is no hit at all (the shape has no fill). + click(&mut s, 0.0, 50.0, NONE); + assert_eq!(s.selection(), vec![r]); + s.pointer(&ev(PointerKind::DoubleClick, 50.0, 50.0, NONE)).unwrap(); + assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(r), "a double-click on the shape edits its points"); +} + +#[test] +fn a_double_click_on_an_unselected_shape_edits_it_without_a_selecting_click() { + let mut s = Session::new(); + let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "select.none", json!({})); + // The press and release of the click that selects it, and the double-click, came in one frame: + // only the double-click reached the tool, with nothing selected. + s.pointer(&ev(PointerKind::DoubleClick, 50.0, 50.0, NONE)).unwrap(); + assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(r)); +} + #[test] fn marquee_selects_what_it_touches() { let mut s = Session::new(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f0115e1f..187e28d4 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1892,7 +1892,17 @@ impl Session { self.tool.overlay.marquee = None; self.cancel_interaction()?; } - let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; + let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + // A double-click anywhere on the one selected shape edits its points, like Enter: also + // inside a hollow (unfilled) shape, where the hit test finds nothing under the pointer. + if let [id] = sel[..] + && doc.node(id).is_some_and(|n| n.kind.is_shape()) + && doc.world_bounds(id).is_some_and(|b| b.contains(ev.p())) + && hit_here.is_none_or(|h| h == id || doc.is_ancestor(id, h)) + { + return crate::vectoredit::begin_many(self, &[id]); + } + let Some(leaf) = hit_here else { return Ok(()) }; let target = select_target(&doc, leaf, &sel, true); // Text: edit at the click. if doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) @@ -1910,8 +1920,11 @@ impl Session { return Ok(()); } // Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active). - if doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) { - let mut ids = crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default(); + // A shape clicked on its own edits even when the click that selects it and the double-click + // arrived in one frame (a press and a release the canvas took together), so no selection was + // made yet; another layer's shapes are reached one level at a time, as below. + if doc.node(target).is_some_and(|n| n.kind.is_shape()) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) { + let mut ids = if sel.contains(&target) { crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default() } else { vec![target] }; ids.retain(|i| *i != target); ids.insert(0, target); return crate::vectoredit::begin_many(self, &ids); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 46451ae0..8126e6f3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -604,7 +604,11 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { open_context_menu(app, pos, mods.command); return true; } - if resp.double_clicked() { + // A third quick click is counted as a triple, not a double (a click elsewhere in between still + // leaves its time in the sequence): it means the same here — edit, enter, open — unless a + // vector or text is being edited, where a triple already has its own meaning. + let editing = app.session.ds().is_ok_and(|d| d.vector_edit.is_some() || d.text_edit.is_some()); + if resp.double_clicked() || (resp.triple_clicked() && !editing) { let ev = pointer_event(app, PointerKind::DoubleClick, pos, &mods); if let Err(e) = app.session.pointer(&ev) { app.toast(e.to_string()); @@ -654,6 +658,13 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } if let Some(k) = kind { send(app, k, pos, &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); + } } true } diff --git a/crates/ui-egui/src/canvas_double_click_tests.rs b/crates/ui-egui/src/canvas_double_click_tests.rs index 1c89757f..c47faa74 100644 --- a/crates/ui-egui/src/canvas_double_click_tests.rs +++ b/crates/ui-egui/src/canvas_double_click_tests.rs @@ -2,11 +2,107 @@ //! began is dropped, so the tool is idle once the button is up. use egui::{Event, Modifiers, PointerButton, Pos2}; +use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; use crate::layers_drag_tests::Ui; +/// One press and release at `p`. +fn click(u: &mut Ui, p: Pos2) { + u.frame(vec![Event::PointerMoved(p)]); + for pressed in [true, false] { + u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } +} + +/// A double-click at `p`. +fn double_click_at(u: &mut Ui, p: Pos2) { + u.frame(vec![Event::PointerMoved(p)]); + for _ in 0..2 { + for pressed in [true, false] { + u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + } + u.frame(vec![]); +} + +#[test] +fn double_clicking_a_selected_shape_enters_vector_edit_like_enter() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let p = u.app.to_screen(250.0, 200.0); + // A click selects it; a double-click right after (three quick clicks) still edits its points. + click(&mut u, p); + assert_eq!(u.app.session.selection(), vec![r]); + double_click_at(&mut u, p); + assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "a double-click enters vector edit"); + // Enter does the same. + u.app.session.execute("vector.edit.end", &json!({})).unwrap(); + u.frame(vec![Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(u.app.session.ds().unwrap().vector_edit.is_some()); +} + +#[test] +fn a_slower_double_click_still_edits_the_points() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + let p = u.app.to_screen(200.0, 200.0); + u.frame(vec![Event::PointerMoved(p)]); + let press = |u: &mut Ui, pressed| { + u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + }; + // The first click, then 0.4 s: inside the platform's 0.5 s double-click window, past egui's + // default 0.3 s. + press(&mut u, true); + press(&mut u, false); + for _ in 0..24 { + u.frame(vec![]); + } + press(&mut u, true); + press(&mut u, false); + assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "a double-click the platform takes edits its points"); +} + +#[test] +fn double_clicking_an_unselected_shape_edits_its_points() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let p = u.app.to_screen(250.0, 200.0); + // Nothing is selected: the double-click selects the shape and edits its points, like Figma. + double_click_at(&mut u, p); + let ve = u.app.session.ds().unwrap().vector_edit.clone(); + assert_eq!(ve.map(|v| v.id), Some(r)); +} + +#[test] +fn a_click_that_leaves_vector_edit_does_not_keep_the_next_double_click_out() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + let shape = u.app.to_screen(250.0, 200.0); + let empty = Pos2::new(u.app.canvas.rect.left() + 30.0, u.app.canvas.rect.bottom() - 30.0); + double_click_at(&mut u, shape); + assert!(u.app.session.ds().unwrap().vector_edit.is_some()); + // One click on empty canvas leaves the edit mode. + click(&mut u, empty); + assert!(u.app.session.ds().unwrap().vector_edit.is_none(), "a click outside leaves vector edit"); + // The next double-click edits the shape again. + double_click_at(&mut u, shape); + assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "a double-click edits it again"); +} + /// Two presses and releases at the bottom left of the canvas, with `modifiers` held. fn double_click(u: &mut Ui, modifiers: Modifiers) { let pos = Pos2::new(u.app.canvas.rect.left() + 30.0, u.app.canvas.rect.bottom() - 30.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6dac06a6..e24bbf24 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1374,6 +1374,9 @@ impl FigApp { // Cmd/Ctrl+= / - / 0 zoom the canvas (view.zoomIn, view.zoomOut, view.zoom100): egui's // own keyboard zoom would scale the whole interface on the same keys. ctx.options_mut(|o| o.zoom_with_keyboard = false); + // A double-click's window is the platform's (0.5 s, Windows' default), not egui's + // stricter 0.3 s: a double-click the OS takes must enter a shape's points too. + ctx.memory_mut(|m| m.options.input_options.max_double_click_delay = 0.5); theme::install_fonts(ctx, false, 0); self.fonts_installed = true; } else if figcraft_text::system_fonts_scanned() { diff --git a/crates/ui-egui/src/panel_width_tests.rs b/crates/ui-egui/src/panel_width_tests.rs index a5a84d04..8c61a3b1 100644 --- a/crates/ui-egui/src/panel_width_tests.rs +++ b/crates/ui-egui/src/panel_width_tests.rs @@ -51,8 +51,9 @@ fn drag(u: &mut Ui, from: Pos2, to: Pos2) { } fn double_click(u: &mut Ui, p: Pos2) { - // Well after any earlier click (a third quick click isn't a double-click). - for _ in 0..40 { + // Well after any earlier click (a third quick click, within twice the platform's 0.5 s + // double-click window, isn't a double-click). + for _ in 0..70 { u.frame(vec![]); } for _ in 0..2 {