From 5866cd2cf61c85a4a01a4bce9a52be9e17db4795 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 13:06:10 +0200 Subject: [PATCH] Tools: Horizontal and Vertical Type Mask (#1055) (#2861) The Type flyout (T, Shift+T cycle) gains Vertical Type Mask and Horizontal Type Mask, in Photoshop's order. They type like the Type tools, with the canvas under Quick Mask's colour and the glyphs clear while editing. On commit the glyph coverage replaces the selection; no layer is left behind and the whole session is one "Type Mask" history step. Escape always cancels and leaves the selection unchanged. The commit runs a new engine command, type.createSelection: a type layer's glyphs (optionally removing that layer, the tools' hidden working text) or type.create's keys directly, with new/add/subtract/intersect and an anti-aliasing override. type.create's layer building is shared through type_cmds::new_text_layer. Original dashed-T icons (ATTRIBUTION.md), tool label translations in every catalog, README tool list and the gaps/parity docs updated. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 3 +- README.md | 4 +- assets/icons/type-mask-vertical.svg | 19 ++ assets/icons/type-mask.svg | 16 ++ crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/type_cmds.rs | 83 ++++---- crates/engine/src/type_mask_cmds.rs | 198 +++++++++++++++++++ crates/ui-egui/src/canvas.rs | 16 +- crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/icon_data.rs | 2 + crates/ui-egui/src/icons.rs | 2 + crates/ui-egui/src/input_tests.rs | 1 + crates/ui-egui/src/lib.rs | 3 +- crates/ui-egui/src/panels.rs | 17 +- crates/ui-egui/src/snap_ui.rs | 2 + crates/ui-egui/src/state.rs | 29 ++- crates/ui-egui/src/swatches_ui.rs | 13 +- crates/ui-egui/src/tool_reset.rs | 2 +- crates/ui-egui/src/type_font_picker_tests.rs | 1 + crates/ui-egui/src/type_tool.rs | 129 +++++++++++- crates/ui-egui/src/type_tool_tests.rs | 80 +++++++- docs/gaps.md | 6 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 40 files changed, 592 insertions(+), 72 deletions(-) create mode 100644 assets/icons/type-mask-vertical.svg create mode 100644 assets/icons/type-mask.svg create mode 100644 crates/engine/src/type_mask_cmds.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 8b657b8247..2c2e3dee0b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -20,9 +20,10 @@ next to them. | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (112 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (112 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/README.md b/README.md index 6ca860804f..1433d065b7 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 60 tools

- Move · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom +

🧰 62 tools

+ Move · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom

🖌️ A real brush engine

diff --git a/assets/icons/type-mask-vertical.svg b/assets/icons/type-mask-vertical.svg new file mode 100644 index 0000000000..6ecce63802 --- /dev/null +++ b/assets/icons/type-mask-vertical.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/type-mask.svg b/assets/icons/type-mask.svg new file mode 100644 index 0000000000..7d4af4a92e --- /dev/null +++ b/assets/icons/type-mask.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index df6671f455..7446677d3f 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1135,6 +1135,7 @@ fn build() -> Vec { v.extend(crate::gradient_fill_cmds::specs()); v.extend(crate::solid_fill_cmds::specs()); v.extend(crate::type_cmds::specs()); + v.extend(crate::type_mask_cmds::specs()); v.extend(crate::transform_cmds::specs()); v.extend(crate::float_cmds::specs()); v.extend(crate::vector_cmds::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eec7901b6f..c93d6fbc24 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -102,6 +102,7 @@ mod trap_cmds; pub mod type_caret_cmds; pub mod type_cmds; pub mod type_extra_cmds; +pub mod type_mask_cmds; pub mod type_spell_cmds; pub mod type_styles_cmds; mod variables_cmds; diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 3357faa9a5..bc52c51fad 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -626,6 +626,52 @@ fn info(s: &Session, p: &Value) -> Result { })) } +/// A type layer (not yet rendered or added) from `type.create`'s params: text, point `x`/`y` or +/// paragraph `box`, `orientation`, character and paragraph keys, starting from the default type +/// styles in the foreground colour. Shared with the Type Mask tools' `type.createSelection`. +pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result { + let orientation = match p.get("orientation") { + None => Orientation::Horizontal, + Some(Value::String(value)) if value == "horizontal" => Orientation::Horizontal, + Some(Value::String(value)) if value == "vertical" => Orientation::Vertical, + _ => return Err(bad(cmd, "orientation must be horizontal or vertical")), + }; + check_kerning(p).map_err(|m| bad(cmd, m))?; + check_size_tracking(p).map_err(|m| bad(cmd, m))?; + let text = norm_text(p.get("text").and_then(Value::as_str).unwrap_or("")); + // Type › Save Default Type Styles sets the starting styles; the colour is always + // the foreground colour, as in Photoshop. + let (mut style, mut para) = s + .type_defaults + .clone() + .unwrap_or_else(|| (CharStyle { font_family: photocraft_text::fonts::DEFAULT_FAMILY.into(), ..Default::default() }, ParagraphStyle::default())); + let fg = s.tools.foreground; + style.color = Color::rgba(fg[0], fg[1], fg[2], fg[3]); + apply_char_props(&mut style, p); + apply_para_props(&mut para, p); + let (shape, transform) = match p.get("box").and_then(Value::as_array) { + Some(b) if b.len() == 4 => { + let v: Vec = b.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); + if v[2] <= 0.0 || v[3] <= 0.0 { + return Err(bad(cmd, "box width and height must be positive")); + } + (TextShape::Box { x: 0.0, y: 0.0, width: v[2], height: v[3] }, Affine::translate(f64::from(v[0]), f64::from(v[1]))) + } + _ => (TextShape::Point, Affine::translate(f32p(p, "x").unwrap_or(0.0).into(), f32p(p, "y").unwrap_or(0.0).into())), + }; + let mut t = TextLayer { + runs: vec![TextRun { len: text.len(), style }], + paragraphs: vec![ParagraphRun { len: text.len(), style: para }], + text, + shape, + transform, + orientation, + ..Default::default() + }; + t.sync_summary(); + Ok(t) +} + const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":0.1..=1296 pt,"color":"#rrggbb"|[r,g,b,a],"tracking":-1000..=10000 (1/1000 em),"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":1/1000em (manual, after each character)|"metrics"|"optical"|"off","caps":"normal|small|all","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##; pub fn specs() -> Vec { @@ -639,42 +685,7 @@ pub fn specs() -> Vec { enabled: has_doc, journal: true, run: |s, p| { - let orientation = match p.get("orientation") { - None => Orientation::Horizontal, - Some(Value::String(value)) if value == "horizontal" => Orientation::Horizontal, - Some(Value::String(value)) if value == "vertical" => Orientation::Vertical, - _ => return Err(bad("type.create", "orientation must be horizontal or vertical")), - }; - check_kerning(p).map_err(|m| bad("type.create", m))?; - check_size_tracking(p).map_err(|m| bad("type.create", m))?; - let text = norm_text(p.get("text").and_then(Value::as_str).unwrap_or("")); - // Type › Save Default Type Styles sets the starting styles; the colour is always - // the foreground colour, as in Photoshop. - let (mut style, mut para) = s.type_defaults.clone().unwrap_or_else(|| (CharStyle { font_family: photocraft_text::fonts::DEFAULT_FAMILY.into(), ..Default::default() }, ParagraphStyle::default())); - let fg = s.tools.foreground; - style.color = Color::rgba(fg[0], fg[1], fg[2], fg[3]); - apply_char_props(&mut style, p); - apply_para_props(&mut para, p); - let (shape, transform) = match p.get("box").and_then(Value::as_array) { - Some(b) if b.len() == 4 => { - let v: Vec = b.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - if v[2] <= 0.0 || v[3] <= 0.0 { - return Err(bad("type.create", "box width and height must be positive")); - } - (TextShape::Box { x: 0.0, y: 0.0, width: v[2], height: v[3] }, Affine::translate(f64::from(v[0]), f64::from(v[1]))) - } - _ => (TextShape::Point, Affine::translate(f32p(p, "x").unwrap_or(0.0).into(), f32p(p, "y").unwrap_or(0.0).into())), - }; - let mut t = TextLayer { - runs: vec![TextRun { len: text.len(), style }], - paragraphs: vec![ParagraphRun { len: text.len(), style: para }], - text, - shape, - transform, - orientation, - ..Default::default() - }; - t.sync_summary(); + let mut t = new_text_layer(s, p, "type.create")?; let name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| layer_name(&t.text)); let id = s.edit("New Type Layer", |doc, active| { refresh(doc, &mut t); diff --git a/crates/engine/src/type_mask_cmds.rs b/crates/engine/src/type_mask_cmds.rs new file mode 100644 index 0000000000..3a4b162a32 --- /dev/null +++ b/crates/engine/src/type_mask_cmds.rs @@ -0,0 +1,198 @@ +//! `type.createSelection`: the Horizontal and Vertical Type Mask tools' result. Typed text +//! becomes a selection in the shape of its glyphs (their anti-aliased coverage) instead of a +//! type layer, as Photoshop's Type Mask tools do. +//! +//! The tools edit a temporary type layer in one coalesced history step and commit with +//! `{"layer": id, "removeLayer": true}`, so the whole session is one undo step that leaves no +//! layer behind. Scripts can skip the layer and pass `type.create`'s keys directly. + +use photocraft_algo::selection::{self as sel, SelectionMode}; +use photocraft_doc::text::AntiAlias; +use photocraft_doc::{Document, LayerContent, LayerId, TextLayer}; +use photocraft_geom::Rect; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +const CMD: &str = "type.createSelection"; + +fn bad(msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } +} + +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} + +fn antialias(p: &Value) -> Result> { + Ok(match p.get("antialias") { + None => None, + Some(v) => Some(match v.as_str() { + Some("none") => AntiAlias::None, + Some("sharp") => AntiAlias::Sharp, + Some("crisp") => AntiAlias::Crisp, + Some("strong") => AntiAlias::Strong, + Some("smooth") => AntiAlias::Smooth, + _ => return Err(bad("antialias must be none, sharp, crisp, strong or smooth")), + }), + }) +} + +/// Glyph coverage of `t` (0..=1 per pixel) over its rendered bounds inside the canvas. The text +/// colour's own alpha doesn't thin the selection: every run renders opaque. +fn coverage(doc: &Document, t: &TextLayer) -> (Vec, Rect) { + let mut t = t.clone(); + for r in &mut t.runs { + r.style.color.alpha = 1.0; + } + let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); + let surface = eng.render(&t, doc.resolution_dpi, doc.pixel_format()).1.surface; + let area = surface.content_bounds().intersect(&doc.bounds()); + if area.is_empty() { + return (Vec::new(), Rect::EMPTY); + } + let mut px = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize]; + surface.read_rgba_into(area, &mut px); + (px.iter().map(|p| p[3]).collect(), area) +} + +/// Coverage of `area`'s mask spread over `to` (which contains `area`), zero elsewhere. +fn widen(m: &[f32], area: Rect, to: Rect) -> Vec { + if area == to { + return m.to_vec(); + } + let (w, aw) = (to.width() as usize, area.width() as usize); + let mut out = vec![0.0; w * to.height() as usize]; + for (y, row) in m.chunks_exact(aw.max(1)).enumerate() { + let start = (area.y0 - to.y0) as usize * w + y * w + (area.x0 - to.x0) as usize; + if let Some(dst) = out.get_mut(start..start + aw) { + dst.copy_from_slice(row); + } + } + out +} + +fn create_selection(s: &mut Session, p: &Value) -> Result { + let mode = match p.get("operation").map(|v| v.as_str()) { + None | Some(Some("new" | "replace")) => SelectionMode::Replace, + Some(Some("add")) => SelectionMode::Add, + Some(Some("subtract")) => SelectionMode::Subtract, + Some(Some("intersect")) => SelectionMode::Intersect, + _ => return Err(bad("operation must be new, add, subtract or intersect")), + }; + let aa = antialias(p)?; + let remove = match p.get("removeLayer") { + None => false, + Some(v) => v.as_bool().ok_or_else(|| bad("removeLayer must be true or false"))?, + }; + let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); + let layer = match p.get("layer") { + None | Some(Value::Null) => None, + Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("layer must be a layer id"))?)), + }; + if remove && layer.is_none() { + return Err(bad("removeLayer needs a layer")); + } + let mut t = match layer { + Some(id) => match &doc.layer(id).ok_or(EngineError::NoLayer(id))?.content { + LayerContent::Text(t) => t.clone(), + _ => return Err(bad(format!("layer {} is not a type layer", id.0))), + }, + None => crate::type_cmds::new_text_layer(s, p, CMD)?, + }; + if let Some(aa) = aa { + t.antialias = aa; + } + let (mask, text_area) = coverage(&doc, &t); + let selected = s.edit("Type Mask", |doc, active| { + let old = doc.selection.as_ref().map(|o| o.content_bounds().intersect(&doc.bounds())).unwrap_or(Rect::EMPTY); + // Replace and Intersect leave nothing outside the text; Add and Subtract keep the rest. + let area = match mode { + SelectionMode::Add | SelectionMode::Subtract if !old.is_empty() => { + if text_area.is_empty() { + old + } else { + old.union(&text_area) + } + } + _ => text_area, + }; + doc.selection = if area.is_empty() { None } else { sel::combine(doc.selection.as_ref(), &widen(&mask, text_area, area), area, mode) }; + if let (true, Some(id)) = (remove, layer) { + let neighbours = crate::layer_multi_cmds::deletion_neighbours(doc, id); + doc.remove(id).ok_or(EngineError::NoLayer(id))?; + if doc.layers.is_empty() { + return Err(EngineError::Other("a document must keep at least one layer".into())); + } + if active.is_some_and(|current| doc.layer(current).is_none()) { + *active = neighbours.into_iter().find(|candidate| doc.layer(*candidate).is_some()); + } + } + Ok(doc.selection.as_ref().map(|m| m.content_bounds())) + })?; + // The Type Mask tools' whole typing session is this one step. + if let Some(st) = s.active_mut() { + st.history.set_current_label("Type Mask"); + } + Ok(json!({ "selected": selected.is_some(), "bounds": selected.map(|r| [r.x0, r.y0, r.x1, r.y1]) })) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: CMD, + label: "Type Mask", + menu: &[], + shortcut: None, + params: r##"{"layer":id? (a type layer whose glyphs become the selection),"removeLayer":bool=false (delete that layer in the same step: the Type Mask tools' temporary text),"operation":"new|add|subtract|intersect"="new","antialias":"none|sharp|crisp|strong|smooth"?, …without "layer", type.create's keys: "text","x","y" or "box","orientation","font","size","align",…}"##, + enabled: has_doc, + journal: true, + run: create_selection, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 300, "height": 300})).unwrap(); + s + } + + fn bounds(s: &Session) -> Option { + s.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds()) + } + + #[test] + fn typed_text_selects_its_glyphs_without_a_layer() { + let mut s = session(); + let layers = s.active().unwrap().doc.layers.len(); + let text = json!({"text": "I", "x": 40, "y": 120, "size": 48}); + s.execute(CMD, text.clone()).unwrap(); + let sel = bounds(&s).expect("the glyph is selected"); + assert_eq!(s.active().unwrap().doc.layers.len(), layers, "no layer added"); + // The same text as a type layer covers the same pixels. + s.execute("edit.undo", json!({})).unwrap(); + assert!(bounds(&s).is_none(), "one undo step"); + let r = s.execute("type.create", text).unwrap(); + let b: Vec = r["bounds"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap() as i32).collect(); + assert_eq!([sel.x0, sel.y0, sel.x1, sel.y1], [b[0], b[1], b[2], b[3]]); + + // Vertical: the word runs down the column, so its selection is taller than wide. + let mut s = session(); + s.execute(CMD, json!({"text": "HELLO", "x": 150, "y": 40, "size": 48, "orientation": "vertical"})).unwrap(); + let v = bounds(&s).unwrap(); + assert!(v.height() > 2 * v.width(), "{v:?}"); + } + + #[test] + fn bad_params_are_errors() { + let mut s = session(); + for p in [json!({"layer": "x"}), json!({"removeLayer": true}), json!({"operation": 3}), json!({"antialias": "blurry"}), json!({"layer": 999})] { + assert!(s.execute(CMD, p.clone()).is_err(), "{p}"); + } + assert!(Session::new().execute(CMD, json!({"text": "I"})).is_err()); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2567b7b18e..4436177bad 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3286,7 +3286,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } } // Preferences › Cursors › Other Cursors: Precise shows a crosshair for every tool. - Tool::Move | Tool::Type | Tool::VerticalType if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise => { + Tool::Move | Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask + if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise => + { egui::CursorIcon::Crosshair } Tool::Move => egui::CursorIcon::Move, @@ -3300,7 +3302,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: egui::CursorIcon::ZoomIn } } - Tool::Type | Tool::VerticalType => egui::CursorIcon::Text, + Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => egui::CursorIcon::Text, Tool::MagneticLasso => crate::magnetic_lasso_ui::cursor(app, &painter, p, xf.zoom), Tool::RedEye => { let pupil = app.ui.tool_options.red_eye_pupil_size.clamp(1.0, 100.0); @@ -3993,7 +3995,7 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View painter.circle_filled(a, 3.0, Color32::WHITE); painter.circle_filled(b, 3.0, Color32::WHITE); } - Tool::Type | Tool::VerticalType => { + Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => { let r = Rect::from_two_pos(xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32)); let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; painter.add(egui::Shape::line(pts.to_vec(), Stroke::new(1.0, Color32::WHITE))); @@ -4394,7 +4396,11 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) polygon_click(app, x, y, mods); return; } - Tool::Type | Tool::VerticalType if crate::type_tool::pointer_down(app, x, y, mods.shift) => return, + Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask + if crate::type_tool::pointer_down(app, x, y, mods.shift) => + { + return; + } _ => {} } crate::paint_mouse::sync_tool_brush(app); @@ -4716,7 +4722,7 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { && crate::shape_dialog::open(app, t, d.start).is_some() => {} t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::finish_shape(app, t, d.start, [end[0], end[1]], d.live), Tool::PathSelection => crate::vector_ui::path_selection_finish(app, d.start, [end[0], end[1]]), - Tool::Type | Tool::VerticalType => crate::type_tool::pointer_up(app, d.start, [end[0], end[1]]), + Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => crate::type_tool::pointer_up(app, d.start, [end[0], end[1]]), Tool::Brush | Tool::Pencil | Tool::Eraser => commit_brush_stroke(app, &d), Tool::RectMarquee | Tool::EllipseMarquee => { // Exactly what the drag showed (`marquee_preview_px`); a click deselects. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4ec735d78a..25c7fcae98 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1632,6 +1632,8 @@ Highlights from: Světla od: History Brush Tool Nástroj štětec historie Horizontal Type Tool Nástroj vodorovný text + Horizontal Type Mask Tool Nástroj maska vodorovného textu + Vertical Type Mask Tool Nástroj maska svislého textu Image Size: Velikost obrazu: Inches Palce Indexed Indexované diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9263cbc050..0af9a027e7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1607,6 +1607,8 @@ Highlights from: Lichter von: History Brush Tool Protokollpinsel-Werkzeug Horizontal Type Tool Horizontales Text-Werkzeug + Horizontal Type Mask Tool Horizontales Textmaskierungswerkzeug + Vertical Type Mask Tool Vertikales Textmaskierungswerkzeug Image Size: Bildgröße: Inches Zoll Indexed Indiziert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 58b2ff5a8d..bc874f8e4a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3243,6 +3243,8 @@ cameraRaw Yellow Κίτρινο Healing Brush Tool Πινέλο επιδιόρθωσης (Healing Brush Tool) History Brush Tool Πινέλο ιστορικού (History Brush Tool) Horizontal Type Tool Οριζόντιο κείμενο (Horizontal Type Tool) + Horizontal Type Mask Tool Μάσκα οριζόντιου κειμένου (Horizontal Type Mask Tool) + Vertical Type Mask Tool Μάσκα κατακόρυφου κειμένου (Vertical Type Mask Tool) Lasso Tool Λάσο (Lasso Tool) Line Tool Γραμμή (Line Tool) Magic Eraser Tool Μαγική γόμα (Magic Eraser Tool) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 17fe479cfa..675568e516 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1615,6 +1615,8 @@ Highlights from: Luces desde: History Brush Tool Herramienta Pincel de historia Horizontal Type Tool Herramienta Texto horizontal + Horizontal Type Mask Tool Herramienta Máscara de texto horizontal + Vertical Type Mask Tool Herramienta Máscara de texto vertical Image Size: Tamaño de imagen: Inches Pulgadas Indexed Indexado diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 91d46cca2c..0e64bd2dd2 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1593,6 +1593,8 @@ Highlights from: Hautes lumières à partir de : History Brush Tool Outil Pinceau d'historique Horizontal Type Tool Outil Texte horizontal + Horizontal Type Mask Tool Outil Masque de texte horizontal + Vertical Type Mask Tool Outil Masque de texte vertical Image Size: Taille de l'image : Inches Pouces Indexed Indexé diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 81ad6b02ca..9957d63ffa 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1587,6 +1587,8 @@ Highlights from: Highlight dari: History Brush Tool Alat History Brush Horizontal Type Tool Alat Teks Horizontal + Horizontal Type Mask Tool Alat Masker Teks Horizontal + Vertical Type Mask Tool Alat Masker Teks Vertikal Image Size: Ukuran Gambar: Inches Inci Indexed Terindeks diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 43eef02440..f04fba1c3a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1613,6 +1613,8 @@ Highlights from: Luci da: History Brush Tool Strumento Pennello storia Horizontal Type Tool Strumento Testo orizzontale + Horizontal Type Mask Tool Strumento Maschera testo orizzontale + Vertical Type Mask Tool Strumento Maschera testo verticale Image Size: Dimensione immagine: Inches Pollici Indexed Indicizzato diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 38cb414938..243f3482b1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1618,6 +1618,8 @@ Highlights from: ハイライトの開始: History Brush Tool ヒストリーブラシツール Horizontal Type Tool 横書き文字ツール + Horizontal Type Mask Tool 横書き文字マスクツール + Vertical Type Mask Tool 縦書き文字マスクツール Image Size: 画像サイズ: Inches インチ Indexed インデックス diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index bd20d61b2c..f621588737 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1586,6 +1586,8 @@ Highlights from: 밝은 영역 시작: History Brush Tool 작업 내역 브러시 도구 Horizontal Type Tool 수평 문자 도구 + Horizontal Type Mask Tool 수평 문자 마스크 도구 + Vertical Type Mask Tool 세로 문자 마스크 도구 Image Size: 이미지 크기: Inches 인치 Indexed 인덱스 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index bbea0bd05e..dc429077e4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1597,6 +1597,8 @@ Highlights from: Hooglichten van: History Brush Tool Historiepenseel Horizontal Type Tool Horizontaal tekstgereedschap + Horizontal Type Mask Tool Horizontaal tekstmaskergereedschap + Vertical Type Mask Tool Verticaal tekstmaskergereedschap Image Size: Afbeeldingsgrootte: Inches Inch Indexed Geïndexeerd diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index db57cdb233..d512f013c9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1611,6 +1611,8 @@ Highlights from: Światła od: History Brush Tool Narzędzie Pędzel historii Horizontal Type Tool Narzędzie Tekst poziomy + Horizontal Type Mask Tool Narzędzie Maska tekstu poziomego + Vertical Type Mask Tool Narzędzie Maska tekstu pionowego Image Size: Rozmiar obrazu: Inches Cale Indexed Indeksowany diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 376e9959af..c58ef14fa1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1592,6 +1592,8 @@ Highlights from: Realces a partir de: History Brush Tool Ferramenta Pincel do histórico Horizontal Type Tool Ferramenta Texto horizontal + Horizontal Type Mask Tool Ferramenta Máscara de texto horizontal + Vertical Type Mask Tool Ferramenta Máscara de texto vertical Image Size: Tamanho: Inches Polegadas Indexed Indexado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a8e339e1d2..5549183d5a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1550,6 +1550,8 @@ Gradient Tool Градиент Paint Bucket Tool Заливка Horizontal Type Tool Горизонтальный текст + Horizontal Type Mask Tool Горизонтальный текст-маска + Vertical Type Mask Tool Вертикальный текст-маска Hand Tool Рука Zoom Tool Масштаб Spot Healing Brush Tool Точечная восстанавливающая кисть diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4154ab2063..f47d6c1be8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1532,6 +1532,8 @@ Gradient Tool Інструмент «Градієнт» Paint Bucket Tool Інструмент «Відро з фарбою» Horizontal Type Tool Інструмент «Горизонтальний текст» + Horizontal Type Mask Tool Інструмент «Горизонтальний текст-маска» + Vertical Type Mask Tool Інструмент «Вертикальний текст-маска» Hand Tool Інструмент «Рука» Zoom Tool Інструмент «Масштаб» Spot Healing Brush Tool Інструмент «Точковий відновлювальний пензель» diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b87a32ea2a..2e884494fa 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1629,6 +1629,8 @@ Highlights from: 高光下限: History Brush Tool 历史记录画笔工具 Horizontal Type Tool 横排文字工具 + Horizontal Type Mask Tool 横排文字蒙版工具 + Vertical Type Mask Tool 直排文字蒙版工具 Image Size: 图像大小: Inches 英寸 Indexed 索引色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index de5eb63621..376014c02c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1622,6 +1622,8 @@ Highlights from: 亮部起點: History Brush Tool 步驟記錄筆刷工具 Horizontal Type Tool 水平文字工具 + Horizontal Type Mask Tool 水平文字遮色片工具 + Vertical Type Mask Tool 垂直文字遮色片工具 Image Size: 影像尺寸: Inches 英吋 Indexed 索引色 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 0dfa6c1a25..59ee332764 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -131,6 +131,8 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("triangle-alert", include_bytes!("../../../assets/icons/triangle-alert.svg")), ("triangle-right", include_bytes!("../../../assets/icons/triangle-right.svg")), ("type", include_bytes!("../../../assets/icons/type.svg")), + ("type-mask", include_bytes!("../../../assets/icons/type-mask.svg")), + ("type-mask-vertical", include_bytes!("../../../assets/icons/type-mask-vertical.svg")), ("undo-2", include_bytes!("../../../assets/icons/undo-2.svg")), ("unlink", include_bytes!("../../../assets/icons/unlink.svg")), ("wand", include_bytes!("../../../assets/icons/wand.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 5791de4cb3..312ed95835 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -110,6 +110,8 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::Gradient => "blend", Tool::PaintBucket => "paint-bucket", Tool::Type | Tool::VerticalType => "type", + Tool::HorizontalTypeMask => "type-mask", + Tool::VerticalTypeMask => "type-mask-vertical", Tool::Hand => "hand", Tool::RotateView => "compass", Tool::Zoom => "zoom-in", diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 1bf0d36118..71b766a145 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -478,6 +478,7 @@ fn type_tool_alt_arrows_kern_the_pair() { anchor: 1, session: "kern-test".into(), created: false, + mask: false, dragging: false, resize: None, preedit: None, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c09cf5994a..9955bb3e69 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1454,7 +1454,8 @@ impl eframe::App for PhotocraftApp { } self.drain_control(ctx); native_menu::run(self, ctx); - if self.ui.text_edit.is_some() && !self.ui.tool.is_type() { + // Switching tools commits, also between the Type and Type Mask tools. + if self.ui.text_edit.as_ref().is_some_and(|ed| !self.ui.tool.is_type() || ed.mask != self.ui.tool.is_type_mask()) { type_tool::commit(self); } if self.ui.pen.as_ref().is_some_and(|p| !p.drawn_with(self.ui.tool)) { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d0aa813f39..d979bb3f1d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -35,7 +35,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[ ], &[ &[Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint], - &[Tool::Type, Tool::VerticalType], + &[Tool::Type, Tool::VerticalType, Tool::VerticalTypeMask, Tool::HorizontalTypeMask], &[Tool::PathSelection, Tool::DirectSelection], &[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape], ], @@ -1225,7 +1225,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } // Every theme gets the font, style and size controls (#1387), as with Auto-Select (#1275). - Tool::Type | Tool::VerticalType => crate::type_tool::options_bar(app, ui), + Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => crate::type_tool::options_bar(app, ui), Tool::Move if t.pro => { let o = &mut app.ui.tool_options; widgets::checkbox(ui, &mut o.move_auto_select, tl!("Auto-Select:")); @@ -4092,8 +4092,17 @@ mod swatch_type_tests { app.run("tools.setColors", json!({"foreground": "#d8452e"})).unwrap(); let fg = app.session.tools.foreground; app.ui.tool = Tool::Type; - app.ui.text_edit = - Some(crate::state::TextEdit { layer: id, caret: 0, anchor: 5, session: "s".into(), created: false, dragging: false, resize: None, preedit: None }); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id, + caret: 0, + anchor: 5, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); let mut h = Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(|ui, app: &mut PhotocraftApp| color_picker(app, ui), app); h.run_steps(4); assert_eq!(h.state().session.tools.foreground, fg); diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 18c5bdd1dd..83d973739d 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -115,6 +115,8 @@ fn is_point_tool(t: Tool) -> bool { | Tool::Line | Tool::Type | Tool::VerticalType + | Tool::HorizontalTypeMask + | Tool::VerticalTypeMask ) } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4cc6f230d1..eb78ee0a91 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -90,6 +90,9 @@ pub enum Tool { PaintBucket, Type, VerticalType, + /// Horizontal Type Mask: types a selection in the shape of the glyphs, no type layer. + HorizontalTypeMask, + VerticalTypeMask, Hand, RotateView, Zoom, @@ -128,7 +131,7 @@ pub enum Tool { } impl Tool { - pub const ALL: [Tool; 60] = [ + pub const ALL: [Tool; 62] = [ Tool::Move, Tool::RectMarquee, Tool::EllipseMarquee, @@ -154,6 +157,8 @@ impl Tool { Tool::PaintBucket, Tool::Type, Tool::VerticalType, + Tool::HorizontalTypeMask, + Tool::VerticalTypeMask, Tool::Hand, Tool::RotateView, Tool::Zoom, @@ -220,6 +225,8 @@ impl Tool { Tool::PaintBucket => "Paint Bucket Tool", Tool::Type => "Horizontal Type Tool", Tool::VerticalType => "Vertical Type Tool", + Tool::HorizontalTypeMask => "Horizontal Type Mask Tool", + Tool::VerticalTypeMask => "Vertical Type Mask Tool", Tool::Hand => "Hand Tool", Tool::RotateView => "Rotate View Tool", Tool::Zoom => "Zoom Tool", @@ -256,7 +263,15 @@ impl Tool { } } pub fn is_type(self) -> bool { - matches!(self, Self::Type | Self::VerticalType) + matches!(self, Self::Type | Self::VerticalType | Self::HorizontalTypeMask | Self::VerticalTypeMask) + } + /// The Type Mask tools: their text becomes a selection on commit. + pub fn is_type_mask(self) -> bool { + matches!(self, Self::HorizontalTypeMask | Self::VerticalTypeMask) + } + /// Type tools that lay text out vertically. + pub fn is_vertical_type(self) -> bool { + matches!(self, Self::VerticalType | Self::VerticalTypeMask) } /// Retouching and painting tools that stroke with the brush (share the brush cursor and chip). @@ -295,7 +310,7 @@ impl Tool { Tool::MagicWand => 'W', Tool::Crop | Tool::Slice | Tool::SliceSelect => 'C', Tool::Gradient | Tool::PaintBucket => 'G', - Tool::Type | Tool::VerticalType => 'T', + Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => 'T', Tool::Hand => 'H', Tool::RotateView => 'R', Tool::Zoom => 'Z', @@ -326,7 +341,7 @@ impl Tool { Tool::MagicWand => "W", Tool::Crop => "C", Tool::Gradient | Tool::PaintBucket => "G", - Tool::Type | Tool::VerticalType => "T", + Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => "T", Tool::Hand => "✋", Tool::RotateView => "🧭", Tool::Zoom => "🔍", @@ -864,6 +879,9 @@ pub struct TextEdit { pub session: String, /// The layer was created by this session (its name follows the text; empty on commit = delete). pub created: bool, + /// A Type Mask tool session: the layer is temporary and commits as a selection. + #[serde(default)] + pub mask: bool, #[serde(skip)] pub dragging: bool, /// Paragraph-box handle being dragged (0-3 corners from top-left clockwise, 4-7 top/right/bottom/left edges). @@ -1257,7 +1275,8 @@ mod tests { assert!(!Tool::RedEye.is_brushlike()); assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp)); assert_eq!(Tool::from_name("Pattern Stamp Tool"), Some(Tool::PatternStamp)); - assert_eq!(Tool::ALL.len(), 60); + assert_eq!(Tool::ALL.len(), 62); + assert_eq!(Tool::from_name("Vertical Type Mask Tool"), Some(Tool::VerticalTypeMask)); assert_eq!(Tool::from_name("addAnchorPoint"), Some(Tool::AddAnchorPoint)); assert_eq!(Tool::from_name("Delete Anchor Point Tool"), Some(Tool::DeleteAnchorPoint)); assert_eq!(Tool::DeleteAnchorPoint.key(), 'P'); diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index 815adb1fc4..f4c0f1d197 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -287,8 +287,17 @@ mod tests { app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); let id = app.run("type.create", json!({"text": "Hello world", "size": 40, "x": 20, "y": 100, "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); app.ui.tool = Tool::Type; - app.ui.text_edit = - Some(crate::state::TextEdit { layer: id, caret: 0, anchor: 5, session: "s".into(), created: false, dragging: false, resize: None, preedit: None }); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id, + caret: 0, + anchor: 5, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); // Recolour with a visible colour: put Red first. app.run("swatches.move", json!({"swatch": "Red", "to": "Grays", "position": 0})).unwrap(); let mut h = Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel(app, ui), app); diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 4a17eb95d3..49fe5610ee 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -146,7 +146,7 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, sample_all_layers, magic_eraser_opacity), Tool::Gradient => reset!(gradient_style, gradient_reverse, gradient_dither, gradient_classic, gradient_blend_mode, fill_opacity), Tool::PaintBucket => reset!(bucket_fill_pattern, fill_opacity, tolerance, anti_alias, contiguous, sample_all_layers), - Tool::Type | Tool::VerticalType => reset!(type_font, type_style, type_size, type_aa, type_align), + Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => reset!(type_font, type_style, type_size, type_aa, type_align), Tool::Zoom => reset!(zoom_scrubby), Tool::SpotHealing => reset!(spot_type, sample_all_layers), Tool::Healing => reset!(clone_aligned, clone_sample), diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 92e10fbff5..195679f4f4 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -365,6 +365,7 @@ fn hovering_a_font_previews_only_the_selected_characters() { anchor: 3, session: "font-preview-test".into(), created: false, + mask: false, dragging: false, resize: None, preedit: None, diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 44db2c9b95..aaec963dca 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -55,8 +55,14 @@ fn to_text(aff: &Affine, x: f64, y: f64) -> (f32, f32) { fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { let doc = app.session.active()?.doc.clone(); let slop = 6.0 / app.point_zoom().max(0.01); - let mut ids: Vec = - doc.walk().into_iter().filter(|(_, _, l)| l.visible && matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); + // A Type Mask session's text is a hidden layer; it is still the text being edited. + let editing = app.ui.text_edit.as_ref().filter(|ed| ed.mask).map(|ed| LayerId(ed.layer)); + let mut ids: Vec = doc + .walk() + .into_iter() + .filter(|(_, _, l)| (l.visible || editing == Some(l.id)) && 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| { // The pixels shown count too: a PSD's type keeps Photoshop's rendering until edited, @@ -106,7 +112,8 @@ pub fn edit_active(app: &mut PhotocraftApp) -> Result<(), String> { commit(app); let key = session_key(app); begin_edit(app, id, &key)?; - app.ui.text_edit = Some(TextEdit { layer: id.0, caret: n, anchor: 0, session: key, created: false, dragging: false, resize: None, preedit: None }); + app.ui.text_edit = + Some(TextEdit { layer: id.0, caret: n, anchor: 0, session: key, created: false, mask: false, dragging: false, resize: None, preedit: None }); } else if let Some(ed) = app.ui.text_edit.as_mut() { ed.anchor = 0; ed.caret = n; @@ -219,6 +226,10 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) -> boo commit(app); return true; // Photoshop: a click outside commits without starting new text } + // The Type Mask tools always start new text: existing type layers are just pixels to them. + if app.ui.tool.is_type_mask() { + return false; + } if let Some(id) = hit_layer(app, x, y) { let _ = app.session.select_layer(id); let key = session_key(app); @@ -226,7 +237,8 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) -> boo return true; } let off = hit_offset(app, id, x, y); - app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, dragging: true, resize: None, preedit: None }); + app.ui.text_edit = + Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, mask: false, dragging: true, resize: None, preedit: None }); return true; } false @@ -256,11 +268,13 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { let (w, h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); let min = 4.0 / app.point_zoom().max(0.01) as f64; let o = app.ui.tool_options.clone(); - // Preferences ▸ Type: "Fill new type layers with placeholder text" (on by default). - let text = if app.session.prefs().type_.fill_new_type_layers_with_placeholder { PLACEHOLDER } else { "" }; + let mask = app.ui.tool.is_type_mask(); + // Preferences ▸ Type: "Fill new type layers with placeholder text" (on by default). The Type + // Mask tools start empty, as Photoshop's do. + let text = if !mask && app.session.prefs().type_.fill_new_type_layers_with_placeholder { PLACEHOLDER } else { "" }; let mut p = json!({ "text": text, - "orientation": if app.ui.tool == crate::state::Tool::VerticalType { "vertical" } else { "horizontal" }, + "orientation": if app.ui.tool.is_vertical_type() { "vertical" } else { "horizontal" }, "font": o.type_font, "fontStyle": o.type_style, "size": o.type_size, @@ -281,9 +295,14 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { if o.type_aa != "sharp" { let _ = app.run("type.edit", json!({"layer": id, "antialias": o.type_aa, "coalesce": key})); } + // A Type Mask's text is a hidden working layer: the canvas shows the mask overlay + // instead, and committing turns it into the selection. + if mask { + let _ = app.run("layer.setProps", json!({"layer": id, "visible": false, "coalesce": key})); + } // Like Photoshop: the placeholder is selected, so typing replaces it. let n = text.chars().count(); - app.ui.text_edit = Some(TextEdit { layer: id, caret: n, anchor: 0, session: key, created: true, dragging: false, resize: None, preedit: None }); + app.ui.text_edit = Some(TextEdit { layer: id, caret: n, anchor: 0, session: key, created: true, mask, dragging: false, resize: None, preedit: None }); } } @@ -598,6 +617,8 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { // Photoshop types a tab character; layout advances it to the next tab stop. Key::Tab if !m.command && !m.alt && !m.shift => insert(app, "\t"), Key::Escape if crate::type_transform::active(app) => crate::type_transform::cancel_drag(app), + // The Type Mask tools: Escape always cancels, leaving the selection as it was. + Key::Escape if ed.mask => cancel(app), // Preferences ▸ Type: with "Use Escape to Commit" off, Escape cancels the // session (undoes it, removing a just-created layer) like the Cancel button. Key::Escape if app.session.prefs().type_.use_esc_to_commit => commit(app), @@ -648,6 +669,20 @@ pub fn commit(app: &mut PhotocraftApp) { crate::type_transform::reset(app); let Some(ed) = app.ui.text_edit.take() else { return }; let Some(text) = current_text(app, LayerId(ed.layer)) else { return }; + if ed.mask { + // Type Mask: the text's glyphs replace the selection and the working layer goes, all in + // the session's one history step. No text: nothing changes. + if !text.trim().is_empty() + && let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "coalesce": ed.session})) + { + app.ui.status = e; + app.ui.status_error = true; + } + if current_text(app, LayerId(ed.layer)).is_some() { + undo_session(app, &ed); + } + return; + } if text.trim().is_empty() && ed.created { let _ = app.run("layer.delete", json!({"layer": ed.layer})); } else if ed.created { @@ -661,6 +696,9 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX let Some(ed) = app.ui.text_edit.clone() else { return }; let id = LayerId(ed.layer); let Some((l, aff, text)) = layout(app, id) else { return }; + if ed.mask { + draw_mask_overlay(app, painter, xf, id); + } let t = crate::theme::Tokens::get(painter.ctx()); // Text space → screen. let scr_t = |x: f32, y: f32| -> Pos2 { @@ -751,6 +789,76 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX } } +/// The Type Mask tools while typing: Quick Mask's colour over the canvas with the glyphs clear, +/// as Photoshop shows the selection-to-be. The glyphs come from the hidden working layer's pixels. +fn draw_mask_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, id: LayerId) { + let Some(st) = app.session.active() else { return }; + let canvas = st.doc.bounds(); + let opts = app.session.quick_mask_options; + let rgb = opts.color.to_rgb(); + let tint = |a: f32| { + let a = (a * opts.opacity).clamp(0.0, 1.0); + let q = |v: f32| (v.clamp(0.0, 1.0) * a * 255.0 + 0.5) as u8; + Color32::from_rgba_premultiplied(q(rgb[0]), q(rgb[1]), q(rgb[2]), (a * 255.0 + 0.5) as u8) + }; + let corners = |r: photocraft_geom::Rect| -> Vec { + let (x0, y0, x1, y1) = (r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32); + vec![xf.to_screen(x0, y0), xf.to_screen(x1, y0), xf.to_screen(x1, y1), xf.to_screen(x0, y1)] + }; + let surface = text_layer(&st.doc, id).and_then(|t| t.cache.as_ref()); + let area = surface.map(|s| s.content_bounds().intersect(&canvas)).unwrap_or(photocraft_geom::Rect::EMPTY); + // One unfeathered mesh, so the bands and the text's box meet without seams. + let mut bands = egui::Mesh::default(); + let mut band = |x0: i32, y0: i32, x1: i32, y1: i32| { + let r = photocraft_geom::Rect { x0, y0, x1, y1 }; + if !r.is_empty() { + let base = bands.vertices.len() as u32; + for p in corners(r) { + bands.colored_vertex(p, tint(1.0)); + } + bands.indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]); + } + }; + let Some(surface) = surface.filter(|_| !area.is_empty()) else { + band(canvas.x0, canvas.y0, canvas.x1, canvas.y1); + painter.add(egui::Shape::mesh(bands)); + return; + }; + band(canvas.x0, canvas.y0, canvas.x1, area.y0); + band(canvas.x0, area.y1, canvas.x1, canvas.y1); + band(canvas.x0, area.y0, area.x0, area.y1); + band(area.x1, area.y0, canvas.x1, area.y1); + painter.add(egui::Shape::mesh(bands)); + // The text's box: a texture of the tint where there is no glyph, cached per revision. + let ctx = painter.ctx(); + let key = (st.doc.id.0, st.revision, id.0); + let cache = egui::Id::new("pc-type-mask-overlay"); + let tex = ctx.data(|d| d.get_temp::<((u64, u64, u64), egui::TextureHandle)>(cache)).filter(|(k, _)| *k == key).map(|(_, t)| t); + let tex = tex.unwrap_or_else(|| { + let max = (ctx.input(|i| i.max_texture_side) as u32).clamp(256, 4096); + let factor = area.width().max(area.height()).div_ceil(max).max(1); + let (w, h) = (area.width().div_ceil(factor) as usize, area.height().div_ceil(factor) as usize); + let mut row = vec![[0.0f32; 4]; area.width() as usize]; + let mut px = Vec::with_capacity(w * h); + for y in (area.y0..area.y1).step_by(factor as usize) { + surface.read_rgba_into(photocraft_geom::Rect { x0: area.x0, y0: y, x1: area.x1, y1: y + 1 }, &mut row); + px.extend(row.iter().step_by(factor as usize).take(w).map(|p| tint(1.0 - p[3]))); + } + px.resize(w * h, tint(1.0)); + let img = egui::ColorImage::new([w, h], px); + let t = ctx.load_texture("type-mask-overlay", img, egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(cache, (key, t.clone()))); + t + }); + let c = corners(area); + let mut mesh = egui::Mesh::with_texture(tex.id()); + for (p, uv) in c.iter().zip([egui::pos2(0.0, 0.0), egui::pos2(1.0, 0.0), egui::pos2(1.0, 1.0), egui::pos2(0.0, 1.0)]) { + mesh.vertices.push(egui::epaint::Vertex { pos: *p, uv, color: Color32::WHITE }); + } + mesh.indices.extend([0, 1, 2, 0, 2, 3]); + painter.add(egui::Shape::mesh(mesh)); +} + /// Font families: bundled, system, and those the host serves (`photocraft_text::served`, fetched /// when picked). Cached until fonts are registered or the served catalog changes. pub fn families() -> Arc> { @@ -1673,6 +1781,11 @@ fn type_options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { pub fn cancel(app: &mut PhotocraftApp) { crate::type_transform::reset(app); let Some(ed) = app.ui.text_edit.take() else { return }; + undo_session(app, &ed); +} + +/// Undo the session's history step, if it is still the latest. +fn undo_session(app: &mut PhotocraftApp, ed: &TextEdit) { let coalesced = app.session.active().is_some_and(|s| s.coalesce.as_deref() == Some(ed.session.as_str())); if coalesced { let _ = app.run("edit.undo", json!({})); diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 741aaabc07..aa74b0be92 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -282,6 +282,7 @@ fn size_applies_at_layer_and_selection_scope() { anchor: 6, session: "s".into(), created: false, + mask: false, dragging: false, resize: None, preedit: None, @@ -428,6 +429,7 @@ fn foreground_colour_recolours_only_selected_type() { anchor, session: "s".into(), created: false, + mask: false, dragging: false, resize: None, preedit: None, @@ -516,6 +518,7 @@ fn text_color_dialog_edits_only_the_selected_range_and_cancel_is_inert() { anchor: 6, session: "text-color-dialog-test".into(), created: false, + mask: false, dragging: false, resize: None, preedit: None, @@ -566,8 +569,17 @@ fn text_color_dialog_without_a_selection_edits_the_whole_layer() { fn empty_ime_events_never_delete_the_selection() { let mut app = new_app(); let id = LayerId(app.run("type.create", json!({"text": "Hello", "size": 40, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); - app.ui.text_edit = - Some(crate::state::TextEdit { layer: id.0, caret: 5, anchor: 0, session: "s".into(), created: false, dragging: false, resize: None, preedit: None }); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + caret: 5, + anchor: 0, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); let mut h = harness(1.0, app); let ime = |h: &mut Harness<'static, PhotocraftApp>, e: egui::ImeEvent| { h.event(egui::Event::Ime(e)); @@ -1290,6 +1302,7 @@ mod multi_layer_formatting { anchor: 1, session: "batch-type-edit".into(), created: false, + mask: false, dragging: false, resize: None, preedit: None, @@ -1481,3 +1494,66 @@ mod multi_layer_formatting { } } } + +fn selection_bounds(app: &PhotocraftApp) -> Option { + app.session.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds()) +} + +/// Click with `tool` at (200, 300), type `text` and commit. +fn type_with(app: &mut PhotocraftApp, tool: crate::state::Tool, text: &str) { + use crate::canvas::{ToolEvent, tool_event}; + app.ui.tool = tool; + tool_event(app, ToolEvent::Down { x: 200.0, y: 300.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x: 200.0, y: 300.0 }, Modifiers::NONE); + super::insert(app, text); // replaces the Type tool's selected placeholder + super::commit(app); +} + +/// #1055: the Type Mask tools type a selection in the shape of the glyphs, the same pixels the +/// Type tool's layer covers, as one history step that adds no layer. Vertical runs down a column. +#[test] +fn type_mask_commits_the_glyphs_as_the_selection_in_one_step() { + use crate::state::Tool; + let mut app = new_app(); + type_with(&mut app, Tool::Type, "I"); + let glyphs = app.session.active().unwrap().doc.layers.last().unwrap().surface().unwrap().content_bounds(); + + let mut app = new_app(); + let layers = app.session.active().unwrap().doc.layers.len(); + let steps = app.session.active().unwrap().history.entries().len(); + type_with(&mut app, Tool::HorizontalTypeMask, "I"); + assert!(app.ui.text_edit.is_none()); + assert_eq!(selection_bounds(&app), Some(glyphs)); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layers.len(), layers, "no layer is left behind"); + assert_eq!(st.history.entries().len(), steps + 1); + assert_eq!(st.history.entries().last().map(String::as_str), Some("Type Mask")); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(selection_bounds(&app), None, "one undo restores the selection"); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers); + + let mut app = new_app(); + type_with(&mut app, Tool::VerticalTypeMask, "HELLO"); + let v = selection_bounds(&app).unwrap(); + assert!(v.height() > 2 * v.width(), "{v:?}"); +} + +/// #1055: Escape cancels a Type Mask session whatever "Use Escape to Commit" says: the selection +/// and the layers stay as they were. +#[test] +fn type_mask_escape_leaves_the_selection_unchanged() { + let mut app = new_app(); + app.run("select.all", json!({})).unwrap(); + let (before, layers) = (selection_bounds(&app), app.session.active().unwrap().doc.layers.len()); + app.ui.tool = crate::state::Tool::HorizontalTypeMask; + let mut h = harness(1.0, app); + let p = xf(h.state()).to_screen(200.0, 300.0); + click(&mut h, p); + assert!(h.state().ui.text_edit.as_ref().is_some_and(|ed| ed.mask), "typing a mask"); + super::insert(h.state_mut(), "I"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.text_edit.is_none()); + assert_eq!(selection_bounds(h.state()), before); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers); +} diff --git a/docs/gaps.md b/docs/gaps.md index af1911959b..d1bddd3266 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -54,10 +54,10 @@ what an agent can't finish alone. scripted check there) is the only real oracle. ### G6. Missing tools -- **Missing (11 of 68):** Artboard, Perspective Crop, Frame, Color Sampler (#1046), Color +- **Missing (9 of 68):** Artboard, Perspective Crop, Frame, Color Sampler (#1046), Color Replacement (engine command exists), Art History Brush, Add Anchor Point, Delete Anchor Point, - Convert Point (only as Pen + ⌥), Horizontal Type Mask, Vertical Type Mask. Freeform Pen and - Curvature Pen landed (#1054). + Convert Point (only as Pen + ⌥). Freeform Pen and Curvature Pen landed (#1054); Horizontal and + Vertical Type Mask landed (#1055, `type.createSelection`). - **Evidence:** `Tool` enum (`crates/ui-egui/src/state.rs`) against the 67 `` entries of Photoshop's `Default Keyboard Shortcuts.kys` plus Remove, Triangle and Curvature Pen. - **Impact:** users look for them by name and shortcut; several are daily tools for designers. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 97a42dd41e..382f9b67c8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -431,7 +431,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s | Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | | Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | | UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. | -| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Color Sampler, Perspective Crop, type masks and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | +| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Color Sampler, Perspective Crop and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | | Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. | | Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. | | Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d1fefd83b3..1d8eb94b1c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -41,7 +41,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Fill | Gradient, Paint Bucket | | | Focus, toning | Blur, Sharpen, Smudge, Dodge, Burn, Sponge | | | Pen | Pen, Freeform Pen, Curvature Pen (#1054) | Add Anchor Point, Delete Anchor Point, Convert Point (⌥ with Pen only) | -| Type | Horizontal, Vertical | Horizontal Type Mask, Vertical Type Mask | +| Type | Horizontal, Vertical, Vertical Type Mask, Horizontal Type Mask | | | Path selection | Path Selection, Direct Selection | | | Shapes | Rectangle, Ellipse, Triangle, Polygon, Line, Custom Shape | (Star is a Polygon option in Photoshop) | | View | Hand, Rotate View, Zoom | |