Tools: Single Row Marquee and Single Column Marquee (#2837) (#2848)

Both tools join the Marquee flyout (no key, outside the M / Shift+M cycle,
as in Photoshop). A click selects the one-pixel row (column) under the
pointer across the whole canvas; a press-drag moves the line until the
release. The selection goes through select.rect, so Shift adds, Alt
subtracts, Shift+Alt intersects and the options-bar modes apply; Feather
applies, Anti-alias and Style/Width/Height are greyed. Selection-mode
cursor badges, the selection right-click menu and snapping cover them.
Original row/column glyphs with an ATTRIBUTION.md row; tool names in all
catalogs; README tool list and the G6 / ui-parity rows 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 01:10:34 +02:00 committed by GitHub
parent 9d3ba499ad
commit 325c5b3194
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 227 additions and 19 deletions

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) |
| `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/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg` and `symmetry-*.svg` (110 files) | 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`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (110 files) | 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/slice-knife.svg` | Slice tool glyph | 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 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |

View File

@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 53 tools</h4>
Move · Rectangular and Elliptical 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 · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
<h4>🧰 55 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 · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>

View File

@ -0,0 +1,18 @@
<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 Single Column Marquee glyph: a dashed column from edge to edge. -->
<path d="M8 3h8" />
<path d="M8 21h8" />
<path d="M12 6.5v1" />
<path d="M12 11.5v1" />
<path d="M12 16.5v1" />
</svg>

After

Width:  |  Height:  |  Size: 422 B

View File

