Masks as Photoshop's: the button reveals the selection, Alt-click shows a mask alone (13.12)
This commit is contained in:
1 parent
cc9bf95722
commit
0b0d36a7df
28 files changed
+554
-72
No files matched your search
@@ -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
|
||||
|
||||
+13
-1
@@ -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<CanvasDocument> &document = m_session.document();
|
||||
showWelcome(!document);
|
||||
const std::optional<ImageLayer> 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());
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <QWidget>
|
||||
|
||||
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<QWidget *> m_rulers;
|
||||
ProjectWorkspace *m_workspace = nullptr;
|
||||
|
||||
@@ -27,7 +27,7 @@ std::optional<ProjectSnapshot> 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<QString> path)
|
||||
void EditorSession::clearProject()
|
||||
{
|
||||
m_collapsedGroupIDs.clear();
|
||||
m_isMaskSelected = false;
|
||||
setIsMaskSelected(false);
|
||||
m_cropRect.reset();
|
||||
m_transformEdit = std::nullopt;
|
||||
m_guideDrag = std::nullopt;
|
||||
|
||||
@@ -35,7 +35,7 @@ void EditorSession::setActiveLayerID(std::optional<QUuid> 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<QUuid>{*id} : QSet<QUuid>();
|
||||
notify();
|
||||
@@ -234,7 +234,7 @@ void EditorSession::restore(const DocumentHistory::Snapshot &snapshot)
|
||||
m_document = snapshot.document;
|
||||
setActiveLayerID(snapshot.activeLayerID);
|
||||
const std::optional<ImageLayer> 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();
|
||||
}
|
||||
@@ -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> &lassoDraft() const { return m_lassoDraft; }
|
||||
LassoKind lassoKind() const { return m_lassoKind; }
|
||||
void setLassoKind(LassoKind kind);
|
||||
@@ -350,6 +350,7 @@ private:
|
||||
std::optional<BlendPreview> m_blendPreview;
|
||||
std::optional<QUuid> m_opacityEditLayerID;
|
||||
bool m_isMaskSelected = false;
|
||||
bool m_viewsMaskAlone = false;
|
||||
std::optional<QUuid> m_activeLayerID;
|
||||
bool m_isProjectBusy = false;
|
||||
bool m_showsNewDocument = false;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#include "Document/EditorSession.h"
|
||||
|
||||
// Swift's mask shown alone, an Alt-click on its thumbnail.
|
||||
std::optional<ImageLayer> EditorSession::maskAloneLayer() const
|
||||
{
|
||||
const std::optional<ImageLayer> layer = activeLayer();
|
||||
if (!m_viewsMaskAlone || !m_isMaskSelected || !layer || !layer->mask)
|
||||
return std::nullopt;
|
||||
return layer;
|
||||
}
|
||||
|
||||
void EditorSession::toggleMaskAlone(QUuid id)
|
||||
{
|
||||
const std::optional<ImageLayer> shown = maskAloneLayer();
|
||||
const bool showing = shown && shown->id == id;
|
||||
selectLayerTarget(id, true);
|
||||
if (m_activeLayerID != id || !m_isMaskSelected)
|
||||
return;
|
||||
m_viewsMaskAlone = !showing;
|
||||
notify();
|
||||
}
|
||||
@@ -15,6 +15,12 @@ public:
|
||||
std::optional<QImage> 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<ImageLayer> 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();
|
||||
@@ -262,7 +262,7 @@ void EditorSession::selectLayerTarget(QUuid id, bool mask)
|
||||
resolveGradient();
|
||||
selectLayer(id);
|
||||
const std::optional<ImageLayer> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<GradientEdit> &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<const RasterSnapshot> &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});
|
||||
}
|
||||
@@ -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<ImageLayer> alone = m_session.maskAloneLayer()) {
|
||||
drawMaskAlone(*alone, document, scale, center, context);
|
||||
return;
|
||||
}
|
||||
handOnDraftEffects(document);
|
||||
m_session.effectsPreviews.prepare(document.layers);
|
||||
std::map<QUuid, ImageLayer> byID;
|
||||
|
||||
@@ -122,6 +122,8 @@ private:
|
||||
// Typed text, which the canvas draws as pixels.
|
||||
std::optional<LayerTextStyle> textStyle;
|
||||
std::optional<LayerTransform> textTransform;
|
||||
// The mask shown alone, though the composite may skip it.
|
||||
std::optional<ImageIdentity> 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<QImage> &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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#include "UI/LayerMaskMenu.h"
|
||||
#include "UI/LayerIcons.h"
|
||||
#include <QEvent>
|
||||
#include <QGuiApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QPainter>
|
||||
|
||||
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<ImageLayer> 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<QWidget *>{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);
|
||||
}
|
||||
+16
-1
@@ -1,8 +1,9 @@
|
||||
#pragma once
|
||||
#include "Document/EditorSession.h"
|
||||
#include <QLabel>
|
||||
#include <QToolButton>
|
||||
|
||||
// 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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<QPoint> m_altMaskPress;
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+25
-11
@@ -188,22 +188,36 @@ void LayersPanelTests::theMaskButtonUsesTheSelection()
|
||||
panel.show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&panel));
|
||||
auto &mask = find<QToolButton>(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<QAction *>("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<QAction *>("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);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
#include "BrushFixtures.h"
|
||||
#include "CanvasFixtures.h"
|
||||
#include "ContentView.h"
|
||||
#include "UI/LayerMaskMenu.h"
|
||||
#include "UI/NativeLayerList.h"
|
||||
#include <QLabel>
|
||||
#include <QToolButton>
|
||||
|
||||
// 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<EditorSession> maskedSession(QUuid &id)
|
||||
{
|
||||
auto session = std::make_unique<EditorSession>();
|
||||
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<MaskAloneBadge *>()->isVisible());
|
||||
session.toggleMaskAlone(id);
|
||||
MaskAloneBadge *badge = nullptr;
|
||||
QTRY_VERIFY((badge = view.findChild<MaskAloneBadge *>()) && badge->isVisible());
|
||||
QCOMPARE(badge->findChild<QLabel *>("maskAloneTitle")->text(), QString("Layer Mask"));
|
||||
QCOMPARE(badge->findChild<QLabel *>("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<CanvasView *>();
|
||||
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<MaskAloneBadge *>()) && badge->isVisible());
|
||||
QLabel &name = *badge->findChild<QLabel *>("maskAloneName");
|
||||
QVERIFY(name.width() <= 220 && name.text().endsWith(QChar(0x2026)));
|
||||
// Its button, not the canvas under it, takes the click.
|
||||
auto &close = *badge->findChild<QToolButton *>("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<MaskAloneBadge *>()) && 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<LayerThumbnailButton *>("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"
|
||||
@@ -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"));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user