mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-02 16:29:34 -06:00
M3: merge current main and resolve PR #575 conflicts
Preserve the upstream behaviour and regression tests alongside this feature. Align the control-server connection-limit test with main closing invalid JSON connections: assert EOF instead of shutting down an already closed socket on macOS.
This commit is contained in:
@@ -74,6 +74,27 @@ fn along_the_flow_skips_hidden_and_absolute_siblings() {
|
||||
assert_eq!(kids(&s, col), vec![c1, c0], "Right does nothing in a lone column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_item_wrapped_lines_move_and_drop_by_line() {
|
||||
let mut s = Session::new();
|
||||
// Full-width items: one per row.
|
||||
let row = al(&mut s, None, "horizontal", 160.0, 200.0);
|
||||
set(&mut s, row, json!({"wrap": true}));
|
||||
let k: Vec<NodeId> = (0..2).map(|_| rect(&mut s, row, 160.0, 20.0)).collect();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(k[1]).unwrap().y0, 30.0, "second row");
|
||||
let slot = crate::drop::auto_layout_slot(s.doc().unwrap(), row, figcraft_geom::Point::new(10.0, 35.0), &[]);
|
||||
assert_eq!(slot, Some((1, Some([-2.0, 30.0, -2.0, 50.0]))));
|
||||
arrow(&mut s, &[k[1]], 0, -1);
|
||||
assert_eq!(kids(&s, row), vec![k[1], k[0]]);
|
||||
|
||||
// Full-height items in a vertical wrap: one per column.
|
||||
let col = al(&mut s, None, "vertical", 200.0, 160.0);
|
||||
set(&mut s, col, json!({"wrap": true}));
|
||||
let c: Vec<NodeId> = (0..3).map(|_| rect(&mut s, col, 20.0, 160.0)).collect();
|
||||
arrow(&mut s, &[c[2]], -1, 0);
|
||||
assert_eq!(kids(&s, col), vec![c[0], c[2], c[1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_rows_move_up_and_down_a_row() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -73,3 +73,52 @@ fn autosaves_keep_their_flag_in_the_file() {
|
||||
assert!(!autosave(&mut t));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_save_themselves_still_get_checkpoints() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-autosave-on-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("auto.figcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.execute("pref.autoSave", &json!({"on": true})).unwrap();
|
||||
rect(&mut s, 0.0);
|
||||
s.execute("file.save", &json!({"path": path})).unwrap();
|
||||
assert!(autosave(&mut s), "saved, but changed since the last version");
|
||||
assert!(!autosave(&mut s), "no change since the checkpoint");
|
||||
// Just opened: nothing to checkpoint.
|
||||
let mut t = Session::empty();
|
||||
t.execute("pref.autoSave", &json!({"on": true})).unwrap();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert!(!autosave(&mut t));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_encoded_elsewhere_is_saved_as_of_its_snapshot() {
|
||||
use crate::cmd::file::FileSnapshot;
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-snapshot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("snap.figcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.save", &json!({"path": path})).unwrap();
|
||||
rect(&mut s, 0.0);
|
||||
let file = FileSnapshot::of(s.ds().unwrap());
|
||||
let bytes = file.encode().unwrap();
|
||||
rect(&mut s, 50.0);
|
||||
assert!(file.save(&mut s, &bytes).unwrap());
|
||||
assert!(s.ds().unwrap().is_dirty(), "the edit since the snapshot is still unsaved");
|
||||
let mut t = Session::empty();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 10.0, "the file is the snapshot");
|
||||
// Saved meanwhile: the older bytes never overwrite the newer file.
|
||||
let file = FileSnapshot::of(s.ds().unwrap());
|
||||
let bytes = file.encode().unwrap();
|
||||
rect(&mut s, 100.0);
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
assert!(!file.save(&mut s, &bytes).unwrap());
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
let mut t = Session::empty();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 110.0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn id(value: &serde_json::Value) -> crate::NodeId {
|
||||
crate::params::id_of(&value["id"]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_steps_target_layers_created_by_earlier_steps() {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": "Card",
|
||||
"commands": [
|
||||
{"command": "node.create", "params": {"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100}},
|
||||
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}},
|
||||
{"command": "node.set", "params": {"ids": [{"$step": 1}], "props": {"name": "Swatch"}}}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let frame = id(&results[0]);
|
||||
let rect = id(&results[1]);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(rect).unwrap().parent, Some(frame));
|
||||
assert_eq!(doc.node(rect).unwrap().name, "Swatch");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_picks_another_part_of_the_result() {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"commands": [
|
||||
{"command": "node.create", "params": {"type": "frame", "width": 50, "height": 50}},
|
||||
{"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0, "field": "id"}}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(id(&results[1])).unwrap().parent, Some(id(&results[0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_references_fail_and_roll_back() {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len();
|
||||
let frame = json!({"command": "node.create", "params": {"type": "frame", "width": 10, "height": 10}});
|
||||
for (bad, needle) in [
|
||||
(json!({"$step": 1}), "earlier step"),
|
||||
(json!({"$step": 5}), "earlier step"),
|
||||
(json!({"$step": -1}), "step index"),
|
||||
(json!({"$step": "0"}), "step index"),
|
||||
(json!({"$step": 0, "field": "nope"}), "no `nope`"),
|
||||
(json!({"$step": 0, "field": 3}), "`field` must be a string"),
|
||||
(json!({"$step": 0, "extra": true}), "not `extra`"),
|
||||
] {
|
||||
let r = s.execute("command.batch", &json!({"commands": [frame, {"command": "node.set", "params": {"ids": [bad], "props": {"name": "x"}}}]}));
|
||||
match r {
|
||||
Err(EngineError::BadParams { msg, .. }) => assert!(msg.contains(needle), "{msg} should mention {needle}"),
|
||||
other => panic!("expected BadParams for {bad}, got {other:?}"),
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_params_are_refused_not_overflowed() {
|
||||
let mut s = Session::new();
|
||||
let mut deep = json!(1);
|
||||
for _ in 0..200 {
|
||||
deep = json!([deep]);
|
||||
}
|
||||
let r = s.execute("command.batch", &json!({"commands": [{"command": "select.none", "params": {"x": deep}}]}));
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_looks_like_a_reference_is_left_alone() {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": "$step 0 {\"$step\": 0}"}}]}))
|
||||
.unwrap();
|
||||
let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone();
|
||||
assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == "$step 0 {\"$step\": 0}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_batch_refers_to_its_own_steps() {
|
||||
let mut s = Session::new();
|
||||
let frame = |name: &str| json!({"command": "node.create", "params": {"type": "frame", "name": name, "width": 100, "height": 100}});
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"commands": [
|
||||
frame("Outer"),
|
||||
{"command": "command.batch", "params": {"commands": [
|
||||
frame("Inner"),
|
||||
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "width": 10, "height": 10}}
|
||||
]}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let inner = &results[1];
|
||||
let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone();
|
||||
assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one");
|
||||
}
|
||||
@@ -156,6 +156,22 @@ pub(crate) fn sync(s: &mut Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The branches of the file in tab `uid` as saving it now would write them: [`sync`] only puts a
|
||||
/// branch tab's latest edits in before the next command.
|
||||
pub fn current(s: &Session, uid: u64) -> Vec<Branch> {
|
||||
let Some(ds) = s.docs.iter().find(|d| d.uid == uid) else { return Vec::new() };
|
||||
let mut branches = ds.branches.clone();
|
||||
for d in &s.docs {
|
||||
let Some((_, id)) = d.branch_of.as_ref().filter(|(main, _)| *main == uid) else { continue };
|
||||
let Some(b) = branches.iter_mut().find(|b| b.id == *id) else { continue };
|
||||
b.head = d.document_itself().clone();
|
||||
if d.versions_changed {
|
||||
b.versions = d.versions.clone();
|
||||
}
|
||||
}
|
||||
branches
|
||||
}
|
||||
|
||||
/// The tab a save writes: the main file's for a branch tab, else the active tab.
|
||||
pub fn file_tab(s: &Session) -> usize {
|
||||
s.ds().ok().and_then(|d| d.branch_of.as_ref()).and_then(|(m, _)| s.docs.iter().position(|d| d.uid == *m)).unwrap_or(s.active)
|
||||
|
||||
@@ -988,6 +988,16 @@ fn batch_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`.
|
||||
/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets.
|
||||
fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option<usize> {
|
||||
if needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
(from..hay.len())
|
||||
.find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase()))))
|
||||
}
|
||||
|
||||
fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let find = req_string("edit.findReplace", p, "find")?;
|
||||
if find.is_empty() {
|
||||
@@ -998,11 +1008,16 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let page = s.page()?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut hits = Vec::new();
|
||||
let lower = find.to_lowercase();
|
||||
let needle: Vec<char> = find.chars().collect();
|
||||
let r_chars: Vec<char> = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default();
|
||||
for i in doc.subtree(page) {
|
||||
let Some(n) = doc.node(i) else { continue };
|
||||
let text_hit = matches!(&n.kind, NodeKind::Text(t) if t.characters.to_lowercase().contains(&lower));
|
||||
let name_hit = names && n.name.to_lowercase().contains(&lower);
|
||||
let text: Option<Vec<char>> = match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.characters.chars().collect()),
|
||||
_ => None,
|
||||
};
|
||||
let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some());
|
||||
let name_hit = names && find_ci(&n.name.chars().collect::<Vec<_>>(), &needle, 0).is_some();
|
||||
if !(text_hit || name_hit) || n.is_page() {
|
||||
continue;
|
||||
}
|
||||
@@ -1010,26 +1025,27 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(r) = &replace {
|
||||
let n = doc.get_mut(i)?;
|
||||
if names && name_hit {
|
||||
n.name = n.name.replace(find.as_str(), r);
|
||||
let chars: Vec<char> = n.name.chars().collect();
|
||||
let mut out = String::new();
|
||||
let mut at = 0;
|
||||
while let Some(pos) = find_ci(&chars, &needle, at) {
|
||||
out.extend(chars.get(at..pos).unwrap_or_default());
|
||||
out.push_str(r);
|
||||
at = pos + needle.len();
|
||||
}
|
||||
out.extend(chars.get(at..).unwrap_or_default());
|
||||
n.name = out;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
// Replace occurrence by occurrence so styles shift correctly.
|
||||
// The search resumes after each inserted replacement, so a replacement that
|
||||
// contains `find` neither loops forever nor stops the other occurrences.
|
||||
let mut guard = 0;
|
||||
if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) {
|
||||
// Replace occurrence by occurrence so styles shift correctly. The search resumes
|
||||
// after each inserted replacement, so a replacement that matches `find` (in any
|
||||
// case) neither loops forever nor stops the other occurrences.
|
||||
let mut cursor = 0;
|
||||
let len = find.chars().count();
|
||||
let r_len = r.chars().count();
|
||||
loop {
|
||||
let from = t.characters.char_indices().nth(cursor).map_or(t.characters.len(), |(b, _)| b);
|
||||
let Some(pos) = t.characters.get(from..).and_then(|tail| tail.find(find.as_str())) else { break };
|
||||
let start = cursor + t.characters.get(from..from + pos).map_or(0, |x| x.chars().count());
|
||||
t.replace_range(start, start + len, r);
|
||||
cursor = start + r_len;
|
||||
guard += 1;
|
||||
if guard > 10_000 {
|
||||
break;
|
||||
}
|
||||
for _ in 0..10_000 {
|
||||
let Some(start) = find_ci(&chars, &needle, cursor) else { break };
|
||||
t.replace_range(start, start + needle.len(), r);
|
||||
chars.splice(start..start + needle.len(), r_chars.iter().copied());
|
||||
cursor = start + r_chars.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"version.autosave",
|
||||
"Autosave version",
|
||||
"{} save an automatic checkpoint when the document has unsaved changes since the last version (the app does this every 30 minutes); keeps the newest 20 autosaves → {saved, version?}",
|
||||
"{} save an automatic checkpoint when the document has unsaved changes since the last version (with `pref.autoSave` on: changes made since it was opened); the app does this every 30 minutes; keeps the newest 20 autosaves → {saved, version?}",
|
||||
false,
|
||||
autosave
|
||||
),
|
||||
@@ -257,8 +257,11 @@ pub const MAX_AUTOSAVES: usize = 20;
|
||||
|
||||
/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version.
|
||||
fn autosave(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let auto_save = s.prefs.auto_save;
|
||||
let ds = s.ds_mut()?;
|
||||
if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() {
|
||||
// A file that saves itself is rarely unsaved: edits since it was opened count then.
|
||||
let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty());
|
||||
if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() {
|
||||
return Ok(json!({"saved": false}));
|
||||
}
|
||||
Ok(match add_version(ds, AUTOSAVE, "", true) {
|
||||
@@ -693,10 +696,71 @@ pub fn thumbnail(doc: &Document) -> Option<Vec<u8>> {
|
||||
|
||||
/// The `.figcraft` file for a document: content, thumbnail and version history.
|
||||
pub fn file_bytes(ds: &DocState) -> Result<Vec<u8>> {
|
||||
let mut doc = (*ds.doc).clone();
|
||||
doc.current_page = Some(ds.page);
|
||||
let thumb = thumbnail(&doc);
|
||||
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &ds.versions, &ds.branches).map_err(|e| EngineError::Other(e.to_string()))
|
||||
FileSnapshot::of(ds).encode()
|
||||
}
|
||||
|
||||
/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the
|
||||
/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then
|
||||
/// [`save`](Self::save) the bytes back on the session's thread.
|
||||
pub struct FileSnapshot {
|
||||
uid: u64,
|
||||
path: Option<String>,
|
||||
revision: u64,
|
||||
doc: Arc<Document>,
|
||||
/// The document as last saved when the snapshot was taken: another save since makes it stale.
|
||||
saved: Arc<Document>,
|
||||
page: NodeId,
|
||||
versions: Vec<Version>,
|
||||
branches: Vec<figcraft_format::Branch>,
|
||||
}
|
||||
|
||||
impl FileSnapshot {
|
||||
pub fn of(ds: &DocState) -> Self {
|
||||
Self {
|
||||
uid: ds.uid,
|
||||
path: ds.path.clone(),
|
||||
revision: ds.revision,
|
||||
doc: ds.doc.clone(),
|
||||
saved: ds.saved_doc.clone(),
|
||||
page: ds.page,
|
||||
versions: ds.versions.clone(),
|
||||
branches: ds.branches.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's bytes: content, thumbnail and version history.
|
||||
pub fn encode(&self) -> Result<Vec<u8>> {
|
||||
let mut doc = (*self.doc).clone();
|
||||
doc.current_page = Some(self.page);
|
||||
let thumb = thumbnail(&doc);
|
||||
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches)
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved
|
||||
/// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document
|
||||
/// was closed, saved, reverted or given another path since: the bytes are out of date.
|
||||
pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result<bool> {
|
||||
let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else {
|
||||
return Ok(false);
|
||||
};
|
||||
write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?;
|
||||
// The history or branches may have changed since: then they still count as unsaved.
|
||||
if ds.revision == self.revision {
|
||||
ds.versions_changed = false;
|
||||
}
|
||||
ds.saved_doc = self.doc;
|
||||
ds.revision += 1;
|
||||
branch_tabs_saved(s, self.uid);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The branch tabs of the file in tab `uid` were saved with it.
|
||||
fn branch_tabs_saved(s: &mut Session, uid: u64) {
|
||||
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
|
||||
tab.mark_saved();
|
||||
}
|
||||
}
|
||||
|
||||
fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -721,9 +785,7 @@ fn save_to(s: &mut Session, path: String) -> Result<Value> {
|
||||
ds.mark_saved();
|
||||
ds.revision += 1;
|
||||
let uid = ds.uid;
|
||||
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
|
||||
tab.mark_saved();
|
||||
}
|
||||
branch_tabs_saved(s, uid);
|
||||
remember(s, &path);
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
@@ -762,12 +824,21 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale:
|
||||
}
|
||||
|
||||
/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own
|
||||
/// area), or their boxes with "include bounding box" and for slices.
|
||||
/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing,
|
||||
/// so they take no room next to visible ones (a hidden-only export keeps its own area).
|
||||
pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option<figcraft_geom::Rect> {
|
||||
let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible));
|
||||
ids.iter()
|
||||
.filter_map(|i| {
|
||||
let slice = doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice));
|
||||
if setting.include_bounding_box || slice { doc.world_bounds(*i) } else { figcraft_render::export_bounds(doc, *i) }
|
||||
let node = doc.node(*i);
|
||||
let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice));
|
||||
if setting.include_bounding_box || slice {
|
||||
doc.world_bounds(*i)
|
||||
} else if any_visible && node.is_some_and(|n| !n.visible) {
|
||||
None
|
||||
} else {
|
||||
figcraft_render::export_bounds(doc, *i)
|
||||
}
|
||||
})
|
||||
.reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Pages.
|
||||
|
||||
use figcraft_doc::proto::Flow;
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -23,6 +24,15 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The page a command targets: the explicit `id` (which must be a page) or the open page.
|
||||
fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result<NodeId> {
|
||||
let Some(target) = id(p, "id") else { return s.page() };
|
||||
if !s.doc()?.pages.contains(&target) {
|
||||
return Err(bad(cmd, format!("{target} is not a page")));
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
|
||||
@@ -71,13 +81,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("page.rename", "Rename page", "{id?, name} (an empty page named with a leading hyphen becomes a divider)", true, |s, p| {
|
||||
let name = req_string("page.rename", p, "name")?;
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let id = page_target(s, "page.rename", p)?;
|
||||
s.doc_mut()?.get_mut(id)?.name = name;
|
||||
settle(s, "page.rename")?;
|
||||
ok()
|
||||
}),
|
||||
spec!("page.delete", "Delete page", "{id?}", true, |s, p| {
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let id = page_target(s, "page.delete", p)?;
|
||||
s.doc_mut()?.remove(id)?;
|
||||
settle(s, "page.delete")?;
|
||||
ok()
|
||||
@@ -87,13 +97,23 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let doc = s.doc_mut()?;
|
||||
let name = format!("Copy of {}", doc.get(src)?.name);
|
||||
let np = doc.add_page(&name);
|
||||
if let NodeKind::Page { background, .. } = doc.get(src)?.kind.clone()
|
||||
&& let NodeKind::Page { background: b, .. } = &mut doc.get_mut(np)?.kind
|
||||
// Each layer and its copy (also those in sections), so flows can follow their start frames.
|
||||
let mut copies = Vec::new();
|
||||
for c in doc.get(src)?.children.clone() {
|
||||
let copy = doc.duplicate_into(c, np, None)?;
|
||||
copies.extend(doc.subtree(c).into_iter().zip(doc.subtree(copy)));
|
||||
}
|
||||
// The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides.
|
||||
if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone()
|
||||
&& let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind
|
||||
{
|
||||
*b = background;
|
||||
}
|
||||
for c in doc.get(src)?.children.clone() {
|
||||
doc.duplicate_into(c, np, None)?;
|
||||
*f = flows
|
||||
.into_iter()
|
||||
.filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl }))
|
||||
.collect();
|
||||
*pr = prototype;
|
||||
*g = guides;
|
||||
}
|
||||
// Keep the new page right after the source.
|
||||
if let Some(si) = doc.pages.iter().position(|x| *x == src) {
|
||||
@@ -107,6 +127,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let k = req_num("page.move", p, "index")?.max(0.0) as usize;
|
||||
let doc = s.doc_mut()?;
|
||||
if !doc.pages.contains(&id) {
|
||||
return Err(bad("page.move", format!("{id} is not a page")));
|
||||
}
|
||||
doc.pages.retain(|x| *x != id);
|
||||
let k = k.min(doc.pages.len());
|
||||
doc.pages.insert(k, id);
|
||||
@@ -114,7 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("page.setBackground", "Page background", "{id?, color}", true, |s, p| {
|
||||
let c = color(p, "color").ok_or_else(|| bad("page.setBackground", "`color` required"))?;
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let id = page_target(s, "page.setBackground", p)?;
|
||||
if let NodeKind::Page { background, .. } = &mut s.doc_mut()?.get_mut(id)?.kind {
|
||||
*background = c;
|
||||
}
|
||||
@@ -160,8 +183,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
guides.remove(k);
|
||||
let ds = s.ds_mut()?;
|
||||
if ds.guide == Some((owner, k)) {
|
||||
ds.guide = None;
|
||||
match ds.guide {
|
||||
Some((o, i)) if o == owner && i == k => ds.guide = None,
|
||||
// A later guide of the same owner moves up one place; keep the selection on it.
|
||||
Some((o, i)) if o == owner && i > k => ds.guide = Some((o, i - 1)),
|
||||
_ => {}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
|
||||
@@ -112,6 +112,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
|
||||
let name = req_string("variable.createCollection", p, "name")?;
|
||||
let doc = s.doc_mut()?;
|
||||
if doc.variables.collections.iter().any(|c| c.name == name) {
|
||||
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
|
||||
}
|
||||
let id = doc.new_collection_id();
|
||||
let modes: Vec<String> = match get(p, "modes") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
|
||||
|
||||
@@ -87,7 +87,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let scopes = match get(p, "scopes") {
|
||||
let scopes = match p.get("scopes") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Array(a)) => {
|
||||
|
||||
@@ -138,8 +138,16 @@ fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
let pages = &ds.doc.pages;
|
||||
let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64;
|
||||
let n = pages.len() as i64;
|
||||
let next = (i + dir).clamp(0, n - 1) as usize;
|
||||
let Some(pg) = pages.get(next).copied() else { return ok() };
|
||||
// Dividers never open: step over them, and stay put when only dividers lie ahead.
|
||||
let mut next = i + dir;
|
||||
let pg = loop {
|
||||
let page = usize::try_from(next).ok().and_then(|k| pages.get(k).copied());
|
||||
match page {
|
||||
Some(p) if !ds.doc.is_divider(p) => break p,
|
||||
Some(_) if dir != 0 && next >= 0 && next < n => next += dir,
|
||||
_ => break ds.page,
|
||||
}
|
||||
};
|
||||
let ds = s.ds_mut()?;
|
||||
ds.page = pg;
|
||||
ds.selection.clear();
|
||||
@@ -334,6 +342,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
|
||||
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
|
||||
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
|
||||
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
|
||||
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
|
||||
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
|
||||
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
|
||||
@@ -369,18 +378,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"url": s.prefs.publish_server, "default": crate::live::DEFAULT_SERVER}))
|
||||
}
|
||||
),
|
||||
spec!("pref.language", "Interface language", "{language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", false, |s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
let lang = lang.trim();
|
||||
// A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system".
|
||||
let tag = lang.len() <= 35
|
||||
&& lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
if !tag {
|
||||
return Err(bad("pref.language", format!("`{lang}` is not a language code")));
|
||||
spec!(
|
||||
"pref.language",
|
||||
"Interface language",
|
||||
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}",
|
||||
false,
|
||||
|s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
let lang = lang.trim();
|
||||
// A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system".
|
||||
let tag = lang.len() <= 35
|
||||
&& lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
if !tag {
|
||||
return Err(bad("pref.language", format!("`{lang}` is not a language code")));
|
||||
}
|
||||
s.prefs.language = lang.to_string();
|
||||
Ok(json!({"language": s.prefs.language}))
|
||||
}
|
||||
s.prefs.language = lang.to_string();
|
||||
Ok(json!({"language": s.prefs.language}))
|
||||
}),
|
||||
),
|
||||
spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")),
|
||||
spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")),
|
||||
spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")),
|
||||
|
||||
@@ -113,8 +113,58 @@ pub(crate) fn num(v: f64) -> String {
|
||||
}
|
||||
|
||||
/// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end
|
||||
/// in "px" are left alone.
|
||||
/// in "px", markup lines (layer text), string literals and comments are left alone.
|
||||
pub fn px_to_rem(code: &str, root: f64) -> String {
|
||||
let mut out = String::with_capacity(code.len());
|
||||
let mut in_comment = false;
|
||||
for line in code.split_inclusive('\n') {
|
||||
if !in_comment && line.trim_start().starts_with('<') {
|
||||
out.push_str(line);
|
||||
continue;
|
||||
}
|
||||
// Lengths outside strings and comments convert; `plain` is where that stretch starts.
|
||||
let mut plain = 0;
|
||||
let mut chars = line.char_indices().peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
if in_comment {
|
||||
if c == '*' && chars.peek().is_some_and(|(_, n)| *n == '/') {
|
||||
chars.next();
|
||||
out.push_str(line.get(plain..i + 2).unwrap_or(""));
|
||||
plain = i + 2;
|
||||
in_comment = false;
|
||||
}
|
||||
} else if c == '/' && chars.peek().is_some_and(|(_, n)| *n == '*') {
|
||||
chars.next();
|
||||
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
|
||||
plain = i;
|
||||
in_comment = true;
|
||||
} else if c == '"' {
|
||||
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
|
||||
let mut end = line.len();
|
||||
while let Some((j, s)) = chars.next() {
|
||||
if s == '\\' {
|
||||
chars.next();
|
||||
} else if s == '"' {
|
||||
end = j + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push_str(line.get(i..end).unwrap_or(""));
|
||||
plain = end;
|
||||
}
|
||||
}
|
||||
let rest = line.get(plain..).unwrap_or("");
|
||||
if in_comment {
|
||||
out.push_str(rest);
|
||||
} else {
|
||||
out.push_str(&lengths_to_rem(rest, root));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `code` with every pixel length in rem of `root` pixels; names that end in "px" are left alone.
|
||||
fn lengths_to_rem(code: &str, root: f64) -> String {
|
||||
let ident = |c: char| c.is_alphanumeric() || c == '_';
|
||||
let mut out = String::with_capacity(code.len());
|
||||
let mut rest = code;
|
||||
|
||||
@@ -615,10 +615,10 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `data-*` attribute names for variant parameters.
|
||||
fn data_attr(p: &Param) -> String {
|
||||
/// `data-*` attribute names for variant parameters, unique among them (in order).
|
||||
fn data_attrs(variants: &[&Param]) -> Vec<String> {
|
||||
let mut used = Vec::new();
|
||||
format!("data-{}", class_name(&p.prop, &mut used))
|
||||
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, &mut used))).collect()
|
||||
}
|
||||
|
||||
/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
|
||||
@@ -628,6 +628,7 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
|
||||
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
|
||||
let root_class = class_name(&root_name, &mut Vec::new());
|
||||
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
|
||||
let attr_names = data_attrs(&variants);
|
||||
let pass = |root: NodeId| {
|
||||
let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
|
||||
let mut m = Markup::new(&vctx, lang);
|
||||
@@ -639,9 +640,10 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
|
||||
m.root_class = Some(root_class.clone());
|
||||
m.root_attrs = variants
|
||||
.iter()
|
||||
.map(|p| match lang {
|
||||
CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)),
|
||||
_ => format!(" {}={{{}}}", data_attr(p), p.ident),
|
||||
.zip(&attr_names)
|
||||
.map(|(p, attr)| match lang {
|
||||
CodeLang::Leptos => format!(" {attr}={{{}.as_str()}}", rust_ident(&p.ident)),
|
||||
_ => format!(" {attr}={{{}}}", p.ident),
|
||||
})
|
||||
.collect();
|
||||
m.node(c.comp, None, 2, "");
|
||||
@@ -653,7 +655,8 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
|
||||
let pairs = variant_props(&vn.name);
|
||||
let attrs: String = variants
|
||||
.iter()
|
||||
.filter_map(|p| pairs.get(&p.prop).map(|val| format!("[{}=\"{}\"]", data_attr(p), val.replace(['"', '\\'], ""))))
|
||||
.zip(&attr_names)
|
||||
.filter_map(|(p, attr)| pairs.get(&p.prop).map(|val| format!("[{attr}=\"{}\"]", val.replace(['"', '\\'], ""))))
|
||||
.collect();
|
||||
for (path, _, decls) in pass(v) {
|
||||
let Some((_, class, base)) = m.rules.iter().find(|(p, _, _)| *p == path) else { continue };
|
||||
|
||||
@@ -314,3 +314,32 @@ fn bound_variables_become_custom_properties() {
|
||||
let react = code(&mut s, c, "react");
|
||||
assert!(react.contains(":root {\n --colors-brand-500: #3366FF;\n}"), "{react}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_colliding_variant_properties_get_distinct_data_attributes() {
|
||||
let mut s = Session::new();
|
||||
let mut variants = Vec::new();
|
||||
for (k, (first, second, fill)) in [("Small", "Big", "#0000FF"), ("Large", "Small", "#FF0000")].iter().enumerate() {
|
||||
let name = format!("Size={first}, size={second}");
|
||||
let f = idv(&ex(
|
||||
&mut s,
|
||||
"frame.create",
|
||||
json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": name, "props": {"fill": fill}}),
|
||||
));
|
||||
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
|
||||
}
|
||||
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
|
||||
ex(&mut s, "node.set", json!({"id": set, "props": {"name": "Chip"}}));
|
||||
for lang in ["react", "leptos"] {
|
||||
let v = component_code(&mut s, set, lang);
|
||||
let def = v["definition"].as_str().unwrap_or_default();
|
||||
let root = def.lines().find(|l| l.trim_start().starts_with("<div ")).unwrap_or_default();
|
||||
let attrs: Vec<&str> =
|
||||
root.split_whitespace().filter_map(|w| w.strip_prefix("data-")).map(|w| w.split('=').next().unwrap_or_default()).collect();
|
||||
assert_eq!(attrs.len(), 2, "{lang}: {root}");
|
||||
assert_ne!(attrs[0], attrs[1], "{lang}: distinct attributes in {root}");
|
||||
let sel = format!(".chip[data-{}=\"Large\"][data-{}=\"Small\"]", attrs[0], attrs[1]);
|
||||
assert!(def.contains(&sel), "{lang}: {sel} in:\n{def}");
|
||||
assert!(def.contains("#FF0000"), "{lang}:\n{def}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,24 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
|
||||
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the move was undone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pen_click_that_wobbles_a_pixel_is_still_a_click() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
// A real click: the mouse moves a pixel or two between press and release.
|
||||
s.pointer(&ev(PointerKind::Down, (100.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (102.0, 101.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, (102.0, 101.0), NONE)).unwrap();
|
||||
let out = s.tool.pen.as_ref().and_then(|p| p.points.first()).map(|(_, _, o)| *o);
|
||||
assert_eq!(out.map(|o| o.hypot()), Some(0.0), "no handle pulled out");
|
||||
assert!(s.tool.overlay.pen_handles.is_empty());
|
||||
// Past the slop it is a drag: the handle follows the pointer from where it was pressed.
|
||||
s.pointer(&ev(PointerKind::Down, (200.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (204.0, 100.0), NONE)).unwrap();
|
||||
let out = s.tool.pen.as_ref().and_then(|p| p.points.last()).map(|(_, _, o)| (o.x, o.y));
|
||||
assert_eq!(out, Some((4.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pen_shows_what_a_click_would_do() {
|
||||
// The Pen tool drawing a new path: three points, then over each of them.
|
||||
|
||||
@@ -93,3 +93,29 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() {
|
||||
click(&mut s, 350.0, 100.0);
|
||||
assert_eq!(s.selection(), vec![b], "everything is hit again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_frames_stay_out_of_the_ready_list_and_its_navigation() {
|
||||
let mut s = Session::new();
|
||||
let screen = frame(&mut s, 0.0, "Screen");
|
||||
let card = idv(&ex(&mut s, "frame.create", json!({"parent": screen, "width": 100, "height": 50, "name": "Card"})));
|
||||
// A status a nested frame already stores (an older file, or set through node.set) is ignored.
|
||||
ex(&mut s, "node.set", json!({"id": card, "props": {"devStatus": "readyForDev"}}));
|
||||
assert_eq!(ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))["items"], json!([]));
|
||||
assert!(s.execute("dev.focus", &json!({"direction": "next"})).is_err(), "nothing is ready");
|
||||
ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}}));
|
||||
let ids = |v: serde_json::Value| -> Vec<serde_json::Value> {
|
||||
v["items"].as_array().map(|a| a.iter().map(|i| i["id"].clone()).collect()).unwrap_or_default()
|
||||
};
|
||||
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen)]);
|
||||
ex(&mut s, "dev.focus", json!({"id": screen}));
|
||||
for direction in ["next", "previous"] {
|
||||
let v = ex(&mut s, "dev.focus", json!({"direction": direction}));
|
||||
assert_eq!((v["id"].clone(), v["count"].clone()), (json!(screen), json!(1)), "{direction} wraps to the only ready frame");
|
||||
}
|
||||
// A frame directly inside a section still counts.
|
||||
let sec = idv(&ex(&mut s, "node.create", json!({"type": "section", "x": 1000, "y": 0, "width": 400, "height": 300})));
|
||||
let in_sec = idv(&ex(&mut s, "frame.create", json!({"parent": sec, "width": 100, "height": 100, "name": "In section"})));
|
||||
ex(&mut s, "node.set", json!({"ids": [sec, in_sec], "props": {"devStatus": "readyForDev"}}));
|
||||
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen), json!(sec), json!(in_sec)]);
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ pub fn has_status(doc: &Document, id: NodeId) -> bool {
|
||||
}
|
||||
|
||||
/// The layers with handoff status `status` (`None`: any status) on `page` (`None`: every page),
|
||||
/// in page and layer order (bounded).
|
||||
/// in page and layer order (bounded). Only layers that can carry a status are listed.
|
||||
pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>) -> Vec<DevItem> {
|
||||
let mut out = Vec::new();
|
||||
let mut visited = 0usize;
|
||||
@@ -243,7 +243,7 @@ pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>
|
||||
return out;
|
||||
}
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) {
|
||||
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) {
|
||||
out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status });
|
||||
}
|
||||
stack.extend(n.children.iter().rev().copied());
|
||||
|
||||
@@ -77,6 +77,26 @@ fn double_clicks_reach_and_edit_text_in_a_group_wherever_they_land() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_text_keeps_other_document_changes_since_open() {
|
||||
let mut s = Session::new();
|
||||
let first = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 80, "height": 40}))["id"]).unwrap();
|
||||
let deleted = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 120, "y": 20, "width": 80, "height": 40}))["id"]).unwrap();
|
||||
let text = id_of(&ex(&mut s, "text.create", json!({"text": "A", "x": 20, "y": 100}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [first], "props": {"x": 650}}));
|
||||
ex(&mut s, "edit.delete", json!({"ids": [deleted]}));
|
||||
|
||||
let bounds = s.doc().unwrap().world_bounds(text).unwrap();
|
||||
double_click(&mut s, bounds.center().x, bounds.center().y, 0.0, 1.0);
|
||||
assert_eq!(editing(&s), Some(text));
|
||||
crate::textedit::insert(&mut s, "B").unwrap();
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
|
||||
assert_eq!(s.doc().unwrap().world_bounds(first).unwrap().x0, 650.0);
|
||||
assert!(!s.doc().unwrap().contains(deleted));
|
||||
assert_eq!(s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters, "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_drags_only_past_the_threshold_in_screen_pixels() {
|
||||
for (zoom, by, moves) in [(1.0, 2.0, false), (1.0, 4.0, true), (4.0, 0.5, false), (4.0, 1.0, true), (0.25, 8.0, false), (0.25, 16.0, true)] {
|
||||
|
||||
@@ -61,12 +61,15 @@ pub fn flip(r: figcraft_geom::Rect, vertical: bool) -> figcraft_geom::Rect {
|
||||
|
||||
/// The visual rows (columns when `vertical`) of a wrapping layout's placed children `kids`, each
|
||||
/// child as (its index in `kids`, its page bounds [`flip`]ped so the flow runs along x). Children
|
||||
/// wrap in flow order, so a new row starts where a child sits before the one before.
|
||||
/// wrap in flow order, so a new row starts where a child sits before the one before, or where it
|
||||
/// does not follow the one before along the flow but sits past it across (one child per line).
|
||||
pub fn wrap_rows(doc: &Document, kids: &[NodeId], vertical: bool) -> Vec<Vec<(usize, figcraft_geom::Rect)>> {
|
||||
let mut rows: Vec<Vec<(usize, figcraft_geom::Rect)>> = Vec::new();
|
||||
for (i, b) in kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, flip(b, vertical)))) {
|
||||
match rows.last_mut() {
|
||||
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5) => row.push((i, b)),
|
||||
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5 && !(b.x0 < prev.x1 - 0.5 && b.y0 >= prev.y1 - 0.5)) => {
|
||||
row.push((i, b))
|
||||
}
|
||||
_ => rows.push(vec![(i, b)]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,3 +77,43 @@ fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
|
||||
let out = s.execute("inspect.code", &json!({"id": card, "language": "leptos", "unit": "rem"})).unwrap();
|
||||
assert!(out["code"].as_str().unwrap().contains("width: 12.5rem;"), "{}", out["code"]);
|
||||
}
|
||||
|
||||
fn web_code(s: &mut Session, id: figcraft_doc::NodeId, language: &str, unit: &str) -> String {
|
||||
let out = s.execute("inspect.code", &json!({"id": id, "language": language, "unit": unit})).unwrap();
|
||||
out["code"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rem_preserves_text_in_web_markup() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(s.execute("frame.create", &json!({"width": 200, "height": 100, "name": "Card"})).unwrap());
|
||||
s.execute("text.create", &json!({"text": "Tile 24px", "parent": frame, "name": "Label"})).unwrap();
|
||||
let mut changed = Vec::new();
|
||||
for language in ["html", "react", "leptos"] {
|
||||
let px = web_code(&mut s, frame, language, "px");
|
||||
let rem = web_code(&mut s, frame, language, "rem");
|
||||
assert!(px.contains("Tile 24px"), "{language}: px control: {px}");
|
||||
assert!(rem.contains("width: 12.5rem;"), "{language}: width: {rem}");
|
||||
assert!(rem.contains("font-size: 0.75rem;"), "{language}: font: {rem}");
|
||||
if !rem.contains("Tile 24px") {
|
||||
changed.push(language);
|
||||
}
|
||||
}
|
||||
assert!(changed.is_empty(), "text rewritten in {changed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rem_preserves_text_property_default() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(s.execute("frame.create", &json!({"width": 100, "height": 40, "name": "Chip"})).unwrap());
|
||||
let label = id(s.execute("text.create", &json!({"text": "24px", "parent": frame, "name": "Label"})).unwrap());
|
||||
let component = id(s.execute("component.create", &json!({"ids": [frame]})).unwrap());
|
||||
s.execute("component.addProperty", &json!({"id": component, "name": "Label", "type": "text", "default": "24px", "layer": label})).unwrap();
|
||||
for unit in ["px", "rem"] {
|
||||
let code = web_code(&mut s, component, "react", unit);
|
||||
assert!(code.contains("label = \"24px\""), "{unit}: changed default: {code}");
|
||||
if unit == "rem" {
|
||||
assert!(code.contains("width: 6.25rem;"), "rem still converts lengths: {code}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,47 @@ pub fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// Deepest params nesting `resolve_step_refs` walks before giving up.
|
||||
const STEP_REF_MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default
|
||||
/// `id`) of `results[n]`, the result of an earlier batch step.
|
||||
fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result<Value> {
|
||||
if depth > STEP_REF_MAX_DEPTH {
|
||||
return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels")));
|
||||
}
|
||||
match v {
|
||||
Value::Object(o) if o.contains_key("$step") => {
|
||||
if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") {
|
||||
return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`")));
|
||||
}
|
||||
let n = o
|
||||
.get("$step")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?;
|
||||
let field = match o.get("field") {
|
||||
None => "id",
|
||||
Some(Value::String(f)) => f.as_str(),
|
||||
Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))),
|
||||
};
|
||||
let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else {
|
||||
let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) };
|
||||
return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})")));
|
||||
};
|
||||
result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`")))
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let mut out = serde_json::Map::with_capacity(o.len());
|
||||
for (k, x) in o {
|
||||
out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?);
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
|
||||
_ => Ok(v.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HistoryEntry {
|
||||
@@ -574,6 +615,9 @@ pub struct Prefs {
|
||||
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
|
||||
/// too). Prototype transitions still play.
|
||||
pub reduce_motion: bool,
|
||||
/// Files that have a path are saved by themselves a few seconds after they change (the app's
|
||||
/// timer; off: only File ▸ Save writes them).
|
||||
pub auto_save: bool,
|
||||
/// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed
|
||||
/// builds, off for development builds.
|
||||
pub invite_links: Option<bool>,
|
||||
@@ -626,6 +670,7 @@ impl Default for Prefs {
|
||||
language: "system".into(),
|
||||
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
|
||||
reduce_motion: false,
|
||||
auto_save: false,
|
||||
invite_links: None,
|
||||
plugins: Vec::new(),
|
||||
plugin_settings: BTreeMap::new(),
|
||||
@@ -1082,9 +1127,20 @@ impl Session {
|
||||
self.ui_requests.push(UiRequest { kind: kind.to_string(), data });
|
||||
}
|
||||
|
||||
/// Run several commands as one undo step.
|
||||
/// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id`
|
||||
/// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field),
|
||||
/// so a step can target a layer that an earlier step of the same batch created. Any object
|
||||
/// with a `$step` key in a step's params is such a reference, except inside a nested
|
||||
/// `command.batch` step: its references are to its own steps.
|
||||
pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result<Vec<Value>> {
|
||||
self.grouped(label, |s| cmds.iter().map(|(id, p)| s.execute(id, p)).collect())
|
||||
self.grouped(label, |s| {
|
||||
let mut results: Vec<Value> = Vec::with_capacity(cmds.len());
|
||||
for (step, (id, p)) in cmds.iter().enumerate() {
|
||||
let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? };
|
||||
results.push(s.execute(id, &p)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
|
||||
@@ -1181,8 +1237,8 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec<NodeId> {
|
||||
pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
// Only what was touched since the last normalisation (edited, added or removed).
|
||||
let mut touched: BTreeSet<NodeId> = doc.take_dirty();
|
||||
// A variable edit changes no layer directly, but bound layers must follow it.
|
||||
if touched.is_empty() && doc.variables == before.variables {
|
||||
// A variable or style edit changes no layer directly, but bound and linked layers must follow it.
|
||||
if touched.is_empty() && doc.variables == before.variables && doc.styles == before.styles {
|
||||
return;
|
||||
}
|
||||
let m = measure::TextMeasure;
|
||||
@@ -1407,6 +1463,8 @@ mod autosave_tests;
|
||||
#[cfg(test)]
|
||||
mod batch_rollback_tests;
|
||||
#[cfg(test)]
|
||||
mod batch_step_ref_tests;
|
||||
#[cfg(test)]
|
||||
mod binding_lists_tests;
|
||||
#[cfg(test)]
|
||||
mod booleans_tests;
|
||||
@@ -1501,6 +1559,8 @@ mod invite_links_tests;
|
||||
#[cfg(test)]
|
||||
mod keymap_tests;
|
||||
#[cfg(test)]
|
||||
mod library_range_styles_tests;
|
||||
#[cfg(test)]
|
||||
mod library_variables_tests;
|
||||
#[cfg(test)]
|
||||
mod live_access_tests;
|
||||
@@ -1529,6 +1589,8 @@ mod open_issues_tests;
|
||||
#[cfg(test)]
|
||||
mod outline_options_tests;
|
||||
#[cfg(test)]
|
||||
mod page_target_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_tests;
|
||||
|
||||
@@ -134,9 +134,12 @@ pub fn closure(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The style ids a layer links to (fill, stroke, effect, grid, text).
|
||||
/// The style ids a layer links to (fill, stroke, effect, grid, text, and text styles on
|
||||
/// character ranges).
|
||||
pub(crate) fn style_links(n: &Node) -> impl Iterator<Item = &String> {
|
||||
[&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten()
|
||||
let chars =
|
||||
n.kind.text().into_iter().flat_map(|t| std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style))).filter_map(|s| s.style_id.as_ref());
|
||||
[&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten().chain(chars)
|
||||
}
|
||||
|
||||
/// Style ids the layers under `roots` link to.
|
||||
@@ -582,11 +585,15 @@ fn local_key(lid: &str, key: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
}
|
||||
|
||||
/// Copy library styles `keys` into `doc` as linked remote styles (with `update`, copies older than
|
||||
/// the library's are refreshed). Returns library style id → local id.
|
||||
/// the library's are refreshed). The variables a written copy binds are imported too and its
|
||||
/// bindings point at their copies (never at this file's variables of the same ids). Returns library
|
||||
/// style id → local id.
|
||||
pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet<String>, update: bool) -> BTreeMap<String, String> {
|
||||
let lid = lib.manifest.id.clone();
|
||||
let mut out = BTreeMap::new();
|
||||
let mut hashes = BTreeSet::new();
|
||||
// Local ids of the copies written now, with the library style they copy.
|
||||
let mut written: Vec<(String, &Style)> = Vec::new();
|
||||
for key in keys {
|
||||
let Some(style) = lib.doc.styles.iter().find(|s| s.id == *key) else { continue };
|
||||
let version = lib.manifest.style(key).map_or(lib.manifest.version, |s| s.version);
|
||||
@@ -598,6 +605,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
|
||||
{
|
||||
*slot = Style { id: r.local.clone(), ..style.clone() };
|
||||
set_record(&mut doc.libraries_mut().styles, ImportedStyle { version, ..r.clone() });
|
||||
written.push((r.local.clone(), style));
|
||||
}
|
||||
r.local
|
||||
}
|
||||
@@ -605,6 +613,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
|
||||
let local = local_key(&lid, key, |l| doc.styles.iter().any(|s| s.id == l));
|
||||
doc.styles.push(Style { id: local.clone(), ..style.clone() });
|
||||
set_record(&mut doc.libraries_mut().styles, ImportedStyle { library: lid.clone(), key: key.clone(), local: local.clone(), version });
|
||||
written.push((local.clone(), style));
|
||||
local
|
||||
}
|
||||
};
|
||||
@@ -613,6 +622,17 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
|
||||
}
|
||||
out.insert(key.clone(), local);
|
||||
}
|
||||
let bound: BTreeSet<VariableId> = written.iter().flat_map(|(_, s)| s.bound.values().cloned()).collect();
|
||||
if !bound.is_empty() {
|
||||
let vars = import_variables(doc, lib, &bound, &BTreeSet::new(), update);
|
||||
for (local, _) in &written {
|
||||
if let Some(slot) = doc.styles.iter_mut().find(|s| s.id == *local) {
|
||||
// Bindings to variables the library lacks are dropped (the published value stays).
|
||||
slot.bound =
|
||||
std::mem::take(&mut slot.bound).into_iter().filter_map(|(f, v)| vars.variables.get(&v).map(|l| (f, l.clone()))).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
copy_images(doc, &lib.doc, &hashes);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Text styles linked on character ranges travel with components (publish and place).
|
||||
|
||||
use crate::Session;
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
|
||||
fn node(value: Value) -> NodeId {
|
||||
NodeId(value["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A component with "Hello" (chars 0..2 linked to a range style) and "World" (whole-layer style).
|
||||
fn fixture() -> (Session, NodeId, String, String) {
|
||||
let mut s = Session::new();
|
||||
let frame = node(run(&mut s, "frame.create", json!({"name":"Card", "width":100, "height":40})));
|
||||
let text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"Hello"})));
|
||||
let range = run(&mut s, "style.create", json!({"type":"text", "name":"Emphasis", "value":{"fontSize":32}}))["id"].as_str().unwrap().to_owned();
|
||||
run(&mut s, "style.apply", json!({"id":text, "style":range, "start":0, "end":2}));
|
||||
let whole_text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"World"})));
|
||||
let whole = run(&mut s, "style.create", json!({"type":"text", "name":"Whole", "value":{"fontSize":24}}))["id"].as_str().unwrap().to_owned();
|
||||
run(&mut s, "style.apply", json!({"id":whole_text, "style":whole}));
|
||||
let component = node(run(&mut s, "component.create", json!({"ids":[frame]})));
|
||||
let source = s.doc().unwrap().node(text).unwrap();
|
||||
let chars = source.kind.text().unwrap();
|
||||
assert!(source.styles.text.is_none());
|
||||
assert_eq!(chars.style_at(0).style_id.as_deref(), Some(range.as_str()));
|
||||
assert_eq!(chars.style_at(2).style_id, None);
|
||||
(s, component, range, whole)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_publish_keeps_range_text_style() {
|
||||
let (mut s, component, range, whole) = fixture();
|
||||
run(&mut s, "library.publish", json!({"include":[component]}));
|
||||
let snapshot = &s.doc().unwrap().published.as_ref().unwrap().doc;
|
||||
assert!(snapshot.style(&whole).is_some());
|
||||
assert!(snapshot.style(&range).is_some(), "character-range style missing from snapshot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_place_imports_range_text_style() {
|
||||
let (mut source, component, range, whole) = fixture();
|
||||
let publication = run(&mut source, "library.publish", json!({}));
|
||||
let library = source.doc().unwrap().published.as_ref().unwrap().clone();
|
||||
assert!(library.doc.style(&range).is_some());
|
||||
let mut target = Session::new();
|
||||
let path = "figcraft://library.test/range-styles";
|
||||
target.libraries.insert(path, Ok(library));
|
||||
run(&mut target, "library.add", json!({"path":path}));
|
||||
let instance = node(run(&mut target, "library.place", json!({"library":publication["id"], "component":component})));
|
||||
let doc = target.doc().unwrap();
|
||||
let children = &doc.node(instance).unwrap().children;
|
||||
let ranged = doc.node(children[0]).unwrap().kind.text().unwrap();
|
||||
let full = doc.node(children[1]).unwrap().kind.text().unwrap();
|
||||
let whole_record = doc.libraries.styles.iter().find(|r| r.key == whole).unwrap();
|
||||
assert_eq!(full.style_at(0).style_id.as_deref(), Some(whole_record.local.as_str()));
|
||||
assert_eq!(ranged.style_at(0).font_size, 32.0);
|
||||
assert!(ranged.style_at(2).style_id.is_none());
|
||||
let record = doc.libraries.styles.iter().find(|r| r.key == range);
|
||||
assert!(record.is_some(), "character-range style missing after placement");
|
||||
assert_eq!(ranged.style_at(0).style_id.as_deref(), record.map(|r| r.local.as_str()));
|
||||
assert!(doc.style(ranged.style_at(0).style_id.as_deref().unwrap()).is_some());
|
||||
}
|
||||
@@ -314,3 +314,33 @@ fn library_variable_commands_fail_cleanly() {
|
||||
}
|
||||
assert!(s.doc().unwrap().libraries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_style_bindings_ignore_unrelated_local_variables() {
|
||||
let d = dir("style-bound");
|
||||
let path = path_str(&d.join("lib.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
ex(&mut lib, "variable.createCollection", json!({"name": "Tokens"}));
|
||||
let var = ex(&mut lib, "variable.create", json!({"collection": "Tokens", "name": "Brand", "type": "color", "value": "#0000ff"}))["id"].clone();
|
||||
let style = ex(&mut lib, "style.create", json!({"type": "paint", "name": "Brand", "value": "#0000ff"}))["id"].clone();
|
||||
ex(&mut lib, "style.bind", json!({"style": style, "field": "fill", "variable": var}));
|
||||
let lid = publish_to(&mut lib, &path);
|
||||
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Local"}));
|
||||
let local = ex(&mut s, "variable.create", json!({"collection": "Local", "name": "Unrelated", "type": "color", "value": "#ff0000"}))["id"].clone();
|
||||
assert_eq!(local, var, "the ids must collide for this test");
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 40, "height": 40})));
|
||||
ex(&mut s, "library.add", json!({"path": path}));
|
||||
ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "id": r}));
|
||||
assert_eq!(fill(&s, r), "0000FF");
|
||||
healthy(&s);
|
||||
|
||||
// An updated style follows the library's variable, not the local one.
|
||||
ex(&mut lib, "variable.set", json!({"variable": "Brand", "value": "#00ffff"}));
|
||||
republish(&mut lib);
|
||||
ex(&mut s, "library.acceptUpdates", json!({}));
|
||||
assert_eq!(fill(&s, r), "00FFFF");
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
@@ -517,6 +517,11 @@ pub trait LiveLink: Send {
|
||||
fn background(&mut self, _job: u64, _req: NetRequest) -> bool {
|
||||
false
|
||||
}
|
||||
/// Remove client `client` from session `session`, which this link hosts: they are
|
||||
/// disconnected and can't join again from their address while it runs. Returns at once.
|
||||
fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> {
|
||||
Err("removing people needs the desktop app or the MCP server".into())
|
||||
}
|
||||
/// End session `session` (stop hosting or leave); the others go on. Nothing for a session
|
||||
/// that is over already.
|
||||
fn leave(&mut self, session: SessionId);
|
||||
@@ -1889,6 +1894,59 @@ fn follow(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"follow": s.live().follow}))
|
||||
}
|
||||
|
||||
/// Remove someone from the open file's session: in a session this app hosts, the hub drops them;
|
||||
/// in a file of yours on a server, the server does (with the file's owner token). They are told
|
||||
/// the session is over for them, keep their copy, and can't join again from their address while
|
||||
/// the session (or the server) runs.
|
||||
fn remove_person(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.removePerson";
|
||||
let client =
|
||||
get(p, "client").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(CMD, "`client` must be a client id"))?;
|
||||
let live = s.live();
|
||||
if live.status != Status::Live {
|
||||
return Err(bad(CMD, "the open file isn't in a live session"));
|
||||
}
|
||||
let name =
|
||||
live.peers.get(&client).map(|p| p.name.clone()).ok_or_else(|| bad(CMD, format!("nobody with client id {client} is in the session")))?;
|
||||
let (id, role, server_file) = (live.id, live.role(), live.server_file());
|
||||
match (role, server_file) {
|
||||
(Some(Role::Host), _) => {
|
||||
let link = s.live.link.as_deref_mut().ok_or_else(|| unavailable(CMD))?;
|
||||
link.remove(id, client).map_err(EngineError::Other)?;
|
||||
}
|
||||
(Some(Role::Guest), Some((server, file))) => {
|
||||
let owner =
|
||||
s.live().published.as_ref().and_then(|p| p.owner.clone()).or_else(|| s.online_owner(&server, &file)).filter(|o| !o.is_empty());
|
||||
let owner = owner.ok_or_else(|| bad(CMD, "only the file's owner can remove people from it"))?;
|
||||
let req = Some(Admin::RemovePeer { file: file.clone(), client });
|
||||
match hosting_request(s, CMD, &server, &file, &owner, req) {
|
||||
Ok((_, Some(AdminReply::Done))) => {}
|
||||
Ok(_) => return Err(EngineError::Other(format!("couldn't remove {name}: the server answered something else"))),
|
||||
// A server older than this request can't read it.
|
||||
Err(e) => return Err(EngineError::Other(format!("couldn't remove {name}: {e} (the server may be older than removing people)"))),
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(CMD, "only the person hosting the session, or the file's owner, can remove people")),
|
||||
}
|
||||
s.message = Some(format!("{name} was removed from the session"));
|
||||
Ok(json!({"removed": client, "name": name}))
|
||||
}
|
||||
|
||||
/// You may remove others from the open file's session: you host it, or it is your file on a
|
||||
/// server.
|
||||
pub fn can_remove_people(s: &Session) -> bool {
|
||||
let live = s.live();
|
||||
live.status == Status::Live
|
||||
&& match live.role() {
|
||||
Some(Role::Host) => true,
|
||||
Some(Role::Guest) => live.server_file().is_some_and(|(server, file)| {
|
||||
live.published.as_ref().and_then(|p| p.owner.as_deref()).is_some_and(|o| !o.is_empty())
|
||||
|| s.online_owner(&server, &file).is_some_and(|o| !o.is_empty())
|
||||
}),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = match (num(p, "x"), num(p, "y")) {
|
||||
(Some(x), Some(y)) => Some((x, y)),
|
||||
@@ -1997,6 +2055,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, _| Ok(s.live_summary())
|
||||
),
|
||||
spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow),
|
||||
spec!(
|
||||
"collab.removePerson",
|
||||
"Remove from session",
|
||||
"{client (a peer's id)} → {removed, name}: they are disconnected (they keep their copy) and can't join again from their address while the session runs; only the person hosting, or the owner of a file on a server, can do it",
|
||||
false,
|
||||
remove_person
|
||||
),
|
||||
spec!(
|
||||
"collab.cursor",
|
||||
"Show cursor at",
|
||||
|
||||
@@ -28,6 +28,8 @@ pub(crate) struct Script {
|
||||
/// The session each presence in `sent` and each message in `msgs` was for.
|
||||
pub(crate) sent_to: Vec<u64>,
|
||||
pub(crate) msgs_to: Vec<u64>,
|
||||
/// The people removed: `(session, client)`.
|
||||
pub(crate) removed: Vec<(u64, u32)>,
|
||||
}
|
||||
|
||||
pub(crate) struct Mock(pub(crate) Arc<Mutex<Script>>);
|
||||
@@ -62,6 +64,10 @@ impl LiveLink for Mock {
|
||||
s.published.push(opts);
|
||||
Ok(())
|
||||
}
|
||||
fn remove(&mut self, session: u64, client: u32) -> Result<(), String> {
|
||||
self.0.lock().unwrap().removed.push((session, client));
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, session: u64) {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.left += 1;
|
||||
@@ -314,6 +320,43 @@ fn the_host_ending_resets_everything() {
|
||||
assert_eq!(s.message.as_deref(), Some("The host ended the session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_removes_people_and_guests_cant() {
|
||||
let (mut s, script) = session();
|
||||
let remove = |s: &mut Session, client: serde_json::Value| s.execute("collab.removePerson", &json!({ "client": client }));
|
||||
// Not in a session: nobody to remove.
|
||||
assert!(matches!(remove(&mut s, json!(2)), Err(EngineError::BadParams { .. })));
|
||||
assert!(!crate::live::can_remove_people(&s));
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
let id = s.live().id;
|
||||
push(&script, vec![Event::PeerJoined(peer(2, "Ada")), Event::PeerJoined(peer(3, "Bo"))]);
|
||||
s.live_poll();
|
||||
assert!(crate::live::can_remove_people(&s));
|
||||
// Only someone who is here, by a client id.
|
||||
for bad in [json!(9), json!(1), json!(-2), json!("2"), json!(4_294_967_296u64)] {
|
||||
assert!(matches!(remove(&mut s, bad.clone()), Err(EngineError::BadParams { .. })), "{bad}");
|
||||
}
|
||||
let r = remove(&mut s, json!(2)).unwrap();
|
||||
assert_eq!((r["removed"].as_u64(), r["name"].as_str()), (Some(2), Some("Ada")));
|
||||
assert_eq!(script.lock().unwrap().removed, [(id, 2)]);
|
||||
assert_eq!(s.message.as_deref(), Some("Ada was removed from the session"));
|
||||
// The host's own session tells them she left.
|
||||
push(&script, vec![Event::PeerLeft(2)]);
|
||||
s.live_poll();
|
||||
assert_eq!(s.live().peers.keys().copied().collect::<Vec<_>>(), [3]);
|
||||
s.execute("collab.stop", &json!({})).unwrap();
|
||||
|
||||
// A guest in someone else's session can't remove anyone.
|
||||
let (mut s, script) = session();
|
||||
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
|
||||
push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana"), peer(3, "Bo")] }]);
|
||||
s.live_poll();
|
||||
assert!(!crate::live::can_remove_people(&s));
|
||||
let e = remove(&mut s, json!(3)).unwrap_err().to_string();
|
||||
assert!(e.contains("only the person hosting"), "{e}");
|
||||
assert!(script.lock().unwrap().removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collab_commands_survive_junk() {
|
||||
let junk = [
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Page commands act on pages: a layer id (a rectangle, a frame) is refused before anything about
|
||||
//! the layer changes, as `page.select` already does.
|
||||
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn name(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().get(id).unwrap().name.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_commands_reject_a_rectangle() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||
let before = name(&s, r);
|
||||
assert!(s.execute("page.select", &json!({"id": r})).is_err());
|
||||
assert!(s.execute("page.rename", &json!({"id": r, "name": "Not a page"})).is_err(), "rename refuses a layer");
|
||||
assert_eq!(name(&s, r), before, "the layer keeps its name");
|
||||
assert!(s.execute("page.setBackground", &json!({"id": r, "color": "#123456"})).is_err(), "background refuses a layer");
|
||||
assert!(s.execute("page.delete", &json!({"id": r})).is_err(), "delete refuses a layer");
|
||||
assert!(s.doc().unwrap().contains(r), "the layer is still there");
|
||||
assert!(s.doc().unwrap().contains(page));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_delete_keeps_a_frame_and_its_child() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f})));
|
||||
assert_eq!(s.doc().unwrap().get(c).unwrap().parent, Some(f));
|
||||
assert!(s.execute("page.delete", &json!({"id": f})).is_err());
|
||||
assert!(s.doc().unwrap().contains(f) && s.doc().unwrap().contains(c), "the frame and its child remain");
|
||||
assert!(s.execute("page.rename", &json!({"id": f, "name": "Not a page"})).is_err());
|
||||
assert_ne!(name(&s, f), "Not a page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_commands_still_work_on_pages() {
|
||||
let mut s = Session::new();
|
||||
let first = s.page().unwrap();
|
||||
let second = id_of(&ex(&mut s, "page.add", json!({})));
|
||||
ex(&mut s, "page.rename", json!({"id": second, "name": "Flows"}));
|
||||
assert_eq!(name(&s, second), "Flows");
|
||||
ex(&mut s, "page.setBackground", json!({"id": second, "color": "#123456"}));
|
||||
match s.doc().unwrap().get(second).unwrap().kind {
|
||||
NodeKind::Page { background, .. } => assert_eq!(background.to_css_hex().to_uppercase(), "#123456"),
|
||||
_ => panic!("not a page"),
|
||||
}
|
||||
ex(&mut s, "page.delete", json!({"id": second}));
|
||||
assert!(!s.doc().unwrap().contains(second));
|
||||
assert!(s.execute("page.delete", &json!({"id": first})).is_err(), "the last ordinary page stays");
|
||||
}
|
||||
@@ -337,6 +337,17 @@ fn pages_by_the_documented_rules() {
|
||||
assert_ne!(s.page().unwrap(), div, "deleting the open page opens a page, never a divider");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_move_refuses_what_is_not_a_page() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, "r", 30.0, 40.0, None);
|
||||
let before = pages(&mut s);
|
||||
assert!(s.execute("page.move", &json!({"id": r, "index": 0})).is_err(), "a layer id is not a page");
|
||||
assert!(s.execute("page.move", &json!({"id": 999999, "index": 0})).is_err(), "a page that isn't there is not a page");
|
||||
assert_eq!(pages(&mut s), before, "the page list is untouched");
|
||||
assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() {
|
||||
let mut s = Session::new();
|
||||
@@ -414,3 +425,25 @@ fn duplicating_a_layer_named_past_the_counter_does_not_overflow() {
|
||||
assert_eq!(name(&s, dup), "Layer 18446744073709551615 2");
|
||||
assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_navigation_skips_dividers() {
|
||||
// A trailing divider: Next page stays on the ordinary page.
|
||||
let mut s = Session::new();
|
||||
let first = s.page().unwrap();
|
||||
let div = id(&ex(&mut s, "page.add", json!({"name": "-"})));
|
||||
assert!(s.execute("page.select", &json!({"id": div})).is_err(), "a divider isn't a page to open");
|
||||
let r = ex(&mut s, "view.nextPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), first, "Next page doesn't open a trailing divider");
|
||||
assert_eq!(id_of(&r["page"]), Some(first), "the result names the current page");
|
||||
// A middle divider: Previous page crosses it, Next page crosses it back.
|
||||
let later = id(&ex(&mut s, "page.add", json!({"name": "Later"})));
|
||||
assert_eq!(s.page().unwrap(), later);
|
||||
let r = ex(&mut s, "view.prevPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), first, "Previous page crosses a divider");
|
||||
assert_eq!(id_of(&r["page"]), Some(first));
|
||||
ex(&mut s, "view.nextPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), later, "Next page crosses a divider");
|
||||
ex(&mut s, "view.nextPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), later, "Next page stops on the last page");
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
let whole_shape =
|
||||
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
|
||||
if merge && whole_shape {
|
||||
return Ok(json!({"id": null, "changed": [], "removed": [], "regions": pick}));
|
||||
return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick}));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
let created = if merge {
|
||||
|
||||
@@ -66,7 +66,8 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
|
||||
made += 4;
|
||||
}
|
||||
// A card group (5 layers).
|
||||
if budget(made) >= 5 {
|
||||
// The card and the instances leave one layer for the grid, which starts with a rectangle.
|
||||
if budget(made) > 5 {
|
||||
let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?;
|
||||
// Children are in the group's space; normalising fits the group around them.
|
||||
let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?;
|
||||
@@ -78,7 +79,7 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
|
||||
}
|
||||
// Three button instances (3 layers each).
|
||||
for k in 0..3 {
|
||||
if budget(made) < 3 {
|
||||
if budget(made) < 3 + 1 {
|
||||
break;
|
||||
}
|
||||
crate::components::create_instance(&mut doc, button, screen, 12.0 + k as f64 * 108.0, SCREEN_H - 44.0)?;
|
||||
@@ -89,12 +90,14 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
|
||||
for k in 0..rest {
|
||||
let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0);
|
||||
let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0;
|
||||
let id = match k % 5 {
|
||||
// The grid starts with a rectangle so even the smallest workload has one.
|
||||
let kind = (k + 2) % 5;
|
||||
let id = match kind {
|
||||
0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?,
|
||||
1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?,
|
||||
_ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?,
|
||||
};
|
||||
if k % 5 != 0 {
|
||||
if kind != 0 {
|
||||
fill(&mut doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0));
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.corners.radii = [3.0; 4];
|
||||
@@ -124,4 +127,16 @@ mod tests {
|
||||
assert!(doc.page_content_bounds(page).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_documents_hold_the_benchmark_targets() {
|
||||
for n in [10, 11, 12, 13, 14, 15] {
|
||||
let (doc, page) = super::large_document(n).unwrap();
|
||||
let nodes: Vec<_> = doc.subtree(page).into_iter().filter_map(|id| doc.node(id)).collect();
|
||||
let has = |f: &dyn Fn(&figcraft_doc::Node) -> bool| nodes.iter().any(|node| f(node));
|
||||
assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Rectangle) && x.name == "Rectangle"), "{n}: no rectangle");
|
||||
assert!(has(&|x| x.name == "Title"), "{n}: no title");
|
||||
assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Component { .. })), "{n}: no component");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,3 +600,27 @@ fn guides_add_move_remove() {
|
||||
s.execute("guide.clear", &json!({})).unwrap();
|
||||
assert!(guides(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_replace_matches_and_replaces_case_insensitively() {
|
||||
let mut s = sess();
|
||||
let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap());
|
||||
let u = id(&s.execute("text.create", &json!({"text": "İ HELLO hello", "x": 0, "y": 40})).unwrap());
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "HELLO box"})).unwrap());
|
||||
let chars = |s: &Session, i| s.doc().unwrap().node(i).unwrap().kind.text().unwrap().characters.clone();
|
||||
let r = s.execute("edit.findReplace", &json!({"find": "hello", "replace": "bye", "layerNames": true})).unwrap();
|
||||
assert_eq!(r["matches"], json!([t, u, f]));
|
||||
assert_eq!(chars(&s, t), "bye");
|
||||
assert_eq!(chars(&s, u), "İ bye bye");
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().name, "bye box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_replace_changing_only_the_case_replaces_every_occurrence() {
|
||||
let mut s = sess();
|
||||
let t = id(&s.execute("text.create", &json!({"text": "figcraft and FIGCRAFT, figcraft", "x": 0, "y": 0})).unwrap());
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "figcraft / figcraft"})).unwrap());
|
||||
s.execute("edit.findReplace", &json!({"find": "figcraft", "replace": "FigCraft", "layerNames": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft");
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft");
|
||||
}
|
||||
|
||||
@@ -192,3 +192,40 @@ fn paragraph_indent_only_moves_left_aligned_text() {
|
||||
let (x, w2) = first_x(&s);
|
||||
assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it");
|
||||
}
|
||||
|
||||
/// A new text made with the Text tool by a press at `from`, a drag to `to` and a release, holding
|
||||
/// `chars`: its box and resize mode.
|
||||
fn text_tool(from: (f64, f64), to: (f64, f64), chars: &str) -> (figcraft_geom::Rect, figcraft_doc::TextAutoResize) {
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Text);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap();
|
||||
crate::textedit::insert(&mut s, chars).unwrap();
|
||||
let t = s.ds().unwrap().text_edit.as_ref().unwrap().id;
|
||||
let doc = s.doc().unwrap();
|
||||
let NodeKind::Text(td) = &doc.node(t).unwrap().kind else { panic!("not a text") };
|
||||
(doc.world_bounds(t).unwrap(), td.auto_resize)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_with_the_text_tool_makes_a_fixed_size_box() {
|
||||
// #360: the drag's size, its top-left where the drag started, and its text wraps in it; a
|
||||
// click, or a drag within a few pixels, still makes auto-width text.
|
||||
use figcraft_doc::TextAutoResize;
|
||||
figcraft_text::scan_system_fonts();
|
||||
let long = "A sentence much longer than eighty pixels";
|
||||
let (line, auto) = text_tool((100.0, 100.0), (100.0, 100.0), long);
|
||||
assert_eq!(auto, TextAutoResize::WidthAndHeight);
|
||||
assert_eq!(text_tool((100.0, 100.0), (101.0, 101.0), long).1, TextAutoResize::WidthAndHeight, "a slip of the hand is a click");
|
||||
let (b, auto) = text_tool((100.0, 100.0), (180.0, 400.0), long);
|
||||
assert_eq!(auto, TextAutoResize::None);
|
||||
assert_eq!((b.x0, b.y0, b.width(), b.height()), (100.0, 100.0, 80.0, 300.0), "the text wraps in a box that keeps its size");
|
||||
// Dragged up and to the left, the box is the same.
|
||||
let (b2, _) = text_tool((180.0, 400.0), (100.0, 100.0), long);
|
||||
assert_eq!((b2.x0, b2.y0, b2.width(), b2.height()), (100.0, 100.0, 80.0, 300.0));
|
||||
// A flat drag still makes a box one line tall.
|
||||
let (b3, _) = text_tool((100.0, 100.0), (180.0, 101.0), "A");
|
||||
assert_eq!((b3.width(), b3.height()), (80.0, line.height()));
|
||||
}
|
||||
|
||||
@@ -38,16 +38,17 @@ pub fn end(s: &mut Session) -> Result<()> {
|
||||
let _ = doc.remove(te.id);
|
||||
s.ds_mut()?.selection.clear();
|
||||
}
|
||||
s.commit_interaction()?;
|
||||
if empty && te.created {
|
||||
// Nothing to undo for a text box that was never filled.
|
||||
// Nothing to undo for a text box that was never filled. The gesture snapshot was taken
|
||||
// after the layer was added, so it still holds it: re-base it on the document without the
|
||||
// layer, and committing then records no step (and leaves the redo stack alone).
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(last) = ds.history.undo.last()
|
||||
&& *last.doc == *ds.doc
|
||||
{
|
||||
ds.history.undo.pop();
|
||||
let doc = ds.doc.clone();
|
||||
if let Some(it) = ds.interaction.as_mut() {
|
||||
it.doc = doc;
|
||||
}
|
||||
}
|
||||
s.commit_interaction()?;
|
||||
let ds = s.ds_mut()?;
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
@@ -146,9 +147,15 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl+] and Cmd/Ctrl+[ while editing: the selected list items a level deeper or out, like
|
||||
/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list stays as it is).
|
||||
/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list and items already
|
||||
/// at the deepest level stay as they are).
|
||||
pub fn list_indent(s: &mut Session, out: bool) -> Result<bool> {
|
||||
list_key(s, "Tab", out)
|
||||
let Some(id) = s.ds()?.text_edit.as_ref().map(|te| te.id) else { return Ok(false) };
|
||||
let before = text_of(s, id)?;
|
||||
if !list_key(s, "Tab", out)? {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(text_of(s, id)? != before)
|
||||
}
|
||||
|
||||
/// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word
|
||||
|
||||
@@ -182,3 +182,49 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() {
|
||||
let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}));
|
||||
let start = ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100}))["id"].clone();
|
||||
ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Checkout"}));
|
||||
ex(&mut s, "proto.settings", json!({"device": "iPhone 16", "responsive": true}));
|
||||
ex(&mut s, "guide.add", json!({"vertical": true, "offset": 25}));
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
let source = s.page().unwrap();
|
||||
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
|
||||
let frames = s.doc().unwrap().node(copy).unwrap().children.clone();
|
||||
let state = |s: &Session, id: NodeId| match &s.doc().unwrap().node(id).unwrap().kind {
|
||||
NodeKind::Page { flows, prototype, guides, .. } => (flows.clone(), prototype.clone(), guides.clone()),
|
||||
_ => panic!("expected a page"),
|
||||
};
|
||||
let (flows, prototype, guides) = state(&s, copy);
|
||||
let (src_flows, src_prototype, src_guides) = state(&s, source);
|
||||
assert_eq!(flows.len(), 1);
|
||||
assert_eq!(flows[0].name, "Checkout");
|
||||
assert_eq!(flows[0].start, frames[1], "the flow starts at the copied frame");
|
||||
assert_eq!(src_flows[0].start, id_of(&start).unwrap(), "the source keeps its own");
|
||||
assert_eq!(prototype, src_prototype);
|
||||
assert_eq!(prototype.device, "iPhone 16");
|
||||
assert!(prototype.responsive);
|
||||
assert_eq!(guides, src_guides);
|
||||
assert_eq!(guides.len(), 1);
|
||||
assert_eq!(ex(&mut s, "proto.present", json!({}))["start"].as_u64(), Some(frames[1].0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_of_a_page_keeps_a_flow_that_starts_in_a_section() {
|
||||
let mut s = Session::new();
|
||||
let start = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
|
||||
ex(&mut s, "object.wrapInSection", json!({"ids": [start]}));
|
||||
ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Onboarding"}));
|
||||
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let section = doc.node(copy).unwrap().children[0];
|
||||
let frame = doc.node(section).unwrap().children[0];
|
||||
match &doc.node(copy).unwrap().kind {
|
||||
NodeKind::Page { flows, .. } => assert_eq!(flows.iter().map(|f| (f.name.as_str(), f.start)).collect::<Vec<_>>(), vec![("Onboarding", frame)]),
|
||||
_ => panic!("expected a page"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +376,13 @@ pub(crate) enum Gesture {
|
||||
TextSelect {
|
||||
id: NodeId,
|
||||
},
|
||||
/// A new text from the Text tool, still pressed: a drag makes it a box the size of the drag
|
||||
/// (at least `line`, the height of its first line, tall).
|
||||
TextBox {
|
||||
id: NodeId,
|
||||
start: Point,
|
||||
line: f64,
|
||||
},
|
||||
PenDrag {
|
||||
anchor: Point,
|
||||
},
|
||||
@@ -1400,6 +1407,11 @@ impl Session {
|
||||
self.tool.gesture = g;
|
||||
return self.draw_drag(ev, start, id, parent, &targets);
|
||||
}
|
||||
Gesture::TextBox { id, start, line } => {
|
||||
let (id, start, line) = (*id, *start, *line);
|
||||
self.tool.gesture = g;
|
||||
return self.text_box_drag(ev, id, start, line);
|
||||
}
|
||||
Gesture::Arc { id, handle, arc } => {
|
||||
let (id, handle, arc) = (*id, *handle, *arc);
|
||||
self.tool.gesture = g;
|
||||
@@ -1444,7 +1456,8 @@ impl Session {
|
||||
}
|
||||
Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()),
|
||||
Gesture::PenDrag { anchor } => {
|
||||
let out = ev.p() - *anchor;
|
||||
// A click's wobble pulls out no handle.
|
||||
let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor };
|
||||
if let Some(pen) = self.tool.pen.as_mut()
|
||||
&& let Some(last) = pen.points.last_mut()
|
||||
{
|
||||
@@ -1818,7 +1831,7 @@ impl Session {
|
||||
self.tool.overlay.pen.clear();
|
||||
self.pencil_finish(&points, &pressure)?;
|
||||
}
|
||||
Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {}
|
||||
Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {}
|
||||
Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {}
|
||||
Gesture::Vector(_) => self.commit_interaction()?,
|
||||
}
|
||||
@@ -1844,9 +1857,10 @@ impl Session {
|
||||
self.widget_click(ev.p(), ev.tol() * 2.0)?;
|
||||
return Ok(());
|
||||
}
|
||||
// The second press began a move or a resize: a double-click isn't one.
|
||||
if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. }) {
|
||||
// The second press began a move, a resize, a shape or a marquee: a double-click isn't one.
|
||||
if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) {
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.tool.overlay.marquee = None;
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) };
|
||||
@@ -1943,15 +1957,39 @@ impl Session {
|
||||
n.width = w.max(1.0);
|
||||
n.height = h;
|
||||
}
|
||||
let line = n.height;
|
||||
d.insert(n, parent, None)?;
|
||||
ds.selection = vec![id];
|
||||
textedit::begin(self, id, Some(0), true)?;
|
||||
self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line };
|
||||
if !self.prefs.keep_tool {
|
||||
self.tool.tool = ToolId::Move;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drag out a new text box: past the drag slop it has the drag's size (at least one line
|
||||
/// tall) and its top-left, and that size is fixed.
|
||||
fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> {
|
||||
if within_slop(ev, start) {
|
||||
return Ok(());
|
||||
}
|
||||
let r = Rect::from_points(start, ev.p());
|
||||
let d = self.ds_mut()?.doc_mut();
|
||||
let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default();
|
||||
let n = d.get_mut(id)?;
|
||||
n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round()));
|
||||
n.width = r.width().round().max(1.0);
|
||||
n.height = r.height().round().max(line);
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.auto_resize = TextAutoResize::None;
|
||||
}
|
||||
self.ds_mut()?.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
let ds = self.ds()?;
|
||||
let doc = ds.doc.clone();
|
||||
|
||||
@@ -114,3 +114,14 @@ fn field_scopes_by_property_and_layer() {
|
||||
assert_eq!(scope(f, "paddingHorizontal"), Some("GAP"));
|
||||
assert_eq!(scope(f, "visible"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_collection_refuses_a_taken_name() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let before = s.doc().unwrap().variables.collections.len();
|
||||
assert!(s.execute("variable.createCollection", &json!({"name": "Theme"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().variables.collections.len(), before);
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"}));
|
||||
assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `vectoredit.rs` re-fit the node afterwards.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::kurbo::{CubicBez, ParamCurve};
|
||||
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
|
||||
use figcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
fn tvec(t: (f64, f64)) -> Vec2 {
|
||||
@@ -236,6 +236,12 @@ pub fn bend(net: &mut VectorNetwork, seg: usize, t: f64, p: Point) {
|
||||
pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<usize> {
|
||||
let s = net.segments.get(seg).copied()?;
|
||||
let c: CubicBez = net.cubic(seg)?;
|
||||
// Which way each region loop walks `seg`, read before the split changes its endpoints.
|
||||
let reversed: Vec<Vec<bool>> = net
|
||||
.regions
|
||||
.iter()
|
||||
.map(|reg| reg.loops.iter().map(|lp| net.loop_walk(lp).iter().find(|(i, _)| *i == seg).is_some_and(|(_, rev)| *rev)).collect())
|
||||
.collect();
|
||||
let t = if t.is_finite() { t.clamp(0.01, 0.99) } else { 0.5 };
|
||||
let l = c.subsegment(0.0..t);
|
||||
let r = c.subsegment(t..1.0);
|
||||
@@ -263,10 +269,12 @@ pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<usiz
|
||||
};
|
||||
net.segments.push(new_seg);
|
||||
let ni = net.segments.len() - 1;
|
||||
for reg in &mut net.regions {
|
||||
for lp in &mut reg.loops {
|
||||
for (r, reg) in net.regions.iter_mut().enumerate() {
|
||||
for (l, lp) in reg.loops.iter_mut().enumerate() {
|
||||
if let Some(pos) = lp.iter().position(|x| *x == seg) {
|
||||
lp.insert(pos + 1, ni);
|
||||
// Walked backwards, the new half (mid→b) comes before `seg` (a→mid).
|
||||
let rev = reversed.get(r).and_then(|v| v.get(l)).copied().unwrap_or(false);
|
||||
lp.insert(if rev { pos } else { pos + 1 }, ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -766,6 +774,19 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64,
|
||||
if ia { b } else { a }
|
||||
};
|
||||
let n = 64;
|
||||
// Upper bound on the curve's speed: no point of a sample interval is farther than the whole
|
||||
// interval length from either end, so only intervals that come that close need a closer look.
|
||||
let speed = 3.0 * [(cb.p1 - cb.p0).hypot(), (cb.p2 - cb.p1).hypot(), (cb.p3 - cb.p2).hypot()].into_iter().fold(0.0, f64::max);
|
||||
let reach = speed / f64::from(n);
|
||||
// A crossing narrower than an interval whose ends are both outside: the closest point.
|
||||
let dip = |a: f64, b: f64| -> Option<f64> {
|
||||
if (cb.eval(a) - c).hypot().min((cb.eval(b) - c).hypot()) > r + reach {
|
||||
return None;
|
||||
}
|
||||
let near = cb.subsegment(a..b).nearest(c, 1e-12);
|
||||
let m = a + (b - a) * near.t;
|
||||
(m > a && m < b && inside(m)).then_some(m)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut start: Option<f64> = if inside(0.0) { Some(0.0) } else { None };
|
||||
let mut prev = 0.0;
|
||||
@@ -773,6 +794,11 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64,
|
||||
let t = f64::from(i) / f64::from(n);
|
||||
match (start, inside(t)) {
|
||||
(None, true) => start = Some(edge(prev, t)),
|
||||
(None, false) => {
|
||||
if let Some(m) = dip(prev, t) {
|
||||
out.push((edge(prev, m), edge(m, t)));
|
||||
}
|
||||
}
|
||||
(Some(s0), false) => {
|
||||
out.push((s0, edge(prev, t)));
|
||||
start = None;
|
||||
@@ -856,6 +882,38 @@ mod tests {
|
||||
assert!((a + b).hypot() < 1e-9, "made symmetric: {a:?} {b:?}");
|
||||
}
|
||||
|
||||
fn loop_is_continuous(n: &VectorNetwork, lp: &[usize]) -> bool {
|
||||
let ends: Vec<(usize, usize)> = n
|
||||
.loop_walk(lp)
|
||||
.iter()
|
||||
.map(|&(i, rev)| {
|
||||
let s = n.segments[i];
|
||||
if rev { (s.b, s.a) } else { (s.a, s.b) }
|
||||
})
|
||||
.collect();
|
||||
(0..ends.len()).all(|k| ends[k].1 == ends[(k + 1) % ends.len()].0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_keeps_region_loop_in_ring_order() {
|
||||
// Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards.
|
||||
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
|
||||
n.segments.push(Segment::line(0, 2));
|
||||
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero });
|
||||
let p = Point::new(25.0, 10.0);
|
||||
assert!(n.fill_contains(p));
|
||||
for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] {
|
||||
let mut m = n.clone();
|
||||
m.regions[0].loops = vec![lp];
|
||||
let mid = split_segment(&mut m, seg, 0.5).unwrap();
|
||||
assert!(loop_is_continuous(&m, &m.regions[0].loops[0]), "loop {:?}", m.regions[0].loops);
|
||||
assert!(m.fill_contains(p), "split of {seg} removed {p:?} from the fill");
|
||||
if seg == 1 {
|
||||
assert_eq!(m.vertices[mid].point(), Point::new(100.0, 50.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inferred_mirroring() {
|
||||
let mut n = open_line();
|
||||
@@ -999,4 +1057,38 @@ mod tests {
|
||||
assert!(!erase(&mut m, Point::new(f64::NAN, 0.0), 5.0));
|
||||
assert!(!erase(&mut m, Point::new(0.0, 0.0), -1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eraser_catches_narrow_crossing_between_samples() {
|
||||
// A radius-1 brush on a long line: the crossing fits between the sampled parameters.
|
||||
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
|
||||
assert!(erase(&mut n, Point::new(5050.0, 0.0), 1.0));
|
||||
assert_eq!(n.segments.len(), 2);
|
||||
let ends: Vec<f64> = n.vertices.iter().map(|v| v.x).collect();
|
||||
assert!(ends.iter().any(|x| (x - 5049.0).abs() < 0.01) && ends.iter().any(|x| (x - 5051.0).abs() < 0.01), "{ends:?}");
|
||||
assert!(ends.iter().any(|x| x.abs() < 1e-9) && ends.iter().any(|x| (x - 6400.0).abs() < 1e-9), "{ends:?}");
|
||||
// A curved segment that only grazes the brush between samples.
|
||||
let mut c = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
|
||||
c.segments[0].ta = (0.0, 3000.0);
|
||||
c.segments[0].tb = (0.0, 3000.0);
|
||||
let cb = c.cubic(0).unwrap();
|
||||
let t = 0.7039;
|
||||
let p = cb.eval(t);
|
||||
let d = figcraft_geom::kurbo::ParamCurveDeriv::deriv(&cb).eval(t).to_vec2().normalize();
|
||||
let normal = Vec2::new(-d.y, d.x);
|
||||
assert!(erase(&mut c, p + normal * 0.5, 1.0));
|
||||
assert_eq!(c.segments.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eraser_catches_crossing_at_full_speed_between_samples() {
|
||||
// The line moves fastest at t = 0.5; centre a tiny brush halfway between two samples there.
|
||||
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
|
||||
let centre = n.cubic(0).unwrap().eval(32.5 / 64.0);
|
||||
assert!(erase(&mut n, centre, 0.5));
|
||||
assert_eq!(n.segments.len(), 2);
|
||||
let ends: Vec<f64> = n.vertices.iter().map(|v| v.x).collect();
|
||||
assert!(ends.iter().any(|x| (x - (centre.x - 0.5)).abs() < 0.01), "{ends:?}");
|
||||
assert!(ends.iter().any(|x| (x - (centre.x + 0.5)).abs() < 0.01), "{ends:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +456,23 @@ fn a_selected_guide_is_deleted_and_nudged_with_the_keyboard() {
|
||||
assert!(s.execute("guide.select", &json!({"index": 7})).is_err(), "only an existing guide can be selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_earlier_guide_keeps_the_selected_guide() {
|
||||
let mut s = Session::new();
|
||||
for offset in [100, 200, 300] {
|
||||
ex(&mut s, "guide.add", json!({"vertical": true, "offset": offset}));
|
||||
}
|
||||
let page = s.page().unwrap();
|
||||
ex(&mut s, "guide.select", json!({"index": 1}));
|
||||
ex(&mut s, "guide.remove", json!({"index": 0}));
|
||||
assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)), "the guide at 200 stays selected at its new index");
|
||||
assert!(s.tool_key("ArrowRight", false, false, false).unwrap());
|
||||
assert_eq!(guide_list(&s, page), vec![(true, 201.0), (true, 300.0)], "the arrow nudges the selected guide, not its neighbour");
|
||||
// A later guide leaves the selection alone.
|
||||
ex(&mut s, "guide.remove", json!({"index": 1}));
|
||||
assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_snap_and_measure() {
|
||||
let mut s = Session::new();
|
||||
|
||||
Reference in New Issue
Block a user