@ -0,0 +1,18 @@
<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 Single Row Marquee glyph: a dashed row from edge to edge. -->
<path d="M3 8v8" />
<path d="M21 8v8" />
<path d="M6.5 12h1" />
<path d="M11.5 12h1" />
<path d="M16.5 12h1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@ -217,6 +217,35 @@ pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] {
[a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()]
}
/// The one-pixel line `[x0, y0, x1, y1]` a Single Row (Single Column) Marquee at `p` selects: the
/// pixel row (column) under `p`, kept on the canvas, across the whole `size`. `None` for other
/// tools, an empty canvas or a point that is not a number.
pub(crate) fn single_line_px(tool: Tool, size: [u32; 2], p: [f64; 2]) -> Option<[f64; 4]> {
let [w, h] = size.map(f64::from);
if w < 1.0 || h < 1.0 || !p[0].is_finite() || !p[1].is_finite() {
return None;
}
match tool {
Tool::SingleRowMarquee => {
let y = p[1].floor().clamp(0.0, h - 1.0);
Some([0.0, y, w, y + 1.0])
}
Tool::SingleColumnMarquee => {
let x = p[0].floor().clamp(0.0, w - 1.0);
Some([x, 0.0, x + 1.0, h])
}
_ => None,
}
}
/// The line a Single Row / Single Column Marquee gesture `d` selects so far: it follows the
/// pointer until the release.
fn single_line_drag_px(app: &PhotocraftApp, d: &Drag) -> Option<[f64; 4]> {
let st = app.session.active()?;
let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
single_line_px(d.tool, [st.doc.size.width, st.doc.size.height], last)
}
/// The marching ants of a marquee drag: its rectangle, or the ellipse inscribed in it, built in
/// document space and mapped point by point through the view, so the hinting lands where the
/// release commits the selection.
@ -2732,7 +2761,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
// Capture intent at the press, before egui's drag threshold: Type transforms keep
// Command, and marquee add/subtract must not become a live Shift/Alt constraint.
let capture_press = tool.is_type() || matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee);
let capture_press = tool.is_type() || matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee);
let tool_press = egui::Id::new(("tool-pointer-press-modifiers", idx));
if capture_press
&& let Some((p, press_mods)) = ui.input(|i| {
@ -3743,6 +3772,13 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
trail.draw(painter, doc_rect, xf.flip, col);
}
t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::draw_shape_preview(app, painter, xf, t, d.start, last, d.live),
Tool::SingleRowMarquee | Tool::SingleColumnMarquee => {
// Mapped corner by corner like the other marquees, so a rotated view shows the line
// where the release commits it (#2512).
if let Some([x0, y0, x1, y1]) = single_line_drag_px(app, d) {
crate::tool_feedback::draw_ants(painter, &marquee_ants(xf, d.tool, [x0, y0], [x1, y1]), true);
}
}
// Nothing selected yet: no outline, as the release would deselect.
Tool::RectMarquee | Tool::EllipseMarquee if marquee.is_none() => {}
Tool::RectMarquee | Tool::EllipseMarquee | Tool::ObjectSelection => {
@ -4444,6 +4480,14 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
let (aa, feather) = (app.ui.tool_options.anti_alias, app.ui.tool_options.feather);
let _ = app.run("select.rect", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "ellipse": d.tool == Tool::EllipseMarquee, "antiAlias": aa, "feather": feather}));
}
// A click (or the end of a drag) selects the line under the pointer, never deselects.
Tool::SingleRowMarquee | Tool::SingleColumnMarquee => {
let Some([x0, y0, x1, y1]) = single_line_drag_px(app, &d) else { return };
let mode = selection_mode(app, d.modifiers);
let feather = app.ui.tool_options.feather;
let _ =
app.run("select.rect", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "antiAlias": false, "feather": feather}));
}
Tool::Patch if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => crate::retouch_ui::finish_patch(app, d.start, [end[0], end[1]]),
Tool::ContentAwareMove if crate::retouch_ui::patch_drags_selection(app, d.start, d.modifiers) => {
crate::retouch_ui::finish_content_aware_move(app, d.start, [end[0], end[1]])

View File

@ -135,7 +135,16 @@ pub const PEN_MENU: &[Row] = &[
pub fn applies(tool: Tool) -> bool {
matches!(
tool,
Tool::RectMarquee | Tool::EllipseMarquee | Tool::Lasso | Tool::PolygonLasso | Tool::MagneticLasso | Tool::MagicWand | Tool::ObjectSelection | Tool::Pen
Tool::RectMarquee
| Tool::EllipseMarquee
| Tool::SingleRowMarquee
| Tool::SingleColumnMarquee
| Tool::Lasso
| Tool::PolygonLasso
| Tool::MagneticLasso
| Tool::MagicWand
| Tool::ObjectSelection
| Tool::Pen
)
}

View File

@ -1605,6 +1605,8 @@
Efficiency: 100% Efektivita: 100 %
Ellipse Tool Nástroj elipsa
Elliptical Marquee Tool Nástroj eliptický výběr
Single Row Marquee Tool Nástroj výběr jednoho řádku
Single Column Marquee Tool Nástroj výběr jednoho sloupce
Emboss Reliéf
Eraser Guma
Eraser Tool Nástroj guma

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1580,6 +1580,8 @@
Efficiency: 100% Effizienz: 100 %
Ellipse Tool Ellipsen-Werkzeug
Elliptical Marquee Tool Ellipsenauswahl-Werkzeug
Single Row Marquee Tool Auswahlwerkzeug (einzelne Zeile)
Single Column Marquee Tool Auswahlwerkzeug (einzelne Spalte)
Emboss Relief
Eraser Radiergummi
Eraser Tool Radiergummi-Werkzeug

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3223,6 +3223,8 @@ cameraRaw Yellow Κίτρινο
Dodge Tool Φωτισμός (Dodge Tool)
Ellipse Tool Έλλειψη (Ellipse Tool)
Elliptical Marquee Tool Ελλειπτική επιλογή (Elliptical Marquee Tool)
Single Row Marquee Tool Επιλογή μίας γραμμής (Single Row Marquee Tool)
Single Column Marquee Tool Επιλογή μίας στήλης (Single Column Marquee Tool)
Eraser Tool Γόμα (Eraser Tool)
Eyedropper Tool Σταγονόμετρο (Eyedropper Tool)
Gradient Tool Διαβάθμιση (Gradient Tool)

Can't render this file because it has a wrong number of fields in line 5.

View File

@ -1588,6 +1588,8 @@
Efficiency: 100% Eficiencia: 100%
Ellipse Tool Herramienta Elipse
Elliptical Marquee Tool Herramienta Marco elíptico
Single Row Marquee Tool Herramienta Marco de fila única
Single Column Marquee Tool Herramienta Marco de columna única
Emboss Relieve
Eraser Borrador
Eraser Tool Herramienta Borrador

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -1566,6 +1566,8 @@
Efficiency: 100% Efficacité : 100 %
Ellipse Tool Outil Ellipse
Elliptical Marquee Tool Outil Sélection elliptique
Single Row Marquee Tool Outil Sélection de ligne unique
Single Column Marquee Tool Outil Sélection de colonne unique
Emboss Relief
Eraser Gomme
Eraser Tool Outil Gomme

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1560,6 +1560,8 @@
Efficiency: 100% Efisiensi: 100%
Ellipse Tool Alat Ellipse
Elliptical Marquee Tool Alat Elliptical Marquee
Single Row Marquee Tool Alat Single Row Marquee
Single Column Marquee Tool Alat Single Column Marquee
Emboss Emboss
Eraser Penghapus
Eraser Tool Alat Penghapus

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -1586,6 +1586,8 @@
Efficiency: 100% Efficienza: 100%
Ellipse Tool Strumento Ellisse
Elliptical Marquee Tool Strumento Selezione ellittica
Single Row Marquee Tool Strumento Selezione singola riga
Single Column Marquee Tool Strumento Selezione singola colonna
Emboss Rilievo
Eraser Gomma
Eraser Tool Strumento Gomma

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -1591,6 +1591,8 @@
Efficiency: 100% 効率: 100%
Ellipse Tool 楕円形ツール
Elliptical Marquee Tool 楕円形選択ツール
Single Row Marquee Tool 一行選択ツール
Single Column Marquee Tool 一列選択ツール
Emboss エンボス
Eraser 消しゴム
Eraser Tool 消しゴムツール

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1559,6 +1559,8 @@
Efficiency: 100% 효율: 100%
Ellipse Tool 타원 도구
Elliptical Marquee Tool 원형 선택 윤곽 도구
Single Row Marquee Tool 단일 행 선택 윤곽 도구
Single Column Marquee Tool 단일 열 선택 윤곽 도구
Emboss 엠보스
Eraser 지우개
Eraser Tool 지우개 도구

Can't render this file because it has a wrong number of fields in line 10.

View File

@ -1570,6 +1570,8 @@
Efficiency: 100% Efficiëntie: 100%
Ellipse Tool Ellipsgereedschap
Elliptical Marquee Tool Ovaal selectiekader
Single Row Marquee Tool Selectiekader enkele rij
Single Column Marquee Tool Selectiekader enkele kolom
Emboss Reliëf
Eraser Gum
Eraser Tool Gumgereedschap

Can't render this file because it contains an unexpected character in line 8 and column 34.

View File

@ -1584,6 +1584,8 @@
Efficiency: 100% Wydajność: 100%
Ellipse Tool Narzędzie Elipsa
Elliptical Marquee Tool Narzędzie Zaznaczenie eliptyczne
Single Row Marquee Tool Narzędzie Zaznaczenie pojedynczego wiersza
Single Column Marquee Tool Narzędzie Zaznaczenie pojedynczej kolumny
Emboss Płaskorzeźba
Eraser Gumka
Eraser Tool Narzędzie Gumka

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1565,6 +1565,8 @@
Efficiency: 100% Eficiência: 100%
Ellipse Tool Ferramenta Elipse
Elliptical Marquee Tool Ferramenta Letreiro elíptico
Single Row Marquee Tool Ferramenta Letreiro de linha única
Single Column Marquee Tool Ferramenta Letreiro de coluna única
Emboss Entalhe
Eraser Borracha
Eraser Tool Ferramenta Borracha

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1528,6 +1528,8 @@
Could not complete the Free Transform command because the layer is locked. Не удалось выполнить команду «Свободное трансформирование», потому что слой заблокирован.
Rectangular Marquee Tool Прямоугольная область
Elliptical Marquee Tool Овальная область
Single Row Marquee Tool Область (горизонтальная строка)
Single Column Marquee Tool Область (вертикальная строка)
Brush Tool Кисть
Pencil Tool Карандаш
Eraser Tool Ластик

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1510,6 +1510,8 @@
Could not complete the Free Transform command because the layer is locked. Не вдалося виконати команду «Вільна трансформація», бо шар заблоковано.
Rectangular Marquee Tool Інструмент «Прямокутне виділення»
Elliptical Marquee Tool Інструмент «Еліптичне виділення»
Single Row Marquee Tool Інструмент «Виділення одного рядка»
Single Column Marquee Tool Інструмент «Виділення одного стовпця»
Brush Tool Інструмент «Пензель»
Pencil Tool Інструмент «Олівець»
Eraser Tool Інструмент «Гумка»

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -1600,6 +1600,8 @@
Efficiency: 100% 效率:100%
Ellipse Tool 椭圆工具
Elliptical Marquee Tool 椭圆选框工具
Single Row Marquee Tool 单行选框工具
Single Column Marquee Tool 单列选框工具
Emboss 浮雕
Eraser 橡皮擦
Eraser Tool 橡皮擦工具

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -1595,6 +1595,8 @@
Efficiency: 100% 效率:100%
Ellipse Tool 橢圓工具
Elliptical Marquee Tool 橢圓選取畫面工具
Single Row Marquee Tool 單列選取畫面工具
Single Column Marquee Tool 單欄選取畫面工具
Emboss 浮雕
Eraser 橡皮擦
Eraser Tool 橡皮擦工具

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -64,6 +64,9 @@ pub static ICONS: &[(&str, &[u8])] = &[
("lock", include_bytes!("../../../assets/icons/lock.svg")),
("lock-open", include_bytes!("../../../assets/icons/lock-open.svg")),
("lollipop", include_bytes!("../../../assets/icons/lollipop.svg")),
// PhotoCraft originals: the Single Row / Single Column Marquee glyphs.
("marquee-column", include_bytes!("../../../assets/icons/marquee-column.svg")),
("marquee-row", include_bytes!("../../../assets/icons/marquee-row.svg")),
("maximize-2", include_bytes!("../../../assets/icons/maximize-2.svg")),
("message-square", include_bytes!("../../../assets/icons/message-square.svg")),
("minus", include_bytes!("../../../assets/icons/minus.svg")),

View File

@ -95,6 +95,8 @@ pub fn tool_icon(t: Tool) -> &'static str {
Tool::Move => "move",
Tool::RectMarquee => "square-dashed",
Tool::EllipseMarquee => "circle-dashed",
Tool::SingleRowMarquee => "marquee-row",
Tool::SingleColumnMarquee => "marquee-column",
Tool::Brush => "brush",
Tool::Pencil => "pencil",
Tool::MixerBrush => "palette",

View File

@ -679,6 +679,61 @@ fn selection_cursor_follows_the_modifiers_and_the_floating_piece() {
assert_eq!(selection_cursor(&app, Tool::RectMarquee, [30.0, 20.0], none), Some(SelCursor::Outline));
}
/// A click with `tool` at document point `(x, y)`, as the canvas sends it.
fn click_with(app: &mut PhotocraftApp, tool: Tool, x: f64, y: f64, m: Modifiers) {
use crate::canvas::{ToolEvent, tool_event};
app.ui.tool = tool;
tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m);
tool_event(app, ToolEvent::Up { x, y }, m);
}
/// Single Row / Single Column Marquee (#2837): a click selects one pixel row (column) across the
/// whole canvas; ⇧ adds, ⌥ subtracts, as with the other marquees.
#[test]
fn single_row_and_column_marquee_select_one_pixel_lines() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.ui.extras.snap = false;
let bounds = |app: &PhotocraftApp| app.session.active().unwrap().doc.selection.as_ref().map_or(Rect::EMPTY, |s| s.content_bounds());
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));
click_with(&mut app, Tool::SingleRowMarquee, 120.3, 37.6, Modifiers::NONE);
assert_eq!(bounds(&app), Rect::new(0, 37, 200, 38));
click_with(&mut app, Tool::SingleRowMarquee, 15.0, 60.2, Modifiers::SHIFT);
assert_eq!(bounds(&app), Rect::new(0, 37, 200, 61));
assert!(at(&app, 5, 37) > 0.99 && at(&app, 199, 60) > 0.99 && at(&app, 100, 50) < 0.01, "two rows, nothing between");
click_with(&mut app, Tool::SingleColumnMarquee, 42.9, 80.0, Modifiers::NONE);
assert_eq!(bounds(&app), Rect::new(42, 0, 43, 100));
click_with(&mut app, Tool::SingleColumnMarquee, 150.5, 3.0, Modifiers::SHIFT);
assert_eq!(bounds(&app), Rect::new(42, 0, 151, 100));
click_with(&mut app, Tool::SingleColumnMarquee, 42.0, 10.0, Modifiers::ALT);
assert_eq!(bounds(&app), Rect::new(150, 0, 151, 100), "⌥ subtracts the column");
}
/// The two single-line marquees sit in the Marquee flyout but M (⇧M) cycles only the
/// Rectangular and Elliptical Marquee, as in Photoshop; automation picks them by name.
#[test]
fn m_cycles_only_the_rectangular_and_elliptical_marquee() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
app.ui.tool = Tool::SingleRowMarquee;
let mut seen = Vec::new();
for _ in 0..4 {
let raw = egui::RawInput {
events: vec![egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }],
..Default::default()
};
ctx.begin_pass(raw);
crate::shortcuts::handle(&mut app, &ctx);
ctx.end_pass().textures_delta.clear();
seen.push(app.ui.tool);
}
assert_eq!(seen, [Tool::RectMarquee, Tool::EllipseMarquee, Tool::RectMarquee, Tool::EllipseMarquee]);
assert_eq!(Tool::from_name("SingleRowMarquee"), Some(Tool::SingleRowMarquee));
assert_eq!(Tool::from_name("Single Column Marquee Tool"), Some(Tool::SingleColumnMarquee));
}
#[test]
fn size_readout_follows_the_ruler_unit() {
use photocraft_engine::prefs::{Unit, UnitsAndRulers};

View File

@ -17,7 +17,7 @@ use crate::{PhotocraftApp, icons, widgets};
const TOOL_SECTIONS: &[&[&[Tool]]] = &[
&[&[Tool::Move]],
&[
&[Tool::RectMarquee, Tool::EllipseMarquee],
&[Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee],
&[Tool::Lasso, Tool::PolygonLasso, Tool::MagneticLasso],
&[Tool::ObjectSelection, Tool::QuickSelection, Tool::MagicWand],
&[Tool::Crop, Tool::Slice, Tool::SliceSelect],
@ -936,7 +936,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
widgets::vline(ui, 22.0);
widgets::checkbox(ui, &mut b.mixer.sample_all_layers, tl!("Sample All Layers"));
}
Tool::RectMarquee | Tool::EllipseMarquee if t.pro => {
Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee if t.pro => {
ui.spacing_mut().item_spacing.x = 2.0;
for (i, (icon, tip)) in [
("square", tl!("New selection").to_string()),
@ -962,16 +962,18 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// Photoshop greys Anti-alias for the Rectangular Marquee (its edges are always hard).
ui.add_enabled_ui(app.ui.tool == Tool::EllipseMarquee, |ui| widgets::checkbox(ui, &mut o.anti_alias, tl!("Anti-alias")));
widgets::vline(ui, 22.0);
opt_label(ui, tl!("Style"));
// A single row or column has no style or size: Photoshop greys them.
let sized = matches!(app.ui.tool, Tool::RectMarquee | Tool::EllipseMarquee);
ui.add_enabled_ui(sized, |ui| opt_label(ui, tl!("Style")));
let styles = [
("normal".to_string(), tl!("Normal")),
("fixedRatio".to_string(), tl!("Fixed Ratio")),
("fixedSize".to_string(), tl!("Fixed Size")),
];
if widgets::dropdown(ui, "marquee-style", &mut o.marquee_style, &styles, 96.0) {
if ui.add_enabled_ui(sized, |ui| widgets::dropdown(ui, "marquee-style", &mut o.marquee_style, &styles, 96.0)).inner {
(o.marquee_width, o.marquee_height) = if o.marquee_style == "fixedSize" { (64.0, 64.0) } else { (1.0, 1.0) };
}
let fixed = o.marquee_style != "normal";
let fixed = sized && o.marquee_style != "normal";
let (unit, range) = if o.marquee_style == "fixedSize" { ("px", 1.0..=300_000.0) } else { ("", 0.001..=999.0) };
ui.add_enabled_ui(fixed, |ui| {
opt_label(ui, tl!("Width"));
@ -1210,6 +1212,15 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
crate::shortcuts::pretty("Shift+Alt")
),
),
Tool::SingleRowMarquee | Tool::SingleColumnMarquee => hint(
ui,
&format!(
"Click to select a one-pixel line · {} add · {} subtract · {} intersect",
crate::shortcuts::pretty("Shift"),
crate::shortcuts::pretty("Alt"),
crate::shortcuts::pretty("Shift+Alt")
),
),
Tool::Move => {
// The Studio themes keep Photoshop's Auto-Select toggle too (#1275).
widgets::checkbox(ui, &mut app.ui.tool_options.move_auto_select, tl!("Auto-Select:"));

View File

@ -102,6 +102,8 @@ fn is_point_tool(t: Tool) -> bool {
t,
Tool::RectMarquee
| Tool::EllipseMarquee
| Tool::SingleRowMarquee
| Tool::SingleColumnMarquee
| Tool::Crop
| Tool::ObjectSelection
| Tool::Pen

View File

@ -65,6 +65,11 @@ pub enum Tool {
Move,
RectMarquee,
EllipseMarquee,
/// Selects one pixel row across the whole canvas at the clicked y (Photoshop's flyout only:
/// no key, not in the ⇧M cycle).
SingleRowMarquee,
/// Selects one pixel column down the whole canvas at the clicked x.
SingleColumnMarquee,
Lasso,
PolygonLasso,
MagneticLasso,
@ -118,10 +123,12 @@ pub enum Tool {
}
impl Tool {
pub const ALL: [Tool; 53] = [
pub const ALL: [Tool; 55] = [
Tool::Move,
Tool::RectMarquee,
Tool::EllipseMarquee,
Tool::SingleRowMarquee,
Tool::SingleColumnMarquee,
Tool::Lasso,
Tool::PolygonLasso,
Tool::MagneticLasso,
@ -179,6 +186,8 @@ impl Tool {
Tool::Move => "Move Tool",
Tool::RectMarquee => "Rectangular Marquee Tool",
Tool::EllipseMarquee => "Elliptical Marquee Tool",
Tool::SingleRowMarquee => "Single Row Marquee Tool",
Tool::SingleColumnMarquee => "Single Column Marquee Tool",
Tool::Brush => "Brush Tool",
Tool::Pencil => "Pencil Tool",
Tool::MixerBrush => "Mixer Brush Tool",
@ -278,6 +287,8 @@ impl Tool {
Tool::CloneStamp | Tool::PatternStamp => 'S',
Tool::HistoryBrush => 'Y',
Tool::Blur | Tool::Sharpen | Tool::Smudge => '\0',
// In the Marquee flyout, but M cycles only the Rectangular and Elliptical Marquee.
Tool::SingleRowMarquee | Tool::SingleColumnMarquee => '\0',
Tool::Dodge | Tool::Burn | Tool::Sponge => 'O',
Tool::QuickSelection | Tool::ObjectSelection => 'W',
Tool::Pen => 'P',
@ -1180,7 +1191,7 @@ 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(), 53);
assert_eq!(Tool::ALL.len(), 55);
assert_eq!(Tool::from_name("Remove Tool"), Some(Tool::Remove));
assert_eq!(Tool::Remove.key(), 'J');
assert!(Tool::Remove.is_brushlike());

View File

@ -37,6 +37,8 @@ pub fn is_selection_tool(tool: Tool) -> bool {
tool,
Tool::RectMarquee
| Tool::EllipseMarquee
| Tool::SingleRowMarquee
| Tool::SingleColumnMarquee
| Tool::Lasso
| Tool::PolygonLasso
| Tool::MagneticLasso

View File

@ -54,10 +54,10 @@ what an agent can't finish alone.
scripted check there) is the only real oracle.
### G6. Missing tools
- **Missing (15 of 68):** Artboard, Single Row Marquee, Single Column Marquee, Perspective Crop,
Frame, Color Sampler (#1046), Color Replacement (engine command exists), Art History Brush,
Freeform Pen and Curvature Pen (#1054), Add Anchor Point, Delete Anchor Point, Convert Point
(only as Pen + ⌥), Horizontal Type Mask, Vertical Type Mask.
- **Missing (13 of 68):** Artboard, Perspective Crop, Frame, Color Sampler (#1046), Color
Replacement (engine command exists), Art History Brush, Freeform Pen and Curvature Pen (#1054),
Add Anchor Point, Delete Anchor Point, Convert Point (only as Pen + ⌥), Horizontal Type Mask,
Vertical Type Mask.
- **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.
- **Impact:** users look for them by name and shortcut; several are daily tools for designers.

View File

@ -168,7 +168,7 @@ parity for the area and are already contained in the dimension rows above.
| Area | % | Hours | Notes |
|---|---:|---:|---|
| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: multi-layer Smart Object conversion (#2604, #2648), layer clipboard between documents (UI-217-23), Stamp Visible, contour editor, smart-object perspective (FILE-215-6), mask targeting by command (#2458) |
| Selections, Select and Mask, channels | 55% | 120–200 | All selection tools except single row/column marquee. Select and Mask is a generic dialog (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget |
| Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a generic dialog (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget |
| Painting and brush engine | 55% | 150–250 | Every Brush Settings section exists; feel is the gap: lag on big brushes, pressure jumps, smoothing and edge quality reports ([brush-parity.md](brush-parity.md)) |
| Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); live stroke preview only for Brush/Eraser (TOOL-213-7) |
| Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right; no transform context menu (TOOL-214-5), no skew fields, puppet mesh is a block grid (TOOL-214-6), Liquify called "useless" by a user (#2606) |
@ -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-09: Pencil, Mixer Brush, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Freeform/Curvature Pen, Add/Delete Anchor Point tools, single row/column marquee, 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-09: Pencil, Mixer Brush, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Freeform/Curvature Pen, Add/Delete Anchor Point tools, 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. |
| 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. |

View File

@ -27,7 +27,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| Group | Present | Missing |
|---|---|---|
| Move, artboard | Move | Artboard (commands `layer.new.artboard*` exist) |
| Marquee | Rectangular, Elliptical | Single Row, Single Column |
| Marquee | Rectangular, Elliptical, Single Row, Single Column | |
| Lasso | Lasso, Polygonal, Magnetic | |
| Selection | Object Selection, Quick Selection, Magic Wand | |
| Crop and slice | Crop, Slice, Slice Select | Perspective Crop |