M6: Dev Mode: behaviour suite; the canvas inspects and never edits, Changed in the ready list, statuses only on frames and sections

This commit is contained in:
bflatastic
2026-10-15 20:22:07 +07:00
parent 4c7cf899f4
commit c3e2caf101
20 changed files with 370 additions and 36 deletions
+1 -1
View File
@@ -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());
+5 -1
View File
@@ -416,8 +416,12 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
} else {
v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s)))
};
let st = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
if !st.is_none() && !crate::devinspect::has_status(doc, id) {
return Err(bad(cmd, "devStatus: only sections and frames at the top of a page or section take one"));
}
let n = doc.get_mut(id)?;
n.dev_status = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
n.dev_status = st;
// Marking a layer (again) starts from its current contents.
n.dev_changed = false;
}
+24 -5
View File
@@ -214,6 +214,19 @@ pub struct DevItem {
pub name: String,
pub page: NodeId,
pub status: DevStatus,
/// Ready for dev and changed since it was marked.
pub changed: bool,
}
/// Whether Dev Mode runs command `id` from the menus and shortcuts: commands that don't edit the
/// document, and Dev Mode's own edits (annotations, statuses, dev resources, code connections,
/// comments). Choosing a tool other than moving around and commenting is refused too.
pub fn dev_mode_allows(id: &str) -> 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<DevStatus>, page: Option<NodeId>
}
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<CommandSpec> {
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<CommandSpec> {
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!(
+1 -1
View File
@@ -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");
}
@@ -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<String> {
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));
}
+2
View File
@@ -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;
+37 -16
View File
@@ -437,8 +437,10 @@ pub struct ToolState {
/// removes; Alt merges).
pub shape_builder: Option<std::sync::Arc<crate::shapebuilder::Arrangement>>,
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<crate::vectoredit::point_snap::PenTargets>,
/// 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<bool> {
// 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);
}
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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" = "デバイス"
+1
View File
@@ -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" = "기기"
+1
View File
@@ -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"
+1
View File
@@ -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" = "设备"
+20 -8
View File
@@ -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<DevStatus>, &str); 3] =
[(Some(DevStatus::ReadyForDev), tr_noop("Ready for dev")), (Some(DevStatus::Completed), tr_noop("Completed")), (None, tr_noop("Any status"))];
const FILTERS: [(Option<DevStatus>, 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);
+26
View File
@@ -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));
}
+12 -2
View File
@@ -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.
+18
View File
@@ -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<u8>, 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),
+9 -2
View File
@@ -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<Key>, 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}"),