From c3e2caf1010c1fd7d93e468c960a9ca15621c337 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 20:22:07 +0700 Subject: [PATCH] M6: Dev Mode: behaviour suite; the canvas inspects and never edits, Changed in the ready list, statuses only on frames and sections --- crates/engine/src/annotations_tests.rs | 2 +- crates/engine/src/cmd/props.rs | 6 +- crates/engine/src/devinspect.rs | 29 ++- crates/engine/src/devinspect_tests.rs | 2 +- crates/engine/src/devmode_behaviour_tests.rs | 207 +++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/tools.rs | 53 +++-- 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/fr.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/dev_mode.rs | 28 ++- crates/ui-egui/src/devmode_tail_tests.rs | 26 +++ crates/ui-egui/src/lib.rs | 14 +- crates/ui-egui/src/menus.rs | 18 ++ crates/ui-egui/src/shortcuts.rs | 11 +- 20 files changed, 370 insertions(+), 36 deletions(-) create mode 100644 crates/engine/src/devmode_behaviour_tests.rs diff --git a/crates/engine/src/annotations_tests.rs b/crates/engine/src/annotations_tests.rs index 7fc7376f..00065049 100644 --- a/crates/engine/src/annotations_tests.rs +++ b/crates/engine/src/annotations_tests.rs @@ -171,7 +171,7 @@ fn ready_for_dev_lists_frames_by_status() { }; let ready = ex(&mut s, "inspect.readyForDev", json!({})); assert_eq!(names(&ready), vec!["Login", "Onboarding", "Profile"], "every page, page and layer order"); - assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev"})); + assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev", "changed": false})); assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]); assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any", "page": page}))), vec!["Login", "Signup", "Onboarding"]); assert!(s.execute("inspect.readyForDev", &json!({"status": "none"})).is_err()); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index dd2daa82..e86d283e 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -416,8 +416,12 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map bool { + const OWN: [&str; 5] = ["annotation.", "dev.", "devResource.", "codeConnect.", "comment."]; + match id.strip_prefix("tool.") { + Some(t) => matches!(t, "move" | "hand" | "comment"), + None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history), + } } /// Most items listed. @@ -244,7 +257,8 @@ pub fn dev_items(doc: &Document, status: Option, page: Option } let Some(n) = doc.node(id) else { continue }; if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) { - out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status }); + let changed = n.dev_status == DevStatus::ReadyForDev && n.dev_changed; + out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status, changed }); } stack.extend(n.children.iter().rev().copied()); } @@ -257,18 +271,21 @@ pub fn specs() -> Vec { spec!( "inspect.readyForDev", "Ready for dev", - "{status?: readyForDev|completed|any (default readyForDev), page? (page id; default every page)} → {items: [{id, name, page, status}]} frames and sections by handoff status (set it with `node.set {devStatus}`)", + "{status?: readyForDev|changed|completed|any (default readyForDev; changed: ready for dev and changed since marked), page? (page id; default every page)} → {items: [{id, name, page, status, changed}]} frames and sections by handoff status (set it with `node.set {devStatus}`)", false, |s, p| { let cmd = "inspect.readyForDev"; - let status = match string(p, "status").as_deref() { + let wanted = string(p, "status"); + let changed_only = wanted.as_deref().is_some_and(|v| v.eq_ignore_ascii_case("changed")); + let status = match wanted.as_deref() { None => Some(DevStatus::ReadyForDev), + _ if changed_only => Some(DevStatus::ReadyForDev), Some(v) if v.eq_ignore_ascii_case("any") || v.eq_ignore_ascii_case("all") => None, Some(v) => Some( DevStatus::ALL .into_iter() .find(|d| !d.is_none() && d.id().eq_ignore_ascii_case(v)) - .ok_or_else(|| crate::bad(cmd, "status: readyForDev|completed|any"))?, + .ok_or_else(|| crate::bad(cmd, "status: readyForDev|changed|completed|any"))?, ), }; let doc = s.doc()?; @@ -276,7 +293,9 @@ pub fn specs() -> Vec { Some(v) => Some(id_of(v).filter(|pg| doc.pages.contains(pg)).ok_or_else(|| crate::bad(cmd, "page: a page id"))?), None => None, }; - Ok(json!({"items": serde_json::to_value(dev_items(doc, status, page)).unwrap_or(Value::Null)})) + let mut items = dev_items(doc, status, page); + items.retain(|i| i.changed || !changed_only); + Ok(json!({"items": serde_json::to_value(items).unwrap_or(Value::Null)})) } ), spec!( diff --git a/crates/engine/src/devinspect_tests.rs b/crates/engine/src/devinspect_tests.rs index be25e02c..5ee590cc 100644 --- a/crates/engine/src/devinspect_tests.rs +++ b/crates/engine/src/devinspect_tests.rs @@ -79,7 +79,7 @@ fn redlines_measure_on_hover_without_alt() { s.tool.overlay.guides.len() }; assert_eq!(hover(&mut s), 0, "plain hover doesn't measure"); - s.tool.redlines = true; + s.tool.dev_mode = true; assert!(hover(&mut s) > 0, "Inspect measures on hover"); } diff --git a/crates/engine/src/devmode_behaviour_tests.rs b/crates/engine/src/devmode_behaviour_tests.rs new file mode 100644 index 00000000..206ffd81 --- /dev/null +++ b/crates/engine/src/devmode_behaviour_tests.rs @@ -0,0 +1,207 @@ +//! Dev Mode behaviour suite, written from the public documentation of handoff tools (in our own +//! words): the canvas inspects and never edits, what the menus and shortcuts may run there, the +//! list of what's ready for development, statuses (ready, changed, completed) and the focus view. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use crate::devinspect::dev_mode_allows; +use crate::tools::{PointerEvent, PointerKind, ToolId}; +use figcraft_doc::*; +use serde_json::{Value, json}; + +fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::at(kind, x, y, 1.0) +} + +fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) { + s.pointer(&ev(PointerKind::Down, from.0, from.1)).unwrap(); + for k in 1..=4 { + let t = f64::from(k) / 4.0; + s.pointer(&ev(PointerKind::Move, from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t)).unwrap(); + } + s.pointer(&ev(PointerKind::Up, to.0, to.1)).unwrap(); +} + +fn frame(s: &mut Session, x: f64, name: &str) -> NodeId { + idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 200, "height": 200, "name": name}))) +} + +fn rect(s: &mut Session, parent: NodeId, x: f64) -> NodeId { + idv(&ex(s, "shape.rectangle", json!({"x": x, "y": 20, "width": 40, "height": 40, "parent": parent}))) +} + +fn names(v: &Value) -> Vec { + v["items"].as_array().map(|a| a.iter().filter_map(|i| i["name"].as_str().map(String::from)).collect()).unwrap_or_default() +} + +fn status(s: &mut Session, ids: &[NodeId], st: &str) { + ex(s, "node.set", json!({"ids": ids, "props": {"devStatus": st}})); +} + +#[test] +fn the_dev_mode_canvas_selects_and_pans_but_never_edits() { + let mut s = Session::new(); + let f = frame(&mut s, 0.0, "Screen"); + let r = rect(&mut s, f, 20.0); + let t = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 100, "y": 120, "parent": f}))); + s.tool.dev_mode = true; + let before = s.doc().unwrap().clone(); + let steps = s.undo_labels(100).len(); + // A press selects; dragging it moves nothing. + drag(&mut s, (40.0, 40.0), (140.0, 90.0)); + assert_eq!(s.selection(), vec![r]); + // Nor do its resize handles. + drag(&mut s, (60.0, 60.0), (120.0, 120.0)); + // Drawing tools only select there. + for tool in [ToolId::Rectangle, ToolId::Frame, ToolId::Text, ToolId::Pen, ToolId::Pencil, ToolId::Scale] { + s.set_tool(tool); + drag(&mut s, (300.0, 300.0), (360.0, 360.0)); + } + s.set_tool(ToolId::Move); + // A double-click on text doesn't start typing; on a frame's layer it selects into it. + ex(&mut s, "select.set", json!({"ids": [t]})); + s.pointer(&ev(PointerKind::DoubleClick, 110.0, 128.0)).unwrap(); + assert!(s.ds().unwrap().text_edit.is_none(), "no text editing"); + ex(&mut s, "select.set", json!({"ids": [r]})); + s.pointer(&ev(PointerKind::DoubleClick, 40.0, 40.0)).unwrap(); + assert!(s.ds().unwrap().vector_edit.is_none(), "no vector editing"); + assert!(*s.doc().unwrap() == before, "the design is untouched"); + assert_eq!(s.undo_labels(100).len(), steps, "nothing to undo"); + // Keys that edit with a tool (a selected guide's Delete) go to the commands instead. + assert!(!s.tool_key("Delete", false, false, false).unwrap()); + // The hand still pans; comments still start. + s.set_tool(ToolId::Hand); + let cx = s.ds().unwrap().view.cx; + s.pointer(&ev(PointerKind::Down, 500.0, 500.0)).unwrap(); + s.pointer(&PointerEvent { sx: 550.0, ..ev(PointerKind::Move, 500.0, 500.0) }).unwrap(); + s.pointer(&ev(PointerKind::Up, 500.0, 500.0)).unwrap(); + assert_ne!(s.ds().unwrap().view.cx, cx, "panned"); + s.set_tool(ToolId::Comment); + s.pointer(&ev(PointerKind::Down, 500.0, 500.0)).unwrap(); + assert!(s.ui_requests.iter().any(|u| u.kind == "comment.new")); + // Back in the design: the same drag moves. + s.tool.dev_mode = false; + s.set_tool(ToolId::Move); + drag(&mut s, (40.0, 40.0), (140.0, 90.0)); + assert!(s.doc().unwrap().node(r).is_some_and(|n| n.transform.as_coeffs()[4] > 100.0), "moved"); +} + +#[test] +fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts() { + for id in [ + "annotation.add", + "annotation.edit", + "dev.resolveChange", + "dev.focus", + "devResource.add", + "codeConnect.set", + "comment.add", + "edit.undo", + "edit.redo", + "edit.copy", + "edit.copyAsCss", + "select.all", + "view.zoomIn", + "view.devMode", + "file.export", + "file.save", + "tool.move", + "tool.hand", + "tool.comment", + "inspect.code", + ] { + assert!(dev_mode_allows(id), "{id} runs in Dev Mode"); + } + for id in [ + "edit.delete", + "edit.paste", + "edit.cut", + "edit.duplicate", + "transform.nudge", + "object.group", + "tool.rectangle", + "tool.text", + "tool.place", + "file.import", + ] { + assert!(!dev_mode_allows(id), "{id} edits the design"); + } +} + +#[test] +fn the_ready_list_shows_frames_and_sections_with_a_status_across_pages() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0, "Login"); + let b = frame(&mut s, 300.0, "Signup"); + let nested = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 50, "height": 50, "parent": a, "name": "Card"}))); + status(&mut s, &[a], "readyForDev"); + status(&mut s, &[b], "completed"); + // A layer inside a frame takes no status of its own (its frame's covers it). + assert!(s.execute("node.set", &json!({"id": nested, "props": {"devStatus": "readyForDev"}})).is_err()); + assert!(s.execute("node.set", &json!({"ids": [a, nested], "props": {"devStatus": "completed"}})).is_err(), "all or nothing"); + assert_eq!(s.doc().unwrap().node(a).map(|n| n.dev_status), Some(DevStatus::ReadyForDev)); + // Clearing one is always fine. + ex(&mut s, "node.set", json!({"id": nested, "props": {"devStatus": null}})); + let pg2 = idv(&ex(&mut s, "page.add", json!({"name": "Settings"}))); + ex(&mut s, "page.select", json!({"id": pg2})); + let c = frame(&mut s, 0.0, "Profile"); + status(&mut s, &[c], "readyForDev"); + assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({}))), vec!["Login", "Profile"]); + assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]); + assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))), vec!["Login", "Signup", "Profile"]); + // Statuses are undoable. + s.undo().unwrap(); + assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({}))), vec!["Login"]); +} + +#[test] +fn ready_frames_edited_after_marking_show_as_changed_until_resolved_or_completed() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0, "Login"); + let b = frame(&mut s, 300.0, "Signup"); + let r = rect(&mut s, a, 20.0); + let outside = idv(&ex(&mut s, "shape.rectangle", json!({"x": 900, "y": 0, "width": 10, "height": 10}))); + status(&mut s, &[a, b], "readyForDev"); + let changed = |s: &mut Session| names(&ex(s, "inspect.readyForDev", json!({"status": "changed"}))); + assert!(changed(&mut s).is_empty(), "marking isn't a change"); + // Edits elsewhere don't count; an edit inside does. + ex(&mut s, "node.set", json!({"id": outside, "props": {"width": 30}})); + assert!(changed(&mut s).is_empty()); + ex(&mut s, "node.set", json!({"id": r, "props": {"width": 60}})); + assert_eq!(changed(&mut s), vec!["Login"]); + let items = ex(&mut s, "inspect.readyForDev", json!({})); + assert_eq!(items["items"][0]["changed"], true, "the list says which changed: {items}"); + assert_eq!(items["items"][1]["changed"], false); + // Still ready for dev (the status itself stays). + assert_eq!(names(&items), vec!["Login", "Signup"]); + // Resolving goes back to plain ready; so does marking it again, or completing it. + ex(&mut s, "dev.resolveChange", json!({"ids": [a]})); + assert!(changed(&mut s).is_empty()); + ex(&mut s, "node.set", json!({"id": r, "props": {"width": 70}})); + status(&mut s, &[a], "completed"); + assert!(changed(&mut s).is_empty()); + let all = ex(&mut s, "inspect.readyForDev", json!({"status": "any"})); + assert_eq!(all["items"][0]["changed"], false, "a completed frame isn't changed: {all}"); + // Completed frames don't turn changed when edited. + ex(&mut s, "node.set", json!({"id": r, "props": {"width": 80}})); + assert!(!s.doc().unwrap().node(a).unwrap().dev_changed); +} + +#[test] +fn the_focus_view_steps_through_what_is_ready_and_skips_completed_frames() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0, "Login"); + let b = frame(&mut s, 300.0, "Signup"); + let c = frame(&mut s, 600.0, "Profile"); + let r = rect(&mut s, c, 620.0); + status(&mut s, &[a, c], "readyForDev"); + status(&mut s, &[b], "completed"); + ex(&mut s, "node.set", json!({"id": r, "props": {"width": 60}})); + let step = |s: &mut Session, d: &str| ex(s, "dev.focus", json!({"direction": d})); + let v = ex(&mut s, "dev.focus", json!({"id": a})); + assert_eq!((v["index"].clone(), v["count"].clone()), (json!(0), json!(2))); + assert_eq!(step(&mut s, "next")["id"], json!(c), "completed ones are skipped, changed ones aren't"); + assert_eq!(step(&mut s, "previous")["id"], json!(a)); + // Any top-level frame opens in it, even one that's completed. + assert_eq!(ex(&mut s, "dev.focus", json!({"id": b}))["index"], json!(null)); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 50abb3fa..9440f090 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1450,6 +1450,8 @@ mod devfocus_tests; #[cfg(test)] mod devinspect_tests; #[cfg(test)] +mod devmode_behaviour_tests; +#[cfg(test)] mod devmode_tail_tests; #[cfg(test)] mod double_click_tests; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index ddd276f9..120f3c17 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -437,8 +437,10 @@ pub struct ToolState { /// removes; Alt merges). pub shape_builder: Option>, pub builder_remove: bool, - /// Show distances from the selection on hover without holding Alt (Dev mode inspection). - pub redlines: bool, + /// Dev Mode: the canvas inspects. Hovering shows distances from the selection without Alt, and + /// presses select, pan and comment but never edit the design (no moving, resizing, drawing, + /// or editing text or vectors). + pub dev_mode: bool, /// Pen snap targets, kept while hovering (see `vectoredit::point_snap`). pub(crate) pen_targets: Option, /// The Scale tool's anchor: column and row (0 start, 1 middle, 2 end) of the point of the @@ -927,6 +929,14 @@ impl Session { } } + /// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects. + fn acting_tool(&self) -> ToolId { + match self.tool.tool { + t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move, + t => t, + } + } + /// Feed one pointer event to the current tool. pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> { // A held button sends the same move every frame: running the gesture again would only @@ -939,11 +949,12 @@ impl Session { } self.tool.last_move = Some(ev.clone()); } - let tool = self.tool.tool; - if matches!(tool, ToolId::Move | ToolId::Scale) && self.origin_pointer(ev)? { + let tool = self.acting_tool(); + let edits = matches!(tool, ToolId::Move | ToolId::Scale) && !self.tool.dev_mode; + if edits && self.origin_pointer(ev)? { return Ok(()); } - if matches!(tool, ToolId::Move | ToolId::Scale) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { + if edits && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { return Ok(()); } if tool == ToolId::ShapeBuilder { @@ -1003,7 +1014,7 @@ impl Session { } fn hover(&mut self, ev: &PointerEvent) -> Result<()> { - let tool = self.tool.tool; + let tool = self.acting_tool(); let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); let mut cursor = tool.cursor(); @@ -1032,7 +1043,7 @@ impl Session { if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { cursor = Cursor::Text; } else if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() && shape_hover.is_none() { - if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) { + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| !self.tool.dev_mode) { cursor = resize_cursor(&doc, &sel, h); } else { let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); @@ -1041,10 +1052,10 @@ impl Session { } hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); // Alt over a selected layer: a drag from here duplicates it. - if ev.alt && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { + if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { cursor = Cursor::Copy; } - if ev.alt || self.tool.redlines { + if ev.alt || self.tool.dev_mode { use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of}; // Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the // selection to a ruler guide, the hovered layer or (over empty canvas) the parent. @@ -1125,7 +1136,9 @@ impl Session { textedit::end(self)?; } // Handles first: a selected ellipse's arc handles (drawn over the box), then the box's. - if self.arc_handles_shown() + let edits = !self.tool.dev_mode; + if edits + && self.arc_handles_shown() && let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom) && let Some(arc) = crate::arc_handles::arc_of(&doc, id) { @@ -1135,7 +1148,8 @@ impl Session { crate::arc_handles::handle_at(&doc, id, handle, arc, ev.zoom).map(|at| ArcHint { handle, label: arc.label(handle), at }); return Ok(()); } - if self.arc_handles_shown() + if edits + && self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) && let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom) { @@ -1144,7 +1158,7 @@ impl Session { self.tool.overlay.shape = shape_hint(&doc, id, handle, ev.zoom); return Ok(()); } - if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) { + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| edits) { let ids = roots(&doc, &sel); if let Handle::Rotate(_) = h { let Some(b) = selection_box(&doc, &ids) else { return Ok(()) }; @@ -1203,7 +1217,7 @@ impl Session { vec![target] }; self.set_selection(new_sel.clone()); - if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) { + if !edits || new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) { return Ok(()); } // Locked layers selected in the layers panel stay put. @@ -1834,8 +1848,10 @@ impl Session { if self.tool.tool == ToolId::Pen { return self.pen_finish(); } + let edits = !self.tool.dev_mode; // Quick clicks on a widget's clickable part are clicks, one by one. - if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && !(ev.shift || ev.cmd || ev.alt) && self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some() { @@ -1852,7 +1868,8 @@ impl Session { let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) 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(_))) + if edits + && doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) { // Entering a text selects all of it; in the text being edited, the word. @@ -1867,7 +1884,7 @@ 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) { + if edits && 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(); ids.retain(|i| *i != target); ids.insert(0, target); @@ -2179,6 +2196,10 @@ impl Session { /// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point /// keys, Enter to finish the shape builder). Returns whether the key was used. pub fn tool_key(&mut self, name: &str, shift: bool, alt: bool, cmd: bool) -> Result { + // Dev Mode edits nothing: keys go to the commands (which it refuses in the app). + if self.tool.dev_mode { + return Ok(false); + } if self.ds().is_ok_and(|d| d.vector_edit.is_some()) { return crate::vectoredit::key(self, name, shift, alt, cmd); } diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 541ec58d..0162ae29 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -365,6 +365,7 @@ status = "draft — needs native review" "Detach variable" = "Variable lösen" "Details" = "Details" "Dev Mode" = "Entwicklermodus" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Der Entwicklermodus prüft nur: wechsle zu Design (Shift+D), um zu bearbeiten" "Dev resources" = "Entwicklerressourcen" "Development" = "Entwicklung" "Device" = "Gerät" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 5a1973f1..c41d4e7f 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -379,6 +379,7 @@ name = "English" "Detach variable" = "Detach variable" "Details" = "Details" "Dev Mode" = "Dev Mode" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Dev Mode only inspects: switch to Design (Shift+D) to edit" "Dev resources" = "Dev resources" "Development" = "Development" "Device" = "Device" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index a0d6f1b6..1da50960 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -380,6 +380,7 @@ status = "draft — needs native review" "Detach variable" = "Separar variable" "Details" = "Detalles" "Dev Mode" = "Modo desarrollo" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "El modo desarrollo solo inspecciona: cambia a Diseño (Shift+D) para editar" "Dev resources" = "Recursos de desarrollo" "Development" = "Desarrollo" "Device" = "Dispositivo" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 4a21123d..daadec8a 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -365,6 +365,7 @@ status = "draft — needs native review" "Detach variable" = "Détacher la variable" "Details" = "Détails" "Dev Mode" = "Mode Dev" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Le mode Dev sert seulement à inspecter : passez en Design (Shift+D) pour modifier" "Dev resources" = "Ressources de développement" "Development" = "Développement" "Device" = "Appareil" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index ee61e607..435b01d5 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -382,6 +382,7 @@ status = "draft — needs native review" "Detach variable" = "変数を切り離す" "Details" = "詳細" "Dev Mode" = "開発モード" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "開発モードは検査専用です。編集するにはデザイン (Shift+D) に切り替えてください" "Dev resources" = "開発リソース" "Development" = "開発" "Device" = "デバイス" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index f2059c91..e2258f48 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -366,6 +366,7 @@ status = "draft — needs native review" "Detach variable" = "변수 분리" "Details" = "세부 정보" "Dev Mode" = "개발 모드" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "개발 모드는 검사 전용입니다. 편집하려면 디자인(Shift+D)으로 전환하세요" "Dev resources" = "개발 리소스" "Development" = "개발" "Device" = "기기" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 71ad304a..016a6ec8 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -364,6 +364,7 @@ status = "draft — needs native review" "Detach variable" = "Desvincular variável" "Details" = "Detalhes" "Dev Mode" = "Modo Dev" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "O modo Dev só inspeciona: mude para Design (Shift+D) para editar" "Dev resources" = "Recursos de desenvolvimento" "Development" = "Desenvolvimento" "Device" = "Dispositivo" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index f51d5e72..dcfc3798 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -367,6 +367,7 @@ status = "draft — needs native review" "Detach variable" = "分离变量" "Details" = "详细" "Dev Mode" = "开发模式" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "开发模式仅用于检查:切换到设计 (Shift+D) 进行编辑" "Dev resources" = "开发资源" "Development" = "开发" "Device" = "设备" diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index 43c00a20..746b686d 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -29,8 +29,12 @@ const CALLOUT_W: f32 = 220.0; const CALLOUT_GAP: f32 = 24.0; /// The status filters of the ready-for-dev list. -const FILTERS: [(Option, &str); 3] = - [(Some(DevStatus::ReadyForDev), tr_noop("Ready for dev")), (Some(DevStatus::Completed), tr_noop("Completed")), (None, tr_noop("Any status"))]; +const FILTERS: [(Option, bool, &str); 4] = [ + (Some(DevStatus::ReadyForDev), false, tr_noop("Ready for dev")), + (Some(DevStatus::ReadyForDev), true, tr_noop("Changed")), + (Some(DevStatus::Completed), false, tr_noop("Completed")), + (None, false, tr_noop("Any status")), +]; /// Which annotations the canvas shows. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -818,7 +822,9 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { let filter = app.ui.dev.filter.min(FILTERS.len() - 1); let key = (ds.uid, ds.revision, filter); if app.ui.dev.ready_key != Some(key) { - app.ui.dev.ready = devinspect::dev_items(&ds.doc, FILTERS.get(filter).and_then(|f| f.0), None); + let (status, changed_only) = FILTERS.get(filter).map_or((None, false), |f| (f.0, f.1)); + app.ui.dev.ready = devinspect::dev_items(&ds.doc, status, None); + app.ui.dev.ready.retain(|i| i.changed || !changed_only); app.ui.dev.ready_key = Some(key); } let doc = ds.doc.clone(); @@ -826,9 +832,9 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { widgets::section_header(ui, tr("Ready for dev"), &[], app.ui.dev.ready.is_empty()); let w = ui.available_width() - PAD * 2.0; row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, FILTERS.get(filter).map_or("", |f| tr(f.1)), w, Some(Icon::Code)); + let resp = widgets::dropdown(ui, FILTERS.get(filter).map_or("", |f| tr(f.2)), w, Some(Icon::Code)); menu_popup(&resp).show(|ui| { - for (i, (_, label)) in FILTERS.iter().enumerate() { + for (i, (_, _, label)) in FILTERS.iter().enumerate() { if menu_check(ui, i == filter, tr(label), "") { app.ui.dev.filter = i; ui.close(); @@ -847,15 +853,21 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { if resp.hovered() { ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover); } - let dot = if item.status == DevStatus::ReadyForDev { t.success } else { t.text_tertiary }; + // The status colours the canvas badges use; a changed one also says so after its name. + let n = doc.node(item.id); + let dot = n.and_then(crate::canvas::status_label).map_or(t.text_tertiary, |(_, c)| c); ui.painter().circle_filled(pos2(r.min.x + PAD + 4.0, r.center().y), 4.0, dot); let page = doc.node(item.page).map(|p| p.name.clone()).unwrap_or_default(); let page_w = ui.painter().layout_no_wrap(page.clone(), regular(10.0), t.text_secondary).size().x.min(90.0); let page_r = Rect::from_min_max(pos2(r.max.x - PAD - page_w, r.min.y), pos2(r.max.x - PAD, r.max.y)); ui.painter().with_clip_rect(page_r).text(page_r.right_center(), Align2::RIGHT_CENTER, page, regular(10.0), t.text_secondary); let name_r = Rect::from_min_max(pos2(r.min.x + PAD + 14.0, r.min.y), pos2(page_r.min.x - 8.0, r.max.y)); - let name = widgets::elide(ui, &item.name, name_r.width()); - ui.painter().text(name_r.left_center(), Align2::LEFT_CENTER, name, regular(11.0), t.text); + // Room for the "Changed" badge (about 60 points) after the name. + let name = widgets::elide(ui, &item.name, name_r.width() - if item.changed { 64.0 } else { 0.0 }); + let name_end = ui.painter().text(name_r.left_center(), Align2::LEFT_CENTER, name, regular(11.0), t.text).max.x; + if let Some(n) = n.filter(|_| item.changed) { + crate::canvas::status_badge(&ui.painter().with_clip_rect(name_r), n, pos2(name_end + 6.0, r.center().y)); + } // Hovered: a button to open it in the focus view, over the page name. let focus_r = Rect::from_center_size(pos2(r.max.x - PAD - 10.0, r.center().y), vec2(22.0, 22.0)); let over_focus = ui.rect_contains_pointer(focus_r); diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs index 9bf3551a..c9030eeb 100644 --- a/crates/ui-egui/src/devmode_tail_tests.rs +++ b/crates/ui-egui/src/devmode_tail_tests.rs @@ -38,3 +38,29 @@ fn compare_view_compares_with_a_saved_version() { assert_eq!(u.app.inspect()["devCompare"]["version"], json!(null)); assert!(u.app.ui.dev.compare.is_some()); } + +#[test] +fn dev_mode_keys_refuse_design_edits_and_say_why() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "Login"})).unwrap()); + let r = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 120, "height": 40, "parent": f})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + let at = egui::pos2(700.0, 450.0); + let x = |u: &Ui| u.app.session.doc().unwrap().node(r).map(|n| n.transform.as_coeffs()[4]); + for key in [egui::Key::Delete, egui::Key::ArrowRight, egui::Key::Num5, egui::Key::R] { + u.key(at, key); + } + assert!(u.app.session.doc().unwrap().contains(r), "not deleted"); + assert_eq!(x(&u), Some(20.0), "not nudged"); + assert_eq!(u.app.session.doc().unwrap().node(r).map(|n| n.opacity), Some(1.0), "opacity kept"); + assert_eq!(u.app.session.tool.tool, figcraft_engine::tools::ToolId::Move, "no drawing tool"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); + // In the design the same keys edit. + u.app.ui.right_tab = crate::RightTab::Design; + u.frame(vec![]); + u.key(at, egui::Key::ArrowRight); + assert_eq!(x(&u), Some(21.0)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 28fadb72..e8b8b666 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1016,8 +1016,18 @@ impl FigApp { } theme::set_doc_profile(&ctx, self.session.doc().map_or(figcraft_color::ColorProfile::Srgb, |d| d.color_profile)); widgets::set_nudge(&ctx, self.session.prefs.nudge_small, self.session.prefs.nudge_big); - // The Inspect tab measures on hover without Alt. - self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; + // Dev Mode (the Inspect tab): the canvas measures on hover without Alt and edits nothing; + // entering it ends editing text or vectors. + let dev = self.ui.right_tab == RightTab::Inspect; + if dev && !self.session.tool.dev_mode { + let editing = self.session.ds().map(|d| (d.text_edit.is_some(), d.vector_edit.is_some())).unwrap_or_default(); + for (on, cmd) in [(editing.0, "text.edit.end"), (editing.1, "vector.edit.end")] { + if on { + self.run(cmd, json!({})); + } + } + } + self.session.tool.dev_mode = dev; if self.ui.present.is_some() { present::show(self, ui); // What went wrong while presenting (a widget's plugin failing) shows over it. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b7e0552a..deb3c435 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -34,6 +34,9 @@ pub const UI_COMMANDS: &[&str] = &[ /// or text from another app); otherwise our copied layers. `event_text` is the text the platform /// handed over with the paste event (the browser has no other way to read it). pub fn paste(app: &mut FigApp, event_text: Option<&str>) { + if dev_mode_refuses(app, "edit.paste") { + return; + } let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); let ours = sys_text.is_some() && sys_text == app.ui.last_copy; let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); @@ -73,7 +76,22 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, u32, u32)> { Some((out.into_inner(), w, h)) } +/// Dev Mode (the Inspect tab) refuses command `id` from the menus, shortcuts and clipboard: it +/// edits the design ([`figcraft_engine::devinspect::dev_mode_allows`]). Says so when it does. +/// shortcut: the layers panel (rename, reorder, hide, lock) and files dropped on the canvas still +/// edit in Dev Mode; route them through here if people trip over it. +pub fn dev_mode_refuses(app: &mut FigApp, id: &str) -> bool { + let refused = app.ui.right_tab == crate::RightTab::Inspect && !figcraft_engine::devinspect::dev_mode_allows(id); + if refused { + app.toast(tr("Dev Mode only inspects: switch to Design (Shift+D) to edit")); + } + refused +} + pub fn invoke(app: &mut FigApp, id: &str) { + if dev_mode_refuses(app, id) { + return; + } match id { "file.open" => app.open_dialog(), "plugin.import" => crate::plugins_ui::import_dialog(app), diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index d5bfb156..29acf83f 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -134,6 +134,8 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { /// Run the command a shortcut is bound to, with the keyboard's own twists (Escape steps out /// before it deselects, Delete ends a pen path, Enter edits the selected text or vector). pub fn run_binding(app: &mut FigApp, id: &str) { + // Dev Mode places and edits nothing: Enter selects children. + let dev = app.ui.right_tab == crate::RightTab::Inspect; match id { "select.none" => { if let Err(e) = app.session.escape() { @@ -145,10 +147,10 @@ pub fn run_binding(app: &mut FigApp, id: &str) { let _ = app.session.pen_finish(); } // Enter with a shape or frame tool chosen places one in the middle of the view. - "select.children" if app.session.tool.tool.draws_shape() => { + "select.children" if !dev && app.session.tool.tool.draws_shape() => { app.run("tool.place", json!({})); } - "select.children" if enter_text(app) => {} + "select.children" if !dev && enter_text(app) => {} _ => crate::menus::invoke(app, id), } } @@ -196,6 +198,8 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if (dx, dy) != (0.0, 0.0) && !m.alt { if app.session.selection().is_empty() { app.run("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift})); + } else if crate::menus::dev_mode_refuses(app, "transform.nudge") { + // Dev Mode: said so, nothing moves. } else if m.command { // Cmd/Ctrl+arrows resize: right/down grow, left/up shrink. app.run("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift})); @@ -292,6 +296,9 @@ pub fn opacity_digit(app: &mut FigApp, key: Key, now: f64) -> bool { if !app.session.prefs.number_keys_opacity || app.session.selection().is_empty() { return false; } + if crate::menus::dev_mode_refuses(app, "object.opacityKeys") { + return true; + } let first = app.ui.opacity_digit.take().filter(|(_, t)| now - t <= OPACITY_CHAIN_SECS).map(|(d, _)| d); let digits = match first { Some(f) => format!("{f}{d}"),