diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 036990b5..3c07dc1c 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -391,6 +391,7 @@ fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&m let (r, damage) = s.edit(label, |doc, _| { let snapshot = doc.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let auto_named = is_auto_named(l); let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other(format!("layer {} is a {} layer, not a type layer", id.0, l.content.kind_name()))); }; @@ -400,6 +401,10 @@ fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&m // Only this layer's pixels changed: the canvas recomposites their old and new area // instead of the whole document (#124). Unknown old pixels mean a full refresh. let damage = before.zip(t.cache.as_ref().map(|c| c.tile_bounds())).map(|(a, b)| a.union(&b)); + let name = auto_named.then(|| layer_name(&t.text)); + if let Some(n) = name { + l.name = n; + } Ok((r, damage)) })?; if let Some(st) = s.active_mut() { @@ -408,8 +413,16 @@ fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&m Ok(r) } -fn layer_name(text: &str) -> String { - let first = text.lines().next().unwrap_or("").trim(); +/// Whether a type layer still has the name it got from its text, so the name follows edits to +/// the text. A layer renamed to anything else keeps its name (#483). +pub(crate) fn is_auto_named(l: &Layer) -> bool { + matches!(&l.content, LayerContent::Text(t) if l.name == layer_name(&t.text)) +} + +/// The name a type layer gets from its text: its first non-empty line, at most 40 characters. +/// The Type tool names new layers with this too, so `is_auto_named` recognises them. +pub fn layer_name(text: &str) -> String { + let first = text.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or(""); let name: String = first.chars().take(40).collect(); if name.is_empty() { "Type Layer".into() } else { name } } @@ -773,6 +786,54 @@ mod tests { assert!(s.active().unwrap().doc.layer(LayerId(id)).is_none()); } + fn layer_name_of(s: &Session, id: u64) -> String { + s.active().unwrap().doc.layer(LayerId(id)).unwrap().name.clone() + } + + #[test] + fn auto_name_follows_the_text() { + // The Type tool's flow (#483): a placeholder, selected, then replaced by typing. + let mut s = session(); + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "Lorem Ipsum", "coalesce": "k"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(layer_name_of(&s, id), "Lorem Ipsum"); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 0, "end": 11, "text": "H"}, "coalesce": "k"})).unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 1, "end": 1, "text": "eading\nsecond line"}, "coalesce": "k"})).unwrap(); + assert_eq!(layer_name_of(&s, id), "Heading"); + // Emptied, the name is the default one and keeps following. + s.execute("type.edit", json!({"layer": id, "text": ""})).unwrap(); + assert_eq!(layer_name_of(&s, id), "Type Layer"); + s.execute("type.edit", json!({"layer": id, "text": "Title"})).unwrap(); + assert_eq!(layer_name_of(&s, id), "Title"); + assert!(s.undo()); + assert_eq!(layer_name_of(&s, id), "Type Layer"); + } + + #[test] + fn auto_name_skips_leading_blank_lines() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "\n \nHello\nworld"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(layer_name_of(&s, id), "Hello"); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 0, "end": 0, "text": "\n"}})).unwrap(); + s.execute("type.edit", json!({"layer": id, "text": "\n\nGoodbye"})).unwrap(); + assert_eq!(layer_name_of(&s, id), "Goodbye"); + } + + #[test] + fn custom_name_survives_text_edits() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "Hello", "name": "Logo"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.edit", json!({"layer": id, "text": "Goodbye"})).unwrap(); + assert_eq!(layer_name_of(&s, id), "Logo"); + // Renamed after creation: kept too. + let id = s.execute("type.create", json!({"x": 0, "y": 80, "text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.renameLayer", json!({"layer": id, "name": "Caption"})).unwrap(); + s.execute("type.edit", json!({"layer": id, "text": "Goodbye"})).unwrap(); + assert_eq!(layer_name_of(&s, id), "Caption"); + // A name given with the edit wins over the text. + s.execute("type.edit", json!({"layer": id, "text": "Again", "name": "Footer"})).unwrap(); + assert_eq!(layer_name_of(&s, id), "Footer"); + } + #[test] fn edit_replace_keeps_styles() { let mut s = session(); diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index 0d58bf27..654cffe9 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -14,7 +14,7 @@ use photocraft_text::render::PathEl; use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; -use crate::type_cmds::{refresh, replace_text, style_paragraphs, style_range}; +use crate::type_cmds::{is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range}; use crate::{EngineError, Result, Session}; /// Photoshop's Paste Lorem Ipsum filler. @@ -70,11 +70,16 @@ fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Tex s.edit(label, |doc, _| { let snapshot = doc.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let auto_named = is_auto_named(l); let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other(format!("layer {} is a {} layer, not a type layer", id.0, l.content.kind_name()))); }; let r = f(t, &snapshot)?; refresh(&snapshot, t); + let name = auto_named.then(|| layer_name(&t.text)); + if let Some(n) = name { + l.name = n; + } Ok(r) }) } @@ -389,7 +394,7 @@ fn paste_lorem(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (w, h) = (d.doc.size.width as f64, d.doc.size.height as f64); let size = p.get("size").and_then(Value::as_f64).unwrap_or((h / 20.0 * 72.0 / f64::from(d.doc.resolution_dpi)).clamp(6.0, 72.0)); - let r = s.execute("type.create", json!({"box": [w * 0.1, h * 0.1, w * 0.8, h * 0.8], "text": LOREM_IPSUM, "size": size, "name": "Lorem ipsum"}))?; + let r = s.execute("type.create", json!({"box": [w * 0.1, h * 0.1, w * 0.8, h * 0.8], "text": LOREM_IPSUM, "size": size}))?; Ok(json!({"layer": r["layer"]})) } diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index b1ec590f..ecdb9218 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -553,7 +553,7 @@ pub fn commit(app: &mut PhotocraftApp) { if text.trim().is_empty() && ed.created { let _ = app.run("layer.delete", json!({"layer": ed.layer})); } else if ed.created { - let name: String = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().chars().take(40).collect(); + let name = photocraft_engine::type_cmds::layer_name(&text); let _ = app.run("type.edit", json!({"layer": ed.layer, "name": name, "coalesce": ed.session})); } } @@ -1385,6 +1385,20 @@ mod tests { assert!(app.ui.text_edit.is_none()); } + #[test] + fn a_name_from_text_after_blank_lines_keeps_following_the_text() { + let mut app = app(); + pointer_up(&mut app, [50.0, 100.0], [50.0, 100.0]); + insert(&mut app, "\n\nTitle"); + let id = app.ui.text_edit.as_ref().unwrap().layer; + commit(&mut app); + let name = |app: &PhotocraftApp| app.session.active().unwrap().doc.layers.last().unwrap().name.clone(); + assert_eq!(name(&app), "Title"); + // Edited later, outside the session that created it (#483). + app.run("type.edit", json!({"layer": id, "text": "\nSubtitle"})).unwrap(); + assert_eq!(name(&app), "Subtitle"); + } + #[test] fn dragging_a_box_handle_resizes_the_paragraph_box() { let mut app = app();