From 0b0d36a7dffa20b0c67872e248724ae1232cf1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 18:04:14 -0400 Subject: [PATCH] Masks as Photoshop's: the button reveals the selection, Alt-click shows a mask alone (13.12) --- CMakeLists.txt | 2 + src/ContentView.cpp | 14 +- src/ContentView.h | 3 + src/Document/EditorSession+Projects.cpp | 4 +- src/Document/EditorSession.cpp | 19 +- src/Document/EditorSession.h | 7 +- src/Document/Filters+Session.cpp | 2 +- src/Document/LayerEffects+Session.cpp | 2 +- src/Document/LayerMask+Alone.cpp | 21 ++ src/Document/LayerMask+Session.h | 6 + src/Document/LayerMask.cpp | 15 +- src/Rendering/EditorCanvas+MaskAlone.cpp | 23 ++ src/Rendering/EditorCanvas.cpp | 7 +- src/Rendering/EditorCanvas.h | 4 + src/UI/LayerIcons.cpp | 6 + src/UI/LayerIcons.h | 2 +- src/UI/LayerMaskMenu.cpp | 60 ++++- src/UI/LayerMaskMenu.h | 17 +- src/UI/NativeLayerList+Buttons.cpp | 14 +- src/UI/NativeLayerList+Cell.cpp | 4 +- src/UI/NativeLayerList.h | 5 +- tests/LayerDragGestureTests.cpp | 12 +- tests/LayerListFocusTests.cpp | 10 +- tests/LayersPanelTests.cpp | 36 ++- tests/MaskAloneTests.cpp | 298 +++++++++++++++++++++++ tests/NativeLayerListTests.cpp | 2 +- tests/SelectionEditTests.cpp | 30 +-- tests/Tests.cmake | 1 + 28 files changed, 554 insertions(+), 72 deletions(-) create mode 100644 src/Document/LayerMask+Alone.cpp create mode 100644 src/Rendering/EditorCanvas+MaskAlone.cpp create mode 100644 tests/MaskAloneTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8983403..bc6c590 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,6 +45,7 @@ add_library(compositor_core STATIC src/Document/LayerAppearance.cpp src/Document/LayerGroups.cpp src/Document/LayerMask.cpp + src/Document/LayerMask+Alone.cpp src/Document/LayerTransform.cpp src/Document/LiveLayerMask.cpp src/Document/PixelAdjust.cpp @@ -202,6 +203,7 @@ add_library(compositor_core STATIC src/Rendering/EditorCanvas+Gradient.cpp src/Rendering/EditorCanvas+Cursors.cpp src/Rendering/BrushCursorOverlay.cpp + src/Rendering/EditorCanvas+MaskAlone.cpp src/Rendering/EditorCanvas+Navigation.cpp src/Rendering/EditorCanvas+Picking.cpp src/Rendering/EditorCanvas+Selection.cpp diff --git a/src/ContentView.cpp b/src/ContentView.cpp index f10ade0..16dcf68 100644 --- a/src/ContentView.cpp +++ b/src/ContentView.cpp @@ -11,6 +11,7 @@ #include "UI/ShapeControls.h" #include "UI/TypeControls.h" #include "UI/LassoControls.h" +#include "UI/LayerMaskMenu.h" #include "UI/NavigationToolHeader.h" #include "UI/TransformInspector.h" #include "UI/NewCanvasSheet.h" @@ -196,7 +197,8 @@ protected: ContentView::ContentView(EditorSession &session, ProjectController *projects, QWidget *parent) : QWidget(parent), m_session(session), m_projects(projects), m_column(new QVBoxLayout(this)), m_canvasSlot(new QGridLayout), - m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dock(new QWidget(this)), m_dropRing(new DropRing(this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))), + m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dock(new QWidget(this)), m_dropRing(new DropRing(this)), + m_maskAloneBadge(new MaskAloneBadge(session, this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))), m_activity(statusText(new ElidedLabel(this))), m_spinner(new QProgressBar(this)) { @@ -240,6 +242,12 @@ ContentView::ContentView(EditorSession &session, ProjectController *projects, QW // The welcome sits over the canvas, as Swift's ZStack. m_canvasSlot->addWidget(m_canvas, 0, 0); m_canvasSlot->addWidget(m_dropRing, 0, 0); + // A layout takes no clicks; the canvas keeps the rest. + auto *foot = new QVBoxLayout; + foot->addStretch(1); + foot->addWidget(m_maskAloneBadge, 0, Qt::AlignHCenter); + foot->addSpacing(14); + m_canvasSlot->addLayout(foot, 0, 0); setAcceptDrops(true); auto *middle = new QHBoxLayout; @@ -390,6 +398,10 @@ void ContentView::synchronize() } const std::optional &document = m_session.document(); showWelcome(!document); + const std::optional alone = m_session.maskAloneLayer(); + if (alone) + m_maskAloneBadge->setLayerName(alone->name); + m_maskAloneBadge->setVisible(alone.has_value()); for (QWidget *ruler : m_rulers) ruler->setVisible(m_session.showsRulers() && document); m_zoom->setVisible(document.has_value()); diff --git a/src/ContentView.h b/src/ContentView.h index bbe4115..46b6081 100644 --- a/src/ContentView.h +++ b/src/ContentView.h @@ -14,6 +14,7 @@ #include class LayersPanel; +class MaskAloneBadge; class ToolButton; class ProjectWorkspace; class QFileDialog; @@ -74,6 +75,8 @@ private: QWidget *const m_dock; // An accent ring while a drop may land. QWidget *const m_dropRing; + // Over the canvas's foot while a mask shows alone. + MaskAloneBadge *const m_maskAloneBadge; // Swift 1.1.7's rulers: the corner, then the two strips. std::vector m_rulers; ProjectWorkspace *m_workspace = nullptr; diff --git a/src/Document/EditorSession+Projects.cpp b/src/Document/EditorSession+Projects.cpp index d465ef5..6f197ff 100644 --- a/src/Document/EditorSession+Projects.cpp +++ b/src/Document/EditorSession+Projects.cpp @@ -27,7 +27,7 @@ std::optional EditorSession::projectSnapshot() const void EditorSession::installProject(const ProjectSnapshot &snapshot, const QString &path) { m_collapsedGroupIDs.clear(); - m_isMaskSelected = false; + setIsMaskSelected(false); m_cropRect.reset(); m_guideDrag = std::nullopt; m_transformEdit = std::nullopt; @@ -78,7 +78,7 @@ void EditorSession::setProjectPath(std::optional path) void EditorSession::clearProject() { m_collapsedGroupIDs.clear(); - m_isMaskSelected = false; + setIsMaskSelected(false); m_cropRect.reset(); m_transformEdit = std::nullopt; m_guideDrag = std::nullopt; diff --git a/src/Document/EditorSession.cpp b/src/Document/EditorSession.cpp index 3c541b3..d2db7b2 100644 --- a/src/Document/EditorSession.cpp +++ b/src/Document/EditorSession.cpp @@ -35,7 +35,7 @@ void EditorSession::setActiveLayerID(std::optional id) { // Another layer: its pixels are the target again. if (id != m_activeLayerID) - m_isMaskSelected = false; + setIsMaskSelected(false); m_activeLayerID = id; m_selectedLayerIDs = id ? QSet{*id} : QSet(); notify(); @@ -234,7 +234,7 @@ void EditorSession::restore(const DocumentHistory::Snapshot &snapshot) m_document = snapshot.document; setActiveLayerID(snapshot.activeLayerID); const std::optional active = activeLayer(); - m_isMaskSelected = keepsMaskTarget && active && active->mask; + setIsMaskSelected(keepsMaskTarget && active && active->mask); if (changedCanvas && m_document) viewport.fit(m_document->size()); qCInfo(lcApp) << "restored a history snapshot of" << (m_document ? int(m_document->layers.size()) : 0) << "layers"; @@ -381,3 +381,18 @@ void EditorSession::requestCanvasFocus() ++m_canvasFocusRequest; notify(); } + +void EditorSession::setIsMaskSelected(bool value) +{ + m_isMaskSelected = value; + if (!value) + m_viewsMaskAlone = false; +} + +void EditorSession::setViewsMaskAlone(bool value) +{ + if (m_viewsMaskAlone == value) + return; + m_viewsMaskAlone = value; + notify(); +} diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h index 4e237ea..487d22b 100644 --- a/src/Document/EditorSession.h +++ b/src/Document/EditorSession.h @@ -93,10 +93,11 @@ public: void selectTool(NavigationTool value); // Tab: the current tool's next mode. void cycleToolMode(); - // Whether the mask thumbnail, not the layer's, is the target. bool isMaskSelected() const { return m_isMaskSelected; } - + // Alt-click: the targeted mask alone, gray, on the canvas. + bool viewsMaskAlone() const { return m_viewsMaskAlone; } + void setViewsMaskAlone(bool value); // A blend mode tried on by hovering; nothing is saved. struct BlendPreview { QUuid layerID; @@ -132,7 +133,6 @@ public: // An unlinked mask, selected, transforms without its layer. bool transformTargetsMask() const; void nudgeLayer(double dx, double dy); - const std::optional &lassoDraft() const { return m_lassoDraft; } LassoKind lassoKind() const { return m_lassoKind; } void setLassoKind(LassoKind kind); @@ -350,6 +350,7 @@ private: std::optional m_blendPreview; std::optional m_opacityEditLayerID; bool m_isMaskSelected = false; + bool m_viewsMaskAlone = false; std::optional m_activeLayerID; bool m_isProjectBusy = false; bool m_showsNewDocument = false; diff --git a/src/Document/Filters+Session.cpp b/src/Document/Filters+Session.cpp index fe4dbe9..94c5da2 100644 --- a/src/Document/Filters+Session.cpp +++ b/src/Document/Filters+Session.cpp @@ -351,7 +351,7 @@ void EditorSession::finishFilterCommit() mask.isEnabled = true; mask.placement = std::nullopt; m_document->layers[index].mask = mask; - m_isMaskSelected = true; + setIsMaskSelected(true); endEdit(); finish(); return; diff --git a/src/Document/LayerEffects+Session.cpp b/src/Document/LayerEffects+Session.cpp index 0cf1996..e68353d 100644 --- a/src/Document/LayerEffects+Session.cpp +++ b/src/Document/LayerEffects+Session.cpp @@ -97,7 +97,7 @@ void EditorSession::selectEffect(LayerEffectKind kind, QUuid id, bool editing) finishEffectsEditing(false); // Swift's `selectedLayerIDs = [id]`: selectLayer sets it, unrefused here. selectLayer(id); - m_isMaskSelected = false; + setIsMaskSelected(false); m_effectSelection = selection; if (editing && m_effectsEditing != selection) { m_effectsEditingOriginal = m_document->layers[size_t(indexOf(m_document->layers, id))].effects.value_or(LayerEffects()); diff --git a/src/Document/LayerMask+Alone.cpp b/src/Document/LayerMask+Alone.cpp new file mode 100644 index 0000000..64c8841 --- /dev/null +++ b/src/Document/LayerMask+Alone.cpp @@ -0,0 +1,21 @@ +#include "Document/EditorSession.h" + +// Swift's mask shown alone, an Alt-click on its thumbnail. +std::optional EditorSession::maskAloneLayer() const +{ + const std::optional layer = activeLayer(); + if (!m_viewsMaskAlone || !m_isMaskSelected || !layer || !layer->mask) + return std::nullopt; + return layer; +} + +void EditorSession::toggleMaskAlone(QUuid id) +{ + const std::optional shown = maskAloneLayer(); + const bool showing = shown && shown->id == id; + selectLayerTarget(id, true); + if (m_activeLayerID != id || !m_isMaskSelected) + return; + m_viewsMaskAlone = !showing; + notify(); +} diff --git a/src/Document/LayerMask+Session.h b/src/Document/LayerMask+Session.h index 0b26b75..4795105 100644 --- a/src/Document/LayerMask+Session.h +++ b/src/Document/LayerMask+Session.h @@ -15,6 +15,12 @@ public: std::optional maskDistortPreview(const ImageLayer &layer) const; // The selection made a mask; without one, a solid mask. void addMask(bool revealing = true); + // The layer whose mask the canvas shows alone, if any. + std::optional maskAloneLayer() const; + // Alt-click on a mask thumbnail: alone, or the composite again. + void toggleMaskAlone(QUuid id); private: + // Swift's didSet: the pixels targeted end the mask view. + void setIsMaskSelected(bool value); void commitMaskTransform(); diff --git a/src/Document/LayerMask.cpp b/src/Document/LayerMask.cpp index 0e0013e..7642a75 100644 --- a/src/Document/LayerMask.cpp +++ b/src/Document/LayerMask.cpp @@ -262,7 +262,7 @@ void EditorSession::selectLayerTarget(QUuid id, bool mask) resolveGradient(); selectLayer(id); const std::optional active = activeLayer(); - m_isMaskSelected = mask && active && active->mask; + setIsMaskSelected(mask && active && active->mask); notify(); } @@ -286,14 +286,15 @@ void EditorSession::addMask(bool revealing) // The selection's coverage, soft where it is feathered. QImage image = PixelAdjust::coverage(current->clip(m_document->size()), width, height, BrushRaster::pixelToDocument(active->transform, width, height)); - if (revealing) + // Revealing, white inside; hiding, black inside. + if (!revealing) image.invertPixels(); const LayerMask mask(LayerMask::assetFrom(image)); finishOpacityEdit(); - beginEdit(QStringLiteral("Add Mask from Selection")); + beginEdit(revealing ? QStringLiteral("Reveal Selection") : QStringLiteral("Hide Selection")); m_document->layers[size_t(index)].mask = mask; m_document->selection = std::nullopt; - m_isMaskSelected = true; + setIsMaskSelected(true); endEdit(); } catch (const std::runtime_error &error) { qCWarning(lcApp).noquote() << "cannot make a mask from the selection:" << error.what(); @@ -312,7 +313,7 @@ void EditorSession::addLayerMask(bool revealing) if (layer.id == active->id) layer.mask = LayerMask::solid(revealing); } - m_isMaskSelected = true; + setIsMaskSelected(true); endEdit(); } @@ -341,7 +342,7 @@ void EditorSession::deleteLayerMask() if (layer.id == active->id) layer.mask = std::nullopt; } - m_isMaskSelected = false; + setIsMaskSelected(false); endEdit(); } @@ -371,7 +372,7 @@ void EditorSession::copyMask(QUuid source, QUuid target) beginEdit(find(target)->mask ? QStringLiteral("Replace Layer Mask") : QStringLiteral("Copy Layer Mask")); find(target)->mask = mask; selectLayer(target); - m_isMaskSelected = true; + setIsMaskSelected(true); endEdit(); } diff --git a/src/Rendering/EditorCanvas+MaskAlone.cpp b/src/Rendering/EditorCanvas+MaskAlone.cpp new file mode 100644 index 0000000..5b9ae63 --- /dev/null +++ b/src/Rendering/EditorCanvas+MaskAlone.cpp @@ -0,0 +1,23 @@ +#include "Rendering/EditorCanvas.h" + +// Swift's drawMaskAlone: gray across the canvas, edge tone past it. +void CanvasView::drawMaskAlone(const ImageLayer &layer, const CanvasDocument &document, double scale, const Center ¢er, QPainter &context) +{ + const LayerMask &mask = layer.mask.value(); + const int tone = qRound(LayerMask::background(mask.asset.thumbnail) * 255); + context.fillRect(QRectF(center(QPointF(0, 0)), document.size() * scale), QColor(tone, tone, tone)); + const std::optional &gradient = m_session.gradientEdit(); + const BrushStroke *stroke = m_session.brushStroke() ? m_session.brushStroke() : gradient ? gradient->raster.get() : nullptr; + // A stroke or gradient laid into it shows at once. + if (stroke && stroke->isMask && stroke->layer.id == layer.id) { + const LayerTransform &placed = stroke->paintTransform; + const std::shared_ptr &raster = mask.asset.raster; + LayerRenderer::drawBrushPreview(raster ? QImage() : mask.asset.image(), placed, center(placed.center()), context, {.scale = scale}, + {.patches = stroke->patches(), .pixelWidth = stroke->width, .pixelHeight = stroke->height, + .paintingMask = false, .sourceRect = stroke->sourceRect, .raster = raster}); + return; + } + // Where a transform drag shows it, as the composite. + const LayerTransform placed = m_session.displayedMaskPlacement(layer).value_or(m_session.displayedTransform(layer)); + LayerRenderer::draw(mask.asset.image(), placed, center(placed.center()), context, {.scale = scale}); +} diff --git a/src/Rendering/EditorCanvas.cpp b/src/Rendering/EditorCanvas.cpp index c9bb0a1..2b44c96 100644 --- a/src/Rendering/EditorCanvas.cpp +++ b/src/Rendering/EditorCanvas.cpp @@ -85,7 +85,8 @@ CanvasView::DisplayState CanvasView::displayState() const .layers = {}, .folderMasks = {}, .textStyle = m_session.textDraft() ? std::optional(m_session.textDraft()->style) : std::nullopt, - .textTransform = m_session.textDraft() && m_inlineTextEditor ? std::optional(m_inlineTextEditor->shownTransform()) : std::nullopt}; + .textTransform = m_session.textDraft() && m_inlineTextEditor ? std::optional(m_inlineTextEditor->shownTransform()) : std::nullopt, + .maskAlone = m_session.maskAloneLayer() ? std::optional(m_session.maskAloneLayer()->mask->asset.identity()) : std::nullopt}; if (!document) return state; // A hidden source still clips: live masks count every layer. @@ -299,6 +300,10 @@ FolderMaskClip::Applier CanvasView::liveFolderMaskClip(const BrushStroke &edit, void CanvasView::drawLayers(const CanvasDocument &document, double scale, const Center ¢er, QPainter &context) { + if (const std::optional alone = m_session.maskAloneLayer()) { + drawMaskAlone(*alone, document, scale, center, context); + return; + } handOnDraftEffects(document); m_session.effectsPreviews.prepare(document.layers); std::map byID; diff --git a/src/Rendering/EditorCanvas.h b/src/Rendering/EditorCanvas.h index 1f15405..c8d1bd4 100644 --- a/src/Rendering/EditorCanvas.h +++ b/src/Rendering/EditorCanvas.h @@ -122,6 +122,8 @@ private: // Typed text, which the canvas draws as pixels. std::optional textStyle; std::optional textTransform; + // The mask shown alone, though the composite may skip it. + std::optional maskAlone; friend bool operator==(const DisplayState &, const DisplayState &) = default; }; // A Zoom press: drags zoom about it, clicks step. @@ -146,6 +148,8 @@ private: void drawStroke(const BrushStroke &stroke, const ImageLayer &layer, const LayerTransform &transform, const std::optional &mask, const LayerRenderer::Options &options, double scale, const Center ¢er, QPainter &target); void drawLayers(const CanvasDocument &document, double scale, const Center ¢er, QPainter &context); + // Alt-click's view: the mask in gray, strokes included. + void drawMaskAlone(const ImageLayer &layer, const CanvasDocument &document, double scale, const Center ¢er, QPainter &context); // Typed text as pixels, where the editor shows it. struct DraftText { QImage image; diff --git a/src/UI/LayerIcons.cpp b/src/UI/LayerIcons.cpp index 0c2ae56..3783484 100644 --- a/src/UI/LayerIcons.cpp +++ b/src/UI/LayerIcons.cpp @@ -313,6 +313,12 @@ void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double case LayerIcon::dottedCircle: dottedCircle(painter); break; case LayerIcon::plusMagnifyingGlass: magnifyingGlass(painter, true); break; case LayerIcon::minusMagnifyingGlass: magnifyingGlass(painter, false); break; + // Swift's xmark at bold weight. + case LayerIcon::xmark: + painter.setPen(QPen(colour, 2.6, Qt::SolidLine, Qt::RoundCap)); + painter.drawLine(QPointF(4, 4), QPointF(14, 14)); + painter.drawLine(QPointF(14, 4), QPointF(4, 14)); + break; } painter.restore(); } diff --git a/src/UI/LayerIcons.h b/src/UI/LayerIcons.h index 267ca7a..ea0d660 100644 --- a/src/UI/LayerIcons.h +++ b/src/UI/LayerIcons.h @@ -8,7 +8,7 @@ enum class LayerIcon { eye, eyeSlash, chevronRight, chevronDown, folder, link, newLayer, newFolder, addMask, trash, layers, text, halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles, rightHalfCircle, hatchedCircle, axes, - scope, lineDiagonal, drop, wind, dottedCircle, plusMagnifyingGlass, minusMagnifyingGlass + scope, lineDiagonal, drop, wind, dottedCircle, plusMagnifyingGlass, minusMagnifyingGlass, xmark }; namespace LayerIcons { diff --git a/src/UI/LayerMaskMenu.cpp b/src/UI/LayerMaskMenu.cpp index 22bb295..7e58d7d 100644 --- a/src/UI/LayerMaskMenu.cpp +++ b/src/UI/LayerMaskMenu.cpp @@ -1,6 +1,9 @@ #include "UI/LayerMaskMenu.h" #include "UI/LayerIcons.h" #include +#include +#include +#include LayerMaskMenu::LayerMaskMenu(EditorSession &session, QWidget *parent) : QToolButton(parent), m_session(session) { @@ -8,7 +11,8 @@ LayerMaskMenu::LayerMaskMenu(EditorSession &session, QWidget *parent) : QToolBut setAccessibleName(QStringLiteral("Add layer mask")); setAutoRaise(true); setFixedSize(32, 40); - connect(this, &QToolButton::clicked, this, [this] { m_session.addMask(); }); + // Swift reads Option from the event that clicked. + connect(this, &QToolButton::clicked, this, [this] { m_session.addMask(!QGuiApplication::keyboardModifiers().testFlag(Qt::AltModifier)); }); connect(&m_session, &EditorSession::changed, this, &LayerMaskMenu::synchronize); synchronize(); } @@ -17,7 +21,8 @@ void LayerMaskMenu::synchronize() { const std::optional active = m_session.activeLayer(); setEnabled(m_session.canEditMask() && !(active && active->mask)); - setToolTip(m_session.selection() ? QStringLiteral("Add layer mask (the selection becomes black)") : QStringLiteral("Add layer mask")); + setToolTip(m_session.selection() ? QStringLiteral("Add layer mask revealing the selection (Alt-click to hide it)") + : QStringLiteral("Add layer mask (Alt-click for a black mask)")); setIcon(LayerIcons::pixmap(LayerIcon::addMask, 16, palette().color(QPalette::PlaceholderText), devicePixelRatio())); } @@ -27,3 +32,54 @@ void LayerMaskMenu::changeEvent(QEvent *event) if (event->type() == QEvent::PaletteChange) synchronize(); } + +MaskAloneBadge::MaskAloneBadge(EditorSession &session, QWidget *parent) : QWidget(parent), m_name(new QLabel(this)) +{ + setObjectName(QStringLiteral("maskAloneBadge")); + setFixedHeight(26); + const auto font = [](QLabel *label, bool semibold, double alpha) { + QFont face = label->font(); + face.setPixelSize(12); + face.setWeight(semibold ? QFont::DemiBold : QFont::Normal); + label->setFont(face); + QPalette colours = label->palette(); + colours.setColor(QPalette::WindowText, QColor::fromRgbF(1, 1, 1, alpha)); + label->setPalette(colours); + }; + auto *icon = new QLabel(this); + icon->setPixmap(LayerIcons::pixmap(LayerIcon::addMask, 11, Qt::white, devicePixelRatioF())); + auto *title = new QLabel(QStringLiteral("Layer Mask"), this); + title->setObjectName(QStringLiteral("maskAloneTitle")); + font(title, true, 1); + m_name->setObjectName(QStringLiteral("maskAloneName")); + m_name->setTextFormat(Qt::PlainText); + m_name->setMaximumWidth(220); + font(m_name, false, 0.6); + auto *close = new QToolButton(this); + close->setObjectName(QStringLiteral("maskAloneClose")); + close->setAutoRaise(true); + close->setIcon(LayerIcons::pixmap(LayerIcon::xmark, 9, Qt::white, devicePixelRatioF())); + close->setToolTip(QStringLiteral("Show the image again (or Alt-click the mask thumbnail)")); + close->setAccessibleName(QStringLiteral("Stop viewing the mask")); + connect(close, &QToolButton::clicked, this, [&session] { session.setViewsMaskAlone(false); }); + auto *row = new QHBoxLayout(this); + row->setContentsMargins(11, 0, 8, 0); + row->setSpacing(7); + for (QWidget *widget : std::initializer_list{icon, title, m_name, close}) + row->addWidget(widget); +} + +// Swift's byTruncatingTail within 220 points. +void MaskAloneBadge::setLayerName(const QString &name) +{ + m_name->setText(m_name->fontMetrics().elidedText(name, Qt::ElideRight, 220)); +} + +void MaskAloneBadge::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(QColor::fromRgbF(1, 1, 1, 0.14), 1)); + painter.setBrush(QColor::fromRgbF(0, 0, 0, 0.75)); + painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 13, 13); +} diff --git a/src/UI/LayerMaskMenu.h b/src/UI/LayerMaskMenu.h index 1a2cd89..08dd120 100644 --- a/src/UI/LayerMaskMenu.h +++ b/src/UI/LayerMaskMenu.h @@ -1,8 +1,9 @@ #pragma once #include "Document/EditorSession.h" +#include #include -// One click adds a mask: white, or the selection black. +// One click adds a mask revealing the selection; Alt, hiding. class LayerMaskMenu : public QToolButton { Q_OBJECT public: @@ -16,3 +17,17 @@ private: EditorSession &m_session; }; + +// While a mask shows alone: whose, and a way back. +class MaskAloneBadge : public QWidget { + Q_OBJECT +public: + explicit MaskAloneBadge(EditorSession &session, QWidget *parent = nullptr); + void setLayerName(const QString &name); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + QLabel *const m_name; +}; diff --git a/src/UI/NativeLayerList+Buttons.cpp b/src/UI/NativeLayerList+Buttons.cpp index 10880c2..79b8637 100644 --- a/src/UI/NativeLayerList+Buttons.cpp +++ b/src/UI/NativeLayerList+Buttons.cpp @@ -70,9 +70,10 @@ LayerThumbnailButton::LayerThumbnailButton(NativeLayerList &list, bool maskTarge setAutoRaise(true); } -void LayerThumbnailButton::setTargeted(bool targeted) +void LayerThumbnailButton::setTargeted(bool targeted, bool alone) { m_targeted = targeted; + m_alone = alone; QWidget::update(); } @@ -155,12 +156,9 @@ void LayerThumbnailButton::mouseReleaseEvent(QMouseEvent *event) if (!m_altMaskPress || event->button() != Qt::LeftButton) return; endAltMaskPress(); - // Released where pressed: a click, not a drag; Shift toggles. - if (!rect().contains(event->position().toPoint())) - return; - m_list.session().selectLayerTarget(layerID, true); - if (event->modifiers().testFlag(Qt::ShiftModifier)) - m_list.session().toggleLayerMask(); + // Released where pressed: the mask alone, or the image again. + if (rect().contains(event->position().toPoint())) + m_list.session().toggleMaskAlone(layerID); } // With a modifier the second press is the button's again. @@ -179,7 +177,7 @@ void LayerThumbnailButton::paintEvent(QPaintEvent *) if (!m_targeted) return; painter.setRenderHint(QPainter::Antialiasing); - painter.setPen(QPen(palette().color(QPalette::Highlight), 2)); + painter.setPen(QPen(m_alone ? QColor(Qt::white) : palette().color(QPalette::Highlight), 2)); painter.setBrush(Qt::NoBrush); painter.drawRoundedRect(QRectF(rect()).adjusted(1, 1, -1, -1), 3, 3); } diff --git a/src/UI/NativeLayerList+Cell.cpp b/src/UI/NativeLayerList+Cell.cpp index 54a7a59..b70fbff 100644 --- a/src/UI/NativeLayerList+Cell.cpp +++ b/src/UI/NativeLayerList+Cell.cpp @@ -31,7 +31,7 @@ LayerCell::LayerCell(NativeLayerList &list) connect(m_disclosure, &QToolButton::clicked, this, [this] { m_list.session().toggleGroupExpansion(m_layerID); }); m_thumbnail->setObjectName(QStringLiteral("layerThumbnail")); m_maskThumbnail->setObjectName(QStringLiteral("maskThumbnail")); - m_maskThumbnail->setToolTip(QStringLiteral("Select layer mask; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)")); + m_maskThumbnail->setToolTip(QStringLiteral("Select layer mask; Alt-click to view it alone; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)")); m_link->setObjectName(QStringLiteral("maskLink")); m_link->setAutoRaise(true); m_link->setFixedSize(9, 20); @@ -159,7 +159,7 @@ void LayerCell::updateTarget() const bool active = session.activeLayerID() == m_layerID && session.selectedLayerIDs().size() == 1; const bool mask = session.isMaskSelected(); m_thumbnail->setTargeted(active && !mask); - m_maskThumbnail->setTargeted(active && mask); + m_maskThumbnail->setTargeted(active && mask, active && session.maskAloneLayer()); QWidget::update(); } diff --git a/src/UI/NativeLayerList.h b/src/UI/NativeLayerList.h index b672fc4..69e6831 100644 --- a/src/UI/NativeLayerList.h +++ b/src/UI/NativeLayerList.h @@ -43,8 +43,8 @@ public: LayerThumbnailButton(NativeLayerList &list, bool maskTarget, QWidget *parent); QUuid layerID; const bool isMaskTarget; - // An accent ring while the target is selected. - void setTargeted(bool targeted); + // An accent ring while targeted; white while shown alone. + void setTargeted(bool targeted, bool alone = false); bool isTargeted() const { return m_targeted; } ~LayerThumbnailButton() override; @@ -63,6 +63,7 @@ private: NativeLayerList &m_list; bool m_targeted = false; + bool m_alone = false; // Alt on a mask: chosen on release, or dragged. std::optional m_altMaskPress; }; diff --git a/tests/LayerDragGestureTests.cpp b/tests/LayerDragGestureTests.cpp index 990cec9..2b11f81 100644 --- a/tests/LayerDragGestureTests.cpp +++ b/tests/LayerDragGestureTests.cpp @@ -36,7 +36,7 @@ private slots: void hoverOverRowsAndCtrlKeepTheCursorRight(); void aSpentPressArmsNothingLater(); void aMaskPressReleasedInAPopupIsOver(); - void altClicksOnAMaskThroughTheWindowChooseAndToggle(); + void altClicksOnAMaskThroughTheWindowShowItAloneAndBack(); void anotherButtonsReleaseLeavesAMaskPress(); }; @@ -260,7 +260,7 @@ void LayerDragGestureTests::aMaskPressReleasedInAPopupIsOver() QCOMPARE(startedDrags.back().rows, uuidString(shown.id(0)).toUtf8()); } -void LayerDragGestureTests::altClicksOnAMaskThroughTheWindowChooseAndToggle() +void LayerDragGestureTests::altClicksOnAMaskThroughTheWindowShowItAloneAndBack() { const auto session = sessionWithLayers(2); Shown shown(*session); @@ -272,11 +272,11 @@ void LayerDragGestureTests::altClicksOnAMaskThroughTheWindowChooseAndToggle() const QPoint at = mask.mapTo(&shown.list, QPoint(5, 5)); // The window sees the release first; the click still lands. QTest::mouseClick(shown.list.windowHandle(), Qt::LeftButton, Qt::AltModifier, at); - QVERIFY(session->isMaskSelected()); - QVERIFY(session->activeLayer().value().mask.value().isEnabled); + QVERIFY(session->isMaskSelected() && session->viewsMaskAlone()); + // Again, the image; Shift no longer toggles the mask. QTest::mouseClick(shown.list.windowHandle(), Qt::LeftButton, Qt::AltModifier | Qt::ShiftModifier, at); - QVERIFY(session->isMaskSelected()); - QVERIFY(!session->activeLayer().value().mask.value().isEnabled); + QVERIFY(session->isMaskSelected() && !session->viewsMaskAlone()); + QVERIFY(session->activeLayer().value().mask.value().isEnabled); QCOMPARE(startedDrags.size(), size_t(0)); } diff --git a/tests/LayerListFocusTests.cpp b/tests/LayerListFocusTests.cpp index 0b33b3f..01d81f7 100644 --- a/tests/LayerListFocusTests.cpp +++ b/tests/LayerListFocusTests.cpp @@ -62,7 +62,7 @@ private slots: void tabAndTheBrushKeysStayWithTheList(); void aSliderDragReleasedOverAPopupStillEnds(); void anotherButtonsReleaseLeavesTheSwipe(); - void altDoubleClickOnTheStripClipsTwiceAndAltShiftTogglesAMask(); + void altDoubleClickOnTheStripClipsTwiceAndAltShowsAMaskAlone(); }; void LayerListFocusTests::aMenuBorrowingFocusKeepsTheRenameAndTheField() @@ -321,7 +321,7 @@ void LayerListFocusTests::anotherButtonsReleaseLeavesTheSwipe() QVERIFY(session->canUndo()); } -void LayerListFocusTests::altDoubleClickOnTheStripClipsTwiceAndAltShiftTogglesAMask() +void LayerListFocusTests::altDoubleClickOnTheStripClipsTwiceAndAltShowsAMaskAlone() { const auto session = sessionWithLayers(2); NativeLayerList list(*session); @@ -339,13 +339,13 @@ void LayerListFocusTests::altDoubleClickOnTheStripClipsTwiceAndAltShiftTogglesAM session->undo(); QCOMPARE(layerWith(*session, top.layerID()).maskSourceID, std::optional(list.cells().at(1)->layerID())); session->redo(); - // Alt+Shift on a mask chooses it and toggles it. + // Alt on a mask shows it alone; Shift adds nothing. session->selectLayers({top.layerID()}, top.layerID()); session->addLayerMask(); session->selectLayerTarget(top.layerID(), false); QTest::mouseClick(&top.maskThumbnail(), Qt::LeftButton, Qt::AltModifier | Qt::ShiftModifier, QPoint(5, 5)); - QVERIFY(session->isMaskSelected()); - QVERIFY(!session->activeLayer().value().mask.value().isEnabled); + QVERIFY(session->isMaskSelected() && session->viewsMaskAlone()); + QVERIFY(session->activeLayer().value().mask.value().isEnabled); } void LayerListFocusTests::tabAndTheBrushKeysStayWithTheList() diff --git a/tests/LayersPanelTests.cpp b/tests/LayersPanelTests.cpp index 1b1bb01..fecb7a8 100644 --- a/tests/LayersPanelTests.cpp +++ b/tests/LayersPanelTests.cpp @@ -188,22 +188,36 @@ void LayersPanelTests::theMaskButtonUsesTheSelection() panel.show(); QVERIFY(QTest::qWaitForWindowExposed(&panel)); auto &mask = find(panel, "addLayerMask"); - QCOMPARE(mask.toolTip(), QString("Add layer mask")); + QCOMPARE(mask.toolTip(), QString("Add layer mask (Alt-click for a black mask)")); + // Alt-click: all black without a selection. + QTest::mouseClick(&mask, Qt::LeftButton, Qt::AltModifier); + QCOMPARE(session.history.undoName(), QString("Add Hide-All Mask")); + session.undo(); + QTest::mouseClick(&mask, Qt::LeftButton); + QCOMPARE(session.history.undoName(), QString("Add Reveal-All Mask")); + session.undo(); QPainterPath box; box.addRect(QRectF(20, 10, 30, 20)); session.applySelection(box, SelectionMode::replace, "Select"); - QCOMPARE(mask.toolTip(), QString("Add layer mask (the selection becomes black)")); + QCOMPARE(mask.toolTip(), QString("Add layer mask revealing the selection (Alt-click to hide it)")); + // The button reveals the selection, as Photoshop's. QTest::mouseClick(&mask, Qt::LeftButton); - QCOMPARE(session.history.undoName(), QString("Add Mask from Selection")); + QCOMPARE(session.history.undoName(), QString("Reveal Selection")); QVERIFY(!session.selection().has_value() && session.isMaskSelected() && !mask.isEnabled()); - QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 0); - QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255); - // The row's menu: black shows only the selection. - session.undo(); - QVERIFY(session.selection().has_value()); - LayerCell &cell = *panel.list().cells().at(0); - panel.list().menuFor(cell, QPoint(cell.width() - 20, 20))->findChild("addBlackMask")->trigger(); - QCOMPARE(session.history.undoName(), QString("Add Mask from Selection")); QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 255); QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 0); + // Alt-click hides it instead. + session.undo(); + QVERIFY(session.selection().has_value()); + QTest::mouseClick(&mask, Qt::LeftButton, Qt::AltModifier); + QCOMPARE(session.history.undoName(), QString("Hide Selection")); + QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 0); + QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255); + // The row's Hide All hides the selection too. + session.undo(); + LayerCell &cell = *panel.list().cells().at(0); + panel.list().menuFor(cell, QPoint(cell.width() - 20, 20))->findChild("addBlackMask")->trigger(); + QCOMPARE(session.history.undoName(), QString("Hide Selection")); + QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(20)[30]), 0); + QCOMPARE(int(session.activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255); } diff --git a/tests/MaskAloneTests.cpp b/tests/MaskAloneTests.cpp new file mode 100644 index 0000000..77820c8 --- /dev/null +++ b/tests/MaskAloneTests.cpp @@ -0,0 +1,298 @@ +#include "BrushFixtures.h" +#include "CanvasFixtures.h" +#include "ContentView.h" +#include "UI/LayerMaskMenu.h" +#include "UI/NativeLayerList.h" +#include +#include + +// Swift's MaskAloneTests: Alt-click shows a mask by itself. +class MaskAloneTests : public QObject { + Q_OBJECT +private slots: + void optionClickTogglesTheMaskViewAndTargetingPixelsEndsIt(); + void aLayerWithoutAMaskHasNothingToShow(); + void anotherLayerOrAnEndedViewShowsTheImage(); + void theCanvasShowsTheMaskInGrayInsteadOfTheImage(); + void aStrokeOrAGradientShowsInTheMaskAsItIsLaid(); + void aPlacedOrDisabledMaskShowsAlone(); + void theBadgeNamesTheMaskAndTakesItsOwnClicks(); + void altClickOnTheThumbnailTogglesItAndOutlinesItWhite(); +}; + +namespace { +// A red layer, 200 by 100, its right half masked. +QUuid masked(EditorSession &session) +{ + session.insert(filled(200, 100, qRgba(255, 0, 0, 255), QStringLiteral("Red"))); + const QUuid id = session.activeLayerID().value(); + QImage mask(200, 100, QImage::Format_Grayscale8); + mask.fill(0); + for (int y = 0; y < 100; ++y) + std::fill_n(mask.scanLine(y), 100, uchar(255)); + rewrite(session, [&](ProjectSnapshot &snapshot) { + record(snapshot, id).transform = LayerTransform{.origin = {0, 0}, .size = {200, 100}}; + setMask(snapshot, id, LayerMask::assetFrom(mask)); + }); + return id; +} + +std::unique_ptr maskedSession(QUuid &id) +{ + auto session = std::make_unique(); + session->createDocument(200, 100); + id = masked(*session); + return session; +} + +// The canvas at 1:1: view points are document pixels. +struct Canvas : Shown { + QUuid id; + Canvas() : Shown(QSize(200, 100), QSize(400, 300)) + { + settle(); + id = masked(session); + session.zoom(1); + canvas->synchronizeDisplay(); + } + QColor at(QPointF point) + { + canvas->synchronizeDisplay(); + const QPointF view = session.viewport.viewPoint(point, documentSize()); + return canvas->grab().toImage().pixelColor(view.toPoint()); + } +}; + +bool red(const QColor &colour) +{ + return colour.red() > 204 && colour.blue() < 102 && colour.green() < 102; +} + +bool gray(const QColor &colour, int value) +{ + return std::abs(colour.red() - value) <= 2 && colour.red() == colour.green() && colour.green() == colour.blue(); +} +} + +void MaskAloneTests::optionClickTogglesTheMaskViewAndTargetingPixelsEndsIt() +{ + QUuid id; + const auto session = maskedSession(id); + session->toggleMaskAlone(id); + QVERIFY(session->maskAloneLayer().value().id == id && session->isMaskSelected() && session->viewsMaskAlone()); + session->toggleMaskAlone(id); + QVERIFY(!session->maskAloneLayer() && session->isMaskSelected()); + session->toggleMaskAlone(id); + session->selectLayerTarget(id, false); + QVERIFY(!session->maskAloneLayer() && !session->viewsMaskAlone()); + session->toggleMaskAlone(id); + session->deleteLayerMask(); + QVERIFY(!session->maskAloneLayer() && !session->viewsMaskAlone()); + session->undo(); + QVERIFY(!session->maskAloneLayer()); +} + +void MaskAloneTests::aLayerWithoutAMaskHasNothingToShow() +{ + QUuid id; + const auto session = maskedSession(id); + session->deleteLayerMask(); + session->toggleMaskAlone(id); + QVERIFY(!session->maskAloneLayer() && !session->isMaskSelected() && !session->viewsMaskAlone()); + // A busy project leaves the target, so the view too. + session->addLayerMask(); + session->selectLayerTarget(id, false); + session->setIsProjectBusy(true); + session->toggleMaskAlone(id); + QVERIFY(!session->viewsMaskAlone()); + // Busy, another layer's targeted mask stays unshown too. + session->setIsProjectBusy(false); + session->addBlankLayer(); + const QUuid other = session->activeLayerID().value(); + session->addLayerMask(); + QVERIFY(session->isMaskSelected()); + session->setIsProjectBusy(true); + session->toggleMaskAlone(id); + QVERIFY(session->activeLayerID() == other && !session->viewsMaskAlone()); +} + +void MaskAloneTests::anotherLayerOrAnEndedViewShowsTheImage() +{ + QUuid id; + const auto session = maskedSession(id); + session->addBlankLayer(); + const QUuid other = session->activeLayerID().value(); + session->toggleMaskAlone(id); + QVERIFY(session->maskAloneLayer().has_value()); + // Another layer targets its pixels: the image again. + session->selectLayer(other); + QVERIFY(!session->maskAloneLayer() && !session->viewsMaskAlone()); + // Alt-clicking another layer's mask shows that one. + session->addLayerMask(false); + session->toggleMaskAlone(id); + session->toggleMaskAlone(other); + QCOMPARE(session->maskAloneLayer().value().id, other); + // The badge's button: each change is announced. + QSignalSpy changed(session.get(), &EditorSession::changed); + session->setViewsMaskAlone(false); + QVERIFY(!session->maskAloneLayer() && session->isMaskSelected()); + QCOMPARE(changed.count(), 1); + session->setViewsMaskAlone(false); + QCOMPARE(changed.count(), 1); +} + +void MaskAloneTests::theCanvasShowsTheMaskInGrayInsteadOfTheImage() +{ + Canvas shown; + QVERIFY(red(shown.at(QPointF(50, 50)))); + shown.session.toggleMaskAlone(shown.id); + QVERIFY(shown.canvas->synchronizeDisplay()); + QVERIFY(gray(shown.at(QPointF(50, 50)), 255)); + QVERIFY(gray(shown.at(QPointF(150, 50)), 0)); + shown.session.toggleMaskAlone(shown.id); + QVERIFY(shown.canvas->synchronizeDisplay()); + QVERIFY(red(shown.at(QPointF(50, 50)))); + // At a crisp zoom too, past the halving. + shown.session.toggleMaskAlone(shown.id); + shown.session.zoom(8); + QVERIFY(gray(shown.at(QPointF(80.5, 50.5)), 255)); + QVERIFY(gray(shown.at(QPointF(120.5, 50.5)), 0)); +} + +void MaskAloneTests::aStrokeOrAGradientShowsInTheMaskAsItIsLaid() +{ + Canvas shown; + EditorSession &session = shown.session; + session.toggleMaskAlone(shown.id); + session.selectTool(NavigationTool::brush); + session.setMaskPaintWhite(false); + session.setBrushSettings(brush(20, 1, 0, 0, 0)); + session.beginBrush(QPointF(50, 50)); + session.continueBrush(QPointF(60, 50)); + QVERIFY(gray(shown.at(QPointF(55, 50)), 0)); + QVERIFY(gray(shown.at(QPointF(55, 80)), 255)); + QVERIFY(session.finishBrushImmediately()); + QVERIFY(gray(shown.at(QPointF(55, 50)), 0)); + QVERIFY(session.maskAloneLayer().has_value()); + // A gradient shows as it is dragged, off its line. + session.selectTool(NavigationTool::gradient); + session.setMaskPaintWhite(true); + session.beginGradient(QPointF(0, 50)); + session.moveGradient(std::nullopt, QPointF(200, 50)); + QVERIFY(session.gradientEdit().has_value()); + const QColor left = shown.at(QPointF(10, 80)), right = shown.at(QPointF(190, 80)); + // Before it, the right half was black. + QVERIFY(gray(right, right.red()) && right.red() > 6 && right.red() < 128); + // What shows is what the gradient commits. + bool done = false; + session.commitGradient([&] { done = true; }); + QTRY_VERIFY(done); + const QImage committed = session.activeLayer().value().mask.value().asset.image(); + QVERIFY(gray(left, committed.pixelColor(10, 80).red()) && gray(right, committed.pixelColor(190, 80).red())); + QVERIFY(gray(shown.at(QPointF(10, 80)), left.red())); +} + +void MaskAloneTests::aPlacedOrDisabledMaskShowsAlone() +{ + Canvas shown; + EditorSession &session = shown.session; + // Turned off, the composite ignores it; alone, it shows. + session.selectLayerTarget(shown.id, true); + session.toggleLayerMask(); + QVERIFY(red(shown.at(QPointF(150, 50)))); + session.toggleMaskAlone(shown.id); + QVERIFY(gray(shown.at(QPointF(150, 50)), 0)); + QVERIFY(gray(shown.at(QPointF(50, 50)), 255)); + // Placed apart, at its placement; its edge tone past it. + rewrite(session, [&](ProjectSnapshot &snapshot) { + record(snapshot, shown.id).maskLinked = false; + record(snapshot, shown.id).maskPlacement = LayerTransform{.origin = {50, 0}, .size = {100, 50}}; + }); + session.toggleMaskAlone(shown.id); + QVERIFY(session.maskAloneLayer().has_value()); + QVERIFY(gray(shown.at(QPointF(60, 20)), 255)); + QVERIFY(gray(shown.at(QPointF(140, 20)), 0)); + const int edge = qRound(LayerMask::background(session.activeLayer().value().mask.value().asset.thumbnail) * 255); + QVERIFY(gray(shown.at(QPointF(20, 80)), edge)); + QVERIFY(edge > 0 && gray(shown.at(QPointF(120, 70)), edge)); +} + +void MaskAloneTests::theBadgeNamesTheMaskAndTakesItsOwnClicks() +{ + EditorSession session; + ContentView view(session); + view.resize(900, 600); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + session.createDocument(200, 100); + const QUuid id = masked(session); + QVERIFY(!view.findChild()->isVisible()); + session.toggleMaskAlone(id); + MaskAloneBadge *badge = nullptr; + QTRY_VERIFY((badge = view.findChild()) && badge->isVisible()); + QCOMPARE(badge->findChild("maskAloneTitle")->text(), QString("Layer Mask")); + QCOMPARE(badge->findChild("maskAloneName")->text(), QString("Red")); + QCOMPARE(badge->height(), 26); + QCOMPARE(badge->layout()->contentsMargins(), QMargins(11, 0, 8, 0)); + QCOMPARE(badge->layout()->spacing(), 7); + // At the canvas's foot, 14 points up, in the middle. + auto *canvas = view.findChild(); + const QRect onView(badge->mapTo(&view, QPoint(0, 0)), badge->size()), canvasOnView(canvas->mapTo(&view, QPoint(0, 0)), canvas->size()); + QCOMPARE(canvasOnView.bottom() - onView.bottom(), 14); + QVERIFY(std::abs(onView.center().x() - canvasOnView.center().x()) <= 1); + // A long name ends in an ellipsis within 220 points. + rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).name = QString(80, QLatin1Char('W')); }); + session.toggleMaskAlone(id); + QTRY_VERIFY((badge = view.findChild()) && badge->isVisible()); + QLabel &name = *badge->findChild("maskAloneName"); + QVERIFY(name.width() <= 220 && name.text().endsWith(QChar(0x2026))); + // Its button, not the canvas under it, takes the click. + auto &close = *badge->findChild("maskAloneClose"); + QCOMPARE(close.toolTip(), QString("Show the image again (or Alt-click the mask thumbnail)")); + QCOMPARE(close.accessibleName(), QString("Stop viewing the mask")); + const QPoint middle = close.mapTo(view.window(), close.rect().center()); + QCOMPARE(view.window()->childAt(middle), &close); + QTest::mouseClick(view.window()->windowHandle(), Qt::LeftButton, Qt::NoModifier, middle); + QVERIFY(!session.maskAloneLayer() && session.isMaskSelected()); + QTRY_VERIFY(!badge->isVisible()); + // The canvas under the badge's foot still takes clicks. + session.toggleMaskAlone(id); + QTRY_VERIFY((badge = view.findChild()) && badge->isVisible()); + const QPoint below = badge->mapTo(view.window(), QPoint(badge->width() / 2, badge->height() + 7)); + QCOMPARE(view.window()->childAt(below), canvas); +} + +void MaskAloneTests::altClickOnTheThumbnailTogglesItAndOutlinesItWhite() +{ + EditorSession session; + NativeLayerList list(session); + list.resize(300, 400); + list.show(); + QVERIFY(QTest::qWaitForWindowExposed(&list)); + session.createDocument(200, 100); + const QUuid id = masked(session); + LayerCell &cell = *list.cells().at(0); + auto *thumbnail = cell.findChild("maskThumbnail"); + QVERIFY(thumbnail); + QCOMPARE(thumbnail->toolTip(), QString("Select layer mask; Alt-click to view it alone; Shift-click to enable/disable; Ctrl-click to select its black " + "areas (Ctrl-Shift adds, Ctrl-Alt subtracts)")); + session.selectLayerTarget(id, false); + const QPoint middle = thumbnail->rect().center(); + QTest::mouseClick(thumbnail, Qt::LeftButton, Qt::AltModifier, middle); + QCOMPARE(session.maskAloneLayer().value().id, id); + // Outlined in white rather than the accent. + const QColor ring = thumbnail->grab().toImage().pixelColor(1, middle.y()); + QCOMPARE(ring, QColor(Qt::white)); + // Released off the thumbnail, the press does nothing. + QTest::mousePress(thumbnail, Qt::LeftButton, Qt::AltModifier, middle); + QTest::mouseRelease(thumbnail, Qt::LeftButton, Qt::AltModifier, QPoint(-40, middle.y())); + QCOMPARE(session.maskAloneLayer().value().id, id); + // Shift no longer toggles the mask on an Alt-click. + QTest::mouseClick(thumbnail, Qt::LeftButton, Qt::AltModifier | Qt::ShiftModifier, middle); + QVERIFY(!session.maskAloneLayer() && session.activeLayer().value().mask.value().isEnabled); + const QColor accent = thumbnail->grab().toImage().pixelColor(1, middle.y()); + QCOMPARE(accent, thumbnail->palette().color(QPalette::Highlight)); +} + +QTEST_MAIN(MaskAloneTests) +#include "MaskAloneTests.moc" diff --git a/tests/NativeLayerListTests.cpp b/tests/NativeLayerListTests.cpp index 63642b6..76b599b 100644 --- a/tests/NativeLayerListTests.cpp +++ b/tests/NativeLayerListTests.cpp @@ -469,7 +469,7 @@ void NativeLayerListTests::ctrlClicksOnThumbnailsLoadSelections() Shown shown(session); LayerCell &row = shown.row(0); QCOMPARE(row.thumbnail().toolTip(), QString("Select image pixels")); - QCOMPARE(row.maskThumbnail().toolTip(), QString("Select layer mask; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)")); + QCOMPARE(row.maskThumbnail().toolTip(), QString("Select layer mask; Alt-click to view it alone; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)")); // Ctrl on the picture: its opaque pixels, the row untouched. QTest::mouseClick(&row.thumbnail(), Qt::LeftButton, Qt::ControlModifier); QCOMPARE(session.history.undoName(), QString("Load Layer Selection")); diff --git a/tests/SelectionEditTests.cpp b/tests/SelectionEditTests.cpp index 922743f..503359b 100644 --- a/tests/SelectionEditTests.cpp +++ b/tests/SelectionEditTests.cpp @@ -55,8 +55,8 @@ void invert(EditorSession &session) class SelectionEditTests : public QObject { Q_OBJECT private slots: - void maskButtonAddsWhiteMaskOrHidesTheSelection(); - void layerMenuMasksUseTheSelection(); + void maskButtonAddsWhiteMaskOrRevealsTheSelection(); + void hidingMasksUseTheSelection(); void maskFromSelectionLinesUpOnScaledLayers(); void deletingWithTheMaskTargetedRemovesOnlyTheMask(); void invertKeepsTransparencyStaysInSelectionAndWorksOnMasks(); @@ -67,7 +67,7 @@ private slots: void aPaintedAssetThatCannotFlattenReportsAndFreesTheProject(); }; -void SelectionEditTests::maskButtonAddsWhiteMaskOrHidesTheSelection() +void SelectionEditTests::maskButtonAddsWhiteMaskOrRevealsTheSelection() { const auto session = editSession(); session->insert(filledRed()); @@ -79,12 +79,12 @@ void SelectionEditTests::maskButtonAddsWhiteMaskOrHidesTheSelection() QVERIFY(!session->activeLayer().value().mask.has_value()); select(*session, QRectF(20, 10, 30, 20)); session->addMask(); - QCOMPARE(session->history.undoName(), QString("Add Mask from Selection")); + QCOMPARE(session->history.undoName(), QString("Reveal Selection")); QVERIFY(!session->selection().has_value() && session->isMaskSelected()); const QImage result = render(*session); - // The selected area is black, hidden; the rest white. - QCOMPARE(pixel(result, 30, 20)[3], 0); - QCOMPARE(pixel(result, 5, 5)[3], 255); + // The selected area is white, visible; the rest black. + QCOMPARE(pixel(result, 30, 20)[3], 255); + QCOMPARE(pixel(result, 5, 5)[3], 0); session->undo(); QVERIFY(!session->activeLayer().value().mask.has_value() && session->selection().has_value()); // A layer with a mask takes no second one. @@ -95,18 +95,18 @@ void SelectionEditTests::maskButtonAddsWhiteMaskOrHidesTheSelection() QCOMPARE(session->history.undoCount(), count + 1); } -void SelectionEditTests::layerMenuMasksUseTheSelection() +void SelectionEditTests::hidingMasksUseTheSelection() { const auto session = editSession(); session->insert(filledRed()); select(*session, QRectF(20, 10, 30, 20)); session->addMask(false); - QCOMPARE(session->history.undoName(), QString("Add Mask from Selection")); + QCOMPARE(session->history.undoName(), QString("Hide Selection")); QVERIFY(!session->selection().has_value() && session->isMaskSelected()); const QImage result = render(*session); - // The selected area is white, visible; the rest black. - QCOMPARE(pixel(result, 30, 20)[3], 255); - QCOMPARE(pixel(result, 5, 5)[3], 0); + // The selected area is black, hidden; the rest white. + QCOMPARE(pixel(result, 30, 20)[3], 0); + QCOMPARE(pixel(result, 5, 5)[3], 255); session->undo(); QVERIFY(!session->activeLayer().value().mask.has_value() && session->selection().has_value()); session->deselect(); @@ -128,7 +128,7 @@ void SelectionEditTests::maskFromSelectionLinesUpOnScaledLayers() record(snapshot, id).transform.size = QSizeF(100, 100); }); select(*session, QRectF(0, 0, 50, 50)); - session->addMask(); + session->addMask(false); // The mask uses the layer's pixel grid. QCOMPARE(session->activeLayer().value().mask.value().asset.size(), QSize(50, 50)); const QImage result = render(*session); @@ -139,7 +139,7 @@ void SelectionEditTests::maskFromSelectionLinesUpOnScaledLayers() session->undo(); session->addBlankLayer(); select(*session, QRectF(10, 10, 20, 20)); - session->addMask(); + session->addMask(false); QCOMPARE(session->activeLayer().value().mask.value().asset.size(), QSize(100, 100)); QCOMPARE(int(session->activeLayer().value().mask.value().asset.image().constScanLine(15)[15]), 0); QCOMPARE(int(session->activeLayer().value().mask.value().asset.image().constScanLine(5)[5]), 255); @@ -153,7 +153,7 @@ void SelectionEditTests::maskFromSelectionLinesUpOnScaledLayers() session->undo(); session->setSelectionAntialiased(soft); session->applySelection(triangle, SelectionMode::replace, "Select"); - session->addMask(); + session->addMask(false); const QImage edge = session->activeLayer().value().mask.value().asset.image(); int partial = 0; for (int x = 0; x < 100; ++x) { diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 13a922d..551fb13 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -135,6 +135,7 @@ compositor_test(LiveMaskDeleteTests) compositor_test(LiveMaskEditTests) compositor_test(LiveMaskSessionTests) compositor_test(LiveMaskTests) +compositor_test(MaskAloneTests) compositor_test(MaskPlacementTests) compositor_test(MaskSignalTests) compositor_test(MaskTracingTests)