diff --git a/crates/engine/src/canvas/textedit.rs b/crates/engine/src/canvas/textedit.rs index 368a48c90..7191f15dd 100644 --- a/crates/engine/src/canvas/textedit.rs +++ b/crates/engine/src/canvas/textedit.rs @@ -184,14 +184,21 @@ fn edit_if(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit) -> bool let doc = ds.doc_mut(); let n = doc.get_mut(te.id)?; let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("not a text layer".into())) }; + let derived_before = crate::cmd::props::text_layer_name(&t.characters); if !f(t, &mut te) { return Ok(false); } // Any edit ends a composition (a commit replaces it with the final text). te.preedit = None; let len = t.char_count(); + let derived_after = crate::cmd::props::text_layer_name(&t.characters); te.caret = te.caret.min(len); te.anchor = te.anchor.min(len); + // The layer's name follows the characters while it is the name they derive, so the Layers + // panel keeps up with what the canvas shows; a name the author typed is left alone. + if crate::cmd::props::text_name_is_derived(&n.name, derived_before.as_ref()) { + n.name = derived_after.unwrap_or_default(); + } if n.source.is_some() { n.overrides.insert("characters".into()); } diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 36e33dd79..41cd260da 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -41,6 +41,30 @@ pub(crate) fn normalize_line_endings(s: &str) -> String { s.replace("\r\n", "\n").replace(['\r', '\u{2028}', '\u{2029}'], "\n") } +/// The name a text layer derives from its characters: the first line, at most 64 characters, or +/// None when that line is blank (a layer with no name of its own, which the Layers panel shows by +/// its kind). +/// +/// A layer whose name is this derived name is one nobody has renamed, so the editor keeps its name +/// in step as the characters are typed ([`crate::textedit`]); any other name was chosen by the +/// author and is kept. Commands never rename, so a layer named for its role keeps that name as a +/// field key even when `characters` replace text that reads like it. +pub(crate) fn text_layer_name(characters: &str) -> Option { + let first: String = characters.lines().next().unwrap_or("").chars().take(64).collect(); + (!first.trim().is_empty()).then_some(first) +} + +/// Whether `name` is one a text layer was given for its characters rather than by its author, so +/// the editor may keep it in step with them. Blank text leaves a layer named `""` when the Text +/// tool draws it and `Text` when `node.create` makes it, and both read as unnamed in the panel. +pub(crate) fn text_name_is_derived(name: &str, derived: Option<&String>) -> bool { + match derived { + Some(d) => name == d, + // `NodeKind::Text(_).label()`, which the panel falls back to for an unnamed layer. + None => name.is_empty() || name == "Text", + } +} + /// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values). pub fn snake(v: &Value) -> Value { match v { diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 1e700c688..12a5dbfbf 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -748,3 +748,71 @@ fn a_link_is_underlined_by_default_and_underline_takes_it_off() { s.execute("text.underline", &json!({"ids": [f]})).unwrap(); assert_eq!(inner_style(&s).decoration, TextDecoration::None, "Underline on the frame reads the text inside it"); } + +fn layer_name(s: &Session, t: NodeId) -> String { + s.doc().unwrap().node(t).unwrap().name.clone() +} + +/// Select everything in the layer being edited and delete it. +fn clear_edited_text(s: &mut Session, t: NodeId) { + let len = s.doc().unwrap().node(t).unwrap().kind.text().unwrap().char_count(); + crate::textedit::set_caret(s, 0, false).unwrap(); + crate::textedit::set_caret(s, len, true).unwrap(); + crate::textedit::key(s, "Backspace", false, false, false).unwrap(); +} + +#[test] +fn typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it() { + // The Layers panel shows a layer's name, so a layer still carrying the name its characters + // gave it has to follow them while they are typed or the panel and the canvas disagree + // (#1747). The target app's plugin API calls this `autoRename`: "Whether updating the + // characters in the text node should update the name of the node… This is automatically reset + // to false if `name` is modified in order to allow the node to keep the new name." + let mut s = Session::new(); + + // Nobody renamed this layer, so typing in it keeps its name up to date. + let t = text(&mut s, "hello", json!({})); + assert_eq!(layer_name(&s, t), "hello", "a new layer is named after its first line"); + crate::textedit::begin(&mut s, t, Some(5), false).unwrap(); + crate::textedit::insert(&mut s, " there").unwrap(); + assert_eq!((chars(&s, t).as_str(), layer_name(&s, t).as_str()), ("hello there", "hello there"), "the name follows what is typed"); + + // A name the author typed is theirs: later typing leaves it alone. + s.execute("node.set", &json!({"id": t, "props": {"name": "Greeting"}})).unwrap(); + crate::textedit::begin(&mut s, t, Some(11), false).unwrap(); + crate::textedit::insert(&mut s, "!").unwrap(); + assert_eq!((chars(&s, t).as_str(), layer_name(&s, t).as_str()), ("hello there!", "Greeting"), "a chosen name is kept"); + + // Only the first line names the layer. + let v = text(&mut s, "first", json!({})); + crate::textedit::begin(&mut s, v, Some(5), false).unwrap(); + crate::textedit::insert(&mut s, "\nsecond").unwrap(); + assert_eq!((chars(&s, v).as_str(), layer_name(&s, v).as_str()), ("first\nsecond", "first"), "the first line alone names it"); + + // Emptied text reads as a new empty text layer does, not as the word that was deleted. + clear_edited_text(&mut s, v); + assert_eq!((chars(&s, v).as_str(), layer_name(&s, v).as_str()), ("", ""), "emptied text has no name of its own, so the panel shows its kind"); + + // The usual way a layer is made: the Text tool, a click, then typing into the empty layer. + use crate::tools::{PointerEvent, PointerKind, ToolId}; + s.set_tool(ToolId::Text); + s.pointer(&PointerEvent::at(PointerKind::Down, 300.0, 300.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 300.0, 300.0, 1.0)).unwrap(); + let drawn = s.ds().unwrap().text_edit.as_ref().unwrap().id; + assert_eq!(layer_name(&s, drawn), "", "a layer the tool drew has no name of its own yet"); + crate::textedit::insert(&mut s, "Pricing").unwrap(); + assert_eq!((chars(&s, drawn).as_str(), layer_name(&s, drawn).as_str()), ("Pricing", "Pricing"), "the first word typed names it"); +} + +#[test] +fn setting_characters_as_a_command_never_renames_the_layer() { + // Only the editor follows the characters. Layer names are field keys for `data.fill`, + // component property bindings and templates, which write `characters` into a layer named for + // its role ("Title") and look it up by that name again, so a command must leave the name as it + // is even when it reads like the text it replaces. + let mut s = Session::new(); + let t = text(&mut s, "Title", json!({})); + assert_eq!(layer_name(&s, t), "Title"); + s.execute("node.set", &json!({"id": t, "props": {"characters": "Site em 7 dias"}})).unwrap(); + assert_eq!((chars(&s, t).as_str(), layer_name(&s, t).as_str()), ("Site em 7 dias", "Title"), "the field keeps its name"); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 6bc72a278..9c9dacf37 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M4, #1747: a text layer still carrying the name its characters gave it is renamed as they are typed, so the Layers panel no longer shows the word a layer started with while the canvas shows another (`hello` typed into `hello there` read as `hello` in the panel). A name the author typed is kept, and only the editor follows the characters: commands leave the name alone, so the layer names `data.fill`, component property bindings and templates use as field keys stay put. Tests: `text_behaviour_tests::typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it`, `setting_characters_as_a_command_never_renames_the_layer`. Grade unchanged | | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 75a426231..5a4effee4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -648,6 +648,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M4, #1747: a text layer still carrying the name its characters gave it is renamed as they are typed, so the Layers panel no longer shows the word a layer started with while the canvas shows another (`hello` typed into `hello there` read as `hello` in the panel). A name the author typed is kept, and only the editor follows the characters: commands leave the name alone, so the layer names `data.fill`, component property bindings and templates use as field keys stay put. Tests: `text_behaviour_tests::typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it`, `setting_characters_as_a_command_never_renames_the_layer`. Grade unchanged | | 2026-11-01 | patch | M4: bulleted lists draw the same bullet at every level of indentation ("Figma bullets will keep the same styling at all levels of indentation", Create bulleted and numbered lists); the deeper levels drew a white bullet and a small square, pinned as silent before. Numbered levels (1., a., i.) are unchanged. Found by a documentation audit of the text rules. Test: `tail_tests::list_numbering_by_level` (the bullets row). Numbers unchanged | | 2026-11-01 | patch | M4: typing `1)` followed by a space at the start of a plain paragraph starts a numbered list, as `1.` already did ("Enter one of the creation characters below followed by a Space: 1. 1)", Create bulleted and numbered lists). Found by a documentation audit of the text rules. Test: `text_tail_tests::typing_a_creation_character_and_a_space_starts_a_list`. Numbers unchanged | | 2026-11-01 | patch | M4: a link made with `text.link` (Cmd/Ctrl+K, Shift+Cmd/Ctrl+U) underlines the linked text unless it has a decoration of its own ("Links are styled with an underline by default. Use the keyboard shortcut to remove the underline", Add links to text); Underline (Cmd/Ctrl+U) takes the underline off and the link stays. Found by a documentation audit of the text rules. Test: `text_behaviour_tests::a_link_is_underlined_by_default_and_underline_takes_it_off`. Numbers unchanged |