Type Mask: Shift/Alt at commit add to or subtract from the selection; no Layers row for the working text (#3036) (#3069)

* Tools: Horizontal and Vertical Type Mask (#1055)

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>

* Type Mask: Shift/Alt at commit combine with the selection; no Layers row for the working text (#3036)

Committing a Type Mask with Shift held (click outside, Cmd+Shift+Enter or
the check button) adds the glyphs to the selection, Alt subtracts them and
Shift+Alt intersects, as in Photoshop; without modifiers it still replaces.
The Layers panel no longer lists the hidden working text layer while a
Type Mask session is open (layer_tree_ui::panel_rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-11 23:43:56 +02:00 committed by GitHub
parent 5e5a0f6d2d
commit 8f7cf009bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 71 additions and 15 deletions

View File

@ -4522,9 +4522,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
polygon_click(app, x, y, mods);
return;
}
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask
if crate::type_tool::pointer_down(app, x, y, mods.shift) =>
{
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask if crate::type_tool::pointer_down(app, x, y, mods) => {
return;
}
_ => {}

View File

@ -10,6 +10,7 @@ use egui::{Rect, Sense, Shape, Stroke, pos2, vec2};
use photocraft_doc::{Document, Layer, LayerContent};
use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::theme::Tokens;
/// Width the disclosure triangle takes in a group row.
@ -34,6 +35,17 @@ pub fn display_rows(doc: &Document, show_all: bool) -> Vec<(usize, &Layer)> {
out
}
/// The Layers panel's rows for `app`: [`display_rows`] (every row while filtered) without a Type
/// Mask session's working text, which is no layer to the user: Photoshop shows only the mask
/// overlay while you type (#3036).
pub fn panel_rows<'a>(app: &PhotocraftApp, doc: &'a Document) -> Vec<(usize, &'a Layer)> {
let mut rows = display_rows(doc, !app.ui.layer_filter.is_empty());
if let Some(working) = app.ui.text_edit.as_ref().filter(|ed| ed.mask).map(|ed| ed.layer) {
rows.retain(|(_, l)| l.id.0 != working);
}
rows
}
/// The hit area of a group row's triangle starting at `x`.
pub fn triangle_rect(row: Rect, x: f32) -> Rect {
Rect::from_min_size(pos2(x - 2.0, row.top()), vec2(TRIANGLE_W + 2.0, row.height()))

View File

@ -2178,7 +2178,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
// Top of the stack first, groups above their contents, closed groups' contents hidden (#126).
let rows = crate::layer_tree_ui::display_rows(&doc, !app.ui.layer_filter.is_empty());
let rows = crate::layer_tree_ui::panel_rows(app, &doc);
let ctx = ui.ctx().clone();
let footer = widgets::footer_height(ui) + ui.spacing().item_spacing.y;
let fill = ui.available_height() > footer + 60.0;

View File

@ -227,7 +227,7 @@ fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, i: u8, x: f64, y: f64) {
}
/// Pointer down with the Type tool. Returns true when the press was consumed (no box drag).
pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) -> bool {
pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) -> bool {
if let Some(ed) = app.ui.text_edit.clone() {
let id = LayerId(ed.layer);
if let Some(i) = box_handle_at(app, id, x, y) {
@ -240,14 +240,15 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) -> boo
let off = hit_offset(app, id, x, y);
if let Some(e) = app.ui.text_edit.as_mut() {
e.caret = off;
if !shift {
if !mods.shift {
e.anchor = off;
}
e.dragging = true;
}
return true;
}
commit(app);
// ⇧/⌥ on that click combine a Type Mask with the selection.
commit_with(app, mods);
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.
@ -654,7 +655,7 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
let to = line_edge(app, id, ed.caret, *key == Key::End);
set(app, to, m.shift);
}
Key::Enter if m.command => commit(app),
Key::Enter if m.command => commit_with(app, *m),
Key::Enter => insert(app, "\n"),
// 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"),
@ -705,6 +706,13 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
/// End the editing session. A new layer left empty is deleted; a new layer is named after its text.
pub fn commit(app: &mut PhotocraftApp) {
commit_with(app, egui::Modifiers::NONE);
}
/// [`commit`] with the modifiers held while committing: a Type Mask adds its glyphs to the
/// selection with ⇧, subtracts them with ⌥ and intersects with ⇧⌥ (Photoshop); with none it
/// replaces the selection.
pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) {
if app.ui.tool.is_type() {
crate::type_transform::finish(app);
}
@ -712,10 +720,11 @@ pub fn commit(app: &mut PhotocraftApp) {
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.
// Type Mask: the text's glyphs become (or combine with) the selection and the working
// layer goes, all in the session's one history step. No text: nothing changes.
let operation = crate::tool_feedback::selection_mode(crate::state::Tool::HorizontalTypeMask, 0, mods);
if !text.trim().is_empty()
&& let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "coalesce": ed.session}))
&& let Err(e) = app.run("type.createSelection", json!({"layer": ed.layer, "removeLayer": true, "operation": operation, "coalesce": ed.session}))
{
app.ui.status = e;
app.ui.status_error = true;
@ -1513,7 +1522,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
)
.clicked()
{
commit(app);
commit_with(app, ui.input(|i| i.modifiers));
}
if crate::icons::button(ui, "ban", 24.0, false, tl!("Cancel any current edits (Esc)")).clicked() {
cancel(app);
@ -2039,7 +2048,7 @@ mod tests {
#[test]
fn click_creates_placeholder_selected_and_typing_replaces_it() {
let mut app = app();
assert!(!pointer_down(&mut app, 50.0, 100.0, false));
assert!(!pointer_down(&mut app, 50.0, 100.0, egui::Modifiers::NONE));
pointer_up(&mut app, [50.0, 100.0], [50.0, 100.0]);
let ed = app.ui.text_edit.clone().unwrap();
assert_eq!((ed.anchor, ed.caret), (0, PLACEHOLDER.chars().count()));
@ -2129,7 +2138,7 @@ mod tests {
let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer);
let steps = app.session.active().unwrap().history.entries().len();
// Top-left corner: the box keeps its bottom-right corner.
assert!(pointer_down(&mut app, 10.0, 10.0, false));
assert!(pointer_down(&mut app, 10.0, 10.0, egui::Modifiers::NONE));
assert_eq!(app.ui.text_edit.as_ref().unwrap().resize, Some(0));
pointer_move(&mut app, 20.0, 25.0);
pointer_move(&mut app, 30.0, 30.0);
@ -2141,7 +2150,7 @@ mod tests {
assert_eq!(app.session.active().unwrap().history.entries().len(), steps);
assert!(app.ui.text_edit.is_some());
// Right edge: only the width changes, and never below the minimum.
assert!(pointer_down(&mut app, 110.0, 45.0, false));
assert!(pointer_down(&mut app, 110.0, 45.0, egui::Modifiers::NONE));
pointer_move(&mut app, 0.0, 99.0);
pointer_up(&mut app, [110.0, 45.0], [0.0, 99.0]);
assert_eq!(box_shape(&app, id), Some((0.0, 0.0, MIN_BOX, 30.0)));

View File

@ -1661,3 +1661,40 @@ fn type_mask_escape_leaves_the_selection_unchanged() {
assert_eq!(selection_bounds(h.state()), before);
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers);
}
/// #3036: ⇧ on the click that commits a Type Mask adds the glyphs to the selection and ⌥
/// subtracts them, as in Photoshop; while typing, the working text has no Layers panel row.
#[test]
fn type_mask_shift_adds_and_alt_subtracts_without_a_layer_row() {
use crate::canvas::{ToolEvent, tool_event};
use crate::state::Tool;
use photocraft_geom::Rect;
let mut app = new_app();
type_with(&mut app, Tool::HorizontalTypeMask, "I");
let glyphs = selection_bounds(&app).unwrap();
let mut app = new_app();
app.ui.extras.snap = false; // the second text would snap to the first one's selected edge
let rect = Rect::new(1000, 100, 1100, 200);
app.run("select.rect", json!({"x": 1000, "y": 100, "width": 100, "height": 100})).unwrap();
// Type, then commit with a click outside the text holding `m`.
let type_and_commit = |app: &mut PhotocraftApp, m: Modifiers| {
app.ui.tool = Tool::HorizontalTypeMask;
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, "I");
let doc = app.session.active().unwrap().doc.clone();
let rows = crate::layer_tree_ui::panel_rows(app, &doc);
assert_eq!(rows.len(), doc.layers.len() - 1, "the working text is not listed");
tool_event(app, ToolEvent::Down { x: 1500.0, y: 800.0, pressure: 1.0 }, m);
tool_event(app, ToolEvent::Up { x: 1500.0, y: 800.0 }, m);
assert!(app.ui.text_edit.is_none());
};
let at = |app: &PhotocraftApp, x: i32, y: i32| app.session.active().unwrap().doc.selection.as_ref().map_or(0.0, |s| s.sample_channel(x, y, 0));
let stem = ((glyphs.x0 + glyphs.x1) / 2, (glyphs.y0 + glyphs.y1) / 2);
type_and_commit(&mut app, Modifiers::SHIFT);
assert_eq!(selection_bounds(&app), Some(rect.union(&glyphs)), "⇧ adds");
assert!(at(&app, stem.0, stem.1) > 0.99 && at(&app, 1050, 150) > 0.99);
type_and_commit(&mut app, Modifiers::ALT);
assert!(at(&app, stem.0, stem.1) < 0.01, "⌥ subtracts the glyphs");
assert!(at(&app, 1050, 150) > 0.99, "and keeps the rest");
}