Select and Mask: live preview in Photoshop's view modes (#1754) (#2970)

The Select and Mask dialog had no preview of the refined selection. It now
previews live in Photoshop's view modes: Onion Skin (default), Marching
Ants, Overlay, On Black, On White, Black & White and On Layers, with a
per-view Opacity, Show Edge (J) and Show Original (P). F cycles the views,
O/M/V/A/T/K/Y pick one and holding X shows the image without a view.

The view state lives in the dialog's fields, so ui.dialog.set drives it.
The preview runs select.refineEdge on the proxy with output=selection and
builds the view from it (veil fill layers, a layer mask, or the refined
selection, whose marching ants the canvas draws scaled back up), through
the existing filter-preview worker.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-11 10:48:26 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d67e28c118
commit 0aaf5b60fe
24 changed files with 683 additions and 14 deletions
+29 -6
View File
@@ -1403,7 +1403,14 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e
}
let cmd = d.fields.get("__command")?.as_str()?.to_string();
// Previews edit what the command will: a targeted layer mask included (#780).
let params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields));
let mut params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields));
// Select and Mask previews in its view mode; holding X shows the image without one.
if crate::select_mask_view::owns(&d.fields) {
if crate::select_mask_view::hides_view(&d.fields) {
return None;
}
params = crate::select_mask_view::request_params(&d.fields, params);
}
let st = app.session.documents().get(idx)?;
// Filters whose features don't scale with a proxy preview at full resolution (#2063).
let k = crate::filter_dialog::preview_factor(&cmd, &params, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app)));
@@ -2636,13 +2643,21 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// in (`layer_edges_rect`), so a press that Auto-Selects a layer outlines that layer.
let layer_edges = painter.add(egui::Shape::Noop);
// Select and Mask outlines its refined selection (on its preview proxy, reduced by `k`) in
// the Marching Ants view and hides the ants in the others.
let refined = crate::select_mask_view::ants(app, idx);
let (outlined, k) = match &refined {
None => (doc.selection.as_ref(), 1),
Some(None) => (None, 1),
Some(Some((proxy, k))) => (proxy.selection.as_ref(), (*k).max(1)),
};
// Selection outline: true boundary, animated marching ants (cached per revision).
if let Some(sel) = doc.selection.as_ref().filter(|_| app.ui.view.shows(app.ui.view.show.selection_edges) && !polygon_replaces_selection(app)) {
if let Some(sel) = outlined.filter(|_| app.ui.view.shows(app.ui.view.show.selection_edges) && !polygon_replaces_selection(app)) {
// Trace at display resolution over the visible part only; key by the mask's tile identity
// (not the document revision) so unrelated edits don't re-trace it. The step is in
// device pixels per document pixel (`View::zoom`, not the point zoom), so a 1 px
// selection still traces at 100% on a scaled display.
let step = (1.0 / view.zoom.max(1e-3)).log2().floor().exp2().clamp(1.0, 64.0) as u32;
let step = (1.0 / (view.zoom * k as f32).max(1e-3)).log2().floor().exp2().clamp(1.0, 64.0) as u32;
let corners = [rect.min, pos2(rect.max.x, rect.min.y), rect.max, pos2(rect.min.x, rect.max.y)];
let docs = [xf.to_doc(corners[0]), xf.to_doc(corners[1]), xf.to_doc(corners[2]), xf.to_doc(corners[3])];
let mut x0 = docs[0][0];
@@ -2655,6 +2670,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
x1 = x1.max(d[0]);
y1 = y1.max(d[1]);
}
let (x0, y0, x1, y1) = (x0 / f64::from(k), y0 / f64::from(k), x1 / f64::from(k), y1 / f64::from(k));
let q = 256 * step as i32; // quantise the region so small pans reuse the cache
// The view centre can be any finite number (`ui.set`, #980). Past ±2³⁰ there is no document
// to outline, and the padding below (at most 2 · 256 · 64) can't overflow i32 from there.
@@ -2671,15 +2687,22 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
^ (vis.x0 as u64) << 8
^ (vis.y0 as u64) << 24
^ (vis.x1 as u64) << 36
^ (vis.y1 as u64) << 48;
^ (vis.y1 as u64) << 48
^ u64::from(k).rotate_left(5);
let fresh = matches!(&app.outline_cache, Some((d, k, _)) if *d == doc.id && *k == key);
if !fresh {
let t0 = crate::gpu_canvas::now_ms();
let b = app.cached_bounds(u64::MAX - doc.id.0, sel).intersect(&vis);
let bounds_key = if k > 1 { u64::MAX / 2 - doc.id.0 } else { u64::MAX - doc.id.0 };
let b = app.cached_bounds(bounds_key, sel).intersect(&vis);
// Filters can occupy every worker in Rayon's global pool. A parallel scan here would
// block the UI thread waiting for those workers, preventing job cancellation (#1554).
let segs =
let mut segs =
if app.session.has_jobs() { crate::outline::outline_scaled_serial(sel, b, step) } else { crate::outline::outline_scaled(sel, b, step) };
if k > 1 {
let s = i32::try_from(k).unwrap_or(1);
let up = |p: [i32; 2]| [p[0].saturating_mul(s), p[1].saturating_mul(s)];
segs.iter_mut().for_each(|(a, b)| (*a, *b) = (up(*a), up(*b)));
}
app.perf.span("outline", crate::gpu_canvas::now_ms() - t0);
app.outline_cache = Some((doc.id, key, std::sync::Arc::new(segs)));
}
+9 -3
View File
@@ -254,9 +254,15 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Long parameter lists (Flame, Lighting Effects) scroll, so the title and the
// OK / Cancel buttons stay inside a small window.
let room = (ctx.content_rect().height() - DIALOG_CHROME).max(120.0);
first_field_focused(ui, id, |ui| {
egui::ScrollArea::vertical().id_salt(id.with("body")).max_height(room).show(ui, |ui| crate::filter_dialog::body(ui, &mut fields));
});
let body = |ui: &mut egui::Ui, fields: &mut serde_json::Map<String, Value>| {
egui::ScrollArea::vertical().id_salt(id.with("body")).max_height(room).show(ui, |ui| crate::filter_dialog::body(ui, fields));
};
// Select and Mask leaves the keyboard to its view keys (F, X, O, V…).
if crate::select_mask_view::owns(&fields) {
body(ui, &mut fields);
} else {
first_field_focused(ui, id, |ui| body(ui, &mut fields));
}
}
DialogKind::Command if fields.contains_key("__form") => crate::view_cmds::form_body(ui, &mut fields),
DialogKind::Command => {}
+8 -1
View File
@@ -330,7 +330,8 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
fields.insert("__command".into(), json!(command));
fields.insert("__label".into(), json!(spec.label));
fields.insert("__filter".into(), json!(true));
if command.starts_with("filter.") || PREVIEWED.contains(&command) {
// Select and Mask previews in its view modes (`select_mask_view`), not as the command's result.
if command.starts_with("filter.") || PREVIEWED.contains(&command) || command == crate::select_mask_view::COMMAND {
fields.insert("__preview".into(), json!(true));
}
for p in parse_spec(spec.params) {
@@ -476,6 +477,9 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
None => return,
},
};
if crate::select_mask_view::owns(f) {
crate::select_mask_view::controls(ui, f);
}
for p in parse_spec(&spec) {
match p.kind {
Kind::Range { min, max, default } => {
@@ -646,6 +650,9 @@ pub fn preview_document_with(
k: u32,
cancel: Option<&photocraft_engine::jobs::JobCtx>,
) -> Option<Document> {
if command == crate::select_mask_view::COMMAND {
return crate::select_mask_view::preview_document(doc, active, params, k, cancel);
}
// Warp Text renders the type again from the layer model (font size, position), which a
// reduced proxy doesn't scale: it runs at full size and the result is reduced (#218).
let (run_k, reduce_k) = if command == "type.warpText" { (1, k) } else { (k, 1) };
+9
View File
@@ -2562,4 +2562,13 @@ cameraData Lens Objektiv
Snap to Origin Přichytit k počátku
Search settings… Hledat nastavení…
No matching settings Žádná odpovídající nastavení
# Select and Mask view modes (#1754)
Onion Skin Cibulová slupka
Marching Ants Pochodující mravenci
On Black Na černé
On White Na bílé
On Layers Na vrstvách
Show Edge Zobrazit okraj
Show Original Zobrazit originál
F cycles the views; hold X to see the image F přepíná zobrazení; podržením X uvidíte obraz
Show rulers Zobrazit pravítka
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -2593,4 +2593,13 @@ cameraData Lens Objektiv
Snap to Origin An Ursprung ausrichten
Search settings… Einstellungen durchsuchen…
No matching settings Keine passenden Einstellungen
# Select and Mask view modes (#1754)
Onion Skin Zwiebelschicht
Marching Ants Laufende Ameisen
On Black Auf Schwarz
On White Auf Weiß
On Layers Auf Ebenen
Show Edge Kante anzeigen
Show Original Original anzeigen
F cycles the views; hold X to see the image F wechselt die Ansicht; X gedrückt halten zeigt das Bild
Show rulers Lineale anzeigen
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -3654,4 +3654,13 @@ cameraData Lens Φακός
Snap to Origin Προσκόλληση στην αρχή
Search settings… Αναζήτηση ρυθμίσεων…
No matching settings Δεν βρέθηκαν ρυθμίσεις
# Select and Mask view modes (#1754)
Onion Skin Φλούδα κρεμμυδιού
Marching Ants Κινούμενο περίγραμμα
On Black Σε μαύρο
On White Σε λευκό
On Layers Σε επίπεδα
Show Edge Εμφάνιση άκρης
Show Original Εμφάνιση αρχικού
F cycles the views; hold X to see the image Το F εναλλάσσει τις προβολές· κρατήστε το X για να δείτε την εικόνα
Show rulers Εμφάνιση χαράκων
Can't render this file because it contains an unexpected character in line 991 and column 18.
+9
View File
@@ -2634,4 +2634,13 @@ cameraData Lens Objetivo
Snap to Origin Ajustar a origen
Search settings… Buscar ajustes…
No matching settings No hay ajustes que coincidan
# Select and Mask view modes (#1754)
Onion Skin Papel cebolla
Marching Ants Hormigas en marcha
On Black Sobre negro
On White Sobre blanco
On Layers Sobre capas
Show Edge Mostrar borde
Show Original Mostrar original
F cycles the views; hold X to see the image F alterna las vistas; mantén X para ver la imagen
Show rulers Mostrar reglas
Can't render this file because it contains an unexpected character in line 4 and column 47.
+9
View File
@@ -2971,4 +2971,13 @@ cameraData Lens Objectif
Snap to Origin Aligner sur l'origine
Search settings… Rechercher des réglages…
No matching settings Aucun réglage correspondant
# Select and Mask view modes (#1754)
Onion Skin Pelure d'oignon
Marching Ants Fourmis en marche
On Black Sur fond noir
On White Sur fond blanc
On Layers Sur les calques
Show Edge Afficher le bord
Show Original Afficher l'original
F cycles the views; hold X to see the image F fait défiler les vues ; maintenez X pour voir l'image
Show rulers Afficher les règles
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -2958,4 +2958,13 @@ cameraData Lens Lensa
Snap to Origin Jepret ke Titik Asal
Search settings… Cari pengaturan…
No matching settings Tidak ada pengaturan yang cocok
# Select and Mask view modes (#1754)
Onion Skin Kulit Bawang
Marching Ants Semut Berbaris
On Black Di Atas Hitam
On White Di Atas Putih
On Layers Di Atas Lapisan
Show Edge Tampilkan Tepi
Show Original Tampilkan Asli
F cycles the views; hold X to see the image F berganti tampilan; tahan X untuk melihat gambar
Show rulers Tampilkan Penggaris
Can't render this file because it contains an unexpected character in line 4 and column 47.
+9
View File
@@ -2625,4 +2625,13 @@ cameraData Lens Obiettivo
Snap to Origin Aggancia all'origine
Search settings… Cerca impostazioni…
No matching settings Nessuna impostazione corrispondente
# Select and Mask view modes (#1754)
Onion Skin Velina
Marching Ants Formiche in marcia
On Black Su nero
On White Su bianco
On Layers Su livelli
Show Edge Mostra bordo
Show Original Mostra originale
F cycles the views; hold X to see the image F scorre le viste; tieni premuto X per vedere l'immagine
Show rulers Mostra righelli
Can't render this file because it contains an unexpected character in line 2573 and column 53.
+9
View File
@@ -3568,4 +3568,13 @@ cameraData Lens レンズ
Snap to Origin 原点にスナップ
Search settings… 設定を検索…
No matching settings 一致する設定はありません
# Select and Mask view modes (#1754)
Onion Skin オニオンスキン
Marching Ants 点線の境界
On Black 黒地
On White 白地
On Layers レイヤー上
Show Edge エッジを表示
Show Original 元の状態を表示
F cycles the views; hold X to see the image F で表示を切り替え、X を押している間は画像を表示
Show rulers 定規を表示
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -3137,4 +3137,13 @@ cameraData Lens 렌즈
Snap to Origin 원점에 스냅
Search settings… 설정 검색…
No matching settings 일치하는 설정 없음
# Select and Mask view modes (#1754)
Onion Skin 어니언 스킨
Marching Ants 점선 윤곽
On Black 검정 배경
On White 흰색 배경
On Layers 레이어 위
Show Edge 가장자리 표시
Show Original 원본 표시
F cycles the views; hold X to see the image F로 보기 전환, X를 누르고 있으면 이미지 표시
Show rulers 눈금자 표시
Can't render this file because it contains an unexpected character in line 3083 and column 53.
+9
View File
@@ -3714,6 +3714,15 @@ cameraData Lens Lens
Search settings… Instellingen zoeken…
No matching settings Geen overeenkomende instellingen
PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer
# Select and Mask view modes (#1754)
Onion Skin Uienschil
Marching Ants Mierenmars
On Black Op zwart
On White Op wit
On Layers Op lagen
Show Edge Rand tonen
Show Original Origineel tonen
F cycles the views; hold X to see the image F wisselt de weergaven; houd X ingedrukt om de afbeelding te zien
Show rulers Linialen tonen
Frame Tool Kadergereedschap
Create a new rectangular frame Nieuw rechthoekig kader maken
Can't render this file because it contains an unexpected character in line 8 and column 34.
+9
View File
@@ -3569,4 +3569,13 @@ cameraData Lens Obiektyw
Snap to Origin Przyciągnij do początku
Search settings… Szukaj ustawień…
No matching settings Brak pasujących ustawień
# Select and Mask view modes (#1754)
Onion Skin Skórka cebuli
Marching Ants Maszerujące mrówki
On Black Na czarnym
On White Na białym
On Layers Na warstwach
Show Edge Pokaż krawędź
Show Original Pokaż oryginał
F cycles the views; hold X to see the image F przełącza widoki; przytrzymaj X, aby zobaczyć obraz
Show rulers Pokaż linijki
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -2978,4 +2978,13 @@ cameraData Lens Lente
Snap to Origin Ajustar à origem
Search settings… Pesquisar configurações…
No matching settings Nenhuma configuração encontrada
# Select and Mask view modes (#1754)
Onion Skin Papel vegetal
Marching Ants Formigas marchando
On Black Sobre preto
On White Sobre branco
On Layers Sobre camadas
Show Edge Mostrar borda
Show Original Mostrar original
F cycles the views; hold X to see the image F alterna as visualizações; segure X para ver a imagem
Show rulers Mostrar réguas
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -2894,6 +2894,15 @@ cameraData Lens Объектив
Rasterize Layer Style Растрировать стиль слоя
Search settings… Поиск настроек…
No matching settings Нет подходящих настроек
# Select and Mask view modes (#1754)
Onion Skin Калька
Marching Ants Бегущие муравьи
On Black На черном
On White На белом
On Layers На слоях
Show Edge Показать край
Show Original Показать исходное
F cycles the views; hold X to see the image F переключает виды; удерживайте X, чтобы увидеть изображение
Show rulers Показать линейки
New Crop Preset… Новый набор параметров кадрирования…
Delete Crop Preset… Удалить набор параметров кадрирования…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -3644,6 +3644,15 @@ cameraData Lens Об'єктив
Search settings… Пошук налаштувань…
No matching settings Немає відповідних налаштувань
PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана
# Select and Mask view modes (#1754)
Onion Skin Калька
Marching Ants Мурашки
On Black На чорному
On White На білому
On Layers На шарах
Show Edge Показати край
Show Original Показати початкове
F cycles the views; hold X to see the image F перемикає види; утримуйте X, щоб побачити зображення
Show rulers Показати лінійки
Frame Tool Інструмент «Рамка»
Create a new rectangular frame Створити прямокутну рамку
Can't render this file because it contains an unexpected character in line 1348 and column 18.
+9
View File
@@ -2563,6 +2563,15 @@ cameraData Lens 镜头
Snap to Origin 贴紧原点
Search settings… 搜索设置…
No matching settings 没有匹配的设置
# Select and Mask view modes (#1754)
Onion Skin 洋葱皮
Marching Ants 蚂蚁线
On Black 黑底
On White 白底
On Layers 图层上
Show Edge 显示边缘
Show Original 显示原始
F cycles the views; hold X to see the image 按 F 切换视图;按住 X 查看图像
Show rulers 显示标尺
New Crop Preset… 新建裁剪预设…
Delete Crop Preset… 删除裁剪预设…
Can't render this file because it contains an unexpected character in line 2515 and column 53.
+9
View File
@@ -2533,6 +2533,15 @@ cameraData Lens 鏡頭
Snap to Origin 靠齊原點
Search settings… 搜尋設定…
No matching settings 沒有符合的設定
# Select and Mask view modes (#1754)
Onion Skin 洋蔥皮
Marching Ants 螞蟻線
On Black 黑底
On White 白底
On Layers 圖層上
Show Edge 顯示邊緣
Show Original 顯示原始
F cycles the views; hold X to see the image 按 F 切換檢視;按住 X 檢視影像
Show rulers 顯示尺標
New Crop Preset… 新增裁切預設集…
Delete Crop Preset… 刪除裁切預設集…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -149,6 +149,7 @@ pub mod rotate_view;
pub mod rulers;
pub mod screen_picker;
pub mod scrollbars;
pub mod select_mask_view;
pub mod served_fonts;
pub mod shape_dialog;
pub(crate) mod shape_fill_ui;
+488
View File
@@ -0,0 +1,488 @@
//! Select and Mask view modes (#1754): how the live preview shows the refined selection.
//!
//! Photoshop's View Mode list: Onion Skin (O), Marching Ants (M), Overlay (V), On Black (A),
//! On White (T), Black & White (K) and On Layers (Y), with an Opacity for the first four that
//! veil the unselected area. F cycles the views, holding X shows the image without one, J toggles
//! Show Edge (the band the edge detection radius refines) and P Show Original (the selection
//! before refinement).
//!
//! The view state lives in the dialog's fields (`__view`, `__opacity`, `__showEdge`,
//! `__showOriginal`, `__hideView`), so `ui.dialog.set` drives it like any other field. The preview
//! runs `select.refineEdge` on the proxy document with `output: selection`, then builds the view
//! from that proxy (veil fill layers, a layer mask, the refined selection for the ants) and the
//! normal filter-preview path composites it.
use std::sync::Arc;
use photocraft_color::{Color, PixelFormat};
use photocraft_doc::{Document, Fill, Layer, LayerContent, LayerId, LayerMask};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::{Map, Value, json};
use crate::PhotocraftApp;
use crate::theme::Tokens;
/// The engine command behind Select › Select and Mask….
pub const COMMAND: &str = "select.refineEdge";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
OnionSkin,
MarchingAnts,
Overlay,
OnBlack,
OnWhite,
BlackWhite,
OnLayers,
}
impl Mode {
/// In menu order, which is also the order F cycles through.
pub const ALL: [Mode; 7] = [Mode::OnionSkin, Mode::MarchingAnts, Mode::Overlay, Mode::OnBlack, Mode::OnWhite, Mode::BlackWhite, Mode::OnLayers];
pub fn id(self) -> &'static str {
match self {
Mode::OnionSkin => "onionSkin",
Mode::MarchingAnts => "marchingAnts",
Mode::Overlay => "overlay",
Mode::OnBlack => "onBlack",
Mode::OnWhite => "onWhite",
Mode::BlackWhite => "blackWhite",
Mode::OnLayers => "onLayers",
}
}
pub fn from_id(id: &str) -> Option<Mode> {
Mode::ALL.into_iter().find(|m| m.id() == id)
}
fn label(self) -> &'static str {
match self {
Mode::OnionSkin => tl!("Onion Skin"),
Mode::MarchingAnts => tl!("Marching Ants"),
Mode::Overlay => tl!("Overlay"),
Mode::OnBlack => tl!("On Black"),
Mode::OnWhite => tl!("On White"),
Mode::BlackWhite => tl!("Black & White"),
Mode::OnLayers => tl!("On Layers"),
}
}
/// The single-letter key that picks this view.
fn key(self) -> egui::Key {
match self {
Mode::OnionSkin => egui::Key::O,
Mode::MarchingAnts => egui::Key::M,
Mode::Overlay => egui::Key::V,
Mode::OnBlack => egui::Key::A,
Mode::OnWhite => egui::Key::T,
Mode::BlackWhite => egui::Key::K,
Mode::OnLayers => egui::Key::Y,
}
}
/// The view's opacity (percent) until it is changed; `None` for views without one.
fn default_opacity(self) -> Option<f32> {
match self {
Mode::OnionSkin | Mode::Overlay => Some(50.0),
Mode::OnBlack | Mode::OnWhite => Some(100.0),
Mode::MarchingAnts | Mode::BlackWhite | Mode::OnLayers => None,
}
}
fn next(self) -> Mode {
let i = Mode::ALL.iter().position(|m| *m == self).unwrap_or(0);
Mode::ALL.get(i + 1).copied().unwrap_or(Mode::OnionSkin)
}
}
/// The view the dialog shows (Onion Skin, Photoshop's default, until another is picked).
pub fn mode(fields: &Map<String, Value>) -> Mode {
fields.get("__view").and_then(Value::as_str).and_then(Mode::from_id).unwrap_or(Mode::OnionSkin)
}
fn flag(fields: &Map<String, Value>, key: &str) -> bool {
fields.get(key).and_then(Value::as_bool).unwrap_or(false)
}
/// The opacity (percent) of `mode`: each view keeps its own, as in Photoshop.
fn opacity(fields: &Map<String, Value>, mode: Mode) -> f32 {
let set = fields.get("__opacity").and_then(|o| o.get(mode.id())).and_then(Value::as_f64).filter(|v| v.is_finite());
set.map(|v| v as f32).or(mode.default_opacity()).unwrap_or(100.0).clamp(0.0, 100.0)
}
fn set_opacity(fields: &mut Map<String, Value>, mode: Mode, value: f32) {
let mut all = fields.get("__opacity").and_then(Value::as_object).cloned().unwrap_or_default();
all.insert(mode.id().into(), json!(value));
fields.insert("__opacity".into(), Value::Object(all));
}
/// Holding X shows the image without the view.
pub fn hides_view(fields: &Map<String, Value>) -> bool {
flag(fields, "__hideView")
}
/// Whether `fields` belong to the Select and Mask dialog.
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.get("__command").and_then(Value::as_str) == Some(COMMAND)
}
/// A view key pressed in the dialog: a view letter picks that view, F the next one, J toggles
/// Show Edge and P Show Original. Returns whether the key was one of them.
pub fn apply_key(fields: &mut Map<String, Value>, key: egui::Key) -> bool {
if key == egui::Key::F {
fields.insert("__view".into(), json!(mode(fields).next().id()));
} else if let Some(m) = Mode::ALL.into_iter().find(|m| m.key() == key) {
fields.insert("__view".into(), json!(m.id()));
} else if key == egui::Key::J || key == egui::Key::P {
let field = if key == egui::Key::J { "__showEdge" } else { "__showOriginal" };
fields.insert(field.into(), json!(!flag(fields, field)));
} else {
return false;
}
true
}
/// The view keys, unless a text field or a popup (the View dropdown) has the keyboard.
fn keys(ctx: &egui::Context, fields: &mut Map<String, Value>) {
if ctx.text_edit_focused() || egui::Popup::is_any_open(ctx) {
fields.insert("__hideView".into(), json!(false));
return;
}
let (pressed, hold_x) = ctx.input(|i| {
let plain = !i.modifiers.command && !i.modifiers.ctrl && !i.modifiers.alt;
let keys: Vec<egui::Key> = i
.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, repeat: false, .. } if plain => Some(*key),
_ => None,
})
.collect();
(keys, plain && i.key_down(egui::Key::X))
});
for key in pressed {
apply_key(fields, key);
}
fields.insert("__hideView".into(), json!(hold_x));
}
/// The View Mode controls at the top of the Select and Mask dialog.
pub fn controls(ui: &mut egui::Ui, fields: &mut Map<String, Value>) {
keys(ui.ctx(), fields);
let t = Tokens::get(ui.ctx());
let mut current = mode(fields);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("View")).color(t.text_dim));
let options: Vec<(Mode, &str)> = Mode::ALL.iter().map(|m| (*m, m.label())).collect();
crate::widgets::dropdown(ui, "select-mask-view", &mut current, &options, 170.0);
});
fields.insert("__view".into(), json!(current.id()));
if current.default_opacity().is_some() {
let mut v = opacity(fields, current);
crate::widgets::slider_row(ui, tl!("Opacity"), &mut v, 0.0..=100.0, "%", None);
set_opacity(fields, current, v.round());
}
for (field, label) in [("__showEdge", tl!("Show Edge")), ("__showOriginal", tl!("Show Original"))] {
let mut on = flag(fields, field);
crate::widgets::checkbox(ui, &mut on, label);
fields.insert(field.into(), json!(on));
}
ui.label(egui::RichText::new(tl!("F cycles the views; hold X to see the image")).color(t.text_dim).small());
ui.add_space(4.0);
crate::widgets::hairline(ui);
ui.add_space(6.0);
}
/// The preview request: the command's params plus the view (`__view`), so a view change
/// recomputes the preview like a parameter change.
pub fn request_params(fields: &Map<String, Value>, mut params: Value) -> Value {
let m = mode(fields);
if let Some(o) = params.as_object_mut() {
o.insert(
"__view".into(),
json!({
"mode": m.id(),
"opacity": opacity(fields, m) / 100.0,
"showEdge": flag(fields, "__showEdge"),
"showOriginal": flag(fields, "__showOriginal"),
}),
);
}
params
}
/// What the canvas outlines while Select and Mask previews `idx`: `None` = the document's own
/// selection (no Select and Mask preview); `Some(None)` = nothing (every view but Marching Ants
/// hides the ants, as in Photoshop); `Some(Some((proxy, k)))` = the refined selection of the
/// preview proxy, reduced by `k`.
pub(crate) fn ants(app: &PhotocraftApp, idx: usize) -> Option<Option<(Arc<Document>, u32)>> {
if app.session.active_index() != Some(idx) {
return None;
}
let d = app.ui.dialogs.iter().find(|d| d.fields.contains_key("__filter"))?;
if !owns(&d.fields) || !flag(&d.fields, "__preview") || hides_view(&d.fields) {
return None;
}
if mode(&d.fields) != Mode::MarchingAnts {
return Some(None);
}
let p = app.filter_preview.as_ref().filter(|p| p.key.command == COMMAND && p.key.dialog == d.id)?;
Some(Some((p.result.clone()?, p.key.k)))
}
/// A one-channel coverage over the canvas: `values` inside `area`, `outside` elsewhere.
struct Coverage {
area: Rect,
values: Vec<f32>,
outside: f32,
}
impl Coverage {
fn empty() -> Coverage {
Coverage { area: Rect::EMPTY, values: Vec::new(), outside: 0.0 }
}
/// `s` (a selection or a layer mask) read over the part of `canvas` that isn't its default.
fn read(s: Option<&Surface>, canvas: Rect) -> Coverage {
let Some(s) = s else { return Coverage::empty() };
let outside = s.default_pixel().first().copied().unwrap_or(0.0).clamp(0.0, 1.0);
let area = if outside == 0.0 { s.content_bounds().intersect(&canvas) } else { canvas };
Coverage { area, values: photocraft_algo::selection::mask_from_surface(Some(s), area), outside }
}
/// The band `r` px either side of the edge of `s`: where grown and shrunk coverage differ.
fn edge_band(s: Option<&Surface>, canvas: Rect, r: usize) -> Coverage {
let Some(s) = s else { return Coverage::empty() };
let reach = i32::try_from(r).unwrap_or(i32::MAX).saturating_add(1);
let outside = s.default_pixel().first().copied().unwrap_or(0.0);
let area = if outside == 0.0 { s.content_bounds().inflate(reach).intersect(&canvas) } else { canvas };
let (w, h) = (area.width() as usize, area.height() as usize);
let values = photocraft_algo::selection::mask_from_surface(Some(s), area);
let grown = photocraft_algo::matting::morph(&values, w, h, r, true);
let shrunk = photocraft_algo::matting::morph(&values, w, h, r, false);
if grown.len() != values.len() || shrunk.len() != values.len() {
return Coverage::empty();
}
Coverage { area, values: grown.iter().zip(&shrunk).map(|(g, s)| (g - s).clamp(0.0, 1.0)).collect(), outside: 0.0 }
}
/// A GRAY8 mask surface holding `f(coverage)`.
fn surface(&self, f: impl Fn(f32) -> f32) -> Surface {
let g = |v: f32| f(v).clamp(0.0, 1.0);
let mut s = Surface::with_default(PixelFormat::GRAY8, &[g(self.outside)]);
if !self.area.is_empty() && self.values.len() == self.area.width() as usize * self.area.height() as usize {
let bytes: Vec<u8> = self.values.iter().map(|v| (g(*v) * 255.0).round() as u8).collect();
s.write_interleaved(self.area, &bytes);
}
s
}
}
/// The view parameters a preview request carries (see [`request_params`]).
struct View {
mode: Mode,
opacity: f32,
show_edge: bool,
show_original: bool,
}
impl View {
fn of(params: &Value) -> View {
let v = params.get("__view");
let b = |key: &str| v.and_then(|v| v.get(key)).and_then(Value::as_bool).unwrap_or(false);
View {
mode: v.and_then(|v| v.get("mode")).and_then(Value::as_str).and_then(Mode::from_id).unwrap_or(Mode::OnionSkin),
opacity: v.and_then(|v| v.get("opacity")).and_then(Value::as_f64).filter(|o| o.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0) as f32,
show_edge: b("showEdge"),
show_original: b("showOriginal"),
}
}
}
/// A pixel length param (`radius`, `feather`) of `params`, reduced by `k`.
fn length(params: &Value, key: &str, k: u32) -> f64 {
params.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0) / f64::from(k.max(1))
}
/// The preview of the Select and Mask dialog for `doc`: the proxy reduced by `k`, shown in the
/// requested view. `None` when the refinement fails or is cancelled.
pub fn preview_document(doc: &Document, active: Option<LayerId>, params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Option<Document> {
let view = View::of(params);
let k = k.max(1);
let mut proxy = crate::proxy::proxy_document(doc, k);
let active = active.filter(|id| proxy.layer(*id).is_some());
// What Select and Mask starts from: the selection, else the active layer's mask.
let original = proxy.selection.clone().or_else(|| active.and_then(|id| proxy.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| m.surface.clone()));
let canvas = Rect::new(0, 0, i32::try_from(proxy.size.width).unwrap_or(i32::MAX), i32::try_from(proxy.size.height).unwrap_or(i32::MAX));
let coverage = if view.show_edge {
Coverage::edge_band(original.as_ref(), canvas, length(params, "radius", k).round().clamp(1.0, 250.0) as usize)
} else if view.show_original {
Coverage::read(original.as_ref(), canvas)
} else {
Coverage::read(refine(&proxy, active, params, k, cancel)?.as_ref(), canvas)
};
show(&mut proxy, active, &view, &coverage);
Some(proxy)
}
/// `select.refineEdge` on the proxy, its output forced to the selection: the refined coverage.
fn refine(proxy: &Document, active: Option<LayerId>, params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Option<Option<Surface>> {
let mut s = photocraft_engine::Session::new();
s.set_inline_job_ctx(cancel.cloned());
s.add_document(proxy.clone(), None);
if let Some(id) = active {
s.select_layer(id).ok()?;
}
let mut p = params.as_object().cloned().unwrap_or_default();
p.remove("__view");
p.insert("output".into(), json!("selection"));
p.insert("decontaminate".into(), json!(false));
for key in ["radius", "feather"] {
if p.contains_key(key) {
p.insert(key.into(), json!(length(params, key, k)));
}
}
s.execute(COMMAND, Value::Object(p)).ok()?;
Some(s.active()?.doc.selection.clone())
}
/// A solid fill layer named for the view.
fn veil(name: &str, color: Color, opacity: f32, mask: Option<Surface>) -> Layer {
let mut l = Layer::new(name, LayerContent::Fill(Fill::Solid(color)));
l.opacity = opacity;
l.mask = mask.map(|surface| LayerMask { surface, ..LayerMask::reveal_all() });
l
}
/// Turn `doc` into `view` of `coverage`.
fn show(doc: &mut Document, active: Option<LayerId>, view: &View, coverage: &Coverage) {
let o = view.opacity;
doc.selection = Some(coverage.surface(|v| v));
match view.mode {
Mode::MarchingAnts => {}
Mode::Overlay => doc.layers.push(veil("Overlay", Color::rgb(1.0, 0.0, 0.0), o, Some(coverage.surface(|v| 1.0 - v)))),
Mode::OnBlack => doc.layers.push(veil("On Black", Color::BLACK, o, Some(coverage.surface(|v| 1.0 - v)))),
Mode::OnWhite => doc.layers.push(veil("On White", Color::WHITE, o, Some(coverage.surface(|v| 1.0 - v)))),
Mode::BlackWhite => {
doc.layers.push(veil("Black", Color::BLACK, 1.0, None));
doc.layers.push(veil("White", Color::WHITE, 1.0, Some(coverage.surface(|v| v))));
}
Mode::OnLayers | Mode::OnionSkin => {
let Some(id) = active else { return };
if view.mode == Mode::OnionSkin
&& let Some(path) = doc.path_of(id)
{
// Only the layer being masked (with its groups and contents) stays visible.
let others: Vec<LayerId> =
doc.walk().into_iter().filter(|(p, _, _)| !path.starts_with(p) && !p.starts_with(&path)).map(|(_, _, l)| l.id).collect();
for other in others {
if let Some(l) = doc.layer_mut(other) {
l.visible = false;
}
}
}
let keep = if view.mode == Mode::OnionSkin { 1.0 - o } else { 0.0 };
let surface = coverage.surface(|v| v + (1.0 - v) * keep);
if let Some(l) = doc.layer_mut(id) {
l.mask = Some(LayerMask { surface, ..LayerMask::reveal_all() });
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::Size;
/// A white 40×20 document with its left half selected.
fn half_selected() -> Document {
let mut doc = Document::with_background("d", Size::new(40, 20), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(Rect::new(0, 0, 20, 20), &[1.0]);
doc.selection = Some(sel);
doc
}
fn view(mode: Mode) -> Value {
json!({"radius": 0, "__view": {"mode": mode.id(), "opacity": 0.5, "showEdge": false, "showOriginal": false}})
}
fn pixel(doc: &Document, x: i32, y: i32) -> Vec<f32> {
let s = photocraft_compose::flatten_to_surface(doc, PixelFormat::RGBA8, None);
s.pixel(x, y)
}
/// Every view shows the selected half as the image and marks the rest its own way.
#[test]
fn each_view_marks_the_unselected_area() {
let doc = half_selected();
let active = doc.layers.first().map(|l| l.id);
let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.02);
let cases = [
(Mode::Overlay, [1.0, 0.5, 0.5, 1.0]),
(Mode::OnBlack, [0.5, 0.5, 0.5, 1.0]),
(Mode::OnWhite, [1.0, 1.0, 1.0, 1.0]),
(Mode::BlackWhite, [0.0, 0.0, 0.0, 1.0]),
(Mode::OnLayers, [0.0, 0.0, 0.0, 0.0]),
(Mode::OnionSkin, [1.0, 1.0, 1.0, 0.5]),
(Mode::MarchingAnts, [1.0, 1.0, 1.0, 1.0]),
];
for (mode, unselected) in cases {
// Through the filter dialog's preview, which every Select and Mask preview takes.
let shown = crate::filter_dialog::preview_document(&doc, active, COMMAND, &view(mode), 1).expect("a preview");
let inside = pixel(&shown, 5, 10);
assert!(near(&inside, &[1.0, 1.0, 1.0, 1.0]), "{mode:?}: selected area {inside:?}");
let outside = pixel(&shown, 35, 10);
assert!(near(&outside, &unselected), "{mode:?}: unselected area {outside:?}, want {unselected:?}");
// The refined selection rides along for the canvas's marching ants.
assert!(shown.selection.as_ref().is_some_and(|s| s.pixel(5, 10)[0] > 0.99 && s.pixel(35, 10)[0] < 0.01), "{mode:?}");
}
}
#[test]
fn view_keys_pick_cycle_and_toggle() {
let mut f = Map::new();
assert_eq!(mode(&f), Mode::OnionSkin, "Photoshop's default view");
assert!(apply_key(&mut f, egui::Key::V));
assert_eq!(mode(&f), Mode::Overlay);
for want in [Mode::OnBlack, Mode::OnWhite, Mode::BlackWhite, Mode::OnLayers, Mode::OnionSkin, Mode::MarchingAnts] {
apply_key(&mut f, egui::Key::F);
assert_eq!(mode(&f), want, "F cycles");
}
apply_key(&mut f, egui::Key::J);
apply_key(&mut f, egui::Key::P);
assert!(flag(&f, "__showEdge") && flag(&f, "__showOriginal"));
apply_key(&mut f, egui::Key::J);
assert!(!flag(&f, "__showEdge"));
assert!(!apply_key(&mut f, egui::Key::Q), "other keys are left alone");
// Each view keeps its own opacity; hostile values fall back to the default.
set_opacity(&mut f, Mode::Overlay, 80.0);
assert_eq!((opacity(&f, Mode::Overlay), opacity(&f, Mode::OnBlack)), (80.0, 100.0));
f.insert("__opacity".into(), json!({"overlay": "x"}));
assert_eq!(opacity(&f, Mode::Overlay), 50.0);
// The view travels with the preview request, and a hostile one still previews.
let p = request_params(&f, json!({"radius": 2}));
assert_eq!(p["__view"]["mode"], json!("marchingAnts"));
let doc = half_selected();
let bad = json!({"radius": -5, "feather": "x", "__view": {"mode": 7, "opacity": "nan"}});
assert!(preview_document(&doc, None, &bad, 3, None).is_some());
}
/// Show Edge shows the band around the selection's edge; Show Original the unrefined selection.
#[test]
fn show_edge_and_show_original() {
let doc = half_selected();
let active = doc.layers.first().map(|l| l.id);
let edge = json!({"radius": 3, "smooth": 100, "__view": {"mode": "blackWhite", "opacity": 0.5, "showEdge": true}});
let shown = preview_document(&doc, active, &edge, 1, None).expect("a preview");
assert!(pixel(&shown, 20, 10)[0] > 0.99, "on the edge");
assert!(pixel(&shown, 5, 10)[0] < 0.01 && pixel(&shown, 35, 10)[0] < 0.01, "away from it");
let original = json!({"radius": 3, "shiftEdge": 100, "__view": {"mode": "blackWhite", "showOriginal": true}});
let shown = preview_document(&doc, active, &original, 1, None).expect("a preview");
assert!(pixel(&shown, 19, 10)[0] > 0.99 && pixel(&shown, 21, 10)[0] < 0.01, "the selection as it was");
}
}
+2 -2
View File
@@ -65,8 +65,8 @@ what an agent can't finish alone.
- **Estimate:** 60–100 h (2–9 h each, per the calibration).
### G7. Workspaces shown as parameter dialogs
- **Missing:** Select and Mask workspace with view modes, Refine Edge brush and outputs
(TOOL-214-1, Refine Hair #2671); Content-Aware Fill workspace with a paintable sampling area
- **Missing:** Select and Mask workspace layout, Refine Edge brush and outputs
(TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754); Content-Aware Fill workspace with a paintable sampling area
(TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Scale through the
transform box (TOOL-214-7); Puppet Warp mesh following the outline (TOOL-214-6); Content-Aware
Move's Transform On Drop (TOOL-213-4).
+1 -1
View File
@@ -145,7 +145,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs |
| TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS |
| TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input |
| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Opens the generic select.refineEdge dialog (menus.rs:308) |
| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); no workspace layout or Refine Edge brush |
| TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs |
| TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447) |
| TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open |
+1 -1
View File
@@ -115,7 +115,7 @@ id = "TOOL-214-1"
target = "Select and Mask workspace: view modes, Refine Edge brush, 6 outputs"
status = "partial"
issue = 214
note = "Opens the generic select.refineEdge dialog (menus.rs:308)"
note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); no workspace layout or Refine Edge brush"
[[item]]
id = "TOOL-214-2"