M3.86: Alt-click the mask thumbnail to view only the mask

transparency.viewOpacityMask {on?, id?} shows only the opacity mask on the
canvas as greyscale coverage (luminance, clip and invert applied; the rest of
the art hidden) and enters mask editing; called again it shows the artwork and
keeps editing. The Transparency panel's mask thumbnail runs it on Alt-click and
is outlined in the accent colour while the mask is viewed. The state is
DocState.mask_view (shown while that mask is edited: DocState::shown_mask) and
reaches the renderer as RenderOptions.mask_view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 03:11:04 +07:00
co-authored by Claude Opus 5.5
parent cbdef4ea1f
commit 03571affbd
9 changed files with 241 additions and 30 deletions
+2
View File
@@ -145,6 +145,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let painter = ui.painter_at(rect);
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let mask_view = st.shown_mask();
// Pasteboard, artboard shadows and paper.
painter.rect_filled(rect, 0.0, t.pasteboard);
@@ -217,6 +218,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let [r, g, b, _] = c.to_rgba8(1.0);
[r, g, b]
}),
mask_view,
..opts
};
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
+2
View File
@@ -439,6 +439,8 @@ mod tests_effectedit;
#[cfg(test)]
mod tests_freeform;
#[cfg(test)]
mod tests_maskview;
#[cfg(test)]
mod tests_stroke;
#[cfg(test)]
mod tests_strokedepth;
@@ -0,0 +1,47 @@
//! Headless frames of the Transparency panel's mask thumbnail (M3.86): Alt-click views only the
//! mask (and edits it), Alt-click again shows the artwork.
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
/// One frame of the Transparency panel with `events`, `modifiers` held.
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>, modifiers: egui::Modifiers) {
let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect();
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| transparency::show(app, ui));
out.textures_delta.clear();
}
/// Click the mask thumbnail with `modifiers` held.
fn click_mask(ctx: &egui::Context, app: &mut VectorcraftApp, modifiers: egui::Modifiers) {
frame(ctx, app, vec![], egui::Modifiers::NONE);
let at = ctx.read_response(egui::Id::new(transparency::MASK_THUMB)).expect("the mask thumbnail is drawn").rect.center();
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers };
frame(ctx, app, vec![egui::Event::PointerMoved(at), button(true), button(false)], modifiers);
frame(ctx, app, vec![], egui::Modifiers::NONE);
}
#[test]
fn alt_clicking_the_mask_thumbnail_views_only_the_mask() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
let b = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [a, b]}));
run(&mut app, "transparency.makeOpacityMask", json!({}));
let ctx = egui::Context::default();
let viewed = |app: &VectorcraftApp| app.session.active().unwrap().shown_mask().map(|n| n.0);
click_mask(&ctx, &mut app, egui::Modifiers::ALT);
assert_eq!(viewed(&app), Some(a));
assert!(app.session.active().unwrap().doc.mask_edit.is_some(), "viewing the mask edits it");
click_mask(&ctx, &mut app, egui::Modifiers::ALT);
assert_eq!(viewed(&app), None);
assert!(app.session.active().unwrap().doc.mask_edit.is_some(), "still editing");
// A plain click while editing does nothing; Alt views again.
click_mask(&ctx, &mut app, egui::Modifiers::NONE);
assert_eq!(viewed(&app), None);
click_mask(&ctx, &mut app, egui::Modifiers::ALT);
assert_eq!(viewed(&app), Some(a));
}
+22 -12
View File
@@ -1,7 +1,8 @@
//! Transparency panel: blend mode, opacity (field + slider popup), object/mask thumbnails with the
//! opacity-mask controls (make/release, link, clip, invert; Shift-click the mask to disable it),
//! Isolate Blending, the three-state Knockout Group (on → neutral → off) and Opacity & Mask Define
//! Knockout Shape; the menu's Page Isolated Blending and Page Knockout Group.
//! opacity-mask controls (make/release, link, clip, invert; Shift-click the mask to disable it,
//! Alt-click it to view only the mask), Isolate Blending, the three-state Knockout Group (on →
//! neutral → off) and Opacity & Mask Define Knockout Shape; the menu's Page Isolated Blending and
//! Page Knockout Group.
use egui::{Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::json;
@@ -14,6 +15,9 @@ use crate::theme::Tokens;
use crate::widgets::{self, TransparencyEdit, menu_item};
use crate::{VectorcraftApp, icons};
/// Interaction id of the mask thumbnail.
pub(super) const MASK_THUMB: &str = "tr-mask-thumb";
/// The panel's values ([`vectorcraft_engine::Session::transparency_info`]) and its first target
/// (the selection, or the object whose mask is being edited), recomputed only when the document
/// or the selection changes.
@@ -100,25 +104,31 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if lresp.on_hover_text(if m.linked { "Unlink the mask" } else { "Link the mask" }).clicked() {
app.run("transparency.setOpacityMask", json!({"linked": !m.linked})).ok();
}
let (mr, mresp) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::click());
let (mr, _) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::hover());
let mresp = ui.interact(mr, egui::Id::new(MASK_THUMB), Sense::click());
let bg = if m.clip != m.invert { [0, 0, 0, 255] } else { [255, 255, 255, 255] };
ui.painter().rect_filled(mr, 0.0, egui::Color32::from_rgb(bg[0], bg[1], bg[2]));
node_thumb(app, ui, "tr-mask", &m.art, mr.shrink(2.0), Some(bg));
ui.painter().rect_stroke(
mr,
0.0,
Stroke::new(mask_w, if editing.is_some() { t.border } else { t.input_border }),
StrokeKind::Inside,
);
// Viewing the mask alone (Alt-click) highlights its thumbnail.
let viewing = app.session.active().is_some_and(|d| d.shown_mask().is_some());
let border = match (viewing, editing.is_some()) {
(true, _) => Stroke::new(2.0, t.accent),
(false, true) => Stroke::new(mask_w, t.border),
(false, false) => Stroke::new(mask_w, t.input_border),
};
ui.painter().rect_stroke(mr, 0.0, border, StrokeKind::Inside);
if m.disabled {
let red = Stroke::new(2.0, egui::Color32::from_rgb(220, 40, 40));
ui.painter().line_segment([mr.left_top(), mr.right_bottom()], red);
ui.painter().line_segment([mr.right_top(), mr.left_bottom()], red);
}
let shift = ui.input(|i| i.modifiers.shift);
if mresp.on_hover_text("Click to edit the mask; Shift-click to disable or enable it").clicked() {
let (shift, alt) = ui.input(|i| (i.modifiers.shift, i.modifiers.alt));
let tip = "Click to edit the mask; Alt-click to view only the mask; Shift-click to disable or enable it";
if mresp.on_hover_text(tip).clicked() {
if shift {
app.run(if m.disabled { "transparency.enableOpacityMask" } else { "transparency.disableOpacityMask" }, json!({})).ok();
} else if alt {
app.run("transparency.viewOpacityMask", json!({"id": id.0})).ok();
} else if editing.is_none() {
app.run("transparency.editOpacityMask", json!({"id": id.0})).ok();
}