mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
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:
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "デバイス"
|
||||
|
||||
@@ -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" = "기기"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "设备"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
Reference in New Issue
Block a user