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 | |