Type layers keep their name in step with their text (#483) (#501)

* Type layers keep their name in step with their text (#483)

A type layer is named after its first line when it's created, but
only the Type tool's first editing session renamed it again. Editing
the text later (clicking back into the layer, type.edit, Paste Lorem
Ipsum into it) left the old name, so a layer re-typed from the
placeholder kept saying "Lorem Ipsum".

Like Photoshop, a type layer whose name is still the one its text
gives it now follows every text edit, in the same history step. A
layer named anything else (given a name at creation or renamed later)
keeps it. Paste Lorem Ipsum no longer gives its new layer a fixed
"Lorem ipsum" name, so that layer follows its text too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Type layer names: the first non-empty line, the same in the Type tool and the engine

Review on #501: the Type tool named a new layer after its first
non-empty line, the engine after its first line, so text starting with
a blank line stopped the name following the text. layer_name now takes
the first non-empty line and commit() uses it. Also drop a product name
from a doc comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yang 2026-10-07 03:12:13 -07:00 committed by GitHub
parent eb2fb2634f
commit 3c3dd881ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 85 additions and 5 deletions

View File

@ -391,6 +391,7 @@ fn with_text_layer<R>(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<R>(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<R>(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();

View File

@ -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<R>(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<Value> {
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"]}))
}

View File

@ -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();