diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/agent_tasks.rs index 64d3b17ca9..f740f4fe06 100644 --- a/crates/automation/tests/agent_tasks.rs +++ b/crates/automation/tests/agent_tasks.rs @@ -214,3 +214,54 @@ async fn agent_completes_ten_scripted_tasks() { c.cancel().await.unwrap(); std::fs::remove_dir_all(dir).unwrap(); } + +/// Click just after "Big", insert a word, then bold through the end of the line. Geometry comes +/// from `type.hitTest` / `type.caret` / `type.navigate`; the edit is the existing `type.edit`. +#[tokio::test(flavor = "multi_thread")] +async fn agent_edits_text_by_position() { + let dir = tmp("type-caret"); + let c = connect(&dir).await; + tool(&c, "doc_new", json!({"width": 480, "height": 200, "background": "white"})).await; + let listed = tool(&c, "command_list", json!({"filter": "type."})).await; + let ids: Vec<&str> = listed.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); + for id in ["type.hitTest", "type.caret", "type.navigate"] { + assert!(ids.contains(&id), "command_list missing {id}: {ids:?}"); + } + + run(&c, "type.create", json!({"x": 40, "y": 90, "text": "Big Sale", "size": 48})).await; + let before = inspect(&c).await["history"].as_array().unwrap().len(); + // The caret after "Big" and the caret after the following space. A quarter of the way is + // the left half of that space, which hit-tests as the caret after "Big". + let after_big = run(&c, "type.caret", json!({"index": 3})).await; + let after_space = run(&c, "type.caret", json!({"index": 4})).await; + let seg = after_big["segment"].as_array().unwrap(); + let (x3, y0, y1) = (seg[0][0].as_f64().unwrap(), seg[0][1].as_f64().unwrap(), seg[1][1].as_f64().unwrap()); + let x4 = after_space["segment"][0][0].as_f64().unwrap(); + assert!(x4 > x3 + 0.5, "space should have width, caret x {x3} -> {x4}"); + let hit = run(&c, "type.hitTest", json!({"x": x3 + 0.25 * (x4 - x3), "y": (y0 + y1) / 2.0})).await; + assert_eq!(hit["index"].as_u64(), Some(3), "{hit}"); + assert_eq!(hit["inside"], true, "{hit}"); + let quiet = inspect(&c).await["history"].as_array().unwrap().len(); + assert_eq!(quiet, before, "caret queries are not history steps"); + + run(&c, "type.edit", json!({"replace": {"start": hit["index"], "end": hit["index"], "text": " Summer"}})).await; + let info = run(&c, "type.info", json!({})).await; + assert_eq!(info["text"], "Big Summer Sale", "{info}"); + + let end = run(&c, "type.navigate", json!({"index": 0, "move": "lineEnd"})).await; + let n = info["text"].as_str().unwrap().chars().count() as u64; + assert_eq!(end["index"].as_u64(), Some(n), "{end}"); + // "Summer" starts one character after the insertion caret (the space in " Summer"). + let summer = hit["index"].as_u64().unwrap() + 1; + run(&c, "type.setStyle", json!({"range": [summer, end["index"]], "fauxBold": true})).await; + let info = run(&c, "type.info", json!({})).await; + assert_eq!(info["text"], "Big Summer Sale"); + let bold = info["runs"].as_array().unwrap().iter().find(|r| r["style"]["faux_bold"] == true); + let bold = bold.unwrap_or_else(|| panic!("no bold run in {info}")); + assert_eq!(bold["start"].as_u64(), Some(summer), "{bold}"); + assert_eq!(bold["end"].as_u64(), end["index"].as_u64(), "{bold}"); + + tool(&c, "doc_close", json!({})).await; + c.cancel().await.unwrap(); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 9d53f66845..b560b107c7 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -961,6 +961,7 @@ fn build() -> Vec { v.extend(crate::type_extra_cmds::specs()); v.extend(crate::type_styles_cmds::specs()); v.extend(crate::type_spell_cmds::specs()); + v.extend(crate::type_caret_cmds::specs()); v.extend(crate::smart_cmds::specs()); v.extend(crate::layer_multi_cmds::specs()); v.extend(crate::prefs::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 009dad0b9c..f5ec24cef5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -77,6 +77,7 @@ pub mod symmetry_cmds; mod timeline_cmds; pub mod transform_cmds; mod trap_cmds; +pub mod type_caret_cmds; pub mod type_cmds; pub mod type_extra_cmds; pub mod type_spell_cmds; diff --git a/crates/engine/src/type_caret_cmds.rs b/crates/engine/src/type_caret_cmds.rs new file mode 100644 index 0000000000..73e047343e --- /dev/null +++ b/crates/engine/src/type_caret_cmds.rs @@ -0,0 +1,443 @@ +//! Headless caret geometry for type layers: `type.hitTest`, `type.caret`, `type.navigate`. +//! +//! Queries (`journal: false`, no history step). Positions are document pixels, so an agent never +//! touches text space; offsets are character indices, the same unit as `type.edit` and +//! `type.setStyle`. The Type tool calls the same word, line and hit-test helpers. + +use photocraft_doc::{Affine, Document, LayerContent, LayerId}; +use photocraft_geom::{Point, Rect}; +use photocraft_text::TextLayout; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +fn req_f64(p: &Value, key: &str, cmd: &str) -> Result { + match p.get(key) { + Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number"))), + None => Err(bad(cmd, format!("missing `{key}`"))), + } +} + +/// `x` when present. Absent is fine; a present non-number is not. +fn opt_f64(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(_) => req_f64(p, key, cmd).map(Some), + } +} + +fn req_index(p: &Value, key: &str, cmd: &str) -> Result { + let Some(v) = p.get(key) else { return Err(bad(cmd, format!("missing `{key}`"))) }; + let n = v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a character index")))?; + usize::try_from(n).map_err(|_| bad(cmd, format!("`{key}` is out of range"))) +} + +fn check_index(text: &str, index: usize, cmd: &str) -> Result<()> { + let n = text.chars().count(); + if index > n { Err(bad(cmd, format!("index {index} is past the end of the text ({n} characters)"))) } else { Ok(()) } +} + +fn resolve_layer(s: &Session, p: &Value, cmd: &str) -> Result { + match p.get("layer") { + Some(v) if !v.is_null() => { + let id = v.as_u64().ok_or_else(|| bad(cmd, "`layer` must be a layer id"))?; + Ok(LayerId(id)) + } + _ => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), + } +} + +/// A type layer laid out once, plus the bits of it the queries need after the borrow ends. +struct Frame { + id: LayerId, + text: String, + transform: Affine, + /// Rendered ink in document pixels (empty text has none). + ink: Option, + layout: TextLayout, +} + +fn frame(doc: &Document, id: LayerId, cmd: &str) -> Result { + let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + let LayerContent::Text(t) = &layer.content else { + return Err(bad(cmd, format!("layer {} is a {} layer, not a type layer", id.0, layer.content.kind_name()))); + }; + let text = t.text.clone(); + let transform = t.transform; + let ink = t.cache.as_ref().map(|c| c.content_bounds()).filter(|r| !r.is_empty()); + let dpi = doc.resolution_dpi; + let layout = { + let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); + eng.layout(t, dpi) + }; + Ok(Frame { id, text, transform, ink, layout }) +} + +fn to_text(aff: Affine, x: f64, y: f64) -> (f32, f32) { + let p = aff.inverse().unwrap_or(Affine::IDENTITY).apply(Point::new(x, y)); + (p.x as f32, p.y as f32) +} + +fn doc_point(aff: Affine, x: f32, y: f32, cmd: &str) -> Result<[f64; 2]> { + let p = aff.apply(Point::new(f64::from(x), f64::from(y))); + if p.x.is_finite() && p.y.is_finite() { Ok([p.x, p.y]) } else { Err(bad(cmd, "the caret is not a finite point")) } +} + +impl Frame { + /// Same hit as the Type tool, without its zoom slop: the laid-out line boxes or the rendered ink. + fn contains(&self, x: f64, y: f64) -> bool { + let (tx, ty) = to_text(self.transform, x, y); + photocraft_text::text_point_inside(&self.layout, tx, ty, 0.0) + || self.ink.is_some_and(|r| x >= f64::from(r.x0) && x <= f64::from(r.x1) && y >= f64::from(r.y0) && y <= f64::from(r.y1)) + } +} + +/// Topmost visible type layer under the point. `walk` is bottom-up; the Type tool hits the top first. +fn topmost(doc: &Document, x: f64, y: f64) -> Option { + let mut ids: Vec = + doc.walk().into_iter().filter(|(_, _, l)| l.visible && matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); + ids.reverse(); + ids.into_iter().find_map(|id| { + let f = frame(doc, id, "type.hitTest").ok()?; + f.contains(x, y).then_some(f) + }) +} + +/// Line-space column for a document x. Translate and scale keep that x as the same column on +/// every line; when a rotation makes document x independent of the column, the caret's own +/// column is kept (there is nothing to solve for). +fn column(layout: &TextLayout, aff: Affine, text: &str, index: usize, doc_x: Option) -> f32 { + let (x, top, bottom) = layout.caret(photocraft_text::byte_index(text, index)); + let Some(doc_x) = doc_x else { return x }; + let y = f64::from(top + bottom) * 0.5; + let [a, _, c, _, e, _] = aff.m; + // Horizontal: text = (line_x, line_y). Vertical: text = (-line_y, line_x). + let (coef, along) = if layout.vertical { (c, doc_x - e + a * y) } else { (a, doc_x - e - c * y) }; + if coef.abs() < 1e-8 { + return x; + } + let solved = along / coef; + if solved.is_finite() { solved as f32 } else { x } +} + +fn hit_test(s: &Session, p: &Value) -> Result { + const CMD: &str = "type.hitTest"; + let x = req_f64(p, "x", CMD)?; + let y = req_f64(p, "y", CMD)?; + let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); + let frame = match p.get("layer") { + Some(v) if !v.is_null() => { + let id = v.as_u64().ok_or_else(|| bad(CMD, "`layer` must be a layer id"))?; + frame(&doc, LayerId(id), CMD)? + } + _ => topmost(&doc, x, y).ok_or_else(|| bad(CMD, "no type layer under the point"))?, + }; + let (tx, ty) = to_text(frame.transform, x, y); + let (index, line) = photocraft_text::hit_char(&frame.layout, &frame.text, tx, ty); + Ok(json!({ "layer": frame.id.0, "index": index, "line": line, "inside": frame.contains(x, y) })) +} + +fn caret(s: &Session, p: &Value) -> Result { + const CMD: &str = "type.caret"; + let index = req_index(p, "index", CMD)?; + let id = resolve_layer(s, p, CMD)?; + let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); + let frame = frame(&doc, id, CMD)?; + check_index(&frame.text, index, CMD)?; + let byte = photocraft_text::byte_index(&frame.text, index); + let seg = frame.layout.caret_segment(byte); + let p0 = doc_point(frame.transform, seg[0].0, seg[0].1, CMD)?; + let p1 = doc_point(frame.transform, seg[1].0, seg[1].1, CMD)?; + let line = photocraft_text::line_index(&frame.layout, byte); + Ok(json!({ "index": index, "line": line, "segment": [p0, p1] })) +} + +fn navigate(s: &Session, p: &Value) -> Result { + const CMD: &str = "type.navigate"; + let index = req_index(p, "index", CMD)?; + let mov = p.get("move").and_then(Value::as_str).ok_or_else(|| bad(CMD, "missing `move`"))?; + let doc_x = opt_f64(p, "x", CMD)?; + let id = resolve_layer(s, p, CMD)?; + let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); + let frame = frame(&doc, id, CMD)?; + check_index(&frame.text, index, CMD)?; + let next = match mov { + "wordPrev" => photocraft_text::word_boundary(&frame.text, index, false), + "wordNext" => photocraft_text::word_boundary(&frame.text, index, true), + "linePrev" | "lineNext" => { + let dir = if mov == "linePrev" { -1 } else { 1 }; + let x = column(&frame.layout, frame.transform, &frame.text, index, doc_x); + photocraft_text::line_step(&frame.layout, &frame.text, index, x, dir) + } + "lineStart" => photocraft_text::line_edge(&frame.layout, &frame.text, index, false), + "lineEnd" => photocraft_text::line_edge(&frame.layout, &frame.text, index, true), + "start" => 0, + "end" => frame.text.chars().count(), + other => { + return Err(bad(CMD, format!("move {other:?} must be wordPrev, wordNext, linePrev, lineNext, lineStart, lineEnd, start or end"))); + } + }; + Ok(json!({ "index": next })) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "type.hitTest", + label: "Hit-Test Type", + menu: &[], + shortcut: None, + params: r##"{"layer":id?, "x":px, "y":px} → {"layer","index":char,"line","inside":bool}. No layer: the topmost visible type layer whose laid-out text or rendered pixels contain the point (same order as the Type tool); a miss is an error. With a layer, index is the nearest caret even when inside is false"##, + enabled: has_doc, + journal: false, + run: |s, p| hit_test(s, p), + }, + CommandSpec { + id: "type.caret", + label: "Type Caret", + menu: &[], + shortcut: None, + params: r##"{"layer":id?, "index":char (0..=length; empty text is 0)} → {"index","line","segment":[[x,y],[x,y]]} caret segment in document pixels, for rotated and vertical type too"##, + enabled: has_doc, + journal: false, + run: |s, p| caret(s, p), + }, + CommandSpec { + id: "type.navigate", + label: "Navigate Type", + menu: &[], + shortcut: None, + params: r##"{"layer":id?, "index":char, "move":"wordPrev|wordNext|linePrev|lineNext|lineStart|lineEnd|start|end", "x":px?} → {"index"}. Words are alphanumeric runs. x is the document x to keep across linePrev/lineNext (a caret segment's x); omitted uses the caret's own column. Empty text returns index 0"##, + enabled: has_doc, + journal: false, + run: |s, p| navigate(s, p), + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + s + } + + fn create(s: &mut Session, p: Value) -> u64 { + s.execute("type.create", p).unwrap()["layer"].as_u64().unwrap() + } + + fn u(v: &Value, k: &str) -> u64 { + v[k].as_u64().unwrap_or_else(|| panic!("missing {k} in {v}")) + } + + /// Caret midpoint hit-tests back to the same character. `inside` is asserted for axis-aligned + /// type; a rotation can land an edge caret a fraction of a pixel outside the line box. + fn round_trip(s: &mut Session, id: u64, check_inside: bool) { + let info = s.execute("type.info", json!({"layer": id})).unwrap(); + let text = info["text"].as_str().unwrap(); + let lines = info["lines"].as_array().unwrap(); + let n = text.chars().count(); + for i in 0..=n { + let c = s.execute("type.caret", json!({"layer": id, "index": i})).unwrap(); + assert_eq!(u(&c, "index"), i as u64, "{text:?} caret {i}: {c}"); + let seg = c["segment"].as_array().unwrap(); + let x = (seg[0][0].as_f64().unwrap() + seg[1][0].as_f64().unwrap()) / 2.0; + let y = (seg[0][1].as_f64().unwrap() + seg[1][1].as_f64().unwrap()) / 2.0; + assert!(x.is_finite() && y.is_finite(), "{c}"); + let h = s.execute("type.hitTest", json!({"layer": id, "x": x, "y": y})).unwrap(); + assert_eq!(u(&h, "index"), i as u64, "{text:?} index {i} at ({x}, {y}) -> {h}"); + assert_eq!(h["line"], c["line"], "{text:?} index {i}"); + assert_eq!(u(&h, "layer"), id); + let on_line = lines.iter().position(|ln| { + let a = ln["start"].as_u64().unwrap(); + let b = ln["end"].as_u64().unwrap(); + (i as u64) >= a && (i as u64) <= b + }); + let expected = on_line.unwrap_or(lines.len().saturating_sub(1)) as u64; + assert_eq!(u(&c, "line"), expected, "{text:?} index {i} lines {lines:?}"); + if check_inside { + assert_eq!(h["inside"], true, "{text:?} index {i} at ({x}, {y})"); + } + } + } + + #[test] + fn commands_are_registered_queries() { + for id in ["type.hitTest", "type.caret", "type.navigate"] { + let spec = crate::command_specs().iter().find(|c| c.id == id).unwrap_or_else(|| panic!("missing {id}")); + assert!(!spec.journal, "{id}"); + assert!(spec.menu.is_empty(), "{id}"); + } + } + + #[test] + fn caret_midpoint_round_trips_for_point_paragraph_transformed_and_vertical() { + let mut s = session(); + let point = create(&mut s, json!({"x": 40, "y": 70, "text": "AäB", "size": 28})); + round_trip(&mut s, point, true); + + let para = create(&mut s, json!({"box": [15, 20, 64, 180], "text": "one two three four six seven eight nine", "size": 16})); + let nlines = s.execute("type.info", json!({"layer": para})).unwrap()["lines"].as_array().unwrap().len(); + assert!(nlines >= 2, "paragraph should wrap, got {nlines} lines"); + round_trip(&mut s, para, true); + + let turned = create(&mut s, json!({"x": 30, "y": 40, "text": "Rotate me", "size": 22})); + let ang = 0.55_f64; + let scale = 1.35; + let (sn, cs) = ang.sin_cos(); + s.execute("type.edit", json!({"layer": turned, "transform": [cs * scale, sn * scale, -sn * scale, cs * scale, 130.0, 80.0]})).unwrap(); + round_trip(&mut s, turned, false); + + let vert = create(&mut s, json!({"x": 200, "y": 30, "text": "Abc\nDef", "size": 20})); + s.execute("type.orientation.vertical", json!({"layer": vert})).unwrap(); + round_trip(&mut s, vert, false); + } + + fn go(s: &mut Session, id: u64, index: usize, mov: &str, x: Option) -> u64 { + let mut p = json!({"layer": id, "index": index, "move": mov}); + if let Some(x) = x { + p["x"] = json!(x); + } + u(&s.execute("type.navigate", p).unwrap(), "index") + } + + #[test] + fn navigate_words_lines_edges_and_keeps_a_column() { + let mut s = session(); + let id = create(&mut s, json!({"x": 20, "y": 60, "text": "hello big world", "size": 18})); + assert_eq!(go(&mut s, id, 0, "wordNext", None), 5); + assert_eq!(go(&mut s, id, 5, "wordNext", None), 9); + assert_eq!(go(&mut s, id, 9, "wordNext", None), 15); + assert_eq!(go(&mut s, id, 15, "wordNext", None), 15); + assert_eq!(go(&mut s, id, 15, "wordPrev", None), 10); + assert_eq!(go(&mut s, id, 7, "wordPrev", None), 6); + assert_eq!(go(&mut s, id, 6, "wordPrev", None), 0); + assert_eq!(go(&mut s, id, 0, "wordPrev", None), 0); + assert_eq!(go(&mut s, id, 4, "start", None), 0); + assert_eq!(go(&mut s, id, 4, "end", None), 15); + assert_eq!(go(&mut s, id, 4, "lineStart", None), 0); + assert_eq!(go(&mut s, id, 4, "lineEnd", None), 15); + + // A newline is not part of either word; line edges agree with type.info. + let id = create(&mut s, json!({"x": 20, "y": 120, "text": "hello\nworld", "size": 18})); + assert_eq!(go(&mut s, id, 0, "wordNext", None), 5); + assert_eq!(go(&mut s, id, 5, "wordNext", None), 11); + assert_eq!(go(&mut s, id, 6, "wordPrev", None), 0); + let info = s.execute("type.info", json!({"layer": id})).unwrap(); + let lines = info["lines"].as_array().unwrap(); + assert_eq!(lines.len(), 2, "{lines:?}"); + let (l0s, l0e) = (lines[0]["start"].as_u64().unwrap(), lines[0]["end"].as_u64().unwrap()); + let (l1s, l1e) = (lines[1]["start"].as_u64().unwrap(), lines[1]["end"].as_u64().unwrap()); + assert_eq!(go(&mut s, id, 2, "lineEnd", None), l0e); + assert_eq!(go(&mut s, id, 2, "lineStart", None), l0s); + assert_eq!(go(&mut s, id, l1s as usize + 1, "lineStart", None), l1s); + assert_eq!(go(&mut s, id, l1s as usize, "lineEnd", None), l1e); + let next = go(&mut s, id, 1, "lineNext", None); + assert!((l1s..=l1e).contains(&next), "lineNext {next} not on {l1s}..={l1e}"); + assert_eq!(go(&mut s, id, 0, "linePrev", None), 0); + assert_eq!(go(&mut s, id, l1e as usize, "lineNext", None), "hello\nworld".chars().count() as u64); + + // Repeated line moves keep the document x of the original column. + let id = create(&mut s, json!({"x": 24, "y": 200, "text": "WWWWWW\nI", "size": 28})); + let info = s.execute("type.info", json!({"layer": id})).unwrap(); + let lines = info["lines"].as_array().unwrap(); + let end0 = lines[0]["end"].as_u64().unwrap(); + let (start1, end1) = (lines[1]["start"].as_u64().unwrap(), lines[1]["end"].as_u64().unwrap()); + assert!(end1 > start1, "{lines:?}"); + let caret0 = s.execute("type.caret", json!({"layer": id, "index": 0})).unwrap(); + let x = caret0["segment"][0][0].as_f64().unwrap(); + assert_eq!(go(&mut s, id, end0 as usize, "lineNext", Some(x)), start1); + assert_eq!(go(&mut s, id, end0 as usize, "lineNext", None), end1); + assert_eq!(go(&mut s, id, start1 as usize, "linePrev", Some(x)), lines[0]["start"].as_u64().unwrap()); + + // Queries do not record history or a journal entry. + let steps = s.active().unwrap().history.entries().len(); + let rev = s.active().unwrap().revision; + let journal = s.journal.len(); + s.execute("type.hitTest", json!({"layer": id, "x": x, "y": caret0["segment"][0][1]})).unwrap(); + s.execute("type.caret", json!({"layer": id, "index": 1})).unwrap(); + s.execute("type.navigate", json!({"layer": id, "index": 0, "move": "end"})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().len(), steps); + assert_eq!(s.active().unwrap().revision, rev); + assert_eq!(s.journal.len(), journal); + } + + #[test] + fn hit_test_picks_the_topmost_visible_type_layer_and_empty_text_is_index_zero() { + let mut s = session(); + let lower = create(&mut s, json!({"x": 50, "y": 80, "text": "AAAA", "size": 36})); + let upper = create(&mut s, json!({"x": 50, "y": 80, "text": "BBBB", "size": 36})); + let c = s.execute("type.caret", json!({"layer": upper, "index": 1})).unwrap(); + let x = (c["segment"][0][0].as_f64().unwrap() + c["segment"][1][0].as_f64().unwrap()) / 2.0; + let y = (c["segment"][0][1].as_f64().unwrap() + c["segment"][1][1].as_f64().unwrap()) / 2.0; + let hit = s.execute("type.hitTest", json!({"x": x, "y": y})).unwrap(); + assert_eq!(u(&hit, "layer"), upper); + assert_eq!(hit["inside"], true); + s.execute("layer.setProps", json!({"layer": upper, "visible": false})).unwrap(); + let hit = s.execute("type.hitTest", json!({"x": x, "y": y})).unwrap(); + assert_eq!(u(&hit, "layer"), lower); + // A named hidden layer is still queried. + let hit = s.execute("type.hitTest", json!({"layer": upper, "x": x, "y": y})).unwrap(); + assert_eq!(u(&hit, "layer"), upper); + assert!(s.execute("type.hitTest", json!({"x": -40.0, "y": -40.0})).is_err()); + + let empty = create(&mut s, json!({"x": 10, "y": 40, "text": "", "size": 18})); + let c = s.execute("type.caret", json!({"layer": empty, "index": 0})).unwrap(); + assert_eq!(u(&c, "index"), 0); + let x = c["segment"][0][0].as_f64().unwrap(); + let y = (c["segment"][0][1].as_f64().unwrap() + c["segment"][1][1].as_f64().unwrap()) / 2.0; + let hit = s.execute("type.hitTest", json!({"layer": empty, "x": x, "y": y})).unwrap(); + assert_eq!(u(&hit, "index"), 0); + for mov in ["wordNext", "wordPrev", "lineNext", "linePrev", "lineStart", "lineEnd", "start", "end"] { + assert_eq!(go(&mut s, empty, 0, mov, None), 0, "{mov}"); + } + } + + #[test] + fn bad_params_are_errors() { + let mut bare = Session::new(); + assert!(matches!(bare.execute("type.hitTest", json!({"x": 0, "y": 0})), Err(EngineError::Disabled(..)))); + assert!(matches!(bare.execute("type.caret", json!({"index": 0})), Err(EngineError::Disabled(..)))); + assert!(matches!(bare.execute("type.navigate", json!({"index": 0, "move": "end"})), Err(EngineError::Disabled(..)))); + + let mut s = session(); + let id = create(&mut s, json!({"x": 10, "y": 40, "text": "Hi", "size": 20})); + let pixel = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let bad = |s: &mut Session, cmd: &str, p: Value| match s.execute(cmd, p) { + Err(EngineError::BadParams { .. }) => {} + other => panic!("{cmd} -> {other:?}"), + }; + bad(&mut s, "type.hitTest", json!({})); + bad(&mut s, "type.hitTest", json!({"x": "1", "y": 2})); + bad(&mut s, "type.hitTest", json!({"layer": id, "x": 1})); + bad(&mut s, "type.hitTest", json!({"layer": "nope", "x": 1, "y": 2})); + bad(&mut s, "type.hitTest", json!({"layer": pixel, "x": 1, "y": 2})); + bad(&mut s, "type.caret", json!({"layer": id})); + bad(&mut s, "type.caret", json!({"layer": id, "index": -1})); + bad(&mut s, "type.caret", json!({"layer": id, "index": 1.5})); + bad(&mut s, "type.caret", json!({"layer": id, "index": 99})); + bad(&mut s, "type.caret", json!({"layer": pixel, "index": 0})); + bad(&mut s, "type.navigate", json!({"layer": id, "index": 0})); + bad(&mut s, "type.navigate", json!({"layer": id, "index": 0, "move": "left"})); + bad(&mut s, "type.navigate", json!({"layer": id, "index": 9, "move": "end"})); + bad(&mut s, "type.navigate", json!({"layer": id, "index": 0, "move": "lineNext", "x": "no"})); + bad(&mut s, "type.navigate", json!({"layer": pixel, "index": 0, "move": "start"})); + assert!(matches!(s.execute("type.caret", json!({"layer": 9_999_999, "index": 0})), Err(EngineError::NoLayer(_)))); + // A point on the named layer but outside its text still returns a caret, marked outside. + let far = s.execute("type.hitTest", json!({"layer": id, "x": 390, "y": 290})).unwrap(); + assert_eq!(far["inside"], false); + assert!(u(&far, "index") <= 2); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 5b2f218dd8..8046a13318 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -272,6 +272,90 @@ impl TextLayout { } } +/// Byte offset of character `index` (the text length when `index` is past the end). +pub fn byte_index(text: &str, index: usize) -> usize { + text.char_indices().nth(index).map_or(text.len(), |(b, _)| b) +} + +/// Character index of byte `byte`, floored to a char boundary so a bad offset never panics. +pub fn char_index(text: &str, byte: usize) -> usize { + let mut byte = byte.min(text.len()); + while byte > 0 && !text.is_char_boundary(byte) { + byte -= 1; + } + text[..byte].chars().count() +} + +/// Line containing a byte offset (the last line when the offset sits past every line). +pub fn line_index(layout: &TextLayout, byte: usize) -> usize { + layout.lines.iter().position(|ln| byte >= ln.range.start && byte <= ln.range.end).unwrap_or_else(|| layout.lines.len().saturating_sub(1)) +} + +/// Nearest caret to a text-space point: character index and the line it sits on. +pub fn hit_char(layout: &TextLayout, text: &str, x: f32, y: f32) -> (usize, usize) { + let byte = layout.hit_test(x, y); + (char_index(text, byte), line_index(layout, byte)) +} + +/// Text-space point inside the laid-out line boxes, expanded by `slop` px on every side. +pub fn text_point_inside(layout: &TextLayout, x: f32, y: f32, slop: f32) -> bool { + let slop = if slop.is_finite() { slop.max(0.0) } else { 0.0 }; + layout.bounds().is_some_and(|b| x >= b[0] - slop && x <= b[2] + slop && y >= b[1] - slop && y <= b[3] + slop) +} + +/// Character index of the word boundary before (`forward` is false) or after `idx`. +/// +/// A word is a run of alphanumeric characters, the rule the Type tool has always used. +pub fn word_boundary(text: &str, idx: usize, forward: bool) -> usize { + let chars: Vec = text.chars().collect(); + let mut i = idx.min(chars.len()); + if forward { + while i < chars.len() && !chars[i].is_alphanumeric() { + i += 1; + } + while i < chars.len() && chars[i].is_alphanumeric() { + i += 1; + } + } else { + while i > 0 && !chars[i - 1].is_alphanumeric() { + i -= 1; + } + while i > 0 && chars[i - 1].is_alphanumeric() { + i -= 1; + } + } + i +} + +/// Caret on the neighbouring line (`dir` < 0 previous, otherwise next), keeping `x` +/// (line space: the position along the line). Past the first or last line the caret +/// goes to the start or end of the text. Line space is the same for both orientations, +/// so a column of vertical type steps the same way a line of horizontal type does. +pub fn line_step(layout: &TextLayout, text: &str, idx: usize, x: f32, dir: i32) -> usize { + let n = text.chars().count(); + let idx = idx.min(n); + let (_, top, bottom) = layout.caret(byte_index(text, idx)); + let h = (bottom - top).max(1.0); + let y = if dir < 0 { top - h * 0.5 } else { bottom + h * 0.5 }; + let Some(bounds) = layout.line_bounds() else { return idx }; + if y < bounds[1] { + return 0; + } + if y > bounds[3] { + return n; + } + char_index(text, layout.hit_test_line(x, y)) +} + +/// Line start (`end` false) or end for the line containing `idx`, as a character index. +pub fn line_edge(layout: &TextLayout, text: &str, idx: usize, end: bool) -> usize { + let n = text.chars().count(); + let idx = idx.min(n); + let byte = byte_index(text, idx); + let line = layout.lines.iter().find(|ln| byte >= ln.range.start && byte <= ln.range.end).or(layout.lines.last()); + line.map_or(idx, |ln| char_index(text, if end { ln.range.end } else { ln.range.start })) +} + const LRM: &str = "\u{200E}"; const RLM: &str = "\u{200F}"; /// A line break inside a paragraph (Shift+Return), stored as U+0003 in PSD type. diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index f7fbc654fe..c899f43303 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -34,7 +34,9 @@ use photocraft_doc::TextLayer; pub use craft_fonts::{CRAFT_FONTS, CraftFont}; pub use fonts::{FaceInfo, FontDb, ResolvedFont}; -pub use layout::{ClusterInfo, LineInfo, PlacedGlyph, TextLayout}; +pub use layout::{ + ClusterInfo, LineInfo, PlacedGlyph, TextLayout, byte_index, char_index, hit_char, line_edge, line_index, line_step, text_point_inside, word_boundary, +}; pub use render::Rendered; /// Font database + layout context. Create once and reuse (font loading and shaping caches). diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 4946a9639c..5b107c7934 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1,6 +1,6 @@ use photocraft_color::{Color, PixelFormat, SampleType}; use photocraft_doc::TextLayer; -use photocraft_doc::text::{CharStyle, FontFeature, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape}; +use photocraft_doc::text::{CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape}; use photocraft_geom::Affine; use crate::{TextEngine, fonts}; @@ -848,3 +848,59 @@ fn works_without_craft_fonts() { assert!(!fb.iter().any(|f| f.contains("BIZ UD"))); } } + +#[test] +fn word_and_line_navigation() { + use crate::layout::{byte_index, char_index, hit_char, line_edge, line_index, line_step, word_boundary}; + assert_eq!(word_boundary("hello big world", 0, true), 5); + assert_eq!(word_boundary("hello big world", 7, false), 6); + assert_eq!(word_boundary("hello big world", 15, false), 10); + assert_eq!(word_boundary("hello", 0, false), 0); + assert_eq!(word_boundary("hello", 5, true), 5); + assert_eq!(word_boundary("", 4, true), 0); + assert_eq!(word_boundary("ab", 100, true), 2); + assert_eq!(word_boundary("ab, cd", 0, true), 2); + assert_eq!(word_boundary("ab, cd", 2, true), 6); + assert_eq!(word_boundary("Größe", 0, true), 5); + + let mut e = TextEngine::new(); + let text = "AäB\ncd"; + for vertical in [false, true] { + let mut t = point(text, 20.0); + if vertical { + t.orientation = Orientation::Vertical; + } + let l = e.layout(&t, 72.0); + let n = text.chars().count(); + for i in 0..=n { + let b = byte_index(text, i); + let [(x0, y0), (x1, y1)] = l.caret_segment(b); + let (idx, line) = hit_char(&l, text, (x0 + x1) / 2.0, (y0 + y1) / 2.0); + assert_eq!(idx, i, "vertical {vertical} index {i}"); + assert_eq!(line, line_index(&l, b), "vertical {vertical} index {i}"); + } + assert!(l.lines.len() >= 2, "vertical {vertical}"); + let end0 = char_index(text, l.lines[0].range.end); + let start1 = char_index(text, l.lines[1].range.start); + assert_eq!(line_edge(&l, text, 1, true), end0, "vertical {vertical}"); + assert_eq!(line_edge(&l, text, 1, false), 0, "vertical {vertical}"); + assert_eq!(line_edge(&l, text, start1, false), start1, "vertical {vertical}"); + assert_eq!(line_edge(&l, text, start1, true), n, "vertical {vertical}"); + let x = l.caret(byte_index(text, 0)).0; + let next = line_step(&l, text, 0, x, 1); + assert!((start1..=n).contains(&next), "vertical {vertical} line_step -> {next}"); + assert_eq!(line_step(&l, text, 0, x, -1), 0, "vertical {vertical}"); + assert_eq!(line_step(&l, text, n, x, 1), n, "vertical {vertical}"); + } + + // A remembered column stays on the short line's start; the caret's own x falls off its end. + let text = "WWWWWW\nI"; + let l = e.layout(&point(text, 30.0), 72.0); + let end0 = line_edge(&l, text, 0, true); + // end0 is the first line's end, which is not the second line. Step from there. + let kept = line_step(&l, text, end0, l.caret(0).0, 1); + let jumped = line_step(&l, text, end0, l.caret(byte_index(text, end0)).0, 1); + assert_eq!(kept, char_index(text, l.lines[1].range.start), "kept column"); + assert_eq!(jumped, char_index(text, l.lines[1].range.end), "own column"); + assert!(jumped > kept); +} diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index fe5e83e528..dd7ec90171 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -26,11 +26,7 @@ fn text_layer(doc: &Document, id: LayerId) -> Option<&TextLayer> { } fn byte_of(text: &str, ci: usize) -> usize { - text.char_indices().nth(ci).map_or(text.len(), |(b, _)| b) -} - -fn char_of(text: &str, bi: usize) -> usize { - text[..bi.min(text.len())].chars().count() + photocraft_text::byte_index(text, ci) } /// Layout of a type layer (cached per document revision) and its text → document transform. @@ -70,7 +66,7 @@ fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { }); let Some((l, aff, _)) = layout(app, *id) else { return shown }; let (tx, ty) = to_text(&aff, x, y); - shown || l.bounds().is_some_and(|b| tx >= b[0] - slop && tx <= b[2] + slop && ty >= b[1] - slop && ty <= b[3] + slop) + shown || photocraft_text::text_point_inside(&l, tx, ty, slop) }) } @@ -123,7 +119,7 @@ pub fn edit_active(app: &mut PhotocraftApp) -> Result<(), String> { fn hit_offset(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize { let Some((l, aff, text)) = layout(app, id) else { return 0 }; let (tx, ty) = to_text(&aff, x, y); - char_of(&text, l.hit_test(tx, ty)) + photocraft_text::hit_char(&l, &text, tx, ty).0 } fn hex(c: [f32; 4]) -> String { @@ -349,26 +345,9 @@ fn ime_update(app: &mut PhotocraftApp, s: &str, commit: bool) { } } -/// Character index of the previous / next word boundary. +/// Character index of the previous / next word boundary (shared with `type.navigate`). fn word_boundary(text: &str, from: usize, forward: bool) -> usize { - let chars: Vec = text.chars().collect(); - let mut i = from.min(chars.len()); - if forward { - while i < chars.len() && !chars[i].is_alphanumeric() { - i += 1; - } - while i < chars.len() && chars[i].is_alphanumeric() { - i += 1; - } - } else { - while i > 0 && !chars[i - 1].is_alphanumeric() { - i -= 1; - } - while i > 0 && chars[i - 1].is_alphanumeric() { - i -= 1; - } - } - i + photocraft_text::word_boundary(text, from, forward) } /// Double-click: select the word under the caret. @@ -392,17 +371,8 @@ pub fn select_word(app: &mut PhotocraftApp) { /// along the line. Works in line space, so it serves both orientations. fn line_step(app: &mut PhotocraftApp, id: LayerId, caret: usize, dir: i32) -> usize { let Some((l, _, text)) = layout(app, id) else { return caret }; - let (x, top, bottom) = l.caret(byte_of(&text, caret)); - let h = (bottom - top).max(1.0); - let y = if dir < 0 { top - h * 0.5 } else { bottom + h * 0.5 }; - let Some(b) = l.line_bounds() else { return caret }; - if y < b[1] { - return 0; - } - if y > b[3] { - return text.chars().count(); - } - char_of(&text, l.hit_test_line(x, y)) + let x = l.caret(byte_of(&text, caret)).0; + photocraft_text::line_step(&l, &text, caret, x, dir) } fn is_vertical(app: &PhotocraftApp, id: LayerId) -> bool { @@ -429,9 +399,7 @@ pub(crate) fn flow_key(key: egui::Key, vertical: bool) -> egui::Key { /// Line start / end for the caret's line. fn line_edge(app: &mut PhotocraftApp, id: LayerId, caret: usize, end: bool) -> usize { let Some((l, _, text)) = layout(app, id) else { return caret }; - let b = byte_of(&text, caret); - let line = l.lines.iter().find(|ln| b >= ln.range.start && b <= ln.range.end).or(l.lines.last()); - line.map_or(caret, |ln| char_of(&text, if end { ln.range.end } else { ln.range.start })) + photocraft_text::line_edge(&l, &text, caret, end) } /// Keyboard input while editing. Returns true when a type edit session is active (single-key diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e616c3f848..0ba58c4ed7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -77,6 +77,9 @@ store expose and persist the same setting; scripts keep using canonical command | `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` | | `document.inspect` | `{}`: layer tree, history, selection bounds | | `document.pixel` | `{"x":10,"y":10}`: composite RGBA | +| `type.hitTest` | `{"layer":id?,"x":px,"y":px}`: character under a document point. No `layer` picks the topmost visible type layer there. Result `{"layer","index","line","inside"}` | +| `type.caret` | `{"layer":id?,"index":char}`: caret segment in document pixels, `{"index","line","segment":[[x,y],[x,y]]}` (rotated and vertical type included) | +| `type.navigate` | `{"layer":id?,"index":char,"move":"wordPrev\|wordNext\|linePrev\|lineNext\|lineStart\|lineEnd\|start\|end","x":px?}`: neighbouring caret. `x` keeps the column across line moves. Result `{"index"}` | UI-level commands (`view.zoomIn`, `window.theme.pro`, `edit.search`, …) are also accepted by `engine.execute` and `ui.menu.invoke`. diff --git a/docs/scorecard.md b/docs/scorecard.md index adaa9bc225..4585d41467 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 6 | 13 | 11 | | | [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 58 of 135 (target 0) | | [Type](#type) | 0 | 3 | 6 | | -| [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) | +| [Automation](#automation) | 2 | 2 | 7 | 626 of 626 menu items live (100.0%) | | [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 27 of 27 crates; panic_hunt present | | [Distribution](#distribution) | 4 | 4 | 3 | | | [Open bugs](#open-bugs) | 12 | 3 | 17 | | @@ -213,7 +213,7 @@ Menu wiring (`docs/parity.md`): 626 of 626 menu items live (100.0%). This counts | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | | AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | -| AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | partial | [#219](https://github.com/storytold/photocraft/issues/219) | filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform; no caret API | +| AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/agent_tasks.rs runs 10 workflows over MCP | ## Reliability diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 530e012811..974492ff9c 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -70,9 +70,9 @@ note = "No such xtask check" [[item]] id = "AUTO-219-9" target = "Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles" -status = "partial" +status = "done" issue = 219 -note = "filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform; no caret API" +note = "type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform" [[item]] id = "AUTO-219-10"