Merge fix/776 into batch 17

This commit is contained in:
bflatastic
2026-10-18 12:36:17 +07:00
6 changed files with 131 additions and 4 deletions
+3
View File
@@ -114,6 +114,9 @@ pub fn record_recent(app: &mut FigApp, key: &str) {
pub fn invoke(app: &mut FigApp, id: &str) {
match id {
// Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab
// removes that interaction, not the layer (#767).
"edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {}
"file.open" => app.open_dialog(),
"plugin.import" => crate::plugins_ui::import_dialog(app),
"file.import" => app.import_dialog(false),
+3
View File
@@ -272,6 +272,9 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool {
crate::proto_panel::open_editor(&ctx, Some((n.source, n.index)));
return true;
}
if pressed && crate::proto_panel::editor(&ctx).is_some() {
crate::proto_panel::open_editor(&ctx, None);
}
false
}
+78
View File
@@ -226,3 +226,81 @@ fn the_presenter_inspects_the_layer_under_the_pointer() {
assert_eq!(presenter(&u)["inspected"], json!(a));
assert_eq!(u.app.inspect()["presentOptions"]["inspect"], json!(true));
}
#[test]
fn deleting_a_prototype_interaction_removes_the_interaction_not_the_component() {
let (s, _a, b, btn) = setup();
let mut u = Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Prototype;
u.run("select.set", json!({"ids": [btn]}));
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
u.frames(1, Vec::new);
assert!(u.app.session.doc().unwrap().node(btn).is_some());
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 1);
assert_eq!(crate::proto_panel::editor(&u.ctx), Some((btn, 0)));
// Pressing Delete when an interaction is selected should delete the interaction, NOT the component.
u.key(pos2(100.0, 100.0), egui::Key::Delete);
assert!(u.app.session.doc().unwrap().node(btn).is_some(), "Bug #767 regression: component was deleted when deleting prototype interaction!");
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0, "the interaction should be removed");
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
// Re-add interaction and test Backspace as well.
u.run("proto.interaction.add", json!({"id": btn, "dest": b}));
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 1);
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
u.frames(1, Vec::new);
u.key(pos2(100.0, 100.0), egui::Key::Backspace);
assert!(u.app.session.doc().unwrap().node(btn).is_some());
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0);
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
// When NO interaction is active, Delete deletes the component as normal.
u.key(pos2(100.0, 100.0), egui::Key::Delete);
assert!(u.app.session.doc().unwrap().node(btn).is_none(), "when no interaction is selected, Delete removes the layer");
}
/// Edit ▸ Delete from the menu bar does the same as the Delete key (#767).
#[test]
fn edit_delete_from_the_menu_removes_the_open_interaction_not_the_layer() {
let (s, _a, _b, btn) = setup();
let mut u = Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Prototype;
u.run("select.set", json!({"ids": [btn]}));
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
u.frames(1, Vec::new);
crate::menus::invoke(&mut u.app, "edit.delete");
let doc = u.app.session.doc().unwrap();
assert!(doc.node(btn).is_some_and(|n| n.interactions.is_empty()), "the interaction goes, the layer stays");
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
crate::menus::invoke(&mut u.app, "edit.delete");
assert!(u.app.session.doc().unwrap().node(btn).is_none(), "with no interaction open, Edit ▸ Delete deletes the layer");
}
#[test]
fn escape_closes_prototype_editor_without_deselecting_layer() {
let (s, a, _b, btn) = setup();
let mut u = Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Prototype;
u.run("select.set", json!({"ids": [btn]}));
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
u.frames(1, Vec::new);
assert_eq!(crate::proto_panel::editor(&u.ctx), Some((btn, 0)));
assert_eq!(u.app.session.selection(), vec![btn]);
// First Escape closes the interaction editor; layer remains selected.
u.key(pos2(100.0, 100.0), egui::Key::Escape);
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
assert_eq!(u.app.session.selection(), vec![btn], "layer remains selected");
// Second Escape steps out to parent frame.
u.key(pos2(100.0, 100.0), egui::Key::Escape);
assert_eq!(u.app.session.selection(), vec![a], "steps out to parent frame");
// Third Escape deselects all.
u.key(pos2(100.0, 100.0), egui::Key::Escape);
assert!(u.app.session.selection().is_empty(), "selection cleared on third escape");
}
+32 -4
View File
@@ -40,6 +40,37 @@ pub fn editor(ctx: &egui::Context) -> Option<(NodeId, usize)> {
ctx.data(|d| d.get_temp(editor_id()))
}
/// Delete the selected interaction (when one is active in the Prototype tab).
/// Returns true if an interaction was deleted.
pub fn delete_selected_interaction(app: &mut FigApp, ctx: &egui::Context) -> bool {
if app.ui.right_tab != crate::RightTab::Prototype {
return false;
}
let Some((node, index)) = editor(ctx) else { return false };
let Ok(ds) = app.session.ds() else { return false };
if ds.doc.node(node).and_then(|n| n.interactions.get(index)).is_none() {
open_editor(ctx, None);
return false;
}
open_editor(ctx, None);
let ids = targets_for(app, node);
app.run("proto.interaction.remove", json!({"ids": ids, "index": index}));
true
}
/// Close the interaction editor if open. Returns true if it was open.
pub fn close_editor(ctx: &egui::Context) -> bool {
if capturing(ctx) || crate::variable_ui::picker_open(ctx) {
return false;
}
if editor(ctx).is_some() {
open_editor(ctx, None);
true
} else {
false
}
}
const TRIGGERS: [&str; 10] = [
tr_noop("On click"),
tr_noop("On drag"),
@@ -607,8 +638,6 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod
#[allow(clippy::too_many_arguments)]
fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: NodeId, node: NodeId, k: usize, it: &Interaction, at: Pos2) {
let t = Tokens::get(ctx);
// Escape in a field (it cancels the edit) doesn't close the editor.
let typing = ctx.egui_wants_keyboard_input();
let width = 280.0;
let screen = ctx.content_rect();
let pos = pos2(at.x - width, at.y.min(screen.max.y - 420.0).max(screen.min.y + 48.0));
@@ -652,8 +681,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N
});
});
});
let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !typing && !capturing(ctx) && !crate::variable_ui::picker_open(ctx);
if close || escape {
if close {
open_editor(ctx, None);
}
}
+3
View File
@@ -135,6 +135,9 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
}
ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, if active { t.text } else { t.text_secondary });
if resp.clicked() {
if app.ui.right_tab != tab {
crate::proto_panel::open_editor(ui.ctx(), None);
}
app.ui.right_tab = tab;
}
x = r.max.x + 2.0;
+12
View File
@@ -212,6 +212,11 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed {
pub fn run_binding(app: &mut FigApp, id: &str) {
match id {
"select.none" => {
if let Some(ctx) = &app.ui_context
&& crate::proto_panel::close_editor(ctx)
{
return;
}
if let Err(e) = app.session.escape() {
app.toast(e.to_string());
}
@@ -230,6 +235,9 @@ pub fn run_binding(app: &mut FigApp, id: &str) {
}
fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, repeat: bool, now: f64, ctx: &Context) -> bool {
if key == Key::Escape && crate::proto_panel::close_editor(ctx) {
return true;
}
if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) {
return true;
}
@@ -240,6 +248,10 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && app.ui.picker.is_some() && crate::handles::remove_selected_stop(app, ctx) {
return true;
}
// An interaction selected in the Prototype tab: Delete removes that interaction, not the component.
if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && crate::proto_panel::delete_selected_interaction(app, ctx) {
return true;
}
// Vector edit mode and the shape builder have their own keys (tools, nudging points, delete,
// join, Enter to finish); the rest fall through.
match app.session.tool_key(key.name(), m.shift, m.alt, m.command) {