diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 211eb7ebc8..aab1cb05c8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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, ¶ms, 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))); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7cc87b2621..e57c01b4c9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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| { + 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 => {} diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index b5ffb786f1..95fef8f4f1 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -330,7 +330,8 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { 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) { 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 { + 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) }; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6a223d9d4b..162909fe2e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9f1df14843..b4ce4f46a6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c635b20fd5..729a3104ff 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 Εμφάνιση χαράκων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4c13ac63f5..c269fdc60e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 896639c491..f487c4fbd5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b37faae5bd..cf74582bb9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e4e4bdd4b0..7fb8e586e8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d56c7b8590..fb0775a6be 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 定規を表示 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4022f9ecde..29a6bbdc96 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 눈금자 표시 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 75d2a4830e..d11991cbe0 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fc02634de7..ae47472ac0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 467c1a278c..8399910802 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ccc1fa0d82..0188128491 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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… Удалить набор параметров кадрирования… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1c2f27e22e..421c02790b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Створити прямокутну рамку diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 45fea1505d..6332b3e087 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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… 删除裁剪预设… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0ba5d78a6e..2c5a5dd0f2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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… 刪除裁切預設集… diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 39ca677e73..e77681ec92 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/select_mask_view.rs b/crates/ui-egui/src/select_mask_view.rs new file mode 100644 index 0000000000..ffd9da7453 --- /dev/null +++ b/crates/ui-egui/src/select_mask_view.rs @@ -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::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 { + 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) -> Mode { + fields.get("__view").and_then(Value::as_str).and_then(Mode::from_id).unwrap_or(Mode::OnionSkin) +} + +fn flag(fields: &Map, 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, 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, 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) -> bool { + flag(fields, "__hideView") +} + +/// Whether `fields` belong to the Select and Mask dialog. +pub fn owns(fields: &Map) -> 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, 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) { + 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 = 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) { + 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, 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, 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, + 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 = 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, params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Option { + 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, params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Option> { + 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) -> 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, 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 = + 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 { + 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"); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index a2ec3dddc4..c4417fbf9d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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). diff --git a/docs/scorecard.md b/docs/scorecard.md index c0c15c3187..6f9b4237c4 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -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 | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 6c9f5f8d55..158c78169e 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -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"