mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 02:32:01 +01:00
Type: hitTest, caret and navigate commands so agents can edit text by position (part of #219) (#623)
* Type: hitTest, caret and navigate commands so agents can edit text by position (part of #219) Stateless queries in document pixels. The word, line and hit-test helpers move from type_tool.rs into photocraft-text, and the Type tool calls the same code. * Regenerate docs/scorecard.md after rebase Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Matt Van Horn
Brandon Thomas
Claude Opus 5.5
parent
9e88c65d33
commit
2baa95d684
@@ -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();
|
||||
}
|
||||
|
||||
@@ -961,6 +961,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
fn req_f64(p: &Value, key: &str, cmd: &str) -> Result<f64> {
|
||||
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<Option<f64>> {
|
||||
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<usize> {
|
||||
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<LayerId> {
|
||||
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<Rect>,
|
||||
layout: TextLayout,
|
||||
}
|
||||
|
||||
fn frame(doc: &Document, id: LayerId, cmd: &str) -> Result<Frame> {
|
||||
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<Frame> {
|
||||
let mut ids: Vec<LayerId> =
|
||||
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<f64>) -> 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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<f64>) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<char> = 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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<LayerId> {
|
||||
});
|
||||
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<char> = 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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user