Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 d2e028c3d4 Crop test: the frame starts at the press origin now (#123)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:54:06 -04:00
Brandon ThomasandClaude Opus 5.5 ace983404e Type tool: PSD type edits on the layout it shows; size edits are live, one step per drag, sized as shown (#123, #124)
- Clicking into a PSD type layer whose pixels are still Photoshop's
  re-renders it with our engine first (inside the edit session's history
  step; Cancel restores Photoshop's pixels), so caret and selection sit on
  what is shown. Hit-testing a type layer also accepts its shown pixels.
- type.edit / type.setStyle damage only the layer's old and new pixels, so
  the canvas recomposites that area instead of the whole document.
- Scrubbing a size (or any) type field coalesces per drag: live preview
  every frame, one history step per drag.
- Options bar and Properties show and edit font size and leading as the
  layer appears (size x transform scale, like Photoshop), and the options
  bar shows the size at the selection.
- examples/type_bench: 6000x4000, 50 type layers, size step went from
  297 ms to 4.6 ms (CPU canvas), 489 ms to 10 ms with drop shadows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:54:06 -04:00
Brandon ThomasandClaude Opus 5.5 23e76002de Canvas: tool gestures start at the press origin, not where egui recognised the drag (#123)
A drag is only recognised once the pointer has moved past egui's click
distance; the Down event used the pointer position at that moment, so a
Type tool drag-selection's anchor landed a glyph or more away from where
the button went down. Adds a text-engine test that the caret geometry
(clusters, lines) sits on the rendered glyphs for tracking, horizontal
scale, mixed sizes and wrapped, centred paragraph text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:54:06 -04:00
7 changed files with 550 additions and 38 deletions

No files matched your search

+30 -3
View File
@@ -358,16 +358,24 @@ pub fn refresh(doc: &Document, t: &mut TextLayer) {
fn with_text_layer<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document) -> Result<R>) -> Result<R> {
let id = layer_id(s, p)?;
s.edit(label, |doc, _| {
let (r, damage) = s.edit(label, |doc, _| {
let snapshot = doc.clone();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
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 before = t.cache.as_ref().map(|c| c.tile_bounds());
let r = f(t, &snapshot)?;
refresh(&snapshot, t);
Ok(r)
})
// 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));
Ok((r, damage))
})?;
if let Some(st) = s.active_mut() {
st.last_damage = damage;
}
Ok(r)
}
fn layer_name(text: &str) -> String {
@@ -756,6 +764,25 @@ mod tests {
assert!(!faces["faces"].as_array().unwrap().is_empty());
}
/// #124: a type edit damages only the layer's old and new pixels (the canvas recomposites
/// that, not the document), and a drag's coalesced steps are one history step.
#[test]
fn type_edits_damage_only_the_layer_and_coalesce() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 3000, "height": 2000})).unwrap();
let id = s.execute("type.create", json!({"x": 100, "y": 100, "text": "Hi", "size": 20})).unwrap()["layer"].as_u64().unwrap();
let steps = s.active().unwrap().history.entries().len();
for k in 0..5 {
s.execute("type.setStyle", json!({"layer": id, "size": 30 + k * 10, "coalesce": "drag"})).unwrap();
let d = s.active().unwrap().last_damage.expect("damage rect");
let r = text_layer(&s, id).cache.unwrap().content_bounds();
assert!(d.intersect(&r) == r && d.width() <= 512 && d.height() <= 512, "{d:?} {r:?}");
}
assert_eq!(s.active().unwrap().history.entries().len(), steps + 1);
assert!(s.undo());
assert_eq!(text_layer(&s, id).runs[0].style.size_pt, 20.0);
}
#[test]
fn style_range_on_empty_text() {
let mut t = TextLayer::default();
+44
View File
@@ -485,3 +485,47 @@ fn engine_data_template_without_engine_dict() {
assert!(matches!(text, Some(E::String(s)) if s == "Hi\r"), "{text:?}");
}
}
/// #123: the caret geometry (clusters, lines) sits on the rendered glyphs, so a click on a glyph
/// lands next to it: its left part before it, its right part after it. Plain text, tracking,
/// horizontal scale, mixed sizes, and centred, wrapped paragraph text.
#[test]
fn clusters_sit_on_rendered_glyphs() {
let mut e = TextEngine::new();
let big = CharStyle { size_pt: 40.0, ..Default::default() };
let small = CharStyle { size_pt: 18.0, ..Default::default() };
let mixed =
TextLayer { text: "HOHOHO".into(), runs: vec![TextRun { len: 3, style: big.clone() }, TextRun { len: 3, style: small.clone() }], ..Default::default() };
let boxed = with_para(
TextLayer { shape: TextShape::Box { x: 0.0, y: 0.0, width: 150.0, height: 200.0 }, ..styled("HOH HOH HOH HOH", small.clone()) },
ParagraphStyle { align: TextAlign::Center, ..Default::default() },
);
let cases = [
("plain", styled("HOHOH", big.clone())),
("tracking", styled("HOHOH", CharStyle { tracking: 300.0, ..big.clone() })),
("hscale", styled("HOHOH", CharStyle { horizontal_scale: 1.6, ..big.clone() })),
("mixed", mixed),
("box", boxed),
];
for (name, t) in cases {
let t = TextLayer { transform: Affine::translate(7.0, 60.0), ..t };
let (l, r) = e.render(&t, 72.0, PixelFormat::RGBA8);
assert!(l.lines.len() >= if name == "box" { 2 } else { 1 }, "{name}");
for c in l.clusters.iter().filter(|c| !t.text[c.range.clone()].trim().is_empty()) {
let ln = &l.lines[c.line];
// Ink under the cluster's middle, between the line's ascent and descent.
let col = photocraft_geom::Rect::new(
(c.x + c.advance * 0.3 + 7.0).floor() as i32,
(ln.baseline - ln.ascent + 60.0).floor() as i32,
(c.x + c.advance * 0.55 + 7.0).ceil() as i32,
(ln.baseline + ln.descent + 60.0).ceil() as i32,
);
assert!(alpha_sum(&r.surface, col) > 1.0, "{name}: no ink under cluster {:?} ({col:?})", c.range);
let y = ln.baseline - ln.ascent * 0.4;
assert_eq!(l.hit_test(c.x + c.advance * 0.2, y), c.range.start, "{name}: left part of {:?}", c.range);
assert_eq!(l.hit_test(c.x + c.advance * 0.8, y), c.range.end, "{name}: right part of {:?}", c.range);
let (cx, top, bottom) = l.caret(c.range.start);
assert!((cx - c.x).abs() < 1e-3 && top < y && bottom > y, "{name}: caret at {:?}", c.range);
}
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Font-size drag latency (#124): a large document with many type layers, one layer's size
//! changed step by step the way the options bar / Properties drag does it (one `type.setStyle`
//! per frame, all sharing one coalesce key), each followed by the CPU canvas refresh the app does
//! for it (`canvas::ensure_texture`, the path without a GPU or when the GPU compositor falls back).
//!
//! ```sh
//! cargo run --release -p photocraft-ui-egui --example type_bench -- [--size 6000x4000] [--layers 50] [--steps 30] [--effects]
//! ```
use std::time::Instant;
use photocraft_ui_egui::{PhotocraftApp, Services};
use serde_json::json;
fn arg(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
}
fn median(mut v: Vec<f64>) -> f64 {
v.sort_by(f64::total_cmp);
v.get(v.len() / 2).copied().unwrap_or(0.0)
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let (w, h) =
arg(&args, "--size").and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse::<u32>().ok()?, b.parse::<u32>().ok()?)))).unwrap_or((6000, 4000));
let n: usize = arg(&args, "--layers").and_then(|s| s.parse().ok()).unwrap_or(50);
let steps: usize = arg(&args, "--steps").and_then(|s| s.parse().ok()).unwrap_or(30);
let effects = args.iter().any(|a| a == "--effects");
// `--no-coalesce`: one history step per step, as the options bar did before #124.
let coalesce = !args.iter().any(|a| a == "--no-coalesce");
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
app.run("file.new", json!({"width": w, "height": h})).expect("new document");
let t0 = Instant::now();
let mut ids = Vec::new();
for i in 0..n {
let (x, y) = (40 + (i % 5) as u32 * (w / 5), 120 + (i / 5) as u32 * (h / (n as u32 / 5 + 1)).max(60));
let r = app
.run("type.create", json!({"x": x, "y": y, "text": format!("Layer {i}: the quick brown fox jumps"), "size": 24 + (i % 5) * 6}))
.expect("type.create");
ids.push(r["layer"].as_u64().unwrap_or(0));
if effects {
let _ = app.run("layer.layerStyle.dropShadow", json!({"layer": r["layer"], "distance": 8, "size": 10}));
}
}
eprintln!("{w}×{h}, {n} type layers created in {:.0} ms", t0.elapsed().as_secs_f64() * 1e3);
app.sync_views();
let ctx = egui::Context::default();
let t0 = Instant::now();
let _ = photocraft_ui_egui::canvas::ensure_texture(&mut app, &ctx, 0);
eprintln!("first canvas composite: {:.0} ms", t0.elapsed().as_secs_f64() * 1e3);
let id = ids.get(n / 2).copied().unwrap_or(0);
let (mut cmd, mut canvas, mut total) = (Vec::new(), Vec::new(), Vec::new());
for s in 0..steps {
let t0 = Instant::now();
app.run("type.setStyle", {
let mut p = json!({"layer": id, "size": 30.0 + s as f32 * 0.5});
if coalesce {
p["coalesce"] = json!("bench-size-drag");
}
p
})
.expect("type.setStyle");
let t1 = Instant::now();
let _ = photocraft_ui_egui::canvas::ensure_texture(&mut app, &ctx, 0);
let t2 = Instant::now();
cmd.push((t1 - t0).as_secs_f64() * 1e3);
canvas.push((t2 - t1).as_secs_f64() * 1e3);
total.push((t2 - t0).as_secs_f64() * 1e3);
}
// Undo steps back to the size before the drag.
let size = |app: &PhotocraftApp| match app.session.active().and_then(|s| s.doc.layer(photocraft_doc::LayerId(id))).map(|l| &l.content) {
Some(photocraft_doc::LayerContent::Text(t)) => t.size_pt,
_ => 0.0,
};
let start = 24.0 + ((n / 2) % 5) as f32 * 6.0;
let mut undos = 0;
while size(&app) != start && undos < steps + 1 && app.session.undo() {
undos += 1;
}
println!(
"size step (median of {steps}): command {:.2} ms, canvas refresh {:.2} ms, total {:.2} ms; undo steps for the drag: {undos}",
median(cmd),
median(canvas),
median(total),
);
}
+3 -1
View File
@@ -1133,8 +1133,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// (Preferences › Tools, `paint_mouse`).
crate::paint_mouse::sync_tool_smoothing(app);
let buttons = crate::paint_mouse::canvas_buttons(app, &response, tool);
// A drag is only recognised once the pointer has moved past egui's click distance: the
// gesture starts where the button went down, not where it is now (#123).
if buttons.started
&& let Some(p) = response.interact_pointer_pos()
&& let Some(p) = ui.input(|i| i.pointer.press_origin()).filter(|p| rect.contains(*p)).or(response.interact_pointer_pos())
{
if tool == Tool::Move && app.ui.transform.is_none() {
begin_transform_controls_at(app, &ctx, &xf, p);
+3 -2
View File
@@ -400,8 +400,9 @@ mod tests {
h.step();
}
let drawn = h.state().ui.crop_rect.unwrap();
// egui starts the drag at the first move past its threshold (220, 170).
assert_eq!((drawn[2] - drawn[0], drawn[3] - drawn[1]), (20.0, 10.0), "40x20 screen px at 200%");
// The frame starts where the button went down (200, 150), not at the first move past
// egui's drag threshold (#123).
assert_eq!((drawn[2] - drawn[0], drawn[3] - drawn[1]), (30.0, 20.0), "60x40 screen px at 200%");
h.event(space(true));
h.step();
for p in [pos2(280.0, 200.0), pos2(300.0, 210.0)] {
+90 -32
View File
@@ -62,12 +62,42 @@ fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<LayerId> {
doc.walk().into_iter().filter(|(_, _, l)| l.visible && matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect();
ids.reverse(); // walk() is bottom-up; hit the topmost first
ids.into_iter().find(|id| {
let Some((l, aff, _)) = layout(app, *id) else { return false };
// The pixels shown count too: a PSD's type keeps Photoshop's rendering until edited,
// which can sit off our layout when fonts are substituted (#123).
let shown = text_layer(&doc, *id).and_then(|t| t.cache.as_ref()).map(|c| c.content_bounds()).is_some_and(|r| {
let s = f64::from(slop);
!r.is_empty() && x >= f64::from(r.x0) - s && x <= f64::from(r.x1) + s && y >= f64::from(r.y0) - s && y <= f64::from(r.y1) + s
});
let Some((l, aff, _)) = layout(app, *id) else { return shown };
let (tx, ty) = to_text(&aff, x, y);
l.bounds().is_some_and(|b| tx >= b[0] - slop && tx <= b[2] + slop && ty >= b[1] - slop && ty <= b[3] + slop)
shown || l.bounds().is_some_and(|b| tx >= b[0] - slop && tx <= b[2] + slop && ty >= b[1] - slop && ty <= b[3] + slop)
})
}
/// True when the layer's pixels are what our engine draws for it. A PSD's type layer keeps
/// Photoshop's pixels until it is edited, and with substituted fonts or a different line layout
/// they sit somewhere else than the glyphs the caret and selection are placed on.
fn shows_own_layout(doc: &Document, t: &TextLayer) -> bool {
let Some(cache) = &t.cache else { return false };
let Ok(mut eng) = photocraft_text::shared().lock() else { return true };
let ours = eng.render(t, doc.resolution_dpi, doc.pixel_format()).1.surface.content_bounds();
let have = cache.content_bounds();
[(ours.x0, have.x0), (ours.y0, have.y0), (ours.x1, have.x1), (ours.y1, have.y1)].iter().all(|(a, b)| (a - b).abs() <= 1)
}
/// Start editing an existing type layer. Like Photoshop, editing shows the text as the type
/// engine lays it out, so a PSD layer is re-rendered first (inside the edit session's history
/// step, so Cancel brings Photoshop's pixels back).
fn begin_edit(app: &mut PhotocraftApp, id: LayerId, key: &str) {
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
if let Some(t) = text_layer(&doc, id)
&& !shows_own_layout(&doc, t)
{
let _ = app.run("type.edit", json!({"layer": id.0, "coalesce": key}));
}
}
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);
@@ -99,8 +129,10 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) -> boo
}
if let Some(id) = hit_layer(app, x, y) {
let _ = app.session.select_layer(id);
let key = session_key(app);
begin_edit(app, id, &key);
let off = hit_offset(app, id, x, y);
app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: session_key(app), created: false, dragging: true, preedit: None });
app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, dragging: true, preedit: None });
return true;
}
false
@@ -566,17 +598,35 @@ fn target(app: &PhotocraftApp) -> Option<(u64, Option<[usize; 2]>)> {
text_layer(&st.doc, id).map(|_| (id.0, None))
}
/// Scale of the target layer's transform. Like Photoshop, sizes show and edit as the layer
/// appears: a 12 pt layer scaled 200% (common in PSDs) reads 24 pt.
fn shown_scale(app: &PhotocraftApp) -> f32 {
let Some((id, _)) = target(app) else { return 1.0 };
let Some(t) = app.session.active().and_then(|st| text_layer(&st.doc, LayerId(id))) else { return 1.0 };
let m = t.transform.m;
let k = (m[0] * m[3] - m[1] * m[2]).abs().sqrt() as f32;
if k.is_finite() && k > 1e-3 { k } else { 1.0 }
}
/// Coalesce key while a field is being scrubbed: every step of one drag shares one history step
/// (Photoshop's single step per scrub, #124). Unique per press, so two drags are two steps.
fn drag_key(ctx: &egui::Context) -> Option<String> {
let id = ctx.dragged_id()?;
let t = ctx.input(|i| i.pointer.press_start_time())?;
Some(format!("type-drag-{}-{}", id.value(), t.to_bits()))
}
/// Apply character/paragraph properties to the target (selection, else whole layer) and remember
/// them as tool defaults.
fn apply(app: &mut PhotocraftApp, props: serde_json::Value) {
fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, props: serde_json::Value) {
let Some((layer, range)) = target(app) else { return };
let mut p = props;
p["layer"] = json!(layer);
if let Some(r) = range {
p["range"] = json!(r);
}
if let Some(ed) = &app.ui.text_edit {
p["coalesce"] = json!(ed.session);
if let Some(key) = app.ui.text_edit.as_ref().map(|ed| ed.session.clone()).or_else(|| drag_key(ctx)) {
p["coalesce"] = json!(key);
}
let _ = app.run("type.setStyle", p);
}
@@ -589,7 +639,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let st = app.session.active()?;
let tl = text_layer(&st.doc, LayerId(id))?;
let run = tl.char_runs().into_iter().next().map(|r| r.style);
Some((tl.font_family.clone(), run.as_ref().map(|s| s.font_style.clone()).unwrap_or_default(), tl.size_pt, tl.color))
// The size at the selection (else the first run), as the layer shows it (#124).
let size = styles_at(app).map_or(tl.size_pt, |(c, _)| c.size_pt) * shown_scale(app);
Some((tl.font_family.clone(), run.as_ref().map(|s| s.font_style.clone()).unwrap_or_default(), size, tl.color))
});
let o = app.ui.tool_options.clone();
let (mut fam, mut style, mut size) = match &shown {
@@ -602,19 +654,20 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let st = styles(&fam);
style = if st.contains(&style) { style } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
app.ui.tool_options.type_style = style.clone();
apply(app, json!({"font": fam, "fontStyle": style}));
apply(app, ui.ctx(), json!({"font": fam, "fontStyle": style}));
}
let opts: Vec<(String, String)> = styles(&fam).into_iter().map(|s| (s.clone(), s)).collect();
let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect();
if crate::widgets::dropdown(ui, "type-style", &mut style, &opts_ref, 110.0) {
app.ui.tool_options.type_style = style.clone();
apply(app, json!({"fontStyle": style}));
apply(app, ui.ctx(), json!({"fontStyle": style}));
}
let (r, _) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover());
crate::icons::paint(ui, r, "type", 13.0, t.icon);
if crate::widgets::value_field(ui, &mut size, 1.0..=1296.0, "pt", 66.0).changed() {
app.ui.tool_options.type_size = size;
apply(app, json!({"size": size}));
let k = shown_scale(app);
apply(app, ui.ctx(), json!({"size": size / k}));
}
let mut aa = o.type_aa.clone();
let aa_opts = [
@@ -641,7 +694,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
{
if crate::icons::button(ui, icon, 24.0, o.type_align == align, tip).clicked() {
app.ui.tool_options.type_align = align.into();
apply(app, json!({"align": align}));
apply(app, ui.ctx(), json!({"align": align}));
}
}
ui.spacing_mut().item_spacing.x = 8.0;
@@ -663,7 +716,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let mut col = Color32::from_rgb(q(c[0]), q(c[1]), q(c[2]));
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
apply(app, json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
}
});
if app.ui.text_edit.is_some() {
@@ -764,7 +817,7 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
app.ui.tool_options.type_font = fam.clone();
let st = styles(&fam);
let style = if st.contains(&c.font_style) { c.font_style.clone() } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
apply(app, json!({"font": fam, "fontStyle": style}));
apply(app, ui.ctx(), json!({"font": fam, "fontStyle": style}));
}
});
ui.horizontal(|ui| {
@@ -772,18 +825,19 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let opts: Vec<(String, String)> = styles(&fam).into_iter().map(|s| (s.clone(), s)).collect();
let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect();
if crate::widgets::dropdown(ui, "props-type-style", &mut style, &opts_ref, 170.0) {
apply(app, json!({"fontStyle": style}));
apply(app, ui.ctx(), json!({"fontStyle": style}));
}
});
let w = ((ui.available_width() - 70.0) / 2.0).clamp(50.0, 90.0);
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt, 0.1..=1296.0, "pt", w) {
let k = shown_scale(app);
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) {
app.ui.tool_options.type_size = v;
apply(app, json!({"size": v}));
apply(app, ui.ctx(), json!({"size": v / k}));
}
let lead = c.leading_pt.unwrap_or(c.size_pt * para.auto_leading.max(0.01));
let lead = c.leading_pt.unwrap_or(c.size_pt * para.auto_leading.max(0.01)) * k;
if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead, 0.1..=5000.0, "pt", w) {
apply(app, json!({"leading": v}));
apply(app, ui.ctx(), json!({"leading": v / k}));
}
});
ui.horizontal(|ui| {
@@ -801,23 +855,23 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
&[("metrics".to_string(), "Metrics"), ("optical".to_string(), "Optical"), ("off".to_string(), "0")],
w,
) {
apply(app, json!({"kerning": k}));
apply(app, ui.ctx(), json!({"kerning": k}));
}
if let Some(v) = num_field(ui, "VA", "Tracking (1/1000 em)", c.tracking, -1000.0..=10000.0, "", w) {
apply(app, json!({"tracking": v}));
apply(app, ui.ctx(), json!({"tracking": v}));
}
});
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "↕T", "Vertical scale", c.vertical_scale * 100.0, 0.0..=1000.0, "%", w) {
apply(app, json!({"verticalScale": v}));
apply(app, ui.ctx(), json!({"verticalScale": v}));
}
if let Some(v) = num_field(ui, "↔T", "Horizontal scale", c.horizontal_scale * 100.0, 0.0..=1000.0, "%", w) {
apply(app, json!({"horizontalScale": v}));
apply(app, ui.ctx(), json!({"horizontalScale": v}));
}
});
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt, -1296.0..=1296.0, "pt", w) {
apply(app, json!({"baselineShift": v}));
apply(app, ui.ctx(), json!({"baselineShift": v}));
}
// Colour chip.
ui.add_space(8.0);
@@ -830,7 +884,7 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let mut col = Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2]));
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
apply(app, json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
}
});
});
@@ -887,7 +941,7 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.painter().line_segment([egui::pos2(gr.left() - 1.0, gr.center().y), egui::pos2(gr.right() + 1.0, gr.center().y)], Stroke::new(1.0, col));
}
if resp.on_hover_text(tip).clicked() {
apply(app, props);
apply(app, ui.ctx(), props);
}
}
});
@@ -923,34 +977,34 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
align_glyph(ui.painter(), r.shrink2(egui::vec2(7.0, 7.0)), a, if on { t.text } else { t.icon });
if resp.on_hover_text(tip).clicked() {
apply(app, json!({"align": key}));
apply(app, ui.ctx(), json!({"align": key}));
}
}
});
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt, -1296.0..=1296.0, "pt", w) {
apply(app, json!({"startIndent": v}));
apply(app, ui.ctx(), json!({"startIndent": v}));
}
if let Some(v) = num_field(ui, "|←", "Indent right margin", para.end_indent_pt, -1296.0..=1296.0, "pt", w) {
apply(app, json!({"endIndent": v}));
apply(app, ui.ctx(), json!({"endIndent": v}));
}
});
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "¶→", "Indent first line", para.first_line_indent_pt, -1296.0..=1296.0, "pt", w) {
apply(app, json!({"firstLineIndent": v}));
apply(app, ui.ctx(), json!({"firstLineIndent": v}));
}
});
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "↑¶", "Add space before paragraph", para.space_before_pt, 0.0..=1296.0, "pt", w) {
apply(app, json!({"spaceBefore": v}));
apply(app, ui.ctx(), json!({"spaceBefore": v}));
}
if let Some(v) = num_field(ui, "¶↓", "Add space after paragraph", para.space_after_pt, 0.0..=1296.0, "pt", w) {
apply(app, json!({"spaceAfter": v}));
apply(app, ui.ctx(), json!({"spaceAfter": v}));
}
});
let mut hy = para.hyphenate;
if crate::widgets::checkbox(ui, &mut hy, "Hyphenate").changed() {
apply(app, json!({"hyphenate": hy}));
apply(app, ui.ctx(), json!({"hyphenate": hy}));
}
}
@@ -963,6 +1017,10 @@ pub fn cancel(app: &mut PhotocraftApp) {
}
}
#[cfg(test)]
#[path = "type_tool_tests.rs"]
mod canvas_tests;
#[cfg(test)]
mod tests {
use super::*;
+290
View File
@@ -0,0 +1,290 @@
//! Type tool through the real canvas (#123, #124): clicks and drags at known glyph positions
//! place the caret and selection on the glyph under the pointer at any zoom, HiDPI scale, pan,
//! view flip and layer transform; size edits apply at layer and selection scope, for native and
//! PSD-round-tripped text, as one history step per drag.
use egui::{Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_doc::{LayerContent, LayerId, TextLayer};
use photocraft_geom::{Affine, Point};
use serde_json::json;
use super::{layout, text_layer};
use crate::PhotocraftApp;
use crate::canvas::ViewXform;
fn harness(ppp: f32, app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_pixels_per_point(ppp).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::shortcuts::handle(app, &ctx);
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(4);
h
}
fn new_app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 1600, "height": 900})).unwrap();
app.sync_views();
app.ui.extras.rulers = false;
app.ui.tool = crate::state::Tool::Type;
app
}
fn xf(app: &PhotocraftApp) -> ViewXform {
let v = &app.ui.views[0];
ViewXform { rect: crate::rulers::content_rect(app, app.last_canvas_rect), zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal }
}
/// Screen position of a text-space point of layer `id`.
fn screen(h: &mut Harness<'static, PhotocraftApp>, id: LayerId, x: f32, y: f32) -> Pos2 {
let app = h.state_mut();
let (_, aff, _) = layout(app, id).unwrap();
let p = aff.apply(Point::new(f64::from(x), f64::from(y)));
xf(app).to_screen(p.x as f32, p.y as f32)
}
/// A point inside glyph `i` (character index): `f` of the way along its advance, in the upper
/// half of its line.
fn glyph(h: &mut Harness<'static, PhotocraftApp>, id: LayerId, i: usize, f: f32) -> Pos2 {
let (l, _, text) = layout(h.state_mut(), id).unwrap();
let b = text.char_indices().nth(i).unwrap().0;
let c = l.clusters.iter().find(|c| c.range.start == b).unwrap().clone();
let ln = &l.lines[c.line];
screen(h, id, c.x + c.advance * f, ln.baseline - ln.ascent * 0.35)
}
fn press(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, down: bool) {
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: Modifiers::NONE });
h.run_steps(1);
}
fn click(h: &mut Harness<'static, PhotocraftApp>, p: Pos2) {
h.hover_at(p);
h.run_steps(1);
press(h, p, true);
press(h, p, false);
h.run_steps(1);
}
fn drag(h: &mut Harness<'static, PhotocraftApp>, a: Pos2, b: Pos2) {
h.hover_at(a);
h.run_steps(1);
press(h, a, true);
for k in 1..=6 {
h.hover_at(a + (b - a) * (k as f32 / 6.0));
h.run_steps(1);
}
press(h, b, false);
h.run_steps(1);
}
fn selection(h: &Harness<'static, PhotocraftApp>) -> (usize, usize) {
let Some(e) = h.state().ui.text_edit.clone() else { return (usize::MAX, usize::MAX) };
(e.anchor, e.caret)
}
/// Every combination the issue lists that this canvas has: zoom 25–200 %, HiDPI 1×/2×, a panned
/// view, View › Flip Horizontal, and point / paragraph text with identity or rotated+scaled layer
/// transforms. Clicks land before/after the glyph under the pointer; drags select from the glyph
/// pressed (not where egui recognised the drag) to the glyph released over.
#[test]
fn clicks_and_drags_land_on_the_glyph_under_the_pointer() {
let rotated = Affine { m: [1.4 * 0.94, 1.4 * 0.342, -1.4 * 0.342, 1.4 * 0.94, 500.0, 420.0] };
for ppp in [1.0, 2.0] {
for zoom in [0.25f32, 0.5, 1.0, 2.0] {
for (flip, pan) in [(false, [0.0, 0.0]), (true, [37.0, -21.0])] {
for (shape, tf) in [("point", None), ("box", None), ("point", Some(rotated)), ("box", Some(rotated))] {
let mut app = new_app();
let size = 60.0 / zoom;
let mut p = json!({"text": "HOHOHO HOHO", "size": size, "x": 300, "y": 420});
if shape == "box" {
p["box"] = json!([250.0, 250.0, size * 6.0, size * 4.0]);
}
let id = LayerId(app.run("type.create", p).unwrap()["layer"].as_u64().unwrap());
if let Some(a) = tf {
app.run("type.edit", json!({"layer": id.0, "transform": a.m})).unwrap();
}
app.ui.view.flip_horizontal = flip;
let mut h = harness(ppp, app);
// Centre the view on the text's middle, then pan.
let (l, aff, _) = layout(h.state_mut(), id).unwrap();
let b = l.bounds().unwrap();
let m = aff.apply(Point::new(f64::from(b[0] + b[2]) / 2.0, f64::from(b[1] + b[3]) / 2.0));
let v = &mut h.state_mut().ui.views[0];
(v.zoom, v.center, v.fit_pending) = (zoom, [m.x as f32 + pan[0], m.y as f32 + pan[1]], false);
h.run_steps(2);
let ctx = format!("ppp {ppp} zoom {zoom} flip {flip} {shape} transformed {}", tf.is_some());
let rect = xf(h.state()).rect;
for (i, f) in [(0, 0.2), (2, 0.8), (4, 0.3), (8, 0.7)] {
let p = glyph(&mut h, id, i, f);
assert!(rect.contains(p), "{ctx}: glyph {i} off screen at {p:?}");
}
// Click into the layer: caret before glyph 2 (left part), then after glyph 4.
let p = glyph(&mut h, id, 2, 0.2);
click(&mut h, p);
assert_eq!(selection(&h), (2, 2), "{ctx}: click on the left of glyph 2");
let p = glyph(&mut h, id, 4, 0.8);
click(&mut h, p);
assert_eq!(selection(&h), (5, 5), "{ctx}: click on the right of glyph 4");
// Drag from glyph 1 (left part) to glyph 8 (right part): characters 1..9.
let (a, b) = (glyph(&mut h, id, 1, 0.25), glyph(&mut h, id, 8, 0.75));
drag(&mut h, a, b);
assert_eq!(selection(&h), (1, 9), "{ctx}: drag selection");
// And backwards, starting inside the egui drag threshold of a glyph edge.
let (a, b) = (glyph(&mut h, id, 5, 0.85), glyph(&mut h, id, 3, 0.15));
drag(&mut h, a, b);
assert_eq!(selection(&h), (6, 3), "{ctx}: backwards drag selection");
}
}
}
}
}
fn text(app: &PhotocraftApp, id: LayerId) -> TextLayer {
text_layer(&app.session.active().unwrap().doc, id).unwrap().clone()
}
/// A PSD type layer keeps Photoshop's pixels until it is edited. Clicking into it re-renders it
/// with our engine first (so the caret sits on what is shown), inside the edit session's single
/// history step; Cancel brings Photoshop's pixels back.
#[test]
fn editing_psd_type_shows_our_layout_and_cancel_restores_it() {
let mut app = new_app();
let id = LayerId(app.run("type.create", json!({"text": "HOHOHO", "size": 120, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap());
// Stand-in for Photoshop's rendering: the same text drawn somewhere else.
let moved = {
let mut t = text(&app, id);
t.transform = Affine::translate(330.0, 380.0);
photocraft_text::shared().lock().unwrap().render(&t, 72.0, photocraft_color::PixelFormat::RGBA8).1.surface
};
let st = app.session.active_mut().unwrap();
let mut doc = (*st.doc).clone();
if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) {
t.cache = Some(moved.clone());
}
st.doc = std::sync::Arc::new(doc);
st.revision += 1;
let steps = app.session.active().unwrap().history.entries().len();
let mut h = harness(1.0, app);
let p = glyph(&mut h, id, 2, 0.2);
click(&mut h, p);
assert_eq!(selection(&h), (2, 2));
let ours = text(h.state(), id).cache.unwrap().content_bounds();
assert_ne!(ours, moved.content_bounds(), "re-rendered where the caret is");
assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 1);
super::insert(h.state_mut(), "x");
assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 1, "one step for the session");
super::cancel(h.state_mut());
assert_eq!(text(h.state(), id).cache.unwrap().content_bounds(), moved.content_bounds());
assert_eq!(text(h.state(), id).text, "HOHOHO");
// A native layer shows our layout already: clicking into it records nothing.
let mut app = new_app();
let id = LayerId(app.run("type.create", json!({"text": "HOHOHO", "size": 120, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap());
let steps = app.session.active().unwrap().history.entries().len();
let mut h = harness(1.0, app);
let p = glyph(&mut h, id, 3, 0.2);
click(&mut h, p);
assert_eq!(selection(&h), (3, 3));
assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps);
}
fn size_at(app: &PhotocraftApp, id: LayerId, ci: usize) -> f32 {
let t = text(app, id);
let b = t.text.char_indices().nth(ci).map_or(t.text.len(), |(b, _)| b);
let mut at = 0;
for r in t.char_runs() {
if b < at + r.len {
return r.style.size_pt;
}
at += r.len;
}
0.0
}
/// Font size from the options bar / Properties: the whole layer when no characters are selected,
/// only the selection when editing with one, as the layer appears (transform scale included), and
/// for type that went through a PSD file.
#[test]
fn size_applies_at_layer_and_selection_scope() {
for psd in [false, true] {
let mut app = new_app();
let id = LayerId(app.run("type.create", json!({"text": "Hello world", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap());
app.run("type.edit", json!({"layer": id.0, "transform": [2.0, 0.0, 0.0, 2.0, 300.0, 420.0]})).unwrap();
let id = if psd {
let doc = (*app.session.active().unwrap().doc).clone();
let out = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap();
let back = photocraft_io::import("t.psd", &out.bytes).unwrap().document;
app.session.add_document(back, None);
app.sync_views();
let d = &app.session.active().unwrap().doc;
let id = d.walk().into_iter().find(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).unwrap();
let _ = app.session.select_layer(id);
id
} else {
id
};
let ctx = egui::Context::default();
assert!((super::shown_scale(&app) - 2.0).abs() < 1e-4, "psd {psd}");
let w0 = text(&app, id).cache.unwrap().content_bounds().width();
// Layer scope: 60 pt as shown = 30 pt in the layer's own space.
let k = super::shown_scale(&app);
super::apply(&mut app, &ctx, json!({"size": 60.0 / k}));
assert_eq!((size_at(&app, id, 0), size_at(&app, id, 10)), (30.0, 30.0), "psd {psd}");
let w1 = text(&app, id).cache.unwrap().content_bounds().width();
assert!(w1 as f32 > w0 as f32 * 1.4, "psd {psd}: the pixels grow ({w0} → {w1})");
// Selection scope: "world" only.
app.ui.text_edit =
Some(crate::state::TextEdit { layer: id.0, caret: 11, anchor: 6, session: "s".into(), created: false, dragging: false, preedit: None });
super::apply(&mut app, &ctx, json!({"size": 10.0}));
assert_eq!((size_at(&app, id, 0), size_at(&app, id, 5), size_at(&app, id, 6), size_at(&app, id, 10)), (30.0, 30.0, 10.0, 10.0), "psd {psd}");
}
}
/// Scrubbing the options bar's size field previews live (the size changes every frame) and is
/// one undo step per drag; each step only damages the type layer's area, not the whole canvas.
#[test]
fn size_drag_is_live_and_one_history_step_per_drag() {
let mut app = new_app();
let id = LayerId(app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap());
let mut h = Harness::builder().with_size(vec2(900.0, 60.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
ui.horizontal(|ui| super::options_bar(app, ui));
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(3);
let steps = h.state().session.active().unwrap().history.entries().len();
let mut last = 20.0;
for d in 0..2 {
let field = h.get_all_by_role(egui::accesskit::Role::SpinButton).next().expect("size field").rect().center();
h.hover_at(field);
h.run_steps(1);
press(&mut h, field, true);
for k in 1..=5 {
h.hover_at(field + vec2(8.0 * k as f32, 0.0));
h.run_steps(1);
let now = size_at(h.state(), id, 0);
assert!(now > last, "drag {d} step {k}: live size {last} → {now}");
last = now;
let dmg = h.state().session.active().unwrap().last_damage.expect("damage rect, not a full refresh");
assert!(!dmg.is_empty() && dmg.width() * dmg.height() <= 512 * 512, "{dmg:?}");
}
press(&mut h, field + vec2(40.0, 0.0), false);
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + d + 1, "one step per drag");
}
assert!(h.state_mut().session.undo());
assert!(h.state_mut().session.undo());
assert_eq!(size_at(h.state(), id, 0), 20.0);
}