mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-02 16:29:34 -06:00
Merge pull request #1485 from StrajnarFilip/claude/fix-1476-guides-reopen
M1: page ruler guides survive save and reopen (#1476)
This commit is contained in:
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged.
|
||||
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
|
||||
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
|
||||
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
|
||||
@@ -348,6 +349,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. |
|
||||
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
|
||||
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
|
||||
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
|
||||
|
||||
@@ -104,13 +104,15 @@ fn place_keeps_the_local_transform_and_refuses_bad_moves() {
|
||||
assert!(d.validate().is_empty());
|
||||
}
|
||||
|
||||
/// A page-less document takes pages inserted with their own ids, refuses a duplicate or a layer
|
||||
/// that isn't a page, and repairs into a file with a page.
|
||||
#[test]
|
||||
fn pages_can_be_inserted_onto_a_page_less_base() {
|
||||
let mut d = Document::without_pages("snapshot");
|
||||
assert!(d.pages.is_empty() && d.node_count() == 0);
|
||||
let p = Node::new(
|
||||
NodeId(C3 | 1),
|
||||
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default(), guides: vec![] },
|
||||
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default() },
|
||||
"P",
|
||||
0.0,
|
||||
0.0,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Ruler guides survive a save and reopen (#1476).
|
||||
|
||||
use crate::{Document, Guide, Node, NodeKind};
|
||||
|
||||
/// A guide: vertical (at an x offset) or horizontal (at a y offset).
|
||||
fn guide(vertical: bool, offset: f64) -> Guide {
|
||||
Guide { vertical, offset }
|
||||
}
|
||||
|
||||
/// A page's guides are written once and read back onto the page.
|
||||
#[test]
|
||||
fn page_guides_round_trip_through_json() {
|
||||
let mut d = Document::new("a");
|
||||
let page = d.first_page().unwrap();
|
||||
d.node_mut(page).unwrap().guides_mut().push(guide(true, 120.0));
|
||||
let json = serde_json::to_string(d.node(page).unwrap()).unwrap();
|
||||
assert_eq!(json.matches("\"guides\"").count(), 1, "{json}");
|
||||
let back: Node = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.guides(), &[guide(true, 120.0)]);
|
||||
assert_eq!(back, *d.node(page).unwrap());
|
||||
}
|
||||
|
||||
/// A frame's guides round trip too, and a layer without guides writes no `guides` key.
|
||||
#[test]
|
||||
fn frame_guides_round_trip_and_none_are_left_out() {
|
||||
let mut d = Document::new("a");
|
||||
let page = d.first_page().unwrap();
|
||||
let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
for id in [page, frame] {
|
||||
let json = serde_json::to_value(d.node(id).unwrap()).unwrap();
|
||||
assert!(json.get("guides").is_none(), "{json}");
|
||||
}
|
||||
d.node_mut(frame).unwrap().guides_mut().push(guide(false, 40.0));
|
||||
let json = serde_json::to_string(d.node(frame).unwrap()).unwrap();
|
||||
assert_eq!(json.matches("\"guides\"").count(), 1, "{json}");
|
||||
let back: Node = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, *d.node(frame).unwrap());
|
||||
}
|
||||
|
||||
/// A page saved with two `guides` keys reads both lists, in file order, and writes them once.
|
||||
#[test]
|
||||
fn a_page_written_with_two_guides_lists_opens_with_both() {
|
||||
// How a page was saved after its file had been reopened: the page kind's list (guides added
|
||||
// since), then the layer's (the ones the file had).
|
||||
let json = r#"{"id": 1, "name": "Page 1", "type": "page", "guides": [{"vertical": true, "offset": 5.0}],
|
||||
"width": 0.0, "height": 0.0, "guides": [{"vertical": false, "offset": 7.0}]}"#;
|
||||
let n: Node = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(n.kind, NodeKind::Page { .. }));
|
||||
assert_eq!(n.guides(), &[guide(true, 5.0), guide(false, 7.0)]);
|
||||
let again: Node = serde_json::from_str(&serde_json::to_string(&n).unwrap()).unwrap();
|
||||
assert_eq!(again.guides(), n.guides());
|
||||
}
|
||||
|
||||
/// A `guides` value that isn't a list of guides is still refused.
|
||||
#[test]
|
||||
fn a_bad_guides_list_is_still_an_error() {
|
||||
for bad in [r#"3"#, r#"[{"vertical": "no"}]"#, r#"{"offset": 1}"#] {
|
||||
let json = format!(r#"{{"id": 1, "name": "F", "type": "frame", "width": 1.0, "height": 1.0, "guides": {bad}}}"#);
|
||||
assert!(serde_json::from_str::<Node>(&json).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -407,13 +407,8 @@ impl Document {
|
||||
/// Add a page at the end.
|
||||
pub fn add_page(&mut self, name: &str) -> NodeId {
|
||||
let id = self.alloc_id();
|
||||
let node = Node::new(
|
||||
id,
|
||||
NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
|
||||
name,
|
||||
0.0,
|
||||
0.0,
|
||||
);
|
||||
let node =
|
||||
Node::new(id, NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() }, name, 0.0, 0.0);
|
||||
self.nodes.insert(id, Arc::new(node));
|
||||
self.dirty.touch(id);
|
||||
self.pages.push(id);
|
||||
@@ -1260,6 +1255,8 @@ mod diff_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_tests;
|
||||
#[cfg(test)]
|
||||
mod guides_tests;
|
||||
#[cfg(test)]
|
||||
mod handoff_tests;
|
||||
#[cfg(test)]
|
||||
mod ids_tests;
|
||||
|
||||
@@ -260,7 +260,7 @@ impl Document {
|
||||
let id = self.alloc_id();
|
||||
let node = Node::new(
|
||||
id,
|
||||
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
|
||||
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() },
|
||||
STORE_NAME,
|
||||
0.0,
|
||||
0.0,
|
||||
|
||||
+79
-12
@@ -66,6 +66,9 @@ pub enum PropDef {
|
||||
}
|
||||
|
||||
/// What a node is, with per-type data.
|
||||
// Text sets the size; the lint counts the gap to the next largest variant, which grew only because
|
||||
// Page's guides moved to the node (#1476).
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum NodeKind {
|
||||
@@ -76,8 +79,6 @@ pub enum NodeKind {
|
||||
flows: Vec<crate::proto::Flow>,
|
||||
#[serde(default)]
|
||||
prototype: crate::proto::PrototypeSettings,
|
||||
#[serde(default)]
|
||||
guides: Vec<Guide>,
|
||||
},
|
||||
Frame,
|
||||
Group,
|
||||
@@ -472,8 +473,8 @@ pub struct Node {
|
||||
pub mask_type: MaskType,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub grids: Vec<LayoutGrid>,
|
||||
/// A frame's ruler guides, in its own coordinates (a page's are in [`NodeKind::Page`]).
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
/// A page's or frame's ruler guides, in its own coordinates (see [`guides_key`]).
|
||||
#[serde(flatten, with = "guides_key")]
|
||||
pub guides: Vec<Guide>,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub exports: Vec<ExportSetting>,
|
||||
@@ -578,6 +579,77 @@ fn is_identity(v: &Affine) -> bool {
|
||||
*v == identity()
|
||||
}
|
||||
|
||||
/// A layer's `guides` key: left out when there are none, and every `guides` list the layer has is
|
||||
/// read. Pages used to keep their guides in [`NodeKind::Page`] while the layer field took the
|
||||
/// `guides` key on reading, so a reopened page lost its guides, and saving it then wrote two
|
||||
/// `guides` keys, which a plain field refuses (#1476). Both lists are kept, in file order.
|
||||
mod guides_key {
|
||||
use super::Guide;
|
||||
use serde::de::{IgnoredAny, MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
use std::fmt;
|
||||
|
||||
const KEY: &str = "guides";
|
||||
|
||||
/// Write `guides` as the layer's `guides` key, or nothing when there are none.
|
||||
pub fn serialize<S: Serializer>(guides: &[Guide], s: S) -> Result<S::Ok, S::Error> {
|
||||
let mut map = s.serialize_map(None)?;
|
||||
if !guides.is_empty() {
|
||||
map.serialize_entry(KEY, guides)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
|
||||
/// Read every `guides` list among the layer's fields, joined in file order.
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<Guide>, D::Error> {
|
||||
d.deserialize_map(Lists)
|
||||
}
|
||||
|
||||
/// Reads the `guides` lists of a layer's fields and skips the other fields.
|
||||
struct Lists;
|
||||
|
||||
impl<'de> Visitor<'de> for Lists {
|
||||
type Value = Vec<Guide>;
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("a layer's fields")
|
||||
}
|
||||
/// Every `guides` value joined, in order; a malformed one is an error.
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Vec<Guide>, A::Error> {
|
||||
let mut guides = Vec::new();
|
||||
while let Some(key) = map.next_key::<Key>()? {
|
||||
if key.0 {
|
||||
guides.extend(map.next_value::<Vec<Guide>>()?);
|
||||
} else {
|
||||
map.next_value::<IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
Ok(guides)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a field name is `guides` (without copying the name).
|
||||
struct Key(bool);
|
||||
|
||||
impl<'de> Deserialize<'de> for Key {
|
||||
/// Compare a field name with `guides` as it is read.
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
struct Name;
|
||||
impl Visitor<'_> for Name {
|
||||
type Value = Key;
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("a field name")
|
||||
}
|
||||
/// `guides` or another field.
|
||||
fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<Key, E> {
|
||||
Ok(Key(s == KEY))
|
||||
}
|
||||
}
|
||||
d.deserialize_identifier(Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// A node with Figma's defaults for its kind.
|
||||
pub fn new(id: NodeId, kind: NodeKind, name: impl Into<String>, width: f64, height: f64) -> Self {
|
||||
@@ -712,17 +784,12 @@ impl Node {
|
||||
|
||||
/// The ruler guides on this page or frame, in its own coordinates.
|
||||
pub fn guides(&self) -> &[Guide] {
|
||||
match &self.kind {
|
||||
NodeKind::Page { guides, .. } => guides,
|
||||
_ => &self.guides,
|
||||
}
|
||||
&self.guides
|
||||
}
|
||||
|
||||
/// The ruler guides on this page or frame, to edit.
|
||||
pub fn guides_mut(&mut self) -> &mut Vec<Guide> {
|
||||
match &mut self.kind {
|
||||
NodeKind::Page { guides, .. } => guides,
|
||||
_ => &mut self.guides,
|
||||
}
|
||||
&mut self.guides
|
||||
}
|
||||
|
||||
/// Children in paint order, bottom to top: the child list, reversed in an auto layout frame
|
||||
|
||||
@@ -33,6 +33,7 @@ fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result<NodeId> {
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// The page and ruler guide commands.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
|
||||
@@ -106,8 +107,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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
|
||||
let guides = doc.get(src)?.guides.clone();
|
||||
doc.get_mut(np)?.guides = guides;
|
||||
if let NodeKind::Page { background, flows, prototype } = doc.get(src)?.kind.clone()
|
||||
&& let NodeKind::Page { background: b, flows: f, prototype: pr } = &mut doc.get_mut(np)?.kind
|
||||
{
|
||||
*b = background;
|
||||
*f = flows
|
||||
@@ -115,7 +118,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.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) {
|
||||
|
||||
@@ -616,13 +616,11 @@ fn document_colors_are_counted_per_page() {
|
||||
assert!(v["colors"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// `guide.add`, `guide.move` and `guide.remove` edit the current page's guides by index.
|
||||
#[test]
|
||||
fn guides_add_move_remove() {
|
||||
let mut s = sess();
|
||||
let guides = |s: &Session| match &s.doc().unwrap().node(s.page().unwrap()).unwrap().kind {
|
||||
NodeKind::Page { guides, .. } => guides.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
let guides = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().guides.clone();
|
||||
let r = s.execute("guide.add", &json!({"vertical": true, "offset": 120})).unwrap();
|
||||
assert_eq!(r["index"], json!(0));
|
||||
s.execute("guide.add", &json!({"vertical": false, "offset": 40.5})).unwrap();
|
||||
|
||||
@@ -183,6 +183,8 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() {
|
||||
assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
|
||||
}
|
||||
|
||||
/// `page.duplicate` copies the page's flows (pointing at the copied frames), prototype settings
|
||||
/// and guides.
|
||||
#[test]
|
||||
fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
|
||||
let mut s = Session::new();
|
||||
@@ -195,9 +197,12 @@ fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
|
||||
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 state = |s: &Session, id: NodeId| {
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
match &n.kind {
|
||||
NodeKind::Page { flows, prototype, .. } => (flows.clone(), prototype.clone(), n.guides.clone()),
|
||||
_ => panic!("expected a page"),
|
||||
}
|
||||
};
|
||||
let (flows, prototype, guides) = state(&s, copy);
|
||||
let (src_flows, src_prototype, src_guides) = state(&s, source);
|
||||
|
||||
@@ -19,11 +19,11 @@ impl<'d> Exporter<'d> {
|
||||
("phase", e("CREATED")),
|
||||
("parentIndex", o(vec![("guid", parent_guid), ("position", s(&position(index)))])),
|
||||
];
|
||||
let children = if let NodeKind::Page { background, guides, prototype, .. } = &n.kind {
|
||||
let children = if let NodeKind::Page { background, prototype, .. } = &n.kind {
|
||||
f.extend([("type", e("CANVAS")), ("name", s(&n.name))]);
|
||||
f.extend(root_fields());
|
||||
f.extend([("backgroundColor", color(*background)), ("backgroundOpacity", fl(background.a.into())), ("backgroundEnabled", b(true))]);
|
||||
if !guides.is_empty() {
|
||||
if !n.guides.is_empty() {
|
||||
self.warn("ruler guides were left out");
|
||||
}
|
||||
if *prototype != Default::default() {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Ruler guides in `.figcraft` files: a reopened page keeps its guides, and files saved with a
|
||||
//! page's guides written twice open again (#1476).
|
||||
|
||||
use figcraft_doc::{Document, Guide, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A guide: vertical (at an x offset) or horizontal (at a y offset).
|
||||
fn guide(vertical: bool, offset: f64) -> Guide {
|
||||
Guide { vertical, offset }
|
||||
}
|
||||
|
||||
/// Page and frame guides survive saving, reopening, saving the reopened file and opening it again.
|
||||
#[test]
|
||||
fn page_and_frame_guides_survive_save_and_reopen() {
|
||||
let mut d = Document::new("ruler");
|
||||
let page = d.first_page().unwrap();
|
||||
let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
d.node_mut(page).unwrap().guides_mut().extend([guide(true, 120.0), guide(false, -30.5)]);
|
||||
d.node_mut(frame).unwrap().guides_mut().push(guide(true, 12.0));
|
||||
let (once, issues) = load_bytes(&save_bytes(&d, None).unwrap()).unwrap();
|
||||
assert!(issues.is_empty(), "{issues:?}");
|
||||
assert_eq!(once.node(page).unwrap().guides(), d.node(page).unwrap().guides());
|
||||
assert_eq!(once.node(frame).unwrap().guides(), d.node(frame).unwrap().guides());
|
||||
// Saving the reopened file and opening it again (where the file stopped opening).
|
||||
let text = serde_json::to_string(&to_json(&once).unwrap()).unwrap();
|
||||
assert_eq!(text.matches("\"guides\"").count(), 2, "one list on the page, one on the frame: {text}");
|
||||
let (twice, _) = load_bytes(&save_bytes(&once, None).unwrap()).unwrap();
|
||||
assert_eq!(twice.node(page).unwrap().guides(), d.node(page).unwrap().guides());
|
||||
}
|
||||
|
||||
/// A `document.json` with a page's `guides` key twice (as saving a reopened page wrote it) opens
|
||||
/// with both lists.
|
||||
#[test]
|
||||
fn a_file_with_a_page_guides_list_written_twice_opens() {
|
||||
let mut d = Document::new("twice");
|
||||
let page = d.first_page().unwrap();
|
||||
d.node_mut(page).unwrap().guides_mut().push(guide(false, 7.0));
|
||||
let text = serde_json::to_string(&to_json(&d).unwrap()).unwrap();
|
||||
// What saving a reopened page wrote: the guides added since, then the ones the file had.
|
||||
let broken = text.replacen("\"guides\":[", "\"guides\":[{\"vertical\":true,\"offset\":5.0}],\"guides\":[", 1);
|
||||
assert_eq!(broken.matches("\"guides\"").count(), 2);
|
||||
let (back, _) = from_json(&broken).unwrap();
|
||||
assert_eq!(back.node(page).unwrap().guides(), &[guide(true, 5.0), guide(false, 7.0)]);
|
||||
}
|
||||
@@ -977,6 +977,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), Form
|
||||
#[cfg(test)]
|
||||
mod branch_tests;
|
||||
#[cfg(test)]
|
||||
mod guides_tests;
|
||||
#[cfg(test)]
|
||||
mod image_budget_tests;
|
||||
#[cfg(test)]
|
||||
mod image_tests;
|
||||
|
||||
Reference in New Issue
Block a user