* 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:
parent
eb2fb2634f
commit
3c3dd881ce
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"]}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue