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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein
2026-10-11 07:06:10 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent c436aa7294
commit 5866cd2cf6
40 changed files with 592 additions and 72 deletions
+2 -1
View File
@@ -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) | | `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) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | 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 | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | 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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`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 | <https://github.com/feathericons/feather> 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/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/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/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 | | `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 |
+2 -2
View File
@@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table> <table>
<tr> <tr>
<td width="33%" valign="top"> <td width="33%" valign="top">
<h4>🧰 60 tools</h4> <h4>🧰 62 tools</h4>
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 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
</td> </td>
<td width="33%" valign="top"> <td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4> <h4>🖌️ A real brush engine</h4>
+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Vertical Type Mask glyph (not from an icon set): a T drawn as marching
ants beside a downward arrow (the text runs top to bottom). -->
<path d="M2.5 7V4h12v3" stroke-dasharray="2.5 2.5" />
<path d="M8.5 4v16" stroke-dasharray="2.5 2.5" />
<path d="M6 20h5" stroke-dasharray="2.5 2.5" />
<path d="M20 8v12" />
<path d="M17.5 17.5 20 20l2.5-2.5" />
</svg>

After

Width:  |  Height:  |  Size: 596 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Horizontal Type Mask glyph (not from an icon set): a T drawn as marching ants. -->
<path d="M4 7V4h16v3" stroke-dasharray="2.5 2.5" />
<path d="M12 4v16" stroke-dasharray="2.5 2.5" />
<path d="M8.5 20h7" stroke-dasharray="2.5 2.5" />
</svg>

After

Width:  |  Height:  |  Size: 472 B

+1
View File
@@ -1135,6 +1135,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::gradient_fill_cmds::specs()); v.extend(crate::gradient_fill_cmds::specs());
v.extend(crate::solid_fill_cmds::specs()); v.extend(crate::solid_fill_cmds::specs());
v.extend(crate::type_cmds::specs()); v.extend(crate::type_cmds::specs());
v.extend(crate::type_mask_cmds::specs());
v.extend(crate::transform_cmds::specs()); v.extend(crate::transform_cmds::specs());
v.extend(crate::float_cmds::specs()); v.extend(crate::float_cmds::specs());
v.extend(crate::vector_cmds::specs()); v.extend(crate::vector_cmds::specs());
+1
View File
@@ -102,6 +102,7 @@ mod trap_cmds;
pub mod type_caret_cmds; pub mod type_caret_cmds;
pub mod type_cmds; pub mod type_cmds;
pub mod type_extra_cmds; pub mod type_extra_cmds;
pub mod type_mask_cmds;
pub mod type_spell_cmds; pub mod type_spell_cmds;
pub mod type_styles_cmds; pub mod type_styles_cmds;
mod variables_cmds; mod variables_cmds;
+47 -36
View File
@@ -626,6 +626,52 @@ fn info(s: &Session, p: &Value) -> Result<Value> {
})) }))
} }
/// 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<TextLayer> {
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<f32> = 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"##; 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<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
@@ -639,42 +685,7 @@ pub fn specs() -> Vec<CommandSpec> {
enabled: has_doc, enabled: has_doc,
journal: true, journal: true,
run: |s, p| { run: |s, p| {
let orientation = match p.get("orientation") { let mut t = new_text_layer(s, p, "type.create")?;
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<f32> = 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 name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| layer_name(&t.text)); 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| { let id = s.edit("New Type Layer", |doc, active| {
refresh(doc, &mut t); refresh(doc, &mut t);
+198
View File
@@ -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<String>) -> 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<Option<AntiAlias>> {
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<f32>, 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<f32> {
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<Value> {
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<CommandSpec> {
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<Rect> {
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<i32> = 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());
}
}
+11 -5
View File
@@ -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. // 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 egui::CursorIcon::Crosshair
} }
Tool::Move => egui::CursorIcon::Move, 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 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::MagneticLasso => crate::magnetic_lasso_ui::cursor(app, &painter, p, xf.zoom),
Tool::RedEye => { Tool::RedEye => {
let pupil = app.ui.tool_options.red_eye_pupil_size.clamp(1.0, 100.0); 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(a, 3.0, Color32::WHITE);
painter.circle_filled(b, 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 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()]; 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))); 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); polygon_click(app, x, y, mods);
return; 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); 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() => {} && 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), 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::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::Brush | Tool::Pencil | Tool::Eraser => commit_brush_stroke(app, &d),
Tool::RectMarquee | Tool::EllipseMarquee => { Tool::RectMarquee | Tool::EllipseMarquee => {
// Exactly what the drag showed (`marquee_preview_px`); a click deselects. // Exactly what the drag showed (`marquee_preview_px`); a click deselects.
+2
View File
@@ -1632,6 +1632,8 @@
Highlights from: Světla od: Highlights from: Světla od:
History Brush Tool Nástroj štětec historie History Brush Tool Nástroj štětec historie
Horizontal Type Tool Nástroj vodorovný text 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: Image Size: Velikost obrazu:
Inches Palce Inches Palce
Indexed Indexované Indexed Indexované
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1607,6 +1607,8 @@
Highlights from: Lichter von: Highlights from: Lichter von:
History Brush Tool Protokollpinsel-Werkzeug History Brush Tool Protokollpinsel-Werkzeug
Horizontal Type Tool Horizontales Text-Werkzeug Horizontal Type Tool Horizontales Text-Werkzeug
Horizontal Type Mask Tool Horizontales Textmaskierungswerkzeug
Vertical Type Mask Tool Vertikales Textmaskierungswerkzeug
Image Size: Bildgröße: Image Size: Bildgröße:
Inches Zoll Inches Zoll
Indexed Indiziert Indexed Indiziert
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3243,6 +3243,8 @@ cameraRaw Yellow Κίτρινο
Healing Brush Tool Πινέλο επιδιόρθωσης (Healing Brush Tool) Healing Brush Tool Πινέλο επιδιόρθωσης (Healing Brush Tool)
History Brush Tool Πινέλο ιστορικού (History Brush Tool) History Brush Tool Πινέλο ιστορικού (History Brush Tool)
Horizontal Type Tool Οριζόντιο κείμενο (Horizontal Type 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) Lasso Tool Λάσο (Lasso Tool)
Line Tool Γραμμή (Line Tool) Line Tool Γραμμή (Line Tool)
Magic Eraser Tool Μαγική γόμα (Magic Eraser Tool) Magic Eraser Tool Μαγική γόμα (Magic Eraser Tool)
Can't render this file because it contains an unexpected character in line 973 and column 18.
+2
View File
@@ -1615,6 +1615,8 @@
Highlights from: Luces desde: Highlights from: Luces desde:
History Brush Tool Herramienta Pincel de historia History Brush Tool Herramienta Pincel de historia
Horizontal Type Tool Herramienta Texto horizontal 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: Image Size: Tamaño de imagen:
Inches Pulgadas Inches Pulgadas
Indexed Indexado Indexed Indexado
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -1593,6 +1593,8 @@
Highlights from: Hautes lumières à partir de : Highlights from: Hautes lumières à partir de :
History Brush Tool Outil Pinceau d'historique History Brush Tool Outil Pinceau d'historique
Horizontal Type Tool Outil Texte horizontal 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 : Image Size: Taille de l'image :
Inches Pouces Inches Pouces
Indexed Indexé Indexed Indexé
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1587,6 +1587,8 @@
Highlights from: Highlight dari: Highlights from: Highlight dari:
History Brush Tool Alat History Brush History Brush Tool Alat History Brush
Horizontal Type Tool Alat Teks Horizontal 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: Image Size: Ukuran Gambar:
Inches Inci Inches Inci
Indexed Terindeks Indexed Terindeks
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -1613,6 +1613,8 @@
Highlights from: Luci da: Highlights from: Luci da:
History Brush Tool Strumento Pennello storia History Brush Tool Strumento Pennello storia
Horizontal Type Tool Strumento Testo orizzontale 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: Image Size: Dimensione immagine:
Inches Pollici Inches Pollici
Indexed Indicizzato Indexed Indicizzato
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
1613
1614
1615
1616
1617
1618
1619
1620
+2
View File
@@ -1618,6 +1618,8 @@
Highlights from: ハイライトの開始: Highlights from: ハイライトの開始:
History Brush Tool ヒストリーブラシツール History Brush Tool ヒストリーブラシツール
Horizontal Type Tool 横書き文字ツール Horizontal Type Tool 横書き文字ツール
Horizontal Type Mask Tool 横書き文字マスクツール
Vertical Type Mask Tool 縦書き文字マスクツール
Image Size: 画像サイズ: Image Size: 画像サイズ:
Inches インチ Inches インチ
Indexed インデックス Indexed インデックス
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1586,6 +1586,8 @@
Highlights from: 밝은 영역 시작: Highlights from: 밝은 영역 시작:
History Brush Tool 작업 내역 브러시 도구 History Brush Tool 작업 내역 브러시 도구
Horizontal Type Tool 수평 문자 도구 Horizontal Type Tool 수평 문자 도구
Horizontal Type Mask Tool 수평 문자 마스크 도구
Vertical Type Mask Tool 세로 문자 마스크 도구
Image Size: 이미지 크기: Image Size: 이미지 크기:
Inches 인치 Inches 인치
Indexed 인덱스 Indexed 인덱스
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
1586
1587
1588
1589
1590
1591
1592
1593
+2
View File
@@ -1597,6 +1597,8 @@
Highlights from: Hooglichten van: Highlights from: Hooglichten van:
History Brush Tool Historiepenseel History Brush Tool Historiepenseel
Horizontal Type Tool Horizontaal tekstgereedschap Horizontal Type Tool Horizontaal tekstgereedschap
Horizontal Type Mask Tool Horizontaal tekstmaskergereedschap
Vertical Type Mask Tool Verticaal tekstmaskergereedschap
Image Size: Afbeeldingsgrootte: Image Size: Afbeeldingsgrootte:
Inches Inch Inches Inch
Indexed Geïndexeerd Indexed Geïndexeerd
Can't render this file because it contains an unexpected character in line 8 and column 34.
+2
View File
@@ -1611,6 +1611,8 @@
Highlights from: Światła od: Highlights from: Światła od:
History Brush Tool Narzędzie Pędzel historii History Brush Tool Narzędzie Pędzel historii
Horizontal Type Tool Narzędzie Tekst poziomy 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: Image Size: Rozmiar obrazu:
Inches Cale Inches Cale
Indexed Indeksowany Indexed Indeksowany
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1592,6 +1592,8 @@
Highlights from: Realces a partir de: Highlights from: Realces a partir de:
History Brush Tool Ferramenta Pincel do histórico History Brush Tool Ferramenta Pincel do histórico
Horizontal Type Tool Ferramenta Texto horizontal 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: Image Size: Tamanho:
Inches Polegadas Inches Polegadas
Indexed Indexado Indexed Indexado
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1550,6 +1550,8 @@
Gradient Tool Градиент Gradient Tool Градиент
Paint Bucket Tool Заливка Paint Bucket Tool Заливка
Horizontal Type Tool Горизонтальный текст Horizontal Type Tool Горизонтальный текст
Horizontal Type Mask Tool Горизонтальный текст-маска
Vertical Type Mask Tool Вертикальный текст-маска
Hand Tool Рука Hand Tool Рука
Zoom Tool Масштаб Zoom Tool Масштаб
Spot Healing Brush Tool Точечная восстанавливающая кисть Spot Healing Brush Tool Точечная восстанавливающая кисть
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1532,6 +1532,8 @@
Gradient Tool Інструмент «Градієнт» Gradient Tool Інструмент «Градієнт»
Paint Bucket Tool Інструмент «Відро з фарбою» Paint Bucket Tool Інструмент «Відро з фарбою»
Horizontal Type Tool Інструмент «Горизонтальний текст» Horizontal Type Tool Інструмент «Горизонтальний текст»
Horizontal Type Mask Tool Інструмент «Горизонтальний текст-маска»
Vertical Type Mask Tool Інструмент «Вертикальний текст-маска»
Hand Tool Інструмент «Рука» Hand Tool Інструмент «Рука»
Zoom Tool Інструмент «Масштаб» Zoom Tool Інструмент «Масштаб»
Spot Healing Brush Tool Інструмент «Точковий відновлювальний пензель» Spot Healing Brush Tool Інструмент «Точковий відновлювальний пензель»
Can't render this file because it contains an unexpected character in line 1330 and column 18.
+2
View File
@@ -1629,6 +1629,8 @@
Highlights from: 高光下限: Highlights from: 高光下限:
History Brush Tool 历史记录画笔工具 History Brush Tool 历史记录画笔工具
Horizontal Type Tool 横排文字工具 Horizontal Type Tool 横排文字工具
Horizontal Type Mask Tool 横排文字蒙版工具
Vertical Type Mask Tool 直排文字蒙版工具
Image Size: 图像大小: Image Size: 图像大小:
Inches 英寸 Inches 英寸
Indexed 索引色 Indexed 索引色
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
1629
1630
1631
1632
1633
1634
1635
1636
+2
View File
@@ -1622,6 +1622,8 @@
Highlights from: 亮部起點: Highlights from: 亮部起點:
History Brush Tool 步驟記錄筆刷工具 History Brush Tool 步驟記錄筆刷工具
Horizontal Type Tool 水平文字工具 Horizontal Type Tool 水平文字工具
Horizontal Type Mask Tool 水平文字遮色片工具
Vertical Type Mask Tool 垂直文字遮色片工具
Image Size: 影像尺寸: Image Size: 影像尺寸:
Inches 英吋 Inches 英吋
Indexed 索引色 Indexed 索引色
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -131,6 +131,8 @@ pub static ICONS: &[(&str, &[u8])] = &[
("triangle-alert", include_bytes!("../../../assets/icons/triangle-alert.svg")), ("triangle-alert", include_bytes!("../../../assets/icons/triangle-alert.svg")),
("triangle-right", include_bytes!("../../../assets/icons/triangle-right.svg")), ("triangle-right", include_bytes!("../../../assets/icons/triangle-right.svg")),
("type", include_bytes!("../../../assets/icons/type.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")), ("undo-2", include_bytes!("../../../assets/icons/undo-2.svg")),
("unlink", include_bytes!("../../../assets/icons/unlink.svg")), ("unlink", include_bytes!("../../../assets/icons/unlink.svg")),
("wand", include_bytes!("../../../assets/icons/wand.svg")), ("wand", include_bytes!("../../../assets/icons/wand.svg")),
+2
View File
@@ -110,6 +110,8 @@ pub fn tool_icon(t: Tool) -> &'static str {
Tool::Gradient => "blend", Tool::Gradient => "blend",
Tool::PaintBucket => "paint-bucket", Tool::PaintBucket => "paint-bucket",
Tool::Type | Tool::VerticalType => "type", Tool::Type | Tool::VerticalType => "type",
Tool::HorizontalTypeMask => "type-mask",
Tool::VerticalTypeMask => "type-mask-vertical",
Tool::Hand => "hand", Tool::Hand => "hand",
Tool::RotateView => "compass", Tool::RotateView => "compass",
Tool::Zoom => "zoom-in", Tool::Zoom => "zoom-in",
+1
View File
@@ -478,6 +478,7 @@ fn type_tool_alt_arrows_kern_the_pair() {
anchor: 1, anchor: 1,
session: "kern-test".into(), session: "kern-test".into(),
created: false, created: false,
mask: false,
dragging: false, dragging: false,
resize: None, resize: None,
preedit: None, preedit: None,
+2 -1
View File
@@ -1454,7 +1454,8 @@ impl eframe::App for PhotocraftApp {
} }
self.drain_control(ctx); self.drain_control(ctx);
native_menu::run(self, 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); type_tool::commit(self);
} }
if self.ui.pen.as_ref().is_some_and(|p| !p.drawn_with(self.ui.tool)) { if self.ui.pen.as_ref().is_some_and(|p| !p.drawn_with(self.ui.tool)) {
+13 -4
View File
@@ -35,7 +35,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[
], ],
&[ &[
&[Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint], &[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::PathSelection, Tool::DirectSelection],
&[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape], &[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). // 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 => { Tool::Move if t.pro => {
let o = &mut app.ui.tool_options; let o = &mut app.ui.tool_options;
widgets::checkbox(ui, &mut o.move_auto_select, tl!("Auto-Select:")); 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(); app.run("tools.setColors", json!({"foreground": "#d8452e"})).unwrap();
let fg = app.session.tools.foreground; let fg = app.session.tools.foreground;
app.ui.tool = Tool::Type; app.ui.tool = Tool::Type;
app.ui.text_edit = app.ui.text_edit = Some(crate::state::TextEdit {
Some(crate::state::TextEdit { layer: id, caret: 0, anchor: 5, session: "s".into(), created: false, dragging: false, resize: None, preedit: None }); 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); 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); h.run_steps(4);
assert_eq!(h.state().session.tools.foreground, fg); assert_eq!(h.state().session.tools.foreground, fg);
+2
View File
@@ -115,6 +115,8 @@ fn is_point_tool(t: Tool) -> bool {
| Tool::Line | Tool::Line
| Tool::Type | Tool::Type
| Tool::VerticalType | Tool::VerticalType
| Tool::HorizontalTypeMask
| Tool::VerticalTypeMask
) )
} }
+24 -5
View File
@@ -90,6 +90,9 @@ pub enum Tool {
PaintBucket, PaintBucket,
Type, Type,
VerticalType, VerticalType,
/// Horizontal Type Mask: types a selection in the shape of the glyphs, no type layer.
HorizontalTypeMask,
VerticalTypeMask,
Hand, Hand,
RotateView, RotateView,
Zoom, Zoom,
@@ -128,7 +131,7 @@ pub enum Tool {
} }
impl Tool { impl Tool {
pub const ALL: [Tool; 60] = [ pub const ALL: [Tool; 62] = [
Tool::Move, Tool::Move,
Tool::RectMarquee, Tool::RectMarquee,
Tool::EllipseMarquee, Tool::EllipseMarquee,
@@ -154,6 +157,8 @@ impl Tool {
Tool::PaintBucket, Tool::PaintBucket,
Tool::Type, Tool::Type,
Tool::VerticalType, Tool::VerticalType,
Tool::HorizontalTypeMask,
Tool::VerticalTypeMask,
Tool::Hand, Tool::Hand,
Tool::RotateView, Tool::RotateView,
Tool::Zoom, Tool::Zoom,
@@ -220,6 +225,8 @@ impl Tool {
Tool::PaintBucket => "Paint Bucket Tool", Tool::PaintBucket => "Paint Bucket Tool",
Tool::Type => "Horizontal Type Tool", Tool::Type => "Horizontal Type Tool",
Tool::VerticalType => "Vertical Type Tool", Tool::VerticalType => "Vertical Type Tool",
Tool::HorizontalTypeMask => "Horizontal Type Mask Tool",
Tool::VerticalTypeMask => "Vertical Type Mask Tool",
Tool::Hand => "Hand Tool", Tool::Hand => "Hand Tool",
Tool::RotateView => "Rotate View Tool", Tool::RotateView => "Rotate View Tool",
Tool::Zoom => "Zoom Tool", Tool::Zoom => "Zoom Tool",
@@ -256,7 +263,15 @@ impl Tool {
} }
} }
pub fn is_type(self) -> bool { 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). /// 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::MagicWand => 'W',
Tool::Crop | Tool::Slice | Tool::SliceSelect => 'C', Tool::Crop | Tool::Slice | Tool::SliceSelect => 'C',
Tool::Gradient | Tool::PaintBucket => 'G', Tool::Gradient | Tool::PaintBucket => 'G',
Tool::Type | Tool::VerticalType => 'T', Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => 'T',
Tool::Hand => 'H', Tool::Hand => 'H',
Tool::RotateView => 'R', Tool::RotateView => 'R',
Tool::Zoom => 'Z', Tool::Zoom => 'Z',
@@ -326,7 +341,7 @@ impl Tool {
Tool::MagicWand => "W", Tool::MagicWand => "W",
Tool::Crop => "C", Tool::Crop => "C",
Tool::Gradient | Tool::PaintBucket => "G", Tool::Gradient | Tool::PaintBucket => "G",
Tool::Type | Tool::VerticalType => "T", Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => "T",
Tool::Hand => "✋", Tool::Hand => "✋",
Tool::RotateView => "🧭", Tool::RotateView => "🧭",
Tool::Zoom => "🔍", Tool::Zoom => "🔍",
@@ -864,6 +879,9 @@ pub struct TextEdit {
pub session: String, pub session: String,
/// The layer was created by this session (its name follows the text; empty on commit = delete). /// The layer was created by this session (its name follows the text; empty on commit = delete).
pub created: bool, pub created: bool,
/// A Type Mask tool session: the layer is temporary and commits as a selection.
#[serde(default)]
pub mask: bool,
#[serde(skip)] #[serde(skip)]
pub dragging: bool, pub dragging: bool,
/// Paragraph-box handle being dragged (0-3 corners from top-left clockwise, 4-7 top/right/bottom/left edges). /// 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!(!Tool::RedEye.is_brushlike());
assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp)); assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp));
assert_eq!(Tool::from_name("Pattern Stamp Tool"), 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("addAnchorPoint"), Some(Tool::AddAnchorPoint));
assert_eq!(Tool::from_name("Delete Anchor Point Tool"), Some(Tool::DeleteAnchorPoint)); assert_eq!(Tool::from_name("Delete Anchor Point Tool"), Some(Tool::DeleteAnchorPoint));
assert_eq!(Tool::DeleteAnchorPoint.key(), 'P'); assert_eq!(Tool::DeleteAnchorPoint.key(), 'P');
+11 -2
View File
@@ -287,8 +287,17 @@ mod tests {
app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); 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(); 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.tool = Tool::Type;
app.ui.text_edit = app.ui.text_edit = Some(crate::state::TextEdit {
Some(crate::state::TextEdit { layer: id, caret: 0, anchor: 5, session: "s".into(), created: false, dragging: false, resize: None, preedit: None }); 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. // Recolour with a visible colour: put Red first.
app.run("swatches.move", json!({"swatch": "Red", "to": "Grays", "position": 0})).unwrap(); 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); let mut h = Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel(app, ui), app);
+1 -1
View File
@@ -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::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::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::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::Zoom => reset!(zoom_scrubby),
Tool::SpotHealing => reset!(spot_type, sample_all_layers), Tool::SpotHealing => reset!(spot_type, sample_all_layers),
Tool::Healing => reset!(clone_aligned, clone_sample), Tool::Healing => reset!(clone_aligned, clone_sample),
@@ -365,6 +365,7 @@ fn hovering_a_font_previews_only_the_selected_characters() {
anchor: 3, anchor: 3,
session: "font-preview-test".into(), session: "font-preview-test".into(),
created: false, created: false,
mask: false,
dragging: false, dragging: false,
resize: None, resize: None,
preedit: None, preedit: None,
+121 -8
View File
@@ -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<LayerId> { fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<LayerId> {
let doc = app.session.active()?.doc.clone(); let doc = app.session.active()?.doc.clone();
let slop = 6.0 / app.point_zoom().max(0.01); let slop = 6.0 / app.point_zoom().max(0.01);
let mut ids: Vec<LayerId> = // A Type Mask session's text is a hidden layer; it is still the text being edited.
doc.walk().into_iter().filter(|(_, _, l)| l.visible && matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); let editing = app.ui.text_edit.as_ref().filter(|ed| ed.mask).map(|ed| LayerId(ed.layer));
let mut ids: Vec<LayerId> = 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.reverse(); // walk() is bottom-up; hit the topmost first
ids.into_iter().find(|id| { ids.into_iter().find(|id| {
// The pixels shown count too: a PSD's type keeps Photoshop's rendering until edited, // 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); commit(app);
let key = session_key(app); let key = session_key(app);
begin_edit(app, id, &key)?; 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() { } else if let Some(ed) = app.ui.text_edit.as_mut() {
ed.anchor = 0; ed.anchor = 0;
ed.caret = n; ed.caret = n;
@@ -219,6 +226,10 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) -> boo
commit(app); commit(app);
return true; // Photoshop: a click outside commits without starting new text 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) { if let Some(id) = hit_layer(app, x, y) {
let _ = app.session.select_layer(id); let _ = app.session.select_layer(id);
let key = session_key(app); 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; return true;
} }
let off = hit_offset(app, id, x, y); 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; return true;
} }
false 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 (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 min = 4.0 / app.point_zoom().max(0.01) as f64;
let o = app.ui.tool_options.clone(); let o = app.ui.tool_options.clone();
// Preferences ▸ Type: "Fill new type layers with placeholder text" (on by default). let mask = app.ui.tool.is_type_mask();
let text = if app.session.prefs().type_.fill_new_type_layers_with_placeholder { PLACEHOLDER } else { "" }; // 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!({ let mut p = json!({
"text": text, "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, "font": o.type_font,
"fontStyle": o.type_style, "fontStyle": o.type_style,
"size": o.type_size, "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" { if o.type_aa != "sharp" {
let _ = app.run("type.edit", json!({"layer": id, "antialias": o.type_aa, "coalesce": key})); 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. // Like Photoshop: the placeholder is selected, so typing replaces it.
let n = text.chars().count(); 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. // 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::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), 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 // Preferences ▸ Type: with "Use Escape to Commit" off, Escape cancels the
// session (undoes it, removing a just-created layer) like the Cancel button. // 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), 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); crate::type_transform::reset(app);
let Some(ed) = app.ui.text_edit.take() else { return }; let Some(ed) = app.ui.text_edit.take() else { return };
let Some(text) = current_text(app, LayerId(ed.layer)) 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 { if text.trim().is_empty() && ed.created {
let _ = app.run("layer.delete", json!({"layer": ed.layer})); let _ = app.run("layer.delete", json!({"layer": ed.layer}));
} else if ed.created { } 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 Some(ed) = app.ui.text_edit.clone() else { return };
let id = LayerId(ed.layer); let id = LayerId(ed.layer);
let Some((l, aff, text)) = layout(app, id) else { return }; 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()); let t = crate::theme::Tokens::get(painter.ctx());
// Text space → screen. // Text space → screen.
let scr_t = |x: f32, y: f32| -> Pos2 { 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<Pos2> {
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 /// 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. /// when picked). Cached until fonts are registered or the served catalog changes.
pub fn families() -> Arc<Vec<String>> { pub fn families() -> Arc<Vec<String>> {
@@ -1673,6 +1781,11 @@ fn type_options(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
pub fn cancel(app: &mut PhotocraftApp) { pub fn cancel(app: &mut PhotocraftApp) {
crate::type_transform::reset(app); crate::type_transform::reset(app);
let Some(ed) = app.ui.text_edit.take() else { return }; 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())); let coalesced = app.session.active().is_some_and(|s| s.coalesce.as_deref() == Some(ed.session.as_str()));
if coalesced { if coalesced {
let _ = app.run("edit.undo", json!({})); let _ = app.run("edit.undo", json!({}));
+78 -2
View File
@@ -282,6 +282,7 @@ fn size_applies_at_layer_and_selection_scope() {
anchor: 6, anchor: 6,
session: "s".into(), session: "s".into(),
created: false, created: false,
mask: false,
dragging: false, dragging: false,
resize: None, resize: None,
preedit: None, preedit: None,
@@ -428,6 +429,7 @@ fn foreground_colour_recolours_only_selected_type() {
anchor, anchor,
session: "s".into(), session: "s".into(),
created: false, created: false,
mask: false,
dragging: false, dragging: false,
resize: None, resize: None,
preedit: None, preedit: None,
@@ -516,6 +518,7 @@ fn text_color_dialog_edits_only_the_selected_range_and_cancel_is_inert() {
anchor: 6, anchor: 6,
session: "text-color-dialog-test".into(), session: "text-color-dialog-test".into(),
created: false, created: false,
mask: false,
dragging: false, dragging: false,
resize: None, resize: None,
preedit: 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() { fn empty_ime_events_never_delete_the_selection() {
let mut app = new_app(); 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()); 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 = app.ui.text_edit = Some(crate::state::TextEdit {
Some(crate::state::TextEdit { layer: id.0, caret: 5, anchor: 0, session: "s".into(), created: false, dragging: false, resize: None, preedit: None }); 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 mut h = harness(1.0, app);
let ime = |h: &mut Harness<'static, PhotocraftApp>, e: egui::ImeEvent| { let ime = |h: &mut Harness<'static, PhotocraftApp>, e: egui::ImeEvent| {
h.event(egui::Event::Ime(e)); h.event(egui::Event::Ime(e));
@@ -1290,6 +1302,7 @@ mod multi_layer_formatting {
anchor: 1, anchor: 1,
session: "batch-type-edit".into(), session: "batch-type-edit".into(),
created: false, created: false,
mask: false,
dragging: false, dragging: false,
resize: None, resize: None,
preedit: None, preedit: None,
@@ -1481,3 +1494,66 @@ mod multi_layer_formatting {
} }
} }
} }
fn selection_bounds(app: &PhotocraftApp) -> Option<photocraft_geom::Rect> {
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);
}
+3 -3
View File
@@ -54,10 +54,10 @@ what an agent can't finish alone.
scripted check there) is the only real oracle. scripted check there) is the only real oracle.
### G6. Missing tools ### 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, 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 Convert Point (only as Pen + ⌥). Freeform Pen and Curvature Pen landed (#1054); Horizontal and
Curvature Pen landed (#1054). Vertical Type Mask landed (#1055, `type.createSelection`).
- **Evidence:** `Tool` enum (`crates/ui-egui/src/state.rs`) against the 67 `<tool>` entries of - **Evidence:** `Tool` enum (`crates/ui-egui/src/state.rs`) against the 67 `<tool>` entries of
Photoshop's `Default Keyboard Shortcuts.kys` plus Remove, Triangle and Curvature Pen. 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. - **Impact:** users look for them by name and shortcut; several are daily tools for designers.
+1 -1
View File
@@ -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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. |
+1 -1
View File
@@ -41,7 +41,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| Fill | Gradient, Paint Bucket | | | Fill | Gradient, Paint Bucket | |
| Focus, toning | Blur, Sharpen, Smudge, Dodge, Burn, Sponge | | | 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) | | 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 | | | Path selection | Path Selection, Direct Selection | |
| Shapes | Rectangle, Ellipse, Triangle, Polygon, Line, Custom Shape | (Star is a Polygon option in Photoshop) | | Shapes | Rectangle, Ellipse, Triangle, Polygon, Line, Custom Shape | (Star is a Polygon option in Photoshop) |
| View | Hand, Rotate View, Zoom | | | View | Hand, Rotate View, Zoom | |