Merge branch 'port-c'
This commit is contained in:
commit
e243751f64
54 files changed
+1428
-233
No files matched your search
@@ -46,6 +46,7 @@ add_library(compositor_core STATIC
|
||||
src/Document/LayerGroups.cpp
|
||||
src/Document/LayerGroups+Order.cpp
|
||||
src/Document/LayerMask.cpp
|
||||
src/Document/LayerMask+Alone.cpp
|
||||
src/Document/LayerTransform.cpp
|
||||
src/Document/LiveLayerMask.cpp
|
||||
src/Document/PixelAdjust.cpp
|
||||
@@ -205,6 +206,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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Swift's BlurTool extension: what a Blur stroke paints.
|
||||
public:
|
||||
// The active layer, or its mask, softened at document size.
|
||||
std::optional<QImage> blurSample(const CanvasDocument &document, bool mask = false) const;
|
||||
// The stroke's pixels or mask softened, in its own grid.
|
||||
std::optional<BrushStroke::Clone> blurSample(const BrushStroke &stroke) const;
|
||||
+25
-35
@@ -2,49 +2,39 @@
|
||||
#include "Document/PixelAdjust.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "Logging.h"
|
||||
#include "Rendering/LayerRenderer.h"
|
||||
#include <cmath>
|
||||
|
||||
// Swift's BlurTool extension: the layer, or its mask, softened.
|
||||
std::optional<QImage> EditorSession::blurSample(const CanvasDocument &document, bool mask) const
|
||||
// Swift's BlurTool extension: the layer's pixels, or mask, softened.
|
||||
std::optional<BrushStroke::Clone> EditorSession::blurSample(const BrushStroke &stroke) const
|
||||
{
|
||||
const std::optional<ImageLayer> layer = activeLayer();
|
||||
if (!layer)
|
||||
const ImageLayer &layer = stroke.layer;
|
||||
const std::optional<ImportedImage> &asset = stroke.isMask ? (layer.mask ? std::optional(layer.mask->asset) : std::nullopt) : layer.asset;
|
||||
if (!asset)
|
||||
return std::nullopt;
|
||||
// The softening follows the brush's size.
|
||||
const double sigma = std::min(30.0, std::max(1.5, m_brushSettings.diameter / 10));
|
||||
// The canvas's softening carried into the layer's pixels.
|
||||
const QTransform &map = stroke.pixelToDocument;
|
||||
const double perPixel = std::max(1e-6, std::sqrt(std::abs(map.m11() * map.m22() - map.m12() * map.m21())));
|
||||
const double sidePixels = std::max(stroke.sourceRect.width(), stroke.sourceRect.height());
|
||||
const double sigma = std::min(std::min(30.0, std::max(1.5, m_brushSettings.diameter / 10)) / perPixel, sidePixels / 2);
|
||||
// Room for the blur to spread past the pixels' edges.
|
||||
const double margin = std::ceil(3 * sigma);
|
||||
const QRectF region = stroke.sourceRect.adjusted(-margin, -margin, margin, margin);
|
||||
// A huge layer's sample is made coarser, within several canvases.
|
||||
const qint64 canvas = m_document ? qint64(m_document->width) * m_document->height : 0;
|
||||
const double budget = double(std::min(DocumentLimits::maxSurfacePixels, std::max<qint64>(16'000'000, 4 * canvas)));
|
||||
const double fit = std::min(1.0, std::sqrt(budget / (region.width() * region.height())));
|
||||
const int width = std::max(1, int(std::ceil(region.width() * fit))), height = std::max(1, int(std::ceil(region.height() * fit)));
|
||||
const QRectF placed(margin * fit, margin * fit, stroke.sourceRect.width() * fit, stroke.sourceRect.height() * fit);
|
||||
try {
|
||||
if (mask) {
|
||||
if (!layer->mask)
|
||||
return std::nullopt;
|
||||
QImage context = BrushRaster::context(document.width, document.height, true);
|
||||
// Past its pixels a mask keeps its edge tone.
|
||||
const int tone = int(std::round(LayerMask::background(layer->mask->asset.thumbnail) * 255));
|
||||
context.fill(QColor(tone, tone, tone));
|
||||
QPainter painter(&context);
|
||||
// Coverage only adds white: the mask's area starts black.
|
||||
const LayerTransform placement = layer->maskTransform();
|
||||
painter.save();
|
||||
QTransform box;
|
||||
box.translate(placement.center().x(), placement.center().y());
|
||||
box.rotateRadians(placement.radians());
|
||||
painter.setTransform(box, true);
|
||||
painter.fillRect(QRectF(-placement.size.width() / 2, -placement.size.height() / 2, placement.size.width(), placement.size.height()),
|
||||
Qt::black);
|
||||
painter.restore();
|
||||
LayerRenderer::drawCoverage(layer->mask->asset.image(), placement, painter);
|
||||
painter.end();
|
||||
return PixelAdjust::gaussianBlur(context, sigma, true);
|
||||
}
|
||||
if (!layer->asset)
|
||||
return std::nullopt;
|
||||
QImage context = BrushRaster::context(document.width, document.height, false);
|
||||
QImage context = BrushRaster::context(width, height, stroke.isMask);
|
||||
// Past its pixels a mask keeps its edge tone.
|
||||
if (stroke.isMask && layer.mask)
|
||||
context.fill(int(std::round(LayerMask::background(layer.mask->asset.thumbnail) * 255)));
|
||||
QPainter painter(&context);
|
||||
// A painted asset flattens here; the catch covers it too.
|
||||
const LayerTransform transform = displayedTransform(*layer);
|
||||
LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {});
|
||||
BrushRaster::draw(asset->image(), placed, painter);
|
||||
painter.end();
|
||||
return PixelAdjust::gaussianBlur(context, sigma, false);
|
||||
return BrushStroke::Clone{PixelAdjust::gaussianBlur(context, sigma * fit, stroke.isMask), region, true};
|
||||
} catch (const ExportError &error) {
|
||||
qCWarning(lcApp) << "the Blur tool cannot sample the layer:" << error.what();
|
||||
return std::nullopt;
|
||||
|
||||
@@ -58,6 +58,14 @@ LayerTransform BrushStroke::transform(const QRectF &bounds) const
|
||||
return result;
|
||||
}
|
||||
|
||||
// Offset into grid pixels, turned and scaled as the layer.
|
||||
QRectF BrushStroke::gridRect(const QRectF &rect, QSizeF offset) const
|
||||
{
|
||||
const QTransform toGrid = pixelToDocument.inverted();
|
||||
const QPointF shift = toGrid.map(QPointF(offset.width(), offset.height())) - toGrid.map(QPointF(0, 0));
|
||||
return rect.translated(-shift);
|
||||
}
|
||||
|
||||
// Swift's heal: the painted spot rebuilt from nearby texture.
|
||||
void BrushStroke::heal()
|
||||
{
|
||||
|
||||
@@ -273,11 +273,12 @@ QImage BrushStroke::clonePixels(const Tile &tile) const
|
||||
if (!clone)
|
||||
return QImage();
|
||||
const int width = int(tile.rect.width()), height = int(tile.rect.height());
|
||||
// Tile pixels to clone pixels: grid, document, then offset.
|
||||
const QTransform toClone = QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument
|
||||
* QTransform::fromTranslate(clone->offset.width(), clone->offset.height());
|
||||
// Colour even for gray clones: edges carry their coverage.
|
||||
const QImage &image = clone->image;
|
||||
// Tile pixels to clone pixels: grid, maybe document, then placed.
|
||||
const QRectF &placed = clone->placed;
|
||||
const QTransform toClone = QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * (clone->inGrid ? QTransform() : pixelToDocument)
|
||||
* QTransform::fromTranslate(-placed.left(), -placed.top()) * QTransform::fromScale(image.width() / placed.width(), image.height() / placed.height());
|
||||
if (toClone.type() > QTransform::TxTranslate) {
|
||||
// Swift's medium quality: bilinear under a scale or turn.
|
||||
QImage sample = BrushRaster::context(width, height, false);
|
||||
|
||||
@@ -111,10 +111,11 @@ public:
|
||||
qint64 pixelLimit = DocumentLimits::documentPixelBudget();
|
||||
// Limits every edit to the document selection; nil when none.
|
||||
std::optional<SelectionClip> selectionClip;
|
||||
// Clone Stamp: a document-sized image, and each point's offset.
|
||||
// Painted through the tip, placed: document or `inGrid` pixels.
|
||||
struct Clone {
|
||||
QImage image;
|
||||
QSizeF offset;
|
||||
QRectF placed;
|
||||
bool inGrid;
|
||||
};
|
||||
std::optional<Clone> clone;
|
||||
// Blur: the clone is the layer softened, in place.
|
||||
@@ -148,6 +149,8 @@ public:
|
||||
QRectF committedBounds() const;
|
||||
LayerTransform committedTransform() const;
|
||||
LayerTransform transform(const QRectF &bounds) const;
|
||||
// `rect` shifted to copy from `offset` document pixels away.
|
||||
QRectF gridRect(const QRectF &rect, QSizeF offset) const;
|
||||
// Spot Healing's end: the painted spot rebuilt from around it.
|
||||
void heal();
|
||||
PaintSnapshot paintSnapshot() const;
|
||||
|
||||
@@ -6,5 +6,5 @@ public:
|
||||
std::optional<QSizeF> cloneStrokeOffset(QPointF point) const;
|
||||
// The source for a brush at `point`: the canvas's crosshair.
|
||||
std::optional<QPointF> cloneSamplePoint(QPointF point) const;
|
||||
// What a stroke copies from, document-sized, taken as it starts.
|
||||
std::optional<QImage> cloneSample(const CanvasDocument &document) const;
|
||||
// What `stroke` copies, `offset` away: its pixels or the canvas.
|
||||
std::optional<BrushStroke::Clone> cloneSample(const CanvasDocument &document, const BrushStroke &stroke, QSizeF offset) const;
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "Logging.h"
|
||||
#include "Rendering/LayerRenderer.h"
|
||||
#include <cmath>
|
||||
|
||||
// Swift's CloneStamp extension: the source, its offset, the sample.
|
||||
@@ -40,20 +39,20 @@ std::optional<QPointF> EditorSession::cloneSamplePoint(QPointF point) const
|
||||
return point + QPointF(m_cloneOffset->width(), m_cloneOffset->height());
|
||||
}
|
||||
|
||||
std::optional<QImage> EditorSession::cloneSample(const CanvasDocument &document) const
|
||||
std::optional<BrushStroke::Clone> EditorSession::cloneSample(const CanvasDocument &document, const BrushStroke &stroke, QSizeF offset) const
|
||||
{
|
||||
try {
|
||||
// A painted asset flattens here; the catch covers it too.
|
||||
if (!m_cloneSettings.sampleAllLayers) {
|
||||
if (!stroke.layer.asset)
|
||||
return std::nullopt;
|
||||
return BrushStroke::Clone{stroke.layer.asset->image(), stroke.gridRect(stroke.sourceRect, offset), true};
|
||||
}
|
||||
QImage context = BrushRaster::context(document.width, document.height, false);
|
||||
QPainter painter(&context);
|
||||
// A painted asset flattens here; the catch covers it too.
|
||||
if (m_cloneSettings.sampleAllLayers) {
|
||||
drawLiveComposite(document, painter);
|
||||
} else if (const std::optional<ImageLayer> layer = activeLayer(); layer && layer->asset) {
|
||||
const LayerTransform transform = displayedTransform(*layer);
|
||||
LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {});
|
||||
}
|
||||
drawLiveComposite(document, painter);
|
||||
painter.end();
|
||||
return context;
|
||||
return BrushStroke::Clone{context, QRectF(-offset.width(), -offset.height(), context.width(), context.height()), false};
|
||||
} catch (const ExportError &error) {
|
||||
qCWarning(lcApp) << "Clone Stamp cannot sample the canvas:" << error.what();
|
||||
return std::nullopt;
|
||||
|
||||
@@ -37,6 +37,27 @@ bool EditorSession::canPaint() const
|
||||
&& (m_isMaskSelected || !layer->adjustment);
|
||||
}
|
||||
|
||||
std::optional<QString> EditorSession::paintRefusal() const
|
||||
{
|
||||
const std::optional<ImageLayer> layer = activeLayer();
|
||||
if (!canEditLayers() || !layer || canPaint())
|
||||
return std::nullopt;
|
||||
if (m_selectedLayerIDs.size() > 1)
|
||||
return QStringLiteral("Several layers are selected. Select just one to paint on it.");
|
||||
if (layer->isGroup && !m_isMaskSelected)
|
||||
return QStringLiteral("“%1” is a folder, which has no pixels of its own. Paint on a layer inside it, or on the folder’s mask.").arg(layer->name);
|
||||
if (!m_document->effectiveVisibleIDs().contains(layer->id))
|
||||
return QStringLiteral("“%1” is hidden, or inside a hidden folder. Show it to paint on it.").arg(layer->name);
|
||||
if (m_isMaskSelected && !(layer->mask && layer->mask->isEnabled))
|
||||
return QStringLiteral("The layer mask is turned off. Shift-click its thumbnail to turn it on, then paint.");
|
||||
if (!m_isMaskSelected && layer->adjustment)
|
||||
return QStringLiteral("“%1” is an adjustment layer, with no pixels to paint. Paint on its mask instead.").arg(layer->name);
|
||||
const std::optional<DocumentSelection> current = selection();
|
||||
if (current && current->isEmpty())
|
||||
return QStringLiteral("Nothing is selected, so there’s nowhere to paint. Choose Select › Deselect (Ctrl+D) to paint anywhere.");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// A tiled raster edit of pixels or mask, within budget.
|
||||
std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings, bool growsMask) const
|
||||
{
|
||||
@@ -74,28 +95,20 @@ void EditorSession::beginBrush(QPointF point)
|
||||
}
|
||||
// Spot Healing and Clone Stamp rework pixels, never a mask.
|
||||
const bool paintsMasks = m_tool == NavigationTool::brush || m_tool == NavigationTool::blur;
|
||||
if ((!paintsMasks && (!isBrushTool(m_tool) || m_isMaskSelected)) || !canPaint())
|
||||
if (!paintsMasks && (!isBrushTool(m_tool) || m_isMaskSelected))
|
||||
return;
|
||||
if (!canPaint()) {
|
||||
setBrushError(paintRefusal());
|
||||
return;
|
||||
}
|
||||
const ImageLayer layer = activeLayer().value();
|
||||
std::optional<BrushStroke::Clone> clone;
|
||||
std::optional<QSizeF> sourceOffset;
|
||||
if (m_tool == NavigationTool::cloneStamp) {
|
||||
const std::optional<QSizeF> offset = cloneStrokeOffset(point);
|
||||
if (!offset) {
|
||||
sourceOffset = cloneStrokeOffset(point);
|
||||
if (!sourceOffset) {
|
||||
setBrushError(QStringLiteral("Alt-click where Clone Stamp should copy from first."));
|
||||
return;
|
||||
}
|
||||
const std::optional<QImage> image = cloneSample(m_document.value());
|
||||
if (!image)
|
||||
return;
|
||||
m_cloneOffset = offset;
|
||||
clone = BrushStroke::Clone{*image, *offset};
|
||||
}
|
||||
// Blur paints a softened copy of the layer, in place.
|
||||
if (m_tool == NavigationTool::blur) {
|
||||
const std::optional<QImage> image = blurSample(m_document.value(), m_isMaskSelected);
|
||||
if (!image)
|
||||
return;
|
||||
clone = BrushStroke::Clone{*image, QSizeF(0, 0)};
|
||||
}
|
||||
finishOpacityEdit();
|
||||
try {
|
||||
@@ -108,9 +121,21 @@ void EditorSession::beginBrush(QPointF point)
|
||||
settings.green = settings.red;
|
||||
settings.blue = settings.red;
|
||||
}
|
||||
m_brushStroke = makeRasterEdit(layer, settings, m_tool == NavigationTool::brush);
|
||||
m_brushStroke->clone = clone;
|
||||
m_brushStroke->isBlur = m_tool == NavigationTool::blur;
|
||||
std::unique_ptr<BrushStroke> stroke = makeRasterEdit(layer, settings, m_tool == NavigationTool::brush);
|
||||
if (sourceOffset) {
|
||||
stroke->clone = cloneSample(m_document.value(), *stroke, *sourceOffset);
|
||||
if (!stroke->clone)
|
||||
return;
|
||||
m_cloneOffset = sourceOffset;
|
||||
}
|
||||
// Blur paints a softened copy of the layer, in place.
|
||||
if (m_tool == NavigationTool::blur) {
|
||||
stroke->clone = blurSample(*stroke);
|
||||
if (!stroke->clone)
|
||||
return;
|
||||
}
|
||||
stroke->isBlur = m_tool == NavigationTool::blur;
|
||||
m_brushStroke = std::move(stroke);
|
||||
m_brushStroke->append(point);
|
||||
m_brushAnchor = point;
|
||||
m_brushPointer = point;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Swift's EditorSession+Brush extension: a stroke from press to commit.
|
||||
public:
|
||||
bool canPaint() const;
|
||||
// Why the target refuses a stroke; none while busy.
|
||||
std::optional<QString> paintRefusal() const;
|
||||
std::unique_ptr<BrushStroke> makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings = BrushSettings(), bool growsMask = false) const;
|
||||
void beginBrush(QPointF point);
|
||||
void continueBrush(QPointF point);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -347,7 +347,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;
|
||||
|
||||
@@ -35,8 +35,13 @@ void EditorSession::setGradientSettings(const GradientSettings &settings)
|
||||
void EditorSession::beginGradient(QPointF point)
|
||||
{
|
||||
const std::optional<ImageLayer> layer = activeLayer();
|
||||
if (m_tool != NavigationTool::gradient || !(canPaint() || m_gradientEdit) || !layer)
|
||||
if (m_tool != NavigationTool::gradient || !layer)
|
||||
return;
|
||||
// Swift explains only past this guard, where nothing refuses.
|
||||
if (!canPaint() && !m_gradientEdit) {
|
||||
setBrushError(paintRefusal());
|
||||
return;
|
||||
}
|
||||
// A new line on this target replaces the pending one.
|
||||
if (m_gradientEdit && m_gradientEdit->raster->layer.id == layer->id && m_gradientEdit->raster->isMask == m_isMaskSelected) {
|
||||
m_gradientEdit->start = point;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -420,5 +420,7 @@ void EditorSession::invertSelection()
|
||||
const std::optional<DocumentSelection> current = selection();
|
||||
if (!m_document || !current)
|
||||
return;
|
||||
setSelection(DocumentSelection{canvasPath(*m_document).subtracted(current->path), current->antialiased, current->feather}, QStringLiteral("Inverse"));
|
||||
const DocumentSelection inverse{canvasPath(*m_document).subtracted(current->path), current->antialiased, current->feather};
|
||||
// The inverse of everything is no selection, as Photoshop's.
|
||||
setSelection(inverse.isEmpty() ? std::nullopt : std::optional(inverse), QStringLiteral("Inverse"));
|
||||
}
|
||||
@@ -196,8 +196,7 @@ void EditorSession::beginWarp(QPointF point)
|
||||
{
|
||||
const std::optional<ImageLayer> layer = activeLayer();
|
||||
if (!canPaint() || m_isMaskSelected || !layer || !layer->asset || !m_document) {
|
||||
if (m_isMaskSelected)
|
||||
setBrushError(QStringLiteral("Smudge and Liquify work on a layer's pixels, not its mask."));
|
||||
setBrushError(m_isMaskSelected ? std::optional(QStringLiteral("Smudge and Liquify work on a layer's pixels, not its mask.")) : paintRefusal());
|
||||
return;
|
||||
}
|
||||
finishOpacityEdit();
|
||||
@@ -235,7 +234,7 @@ void EditorSession::finishWarp()
|
||||
settings.hardness = 1;
|
||||
settings.opacity = 1;
|
||||
const std::unique_ptr<BrushStroke> stroke = makeRasterEdit(current, settings);
|
||||
stroke->clone = BrushStroke::Clone{warp->image(), QSizeF(0, 0)};
|
||||
stroke->clone = BrushStroke::Clone{warp->image(), QRectF(QPointF(0, 0), warp->image().size()), false};
|
||||
stroke->replacesWithClone = true;
|
||||
stroke->editName = rawValue(warp->mode);
|
||||
for (const QPointF step : warp->points())
|
||||
|
||||
@@ -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,15 @@ bool CanvasView::moveSelectionTool(QPointF point, Qt::KeyboardModifiers modifier
|
||||
{
|
||||
if (m_pixelDragStart && m_session.document()) {
|
||||
const QPointF pixel = m_session.viewport.documentPoint(point, m_session.document()->size());
|
||||
m_session.movePixels(QSizeF(pixel.x() - m_pixelDragStart->x(), pixel.y() - m_pixelDragStart->y()));
|
||||
QSizeF offset(pixel.x() - m_pixelDragStart->x(), pixel.y() - m_pixelDragStart->y());
|
||||
// Shift keeps the pixels on a line, the longer way.
|
||||
if (modifiers.testFlag(Qt::ShiftModifier)) {
|
||||
if (std::abs(offset.width()) >= std::abs(offset.height()))
|
||||
offset.setHeight(0);
|
||||
else
|
||||
offset.setWidth(0);
|
||||
}
|
||||
m_session.movePixels(offset);
|
||||
updateCursor();
|
||||
synchronizeDisplay();
|
||||
return true;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,20 +2,10 @@
|
||||
#include "IO/RecentProjects.h"
|
||||
#include "Logging.h"
|
||||
#include "UI/KeyboardShortcuts.h"
|
||||
#include "UI/NativeLayerList.h"
|
||||
#include <QApplication>
|
||||
#include <QMenu>
|
||||
|
||||
namespace {
|
||||
// The layer list holding the keys, as Swift's table.
|
||||
NativeLayerList *focusedList()
|
||||
{
|
||||
for (QWidget *widget = QApplication::focusWidget(); widget; widget = widget->parentWidget())
|
||||
if (auto *list = qobject_cast<NativeLayerList *>(widget))
|
||||
return list;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// An entry's name: Hue/Saturation is newHueSaturationAdjustment.
|
||||
QString adjustmentName(AdjustmentKind kind)
|
||||
{
|
||||
@@ -186,8 +176,6 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
|
||||
m_field->selectAll();
|
||||
else if (InlineTextEditor *text = typedText())
|
||||
text->selectAll();
|
||||
else if (NativeLayerList *list = focusedList())
|
||||
list->selectAllRows();
|
||||
else
|
||||
session().selectAll();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <QApplication>
|
||||
#include <QContextMenuEvent>
|
||||
#include <QKeyEvent>
|
||||
#include <QLocale>
|
||||
#include <QMenu>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
@@ -30,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);
|
||||
@@ -115,7 +116,7 @@ void LayerCell::configure(const ImageLayer &layer, bool enabled, int depth, bool
|
||||
m_dimensions->setText(m_editableText ? QStringLiteral("Text · Double-click to edit")
|
||||
: m_isAdjustment ? QStringLiteral("Adjustment · Double-click to edit")
|
||||
: layer.isGroup ? QStringLiteral("Folder")
|
||||
: QStringLiteral("%1 × %2 px").arg(std::lround(layer.size().width())).arg(std::lround(layer.size().height())));
|
||||
: sizeLabel(layer));
|
||||
m_dimensions->setToolTip(QString());
|
||||
if (layer.maskSourceID && session.document()) {
|
||||
const int source = indexOf(session.document()->layers, layer.maskSourceID);
|
||||
@@ -158,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();
|
||||
}
|
||||
|
||||
@@ -335,3 +336,18 @@ bool LayerCell::eventFilter(QObject *watched, QEvent *event)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
QString sizeLabel(const ImageLayer &layer)
|
||||
{
|
||||
const QString text = QStringLiteral("%1 × %2 px").arg(std::lround(layer.size().width())).arg(std::lround(layer.size().height()));
|
||||
const int pixels = layer.asset ? layer.asset->size().width() : 0;
|
||||
if (pixels <= 0)
|
||||
return text;
|
||||
// Across the width, as the Move bar's Scale field.
|
||||
const double percent = layer.size().width() / pixels * 100;
|
||||
if (std::abs(percent - 100) < 0.05)
|
||||
return text;
|
||||
// Swift's .number to one place at most, ties to even.
|
||||
const double rounded = std::nearbyint(percent * 10) / 10;
|
||||
return text + QStringLiteral(" · ") + QLocale().toString(rounded, 'f', rounded == std::trunc(rounded) ? 0 : 1) + QStringLiteral("%");
|
||||
}
|
||||
@@ -261,13 +261,3 @@ void NativeLayerList::changeEvent(QEvent *event)
|
||||
if (event->type() == QEvent::PaletteChange)
|
||||
QMetaObject::invokeMethod(this, &NativeLayerList::update, Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
void NativeLayerList::selectAllRows()
|
||||
{
|
||||
if (m_rows.empty())
|
||||
return;
|
||||
QSet<QUuid> ids;
|
||||
for (const ImageLayer &row : m_rows)
|
||||
ids.insert(row.id);
|
||||
m_session.selectLayers(ids, m_rows.front().id);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -215,8 +216,6 @@ public:
|
||||
QString dragToken() const { return m_dragToken; }
|
||||
|
||||
EditorSession &session() const { return m_session; }
|
||||
// The table's Select All: every row, the top one primary.
|
||||
void selectAllRows();
|
||||
void update();
|
||||
std::vector<LayerCell *> cells() const { return m_cells; }
|
||||
// The row under a point of this list, or -1.
|
||||
@@ -283,3 +282,6 @@ private:
|
||||
const QMimeData *m_dragData = nullptr;
|
||||
Qt::DropActions m_dragActions;
|
||||
};
|
||||
|
||||
// The size on the canvas and, once scaled, how much.
|
||||
QString sizeLabel(const ImageLayer &layer);
|
||||
@@ -101,7 +101,8 @@ TransformInspector::TransformInspector(EditorSession &session, QWidget *parent)
|
||||
m_rotation(new TransformValueField(QStringLiteral("°"), QString(), -360, 360, session, [this](double number) {
|
||||
change([number](LayerTransform &value) { value.rotation = std::fmod(number, 360.0); });
|
||||
})),
|
||||
m_sampling(new QComboBox(this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_apply(new QPushButton(QStringLiteral("Apply"), this))
|
||||
m_sampling(new QComboBox(this)), m_cancel(new QPushButton(QStringLiteral("Cancel"), this)), m_apply(new QPushButton(QStringLiteral("Apply"), this)),
|
||||
m_pendingButtons(new QWidget(this)), m_fade(new QGraphicsOpacityEffect(m_pendingButtons)), m_fadeIn(new QPropertyAnimation(m_fade, "opacity", this))
|
||||
{
|
||||
m_autoSelect->setObjectName(QStringLiteral("transformAutoSelect"));
|
||||
m_autoSelect->setToolTip(QStringLiteral("Select layers by clicking the canvas. When off, hold Ctrl to select a layer."));
|
||||
@@ -162,8 +163,25 @@ TransformInspector::TransformInspector(EditorSession &session, QWidget *parent)
|
||||
// Swift's configuredNativeShortcut: Return and Escape, as remapped.
|
||||
NativeShortcut::bind(*this, m_apply, m_cancel);
|
||||
connect(m_apply, &QPushButton::clicked, this, [this] { m_session.commitTransform(); });
|
||||
m_pendingButtons->setObjectName(QStringLiteral("pendingTransform"));
|
||||
auto *pending = new QHBoxLayout(m_pendingButtons);
|
||||
pending->setContentsMargins(0, 0, 0, 0);
|
||||
pending->setSpacing(12);
|
||||
pending->addWidget(m_cancel);
|
||||
pending->addWidget(m_apply);
|
||||
// Unseen, not hidden: Return and Escape still reach a drag.
|
||||
m_pendingButtons->setGraphicsEffect(m_fade);
|
||||
m_fade->setOpacity(0);
|
||||
m_pendingButtons->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
m_cancel->setFocusPolicy(Qt::NoFocus);
|
||||
m_apply->setFocusPolicy(Qt::NoFocus);
|
||||
// SwiftUI's easeOut over 0.12 seconds.
|
||||
QEasingCurve easeOut(QEasingCurve::BezierSpline);
|
||||
easeOut.addCubicBezierSegment(QPointF(0, 0), QPointF(0.58, 1), QPointF(1, 1));
|
||||
m_fadeIn->setEasingCurve(easeOut);
|
||||
m_fadeIn->setDuration(120);
|
||||
// Before the stretch ending the row, in Swift's order.
|
||||
for (QWidget *widget : std::initializer_list<QWidget *>{m_autoSelect, m_showControls, m_fields, m_cancel, m_apply})
|
||||
for (QWidget *widget : std::initializer_list<QWidget *>{m_autoSelect, m_showControls, m_fields, m_pendingButtons})
|
||||
row->insertWidget(row->count() - 1, widget, widget == m_fields ? 1 : 0);
|
||||
// The scroll view takes the spare room, as SwiftUI's does.
|
||||
row->setStretch(row->count() - 1, 0);
|
||||
@@ -278,4 +296,16 @@ void TransformInspector::synchronize()
|
||||
m_fields->widget()->setEnabled((m_session.canTransform() || editing) && !distorted);
|
||||
m_cancel->setEnabled(editing);
|
||||
m_apply->setEnabled(editing);
|
||||
// Typed values, Ctrl+T, a distortion wait; a handle drag applies.
|
||||
const bool pending = editing && m_session.transformEdit()->persistent;
|
||||
if (pending == m_pending)
|
||||
return;
|
||||
m_pending = pending;
|
||||
m_pendingButtons->setAttribute(Qt::WA_TransparentForMouseEvents, !pending);
|
||||
m_cancel->setFocusPolicy(pending ? Qt::StrongFocus : Qt::NoFocus);
|
||||
m_apply->setFocusPolicy(pending ? Qt::StrongFocus : Qt::NoFocus);
|
||||
m_fadeIn->stop();
|
||||
m_fadeIn->setStartValue(m_fade->opacity());
|
||||
m_fadeIn->setEndValue(pending ? 1.0 : 0.0);
|
||||
m_fadeIn->start();
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
#include "UI/ToolHeaderStyle.h"
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QGraphicsOpacityEffect>
|
||||
#include <QLineEdit>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QToolButton>
|
||||
@@ -76,4 +78,9 @@ private:
|
||||
QComboBox *const m_sampling;
|
||||
QPushButton *const m_cancel;
|
||||
QPushButton *const m_apply;
|
||||
// Cancel and Apply, seen only while an edit waits.
|
||||
QWidget *const m_pendingButtons;
|
||||
QGraphicsOpacityEffect *const m_fade;
|
||||
QPropertyAnimation *const m_fadeIn;
|
||||
bool m_pending = false;
|
||||
};
|
||||
+107
-40
@@ -55,7 +55,9 @@ private slots:
|
||||
void clearEdgesFadeAndClampedEdgesHold();
|
||||
void grayBlursAndBadInputIsRefused();
|
||||
void theSampleFollowsTheBrushSize();
|
||||
void aMaskSampleKeepsItsEdgeToneAndPlacement();
|
||||
void theSampleIsInTheLayersOwnPixels();
|
||||
void aHugeLayersSampleIsMadeCoarser();
|
||||
void aMaskSampleKeepsItsEdgeTone();
|
||||
void aBlurStrokeSoftensUnderTheBrushAsOneStep();
|
||||
void aBlurStrokeSoftensAMask();
|
||||
void aMaskBetweenPixelsTakesTheFraction();
|
||||
@@ -127,26 +129,107 @@ void BlurToolTests::grayBlursAndBadInputIsRefused()
|
||||
QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::gaussianBlur(step.convertToFormat(QImage::Format_RGB32), 2, true));
|
||||
}
|
||||
|
||||
// The expected sample: `image` at `margin` in, then blurred.
|
||||
QImage sampled(const QImage &image, QSizeF size, int margin, double sigma, bool mask, int tone = 0, double fit = 1)
|
||||
{
|
||||
QImage context = BrushRaster::context(int(std::ceil((size.width() + 2 * margin) * fit)), int(std::ceil((size.height() + 2 * margin) * fit)), mask);
|
||||
if (mask)
|
||||
context.fill(tone);
|
||||
QPainter painter(&context);
|
||||
BrushRaster::draw(image, QRectF(margin * fit, margin * fit, size.width() * fit, size.height() * fit), painter);
|
||||
painter.end();
|
||||
return PixelAdjust::gaussianBlur(context, sigma * fit, mask);
|
||||
}
|
||||
|
||||
void BlurToolTests::theSampleFollowsTheBrushSize()
|
||||
{
|
||||
EditorSession session;
|
||||
blurring(session, BrushSettings{.diameter = 60});
|
||||
const CanvasDocument document = session.document().value();
|
||||
const QImage sharp = edge(60, 40);
|
||||
// A tenth of the size, within 1.5 and 30.
|
||||
// A tenth of the size, from 1.5 to 30.
|
||||
for (const auto &[diameter, sigma] : {std::pair(60.0, 6.0), std::pair(5.0, 1.5), std::pair(900.0, 30.0)}) {
|
||||
BrushSettings settings = session.brushSettings();
|
||||
settings.diameter = diameter;
|
||||
session.setBrushSettings(settings);
|
||||
QCOMPARE(session.blurSample(document).value(), PixelAdjust::gaussianBlur(sharp, sigma, false));
|
||||
const std::unique_ptr<BrushStroke> stroke = session.makeRasterEdit(session.activeLayer().value(), settings);
|
||||
const BrushStroke::Clone sample = session.blurSample(*stroke).value();
|
||||
const int margin = int(std::ceil(3 * sigma));
|
||||
QCOMPARE(sample.image, sampled(sharp, QSizeF(60, 40), margin, sigma, false));
|
||||
QCOMPARE(sample.placed, QRectF(-margin, -margin, 60 + 2 * margin, 40 + 2 * margin));
|
||||
QVERIFY(sample.inGrid);
|
||||
}
|
||||
// No layer, nothing to soften.
|
||||
session.selectLayer(std::nullopt);
|
||||
QVERIFY(!session.blurSample(document));
|
||||
QVERIFY(!session.blurSample(document, true));
|
||||
// A blank layer or missing mask has nothing to soften.
|
||||
session.addBlankLayer();
|
||||
const BrushSettings settings = session.brushSettings();
|
||||
QVERIFY(!session.blurSample(*session.makeRasterEdit(session.activeLayer().value(), settings)));
|
||||
QVERIFY(!session.blurSample(BrushStroke(session.activeLayer().value(), true, settings, QSizeF(60, 40))));
|
||||
// So a Blur stroke there never begins.
|
||||
session.beginBrush(QPointF(30, 20));
|
||||
QVERIFY(!session.brushStroke());
|
||||
}
|
||||
|
||||
void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement()
|
||||
void BlurToolTests::theSampleIsInTheLayersOwnPixels()
|
||||
{
|
||||
// Twice as fine as the canvas: the softening doubles.
|
||||
EditorSession session;
|
||||
blurring(session, BrushSettings{.diameter = 20});
|
||||
const QImage fine = edge(120, 80);
|
||||
session.insert(ImportedImage(fine, fine, QStringLiteral("Fine")));
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {0, 0}, .size = {60, 40}}; });
|
||||
const BrushSettings settings = session.brushSettings();
|
||||
BrushStroke stroke(session.activeLayer().value(), false, settings, QSizeF(60, 40));
|
||||
QCOMPARE(stroke.sourceRect, QRectF(0, 0, 120, 80));
|
||||
BrushStroke::Clone sample = session.blurSample(stroke).value();
|
||||
QCOMPARE(sample.image, sampled(fine, QSizeF(120, 80), 12, 4, false));
|
||||
QCOMPARE(sample.placed, QRectF(-12, -12, 144, 104));
|
||||
// Turned, the softening is the same in its own pixels.
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.rotation = 45; });
|
||||
BrushStroke turned(session.activeLayer().value(), false, settings, QSizeF(60, 40));
|
||||
sample = session.blurSample(turned).value();
|
||||
QCOMPARE(sample.image, sampled(fine, QSizeF(120, 80), 12, 4, false));
|
||||
QCOMPARE(sample.placed, turned.sourceRect.adjusted(-12, -12, 12, 12));
|
||||
// A tiny layer caps the softening at half its side.
|
||||
session.setBrushSettings(BrushSettings{.diameter = 60});
|
||||
QImage tiny = BrushRaster::context(4, 2, false);
|
||||
tiny.fill(Qt::red);
|
||||
BrushStroke small(ImageLayer(ImportedImage(tiny, tiny, QStringLiteral("Tiny")), QPointF(10, 10)), false, settings, QSizeF(60, 40));
|
||||
sample = session.blurSample(small).value();
|
||||
QCOMPARE(sample.image, sampled(tiny, QSizeF(4, 2), 6, 2, false));
|
||||
QCOMPARE(sample.placed, small.sourceRect.adjusted(-6, -6, 6, 6));
|
||||
}
|
||||
|
||||
void BlurToolTests::aHugeLayersSampleIsMadeCoarser()
|
||||
{
|
||||
// Sixteen million pixels at most, or four canvases.
|
||||
EditorSession session;
|
||||
session.createDocument(100, 100);
|
||||
const QImage huge = edge(4200, 3900);
|
||||
session.insert(ImportedImage(huge, huge, QStringLiteral("Huge")));
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {0, 0}, .size = {420, 390}}; });
|
||||
session.setBrushSettings(BrushSettings{.diameter = 15});
|
||||
const BrushSettings settings = session.brushSettings();
|
||||
BrushStroke stroke(session.activeLayer().value(), false, settings, QSizeF(100, 100));
|
||||
QCOMPARE(stroke.sourceRect.size(), QSizeF(4200, 3900));
|
||||
// A tenth as large: softening 15, margin 45.
|
||||
const BrushStroke::Clone sample = session.blurSample(stroke).value();
|
||||
QCOMPARE(sample.placed, stroke.sourceRect.adjusted(-45, -45, 45, 45));
|
||||
const double fit = std::sqrt(16'000'000.0 / (4290.0 * 3990.0));
|
||||
QCOMPARE(sample.image.size(), QSize(4148, 3858));
|
||||
QCOMPARE(sample.image, sampled(huge, QSizeF(4200, 3900), 45, 15, false, 0, fit));
|
||||
// A larger canvas lifts the budget: four of them.
|
||||
EditorSession roomy;
|
||||
roomy.createDocument(2100, 2100);
|
||||
roomy.insert(ImportedImage(huge, huge, QStringLiteral("Huge")));
|
||||
const QUuid roomyID = roomy.activeLayerID().value();
|
||||
rewrite(roomy, [&](ProjectSnapshot &snapshot) { record(snapshot, roomyID).transform = LayerTransform{.origin = {0, 0}, .size = {420, 390}}; });
|
||||
roomy.setBrushSettings(settings);
|
||||
BrushStroke whole(roomy.activeLayer().value(), false, settings, QSizeF(2100, 2100));
|
||||
QCOMPARE(roomy.blurSample(whole).value().image.size(), QSize(4290, 3990));
|
||||
}
|
||||
|
||||
void BlurToolTests::aMaskSampleKeepsItsEdgeTone()
|
||||
{
|
||||
EditorSession session;
|
||||
blurring(session, BrushSettings{.diameter = 20});
|
||||
@@ -154,7 +237,7 @@ void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement()
|
||||
// A white mask with a black bar, placed mid-layer.
|
||||
QImage mask(20, 20, QImage::Format_Grayscale8);
|
||||
mask.fill(255);
|
||||
for (int y = 0; y < 20; ++y)
|
||||
for (int y = 2; y < 18; ++y)
|
||||
for (int x = 8; x < 12; ++x)
|
||||
mask.scanLine(y)[x] = 0;
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) {
|
||||
@@ -162,34 +245,18 @@ void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement()
|
||||
record(snapshot, id).maskPlacement = LayerTransform{.origin = {20, 10}, .size = {20, 20}};
|
||||
record(snapshot, id).maskLinked = false;
|
||||
});
|
||||
const QImage sample = session.blurSample(session.document().value(), true).value();
|
||||
QCOMPARE(sample.format(), QImage::Format_Grayscale8);
|
||||
QImage expected(60, 40, QImage::Format_Grayscale8);
|
||||
BrushStroke stroke(session.activeLayer().value(), true, session.brushSettings(), QSizeF(60, 40));
|
||||
const BrushStroke::Clone sample = session.blurSample(stroke).value();
|
||||
QCOMPARE(sample.image.format(), QImage::Format_Grayscale8);
|
||||
// Past its pixels the mask keeps its edge's tone: white.
|
||||
expected.fill(255);
|
||||
QPainter painter(&expected);
|
||||
painter.drawImage(QRectF(20, 10, 20, 20), mask);
|
||||
painter.end();
|
||||
QCOMPARE(sample, PixelAdjust::gaussianBlur(expected, 2, true));
|
||||
QVERIFY(sample.constScanLine(20)[30] < 128 && sample.constScanLine(2)[2] == 255);
|
||||
// Turned, the black box turns with the mask.
|
||||
EditorSession turned;
|
||||
turned.createDocument(100, 60);
|
||||
const QImage wide = edge(100, 60);
|
||||
turned.insert(ImportedImage(wide, wide, QStringLiteral("Edge")));
|
||||
const QUuid wideID = turned.activeLayerID().value();
|
||||
QImage white(40, 40, QImage::Format_Grayscale8);
|
||||
white.fill(255);
|
||||
rewrite(turned, [&](ProjectSnapshot &snapshot) {
|
||||
setMask(snapshot, wideID, LayerMask::assetFrom(white));
|
||||
record(snapshot, wideID).maskPlacement = LayerTransform{.origin = {30, 10}, .size = {40, 40}, .rotation = 30};
|
||||
record(snapshot, wideID).maskLinked = false;
|
||||
});
|
||||
turned.setBrushSettings(BrushSettings{.diameter = 5});
|
||||
const QImage soft = turned.blurSample(turned.document().value(), true).value();
|
||||
// Past the true box, inside one turned the other way.
|
||||
QVERIFY2(soft.constScanLine(36)[73] > 240, qPrintable(QString::number(soft.constScanLine(36)[73])));
|
||||
QVERIFY2(soft.constScanLine(24)[27] > 240, qPrintable(QString::number(soft.constScanLine(24)[27])));
|
||||
QCOMPARE(sample.image, sampled(mask, QSizeF(20, 20), 6, 2, true, 255));
|
||||
QCOMPARE(sample.placed, QRectF(-6, -6, 32, 32));
|
||||
QVERIFY(sample.image.constScanLine(16)[16] < 128 && sample.image.constScanLine(1)[1] == 255);
|
||||
// A black edge keeps black past it.
|
||||
mask.fill(0);
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(mask)); });
|
||||
BrushStroke black(session.activeLayer().value(), true, session.brushSettings(), QSizeF(60, 40));
|
||||
QCOMPARE(session.blurSample(black).value().image, sampled(mask, QSizeF(20, 20), 6, 2, true, 0));
|
||||
}
|
||||
|
||||
void BlurToolTests::aBlurStrokeSoftensUnderTheBrushAsOneStep()
|
||||
@@ -260,7 +327,7 @@ void BlurToolTests::aMaskBetweenPixelsTakesTheFraction()
|
||||
for (int y = 0; y < 20; ++y)
|
||||
for (int x = 0; x < 80; ++x)
|
||||
steps.scanLine(y)[x] = x < 21 ? 0 : 200;
|
||||
paint.clone = BrushStroke::Clone{steps, QSizeF(0, 0)};
|
||||
paint.clone = documentClone(steps, QSizeF(0, 0));
|
||||
paint.isBlur = true;
|
||||
paint.append(QPointF(21, 10));
|
||||
const QImage tile = paint.patches().at(0).image;
|
||||
@@ -280,7 +347,7 @@ void BlurToolTests::aMaskPastTheCanvasEdgeKeepsItsTone()
|
||||
white.fill(255);
|
||||
layer.mask = LayerMask(LayerMask::assetFrom(white));
|
||||
BrushStroke paint(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20));
|
||||
paint.clone = BrushStroke::Clone{white, QSizeF(0, 0)};
|
||||
paint.clone = documentClone(white, QSizeF(0, 0));
|
||||
paint.isBlur = true;
|
||||
paint.append(QPointF(0, 10));
|
||||
const QImage tile = paint.patches().at(0).image;
|
||||
@@ -291,7 +358,7 @@ void BlurToolTests::aMaskPastTheCanvasEdgeKeepsItsTone()
|
||||
black.fill(0);
|
||||
layer.mask = LayerMask(LayerMask::assetFrom(black));
|
||||
BrushStroke over(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20));
|
||||
over.clone = BrushStroke::Clone{white, QSizeF(0, 0)};
|
||||
over.clone = documentClone(white, QSizeF(0, 0));
|
||||
over.isBlur = true;
|
||||
over.append(QPointF(0, 10));
|
||||
QCOMPARE(int(over.patches().at(0).image.constScanLine(10)[0]), 128);
|
||||
|
||||
@@ -10,6 +10,12 @@ inline BrushSettings brush(double diameter, double hardness, double red, double
|
||||
return BrushSettings{.diameter = diameter, .hardness = hardness, .red = red, .green = green, .blue = blue, .opacity = opacity};
|
||||
}
|
||||
|
||||
// A document-pixel clone, copied from `offset` away.
|
||||
inline BrushStroke::Clone documentClone(const QImage &image, QSizeF offset = QSizeF(0, 0))
|
||||
{
|
||||
return {image, QRectF(-offset.width(), -offset.height(), image.width(), image.height()), false};
|
||||
}
|
||||
|
||||
inline ImageLayer blankLayer(int width, int height)
|
||||
{
|
||||
return ImageLayer(QStringLiteral("Layer 1"), QSizeF(width, height));
|
||||
|
||||
@@ -68,6 +68,7 @@ private slots:
|
||||
void theActiveLayerAloneOrEveryVisibleLayerIsSampled();
|
||||
void aMaskIsNeverCloned();
|
||||
void aScaledLayerSamplesThroughItsGrid();
|
||||
void aGridCloneIsScaledOntoItsPlace();
|
||||
void aLayerBetweenPixelsSamplesTheFraction();
|
||||
void aQuarterPixelWeighsItsNeighbours();
|
||||
void opacityAndTheSelectionLimitTheClone();
|
||||
@@ -249,7 +250,7 @@ void CloneStampTests::aScaledLayerSamplesThroughItsGrid()
|
||||
ImageLayer layer(ImportedImage(clear, clear, QStringLiteral("Scaled")), QPointF(0, 0));
|
||||
layer.transform.size = QSizeF(40, 20);
|
||||
BrushStroke paint(layer, false, brush(30, 1, 0, 0, 0), QSizeF(80, 20));
|
||||
paint.clone = BrushStroke::Clone{edged(80, 20, 31), QSizeF(10, 0)};
|
||||
paint.clone = documentClone(edged(80, 20, 31), QSizeF(10, 0));
|
||||
paint.append(QPointF(21, 10));
|
||||
const QImage tile = paint.patches().at(0).image;
|
||||
// Grid pixel 10 sits at document 21, sampling 31.
|
||||
@@ -258,13 +259,25 @@ void CloneStampTests::aScaledLayerSamplesThroughItsGrid()
|
||||
QCOMPARE(pixel(tile, 11, 5), bluePixel);
|
||||
}
|
||||
|
||||
void CloneStampTests::aGridCloneIsScaledOntoItsPlace()
|
||||
{
|
||||
// Half as many pixels as their place: each spans two.
|
||||
ImageLayer layer(QStringLiteral("Blank"), QSizeF(40, 20));
|
||||
BrushStroke paint(layer, false, brush(20, 1, 0, 0, 0), QSizeF(40, 20));
|
||||
paint.clone = BrushStroke::Clone{edged(20, 10, 10), QRectF(0, 0, 40, 20), true};
|
||||
paint.append(QPointF(15, 10));
|
||||
const QImage tile = paint.patches().at(0).image;
|
||||
QCOMPARE(pixel(tile, 17, 10), redPixel);
|
||||
QCOMPARE(pixel(tile, 22, 10), bluePixel);
|
||||
}
|
||||
|
||||
void CloneStampTests::aLayerBetweenPixelsSamplesTheFraction()
|
||||
{
|
||||
// Half a pixel right: each grid centre falls between two.
|
||||
ImageLayer layer(QStringLiteral("Blank"), QSizeF(40, 20));
|
||||
layer.transform.origin = QPointF(0.5, 0);
|
||||
BrushStroke paint(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 20));
|
||||
paint.clone = BrushStroke::Clone{edged(80, 20, 31), QSizeF(3, 0)};
|
||||
paint.clone = documentClone(edged(80, 20, 31), QSizeF(3, 0));
|
||||
paint.append(QPointF(28, 10));
|
||||
const QPointF centre = paint.pixelToDocument.map(QPointF(28.5, 10.5));
|
||||
QCOMPARE(centre, QPointF(28, 10.5));
|
||||
@@ -279,7 +292,7 @@ void CloneStampTests::aLayerBetweenPixelsSamplesTheFraction()
|
||||
QImage rows = BrushRaster::context(40, 40, false);
|
||||
rows.fill(QColor(255, 0, 0));
|
||||
QPainter(&rows).fillRect(QRect(0, 12, 40, 28), QColor(0, 0, 255));
|
||||
down.clone = BrushStroke::Clone{rows, QSizeF(0, 0)};
|
||||
down.clone = documentClone(rows, QSizeF(0, 0));
|
||||
down.append(QPointF(20, 12));
|
||||
const QImage lowTile = down.patches().at(0).image;
|
||||
const int row = int(std::floor(down.pixelToDocument.inverted().map(QPointF(20, 12)).y()));
|
||||
@@ -297,7 +310,7 @@ void CloneStampTests::aQuarterPixelWeighsItsNeighbours()
|
||||
return std::abs(pixel[0] - red) <= 2 && pixel[1] == 0 && std::abs(pixel[2] - blue) <= 2 && pixel[3] == 255;
|
||||
};
|
||||
BrushStroke across(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 40));
|
||||
across.clone = BrushStroke::Clone{edged(80, 40, 31), QSizeF(0, 0)};
|
||||
across.clone = documentClone(edged(80, 40, 31), QSizeF(0, 0));
|
||||
across.append(QPointF(31, 12));
|
||||
QCOMPARE(across.pixelToDocument.map(QPointF(31.5, 12.5)), QPointF(30.75, 12.25));
|
||||
const std::vector<int> mixed = pixel(across.patches().at(0).image, 31, 12);
|
||||
@@ -307,7 +320,7 @@ void CloneStampTests::aQuarterPixelWeighsItsNeighbours()
|
||||
rows.fill(QColor(255, 0, 0));
|
||||
QPainter(&rows).fillRect(QRect(0, 12, 80, 28), QColor(0, 0, 255));
|
||||
BrushStroke down(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 40));
|
||||
down.clone = BrushStroke::Clone{rows, QSizeF(0, 0)};
|
||||
down.clone = documentClone(rows, QSizeF(0, 0));
|
||||
down.append(QPointF(20, 12));
|
||||
const std::vector<int> stacked = pixel(down.patches().at(0).image, 20, 12);
|
||||
QVERIFY2(near(stacked, 64, 191), qPrintable(QString("%1 %2").arg(stacked[0]).arg(stacked[2])));
|
||||
@@ -321,7 +334,7 @@ void CloneStampTests::opacityAndTheSelectionLimitTheClone()
|
||||
over.fill(QColor(255, 0, 0));
|
||||
BrushStroke half(ImageLayer(ImportedImage(under, under, QStringLiteral("Blue")), QPointF(0, 0)), false, brush(10, 1, 0, 0, 0, 0.5),
|
||||
QSizeF(40, 20));
|
||||
half.clone = BrushStroke::Clone{over, QSizeF(0, 0)};
|
||||
half.clone = documentClone(over, QSizeF(0, 0));
|
||||
half.append(QPointF(10, 10));
|
||||
// Each byte is rounded once: 127.5 and 127.5 make 128.
|
||||
QCOMPARE(pixel(half.patches().at(0).image, 10, 10), (std::vector<int>{128, 0, 128, 255}));
|
||||
@@ -337,7 +350,7 @@ void CloneStampTests::opacityAndTheSelectionLimitTheClone()
|
||||
painter.setCompositionMode(QPainter::CompositionMode_Clear);
|
||||
painter.fillRect(QRect(0, 0, 40, 5), Qt::transparent);
|
||||
}
|
||||
bounded.clone = BrushStroke::Clone{sample, QSizeF(0, 0)};
|
||||
bounded.clone = documentClone(sample, QSizeF(0, 0));
|
||||
bounded.append(QPointF(10, 8));
|
||||
const QImage tile = bounded.patches().at(0).image;
|
||||
QCOMPARE(pixel(tile, 8, 8), redPixel);
|
||||
|
||||
@@ -36,7 +36,7 @@ private slots:
|
||||
void hoverOverRowsAndCtrlKeepTheCursorRight();
|
||||
void aSpentPressArmsNothingLater();
|
||||
void aMaskPressReleasedInAPopupIsOver();
|
||||
void altClicksOnAMaskThroughTheWindowChooseAndToggle();
|
||||
void altClicksOnAMaskThroughTheWindowShowItAloneAndBack();
|
||||
void anotherButtonsReleaseLeavesAMaskPress();
|
||||
};
|
||||
|
||||
@@ -270,7 +270,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);
|
||||
@@ -282,11 +282,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"));
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
#include "BrushFixtures.h"
|
||||
#include "CanvasFixtures.h"
|
||||
#include "UI/NativeLayerList.h"
|
||||
|
||||
// Swift's NativeResolutionPaintTests: paint lands at the layer's resolution.
|
||||
class NativeResolutionPaintTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void erasingAScaledDownImageKeepsItsResolution();
|
||||
void erasingAnUnevenOrTurnedLayerKeepsItsResolution();
|
||||
void canvasShowsTheErasedEdgeSmoothlyAfterScalingBackUp();
|
||||
void paintingABlankLayerThatWasScaledDownPaintsAtDocumentResolution();
|
||||
void cloneStampCopiesTheLayersOwnDetail();
|
||||
void cloneStampCarriesTheOffsetIntoTheLayer();
|
||||
void blurKeepsTheLayersResolution();
|
||||
void layersPanelSaysHowFarALayerIsScaled();
|
||||
};
|
||||
|
||||
namespace {
|
||||
std::vector<int> channel(const QImage &image, int which)
|
||||
{
|
||||
const QImage bytes = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
|
||||
std::vector<int> values;
|
||||
values.reserve(size_t(bytes.width()) * bytes.height());
|
||||
for (int y = 0; y < bytes.height(); ++y)
|
||||
for (int x = 0; x < bytes.width(); ++x)
|
||||
values.push_back(bytes.constScanLine(y)[x * 4 + which]);
|
||||
return values;
|
||||
}
|
||||
|
||||
// Where each row first drops below half alpha, from `start`.
|
||||
std::vector<int> edge(const std::vector<int> &values, int width, int first, int last, int start)
|
||||
{
|
||||
std::vector<int> outline;
|
||||
for (int y = first; y <= last; ++y) {
|
||||
int x = start;
|
||||
while (x < width && values[size_t(y) * width + x] >= 128)
|
||||
++x;
|
||||
outline.push_back(x);
|
||||
}
|
||||
return outline;
|
||||
}
|
||||
|
||||
int largestStep(const std::vector<int> &outline)
|
||||
{
|
||||
int largest = 0;
|
||||
for (size_t index = 1; index < outline.size(); ++index)
|
||||
largest = std::max(largest, std::abs(outline[index] - outline[index - 1]));
|
||||
return largest;
|
||||
}
|
||||
|
||||
// A red square of `source` pixels, shown `scale` times, centred.
|
||||
std::unique_ptr<EditorSession> session(double scale, int source = 1000)
|
||||
{
|
||||
auto made = std::make_unique<EditorSession>();
|
||||
made->createDocument(200, 200);
|
||||
made->insert(filled(source, source, qRgba(255, 0, 0, 255), QStringLiteral("Photo")));
|
||||
const QUuid id = made->activeLayerID().value();
|
||||
const double side = source * scale;
|
||||
rewrite(*made, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {100 - side / 2, 100 - side / 2}, .size = {side, side}}; });
|
||||
made->selectTool(NavigationTool::brush);
|
||||
made->setBrushMode(BrushToolMode::erase);
|
||||
made->setBrushSettings(brush(40, 1, 0, 0, 0));
|
||||
return made;
|
||||
}
|
||||
|
||||
// Black and white two-pixel checks left, red right, at 10%.
|
||||
std::unique_ptr<EditorSession> checkerSession()
|
||||
{
|
||||
QImage image = BrushRaster::context(1000, 1000, false);
|
||||
QPainter painter(&image);
|
||||
painter.fillRect(QRect(500, 0, 500, 1000), Qt::red);
|
||||
painter.fillRect(QRect(0, 0, 500, 1000), Qt::white);
|
||||
for (int y = 0; y < 1000; y += 2)
|
||||
for (int x = (y / 2) % 2 == 0 ? 0 : 2; x < 500; x += 4)
|
||||
painter.fillRect(QRect(x, y, 2, 2), Qt::black);
|
||||
painter.end();
|
||||
auto made = std::make_unique<EditorSession>();
|
||||
made->createDocument(200, 200);
|
||||
made->insert(ImportedImage(image, image, QStringLiteral("Checker")));
|
||||
const QUuid id = made->activeLayerID().value();
|
||||
rewrite(*made, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {50, 50}, .size = {100, 100}}; });
|
||||
return made;
|
||||
}
|
||||
|
||||
bool holdsBoth(const std::vector<int> &values)
|
||||
{
|
||||
return std::any_of(values.begin(), values.end(), [](int value) { return value < 30; })
|
||||
&& std::any_of(values.begin(), values.end(), [](int value) { return value > 225; });
|
||||
}
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::erasingAScaledDownImageKeepsItsResolution()
|
||||
{
|
||||
const auto made = session(0.1);
|
||||
made->beginBrush(QPointF(100, 100));
|
||||
made->finishBrush();
|
||||
QVERIFY(!made->brushError());
|
||||
const QImage image = made->activeLayer().value().asset.value().image();
|
||||
QCOMPARE(image.size(), QSize(1000, 1000));
|
||||
// A 40-pixel hole at 10%: 400 image pixels.
|
||||
const std::vector<int> values = channel(image, 3);
|
||||
QCOMPARE(values[500 * 1000 + 500], 0);
|
||||
QCOMPARE(values[500 * 1000 + 250], 255);
|
||||
QCOMPARE(values[500 * 1000 + 280], 255);
|
||||
QCOMPARE(values[500 * 1000 + 320], 0);
|
||||
// A coarse grid's outline would step by ten a row.
|
||||
QVERIFY(largestStep(edge(values, 1000, 330, 470, 250)) <= 4);
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::erasingAnUnevenOrTurnedLayerKeepsItsResolution()
|
||||
{
|
||||
// Swift's CPU fallback case: the twin's only path.
|
||||
for (const bool rotated : {false, true}) {
|
||||
const auto made = session(0.1);
|
||||
ImageLayer layer = made->activeLayer().value();
|
||||
layer.transform.size.setHeight(layer.transform.size.height() + 0.013);
|
||||
if (rotated)
|
||||
layer.transform.rotation = 30;
|
||||
BrushSettings settings = made->brushSettings();
|
||||
settings.erasing = true;
|
||||
BrushStroke stroke(layer, false, settings, QSizeF(200, 200));
|
||||
stroke.append(QPointF(100, 100));
|
||||
stroke.flush();
|
||||
const QImage image = stroke.paintSnapshot().asset.image();
|
||||
QCOMPARE(image.width(), 1000);
|
||||
const std::vector<int> values = channel(image, 3);
|
||||
QCOMPARE(values[500 * 1000 + 500], 0);
|
||||
QVERIFY2(largestStep(edge(values, 1000, 330, 470, 250)) <= 4, rotated ? "rotated" : "uneven");
|
||||
}
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::canvasShowsTheErasedEdgeSmoothlyAfterScalingBackUp()
|
||||
{
|
||||
Shown shown(QSize(400, 400), QSize(800, 600));
|
||||
shown.settle();
|
||||
EditorSession &session = shown.session;
|
||||
session.insert(filled(1000, 1000, qRgba(255, 0, 0, 255), QStringLiteral("Photo")));
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
LayerTransform small{.origin = {180, 180}, .size = {40, 40}};
|
||||
small.sampling = LayerSampling::high;
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = small; });
|
||||
session.zoom(1);
|
||||
shown.canvas->synchronizeDisplay();
|
||||
shown.canvas->grab();
|
||||
session.selectTool(NavigationTool::brush);
|
||||
session.setBrushMode(BrushToolMode::erase);
|
||||
session.setBrushSettings(brush(20, 1, 0, 0, 0));
|
||||
session.beginBrush(QPointF(200, 200));
|
||||
QVERIFY(session.finishBrushImmediately());
|
||||
shown.canvas->synchronizeDisplay();
|
||||
shown.canvas->grab();
|
||||
// Scaled back up as the Move tool would.
|
||||
LayerTransform large = session.activeLayer().value().transform;
|
||||
large.origin = {0, 0};
|
||||
large.size = {400, 400};
|
||||
session.selectTool(NavigationTool::move);
|
||||
session.beginTransform();
|
||||
session.previewTransform(large);
|
||||
session.commitTransform();
|
||||
shown.canvas->synchronizeDisplay();
|
||||
const QImage view = shown.canvas->grab().toImage();
|
||||
// A 500-pixel hole: 200 document pixels, a point each.
|
||||
const QPointF point = session.viewport.viewPoint(QPointF(200, 200), shown.documentSize());
|
||||
const int cx = int(point.x()), cy = int(point.y());
|
||||
const auto erased = [&](int x, int y) {
|
||||
const QColor colour = view.pixelColor(x, y);
|
||||
return colour.redF() < 0.6 || colour.greenF() > 0.6;
|
||||
};
|
||||
QVERIFY(erased(cx, cy));
|
||||
QVERIFY(!erased(cx - 120, cy));
|
||||
std::vector<int> outline;
|
||||
for (int y = cy - 70; y <= cy - 5; ++y) {
|
||||
int x = cx - 120;
|
||||
while (x < cx && !erased(x, y))
|
||||
++x;
|
||||
outline.push_back(x);
|
||||
}
|
||||
QVERIFY(largestStep(outline) <= 3);
|
||||
// The arc's widest row reaches a hundred points out.
|
||||
QVERIFY(std::abs(outline.back() - (cx - 100)) <= 2);
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::paintingABlankLayerThatWasScaledDownPaintsAtDocumentResolution()
|
||||
{
|
||||
EditorSession made;
|
||||
made.createDocument(200, 200);
|
||||
made.addBlankLayer();
|
||||
const QUuid id = made.activeLayerID().value();
|
||||
rewrite(made, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {90, 90}, .size = {20, 20}}; });
|
||||
made.selectTool(NavigationTool::brush);
|
||||
made.setBrushSettings(brush(40, 1, 1, 0, 0));
|
||||
made.beginBrush(QPointF(100, 100));
|
||||
made.finishBrush();
|
||||
const ImageLayer layer = made.activeLayer().value();
|
||||
const QImage image = layer.asset.value().image();
|
||||
// A layer pixel per document pixel, however small it was.
|
||||
QVERIFY(std::abs(image.width() - layer.transform.size.width()) <= 1);
|
||||
QVERIFY(image.width() >= 38);
|
||||
QCOMPARE(layer.transform.size, QSizeF(image.size()));
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::cloneStampCopiesTheLayersOwnDetail()
|
||||
{
|
||||
const auto made = checkerSession();
|
||||
made->selectTool(NavigationTool::cloneStamp);
|
||||
made->setCloneSource(QPointF(70, 100));
|
||||
made->setBrushSettings(brush(10, 1, 0, 0, 0));
|
||||
made->beginBrush(QPointF(130, 100));
|
||||
made->finishBrush();
|
||||
QVERIFY(!made->brushError());
|
||||
const QImage image = made->activeLayer().value().asset.value().image();
|
||||
QCOMPARE(image.width(), 1000);
|
||||
// The checks, too fine to see at 10%, arrive whole.
|
||||
const std::vector<int> values = channel(image, 1);
|
||||
QVERIFY(holdsBoth(std::vector<int>(values.begin() + 500 * 1000 + 790, values.begin() + 500 * 1000 + 810)));
|
||||
// Copied pixel for pixel, 600 image pixels to the left.
|
||||
for (int x = 790; x < 810; ++x)
|
||||
QCOMPARE(values[500 * 1000 + x], values[500 * 1000 + x - 600]);
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::cloneStampCarriesTheOffsetIntoTheLayer()
|
||||
{
|
||||
// Flipped, the source lies the other way in pixels.
|
||||
const auto made = checkerSession();
|
||||
const QUuid id = made->activeLayerID().value();
|
||||
rewrite(*made, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.flipX = true; });
|
||||
BrushStroke stroke(made->activeLayer().value(), false, brush(10, 1, 0, 0, 0), QSizeF(200, 200));
|
||||
QCOMPARE(stroke.gridRect(QRectF(0, 0, 1000, 1000), QSizeF(-60, 0)), QRectF(-600, 0, 1000, 1000));
|
||||
made->selectTool(NavigationTool::cloneStamp);
|
||||
made->setCloneSource(QPointF(130, 100));
|
||||
made->setBrushSettings(brush(10, 1, 0, 0, 0));
|
||||
made->beginBrush(QPointF(70, 100));
|
||||
made->finishBrush();
|
||||
const std::vector<int> values = channel(made->activeLayer().value().asset.value().image(), 1);
|
||||
for (int x = 790; x < 810; ++x)
|
||||
QCOMPARE(values[500 * 1000 + x], values[500 * 1000 + x - 600]);
|
||||
// Turned a quarter, across the canvas is down the pixels.
|
||||
rewrite(*made, [&](ProjectSnapshot &snapshot) {
|
||||
record(snapshot, id).transform.flipX = false;
|
||||
record(snapshot, id).transform.rotation = 90;
|
||||
});
|
||||
BrushStroke turned(made->activeLayer().value(), false, brush(10, 1, 0, 0, 0), QSizeF(200, 200));
|
||||
const QRectF shifted = turned.gridRect(turned.sourceRect, QSizeF(6, 0));
|
||||
QCOMPARE(shifted.topLeft() - turned.sourceRect.topLeft(), QPointF(0, 60));
|
||||
QCOMPARE(shifted.size(), turned.sourceRect.size());
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::blurKeepsTheLayersResolution()
|
||||
{
|
||||
const auto made = checkerSession();
|
||||
made->setBlurMode(BlurToolMode::blur);
|
||||
made->selectTool(NavigationTool::blur);
|
||||
made->setBrushSettings(brush(20, 1, 0, 0, 0));
|
||||
made->beginBrush(QPointF(100, 100));
|
||||
QVERIFY(made->brushStroke());
|
||||
made->finishBrush();
|
||||
QVERIFY(!made->brushError());
|
||||
const QImage image = made->activeLayer().value().asset.value().image();
|
||||
QCOMPARE(image.width(), 1000);
|
||||
const std::vector<int> values = channel(image, 1);
|
||||
// Under the brush the checks go gray; far off, not.
|
||||
for (int x = 480; x < 490; ++x)
|
||||
QVERIFY(values[500 * 1000 + x] > 40 && values[500 * 1000 + x] < 215);
|
||||
QVERIFY(holdsBoth(std::vector<int>(values.begin() + 100 * 1000 + 100, values.begin() + 100 * 1000 + 104)));
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::layersPanelSaysHowFarALayerIsScaled()
|
||||
{
|
||||
const auto made = session(0.05);
|
||||
ImageLayer layer = made->activeLayer().value();
|
||||
QCOMPARE(sizeLabel(layer), QString("50 × 50 px · 5%"));
|
||||
layer.transform.size = QSizeF(1000, 1000);
|
||||
QCOMPARE(sizeLabel(layer), QString("1000 × 1000 px"));
|
||||
layer.transform.size = QSizeF(25, 25);
|
||||
QCOMPARE(sizeLabel(layer), QString("25 × 25 px · 2.5%"));
|
||||
// Across the width; under a twentieth percent shows none.
|
||||
layer.transform.size = QSizeF(1000.45, 300);
|
||||
QCOMPARE(sizeLabel(layer), QString("1000 × 300 px"));
|
||||
layer.transform.size = QSizeF(1000.55, 1000);
|
||||
QCOMPARE(sizeLabel(layer), QString("1001 × 1000 px · 100.1%"));
|
||||
// Rounded to a whole number, no decimal shows.
|
||||
layer.transform.size = QSizeF(499.9, 300);
|
||||
QCOMPARE(sizeLabel(layer), QString("500 × 300 px · 50%"));
|
||||
layer.transform.size = QSizeF(2333, 2000);
|
||||
QCOMPARE(sizeLabel(layer), QString("2333 × 2000 px · 233.3%"));
|
||||
// A blank layer has no pixels to measure against.
|
||||
QCOMPARE(sizeLabel(ImageLayer(QStringLiteral("Blank"), QSizeF(30, 20))), QString("30 × 20 px"));
|
||||
// The row shows it.
|
||||
NativeLayerList list(*made);
|
||||
list.resize(300, 200);
|
||||
list.show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&list));
|
||||
QCOMPARE(list.cells().at(0)->findChild<QLabel *>("layerDimensions")->text(), QString("50 × 50 px · 5%"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(NativeResolutionPaintTests)
|
||||
#include "NativeResolutionPaintTests.moc"
|
||||
@@ -0,0 +1,148 @@
|
||||
#include "BrushFixtures.h"
|
||||
#include "SelectionFixtures.h"
|
||||
|
||||
// Why a stroke is refused, as Photoshop explains it.
|
||||
class PaintRefusalTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void eachRefusalSaysWhy();
|
||||
void theGradientAndTheSmearSayWhyToo();
|
||||
void aBusyEditorOrAnotherToolSaysNothing();
|
||||
};
|
||||
|
||||
namespace {
|
||||
std::unique_ptr<EditorSession> makeSession()
|
||||
{
|
||||
auto session = std::make_unique<EditorSession>();
|
||||
session->createDocument(100, 80, true);
|
||||
session->selectTool(NavigationTool::brush);
|
||||
return session;
|
||||
}
|
||||
|
||||
// The press's error, cleared for the next.
|
||||
std::optional<QString> pressed(EditorSession &session)
|
||||
{
|
||||
session.beginBrush(QPointF(10, 10));
|
||||
const std::optional<QString> error = session.brushError();
|
||||
session.setBrushError(std::nullopt);
|
||||
return error;
|
||||
}
|
||||
|
||||
void emptySelection(EditorSession &session)
|
||||
{
|
||||
session.applySelection(rectPath(QRectF(0, 0, 10, 10)), SelectionMode::replace, "Select");
|
||||
session.applySelection(rectPath(QRectF(0, 0, 100, 80)), SelectionMode::subtract, "Subtract");
|
||||
QVERIFY(session.selection().value().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
void PaintRefusalTests::eachRefusalSaysWhy()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
const QUuid layer = session->activeLayerID().value();
|
||||
QCOMPARE(session->paintRefusal(), std::nullopt);
|
||||
// Two layers, ahead of everything else.
|
||||
session->addGroup();
|
||||
const QUuid folder = session->activeLayerID().value();
|
||||
session->selectLayers({layer, folder}, folder);
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("Several layers are selected. Select just one to paint on it."));
|
||||
session->selectLayer(folder);
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("“Folder 1” is a folder, which has no pixels of its own. Paint on a layer inside it, or on the "
|
||||
"folder’s mask."));
|
||||
// Its mask paints; turned off, the mask says so.
|
||||
session->addLayerMask();
|
||||
QVERIFY(session->isMaskSelected() && session->canPaint());
|
||||
session->toggleLayerMask();
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("The layer mask is turned off. Shift-click its thumbnail to turn it on, then paint."));
|
||||
session->toggleLayerMask();
|
||||
session->selectLayer(layer);
|
||||
session->toggleLayerVisibility(layer);
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("“Layer 1” is hidden, or inside a hidden folder. Show it to paint on it."));
|
||||
session->toggleLayerVisibility(layer);
|
||||
// Inside a hidden folder too.
|
||||
QVERIFY(session->placeLayer(layer, folder));
|
||||
session->toggleLayerVisibility(folder);
|
||||
session->selectLayer(layer);
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("“Layer 1” is hidden, or inside a hidden folder. Show it to paint on it."));
|
||||
session->toggleLayerVisibility(folder);
|
||||
QVERIFY(session->canPaint());
|
||||
session->addLayerMask();
|
||||
QVERIFY(session->isMaskSelected());
|
||||
session->toggleLayerMask();
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("The layer mask is turned off. Shift-click its thumbnail to turn it on, then paint."));
|
||||
// The pixels still paint under a mask turned off.
|
||||
session->selectLayerTarget(layer, false);
|
||||
QCOMPARE(session->paintRefusal(), std::nullopt);
|
||||
session->toggleLayerMask();
|
||||
session->addAdjustment(AdjustmentKind::levels);
|
||||
session->setAdjustmentEditingID(std::nullopt);
|
||||
const QUuid adjustment = session->activeLayerID().value();
|
||||
session->selectLayerTarget(adjustment, false);
|
||||
QVERIFY(!session->isMaskSelected());
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("“Levels” is an adjustment layer, with no pixels to paint. Paint on its mask instead."));
|
||||
// Its mask paints, so the empty selection is why.
|
||||
if (!session->activeLayer().value().mask)
|
||||
session->addLayerMask();
|
||||
session->selectLayerTarget(adjustment, true);
|
||||
emptySelection(*session);
|
||||
QCOMPARE(pressed(*session),
|
||||
std::optional<QString>("Nothing is selected, so there’s nowhere to paint. Choose Select › Deselect (Ctrl+D) to paint anywhere."));
|
||||
session->deselect();
|
||||
session->selectLayer(layer);
|
||||
emptySelection(*session);
|
||||
QCOMPARE(pressed(*session),
|
||||
std::optional<QString>("Nothing is selected, so there’s nowhere to paint. Choose Select › Deselect (Ctrl+D) to paint anywhere."));
|
||||
QVERIFY(!session->brushStroke());
|
||||
session->deselect();
|
||||
session->beginBrush(QPointF(10, 10));
|
||||
QVERIFY(session->brushStroke() && !session->brushError());
|
||||
session->cancelBrush();
|
||||
}
|
||||
|
||||
void PaintRefusalTests::theGradientAndTheSmearSayWhyToo()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
session->insert(filledRed());
|
||||
emptySelection(*session);
|
||||
const QString empty("Nothing is selected, so there’s nowhere to paint. Choose Select › Deselect (Ctrl+D) to paint anywhere.");
|
||||
session->selectTool(NavigationTool::gradient);
|
||||
session->beginGradient(QPointF(5, 5));
|
||||
QCOMPARE(session->brushError(), std::optional(empty));
|
||||
QVERIFY(!session->gradientEdit());
|
||||
session->setBrushError(std::nullopt);
|
||||
session->selectTool(NavigationTool::blur);
|
||||
session->setBlurMode(BlurToolMode::smudge);
|
||||
QCOMPARE(pressed(*session), std::optional(empty));
|
||||
// The mask's own words come first.
|
||||
session->deselect();
|
||||
session->addLayerMask();
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("Smudge and Liquify work on a layer's pixels, not its mask."));
|
||||
session->toggleLayerMask();
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("Smudge and Liquify work on a layer's pixels, not its mask."));
|
||||
}
|
||||
|
||||
void PaintRefusalTests::aBusyEditorOrAnotherToolSaysNothing()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
emptySelection(*session);
|
||||
session->setIsProjectBusy(true);
|
||||
QCOMPARE(session->paintRefusal(), std::nullopt);
|
||||
session->setBrushError(QStringLiteral("Earlier"));
|
||||
session->beginBrush(QPointF(10, 10));
|
||||
QCOMPARE(session->brushError(), std::nullopt);
|
||||
session->setIsProjectBusy(false);
|
||||
// Spot Healing never paints a mask, and says nothing.
|
||||
session->deselect();
|
||||
session->addLayerMask();
|
||||
session->toggleLayerMask();
|
||||
session->selectTool(NavigationTool::spotHealing);
|
||||
session->setBrushError(QStringLiteral("Earlier"));
|
||||
session->beginBrush(QPointF(10, 10));
|
||||
QCOMPARE(session->brushError(), std::optional<QString>("Earlier"));
|
||||
session->selectTool(NavigationTool::move);
|
||||
session->beginBrush(QPointF(10, 10));
|
||||
QCOMPARE(session->brushError(), std::optional<QString>("Earlier"));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(PaintRefusalTests)
|
||||
#include "PaintRefusalTests.moc"
|
||||
@@ -121,17 +121,22 @@ void SelectMenuTests::selectAllInAFieldIsTheFields()
|
||||
QVERIFY(!bar.session().selection().has_value());
|
||||
QTest::keyClick(editor, Qt::Key_Escape);
|
||||
QTRY_VERIFY(!bar.session().renamingLayerID().has_value());
|
||||
// The list holds the keys: its rows, as Swift's table.
|
||||
// The list holds the keys: the canvas, never every layer.
|
||||
bar.session().addBlankLayer();
|
||||
NativeLayerList *list = bar.window.findChild<NativeLayerList *>();
|
||||
list->setFocus();
|
||||
QTRY_VERIFY(list->hasFocus());
|
||||
const std::vector<ImageLayer> &layers = bar.session().document().value().layers;
|
||||
bar.action("selectAll").trigger();
|
||||
QCOMPARE(bar.session().selectedLayerIDs(), (QSet<QUuid>{layers[0].id, layers[1].id}));
|
||||
QCOMPARE(bar.session().activeLayerID(), std::optional(layers[1].id));
|
||||
QVERIFY(!bar.session().selection().has_value());
|
||||
// Elsewhere it selects the canvas.
|
||||
const QSet<QUuid> selected = bar.session().selectedLayerIDs();
|
||||
const std::optional<QUuid> active = bar.session().activeLayerID();
|
||||
QCOMPARE(selected.size(), 1);
|
||||
QTest::keyClick(list, Qt::Key_A, Qt::ControlModifier);
|
||||
QCOMPARE(bar.session().selection().value().path.boundingRect(), QRectF(0, 0, 50, 50));
|
||||
QCOMPARE(bar.session().history.undoName(), QString("Select All"));
|
||||
QCOMPARE(bar.session().selectedLayerIDs(), selected);
|
||||
QCOMPARE(bar.session().activeLayerID(), active);
|
||||
QVERIFY(list->hasFocus());
|
||||
// Elsewhere it selects the canvas too.
|
||||
bar.session().deselect();
|
||||
bar.window.findChild<CanvasView *>()->setFocus();
|
||||
QTRY_VERIFY(!list->hasFocus());
|
||||
bar.action("selectAll").trigger();
|
||||
|
||||
@@ -11,6 +11,7 @@ private slots:
|
||||
void focusLossEndsAMarqueeDraftAndAnOutlineMove();
|
||||
void shiftDeleteBeginsAContentAwareFill();
|
||||
void aCtrlDragMovesSelectedPixelsAndCtrlArrowsNudgeThem();
|
||||
void shiftKeepsMovedPixelsOnALine();
|
||||
};
|
||||
|
||||
void SelectionCanvasTests::marqueeDragsSelectShiftAddsAltSubtractsAndAFreshShiftSquares()
|
||||
@@ -322,5 +323,37 @@ void SelectionCanvasTests::aCtrlDragMovesSelectedPixelsAndCtrlArrowsNudgeThem()
|
||||
QVERIFY(session.lassoDraft().has_value() && !session.pixelMove());
|
||||
}
|
||||
|
||||
void SelectionCanvasTests::shiftKeepsMovedPixelsOnALine()
|
||||
{
|
||||
Canvas shown;
|
||||
EditorSession &session = shown.session;
|
||||
QImage red = BrushRaster::context(400, 300, false);
|
||||
red.fill(Qt::red);
|
||||
session.insert(ImportedImage(red, red, "Red"));
|
||||
session.applySelection(rectPath(QRectF(20, 20, 40, 40)), SelectionMode::replace, "Select");
|
||||
shown.canvas->synchronizeDisplay();
|
||||
const auto box = [&] { return session.displayedSelection().value().path.boundingRect(); };
|
||||
shown.press(QPointF(40, 40), Qt::ControlModifier);
|
||||
QVERIFY(session.pixelMove());
|
||||
// Across: the drag went further that way.
|
||||
shown.move(QPointF(100, 55), Qt::ControlModifier | Qt::ShiftModifier);
|
||||
QCOMPARE(box(), QRectF(80, 20, 40, 40));
|
||||
// Down, then a tie keeps across.
|
||||
shown.move(QPointF(50, 100), Qt::ControlModifier | Qt::ShiftModifier);
|
||||
QCOMPARE(box(), QRectF(20, 80, 40, 40));
|
||||
shown.move(QPointF(10, 10), Qt::ControlModifier | Qt::ShiftModifier);
|
||||
QCOMPARE(box(), QRectF(-10, 20, 40, 40));
|
||||
// Lengths, not signs: left beats a shorter down.
|
||||
shown.move(QPointF(0, 50), Qt::ControlModifier | Qt::ShiftModifier);
|
||||
QCOMPARE(box(), QRectF(-20, 20, 40, 40));
|
||||
// Without Shift both ways.
|
||||
shown.move(QPointF(60, 55), Qt::ControlModifier);
|
||||
QCOMPARE(box(), QRectF(40, 35, 40, 40));
|
||||
shown.move(QPointF(70, 45), Qt::ControlModifier | Qt::ShiftModifier);
|
||||
shown.release(QPointF(70, 45), Qt::ControlModifier | Qt::ShiftModifier);
|
||||
QTRY_VERIFY(!session.pixelMove());
|
||||
QCOMPARE(session.selection().value().path.boundingRect(), QRectF(50, 20, 40, 40));
|
||||
}
|
||||
|
||||
QTEST_MAIN(SelectionCanvasTests)
|
||||
#include "SelectionCanvasTests.moc"
|
||||
@@ -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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ private slots:
|
||||
void polygonalCornersCanBeRemovedAndClosed();
|
||||
void antialiasingControlsEdgeCoverage();
|
||||
void selectAllInverseAndToolSwitchCancelsDraft();
|
||||
void inverseOfEverythingDeselects();
|
||||
void cursorBadgeFollowsModifiersButKeepsAnOutlinesStartingMode();
|
||||
void draggingMovesTheOutlineInWholePixelsAsOneUndo();
|
||||
void movingOffCanvasAndBackKeepsTheWholeShape();
|
||||
@@ -180,6 +181,23 @@ void SelectionTests::selectAllInverseAndToolSwitchCancelsDraft()
|
||||
QCOMPARE(session->history.undoCount(), count + 1);
|
||||
}
|
||||
|
||||
// Swift's: Select All then Inverse leaves nothing selected.
|
||||
void SelectionTests::inverseOfEverythingDeselects()
|
||||
{
|
||||
const auto session = selectionSession();
|
||||
session->selectAll();
|
||||
session->invertSelection();
|
||||
QVERIFY(!session->selection().has_value());
|
||||
QCOMPARE(session->history.undoName(), QString("Inverse"));
|
||||
session->undo();
|
||||
QVERIFY(!session->selection().value().isEmpty());
|
||||
// Everything but a corner inverts to that corner.
|
||||
lasso(*session, square(0, 0, 10));
|
||||
session->invertSelection();
|
||||
session->invertSelection();
|
||||
QCOMPARE(bounds(*session), QRectF(0, 0, 10, 10));
|
||||
}
|
||||
|
||||
void SelectionTests::cursorBadgeFollowsModifiersButKeepsAnOutlinesStartingMode()
|
||||
{
|
||||
const auto session = selectionSession();
|
||||
|
||||
@@ -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)
|
||||
@@ -143,6 +144,7 @@ compositor_test(ProjectManifestTests)
|
||||
compositor_test(NativeLayerListTests)
|
||||
compositor_test(LayerContextMenuTests)
|
||||
compositor_test(LayerContextMenuEdgeTests)
|
||||
compositor_test(NativeResolutionPaintTests)
|
||||
compositor_test(NavigationToolHeaderTests)
|
||||
compositor_test(NumericScrubTests)
|
||||
compositor_test(ScrubbableLabelsTests)
|
||||
@@ -300,6 +302,7 @@ compositor_test(LayerFlipTests)
|
||||
compositor_test(LayerMergeTests)
|
||||
compositor_test(PixelEditTests)
|
||||
compositor_test(PixelInvertTests)
|
||||
compositor_test(PaintRefusalTests)
|
||||
compositor_test(PixelMoveTests)
|
||||
compositor_test(SelectionTests)
|
||||
compositor_test(SelectionFeatherTests)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "UI/LayerIcons.h"
|
||||
#include "UI/TransformInspector.h"
|
||||
#include <QtTest>
|
||||
#include <cmath>
|
||||
|
||||
// The Move tool's bar: fields, settings, Apply and Cancel.
|
||||
namespace {
|
||||
@@ -57,6 +58,7 @@ private slots:
|
||||
void aMenuBorrowsTheFocusAndTheBarsKeysApplyOrCancel();
|
||||
void theLockIconFollowsThePalette();
|
||||
void theFieldsTakeTheSpareRoom();
|
||||
void cancelAndApplyShowOnlyWhileAnEditWaits();
|
||||
};
|
||||
|
||||
void TransformInspectorTests::theFieldsShowTheLayerAndTypingPreviews()
|
||||
@@ -301,10 +303,57 @@ void TransformInspectorTests::theFieldsTakeTheSpareRoom()
|
||||
const QRect natural = flip.geometry();
|
||||
// Swift's ScrollView takes the room; its content keeps its size.
|
||||
bar.header->resize(1600, bar.header->height());
|
||||
QTRY_VERIFY(bar.control<QPushButton>("applyTransform").geometry().right() > 1600 - 40);
|
||||
QTRY_VERIFY(bar.control<QWidget>("pendingTransform").geometry().right() > 1600 - 40);
|
||||
QCOMPARE(flip.geometry(), natural);
|
||||
QCOMPARE(flip.width(), flip.sizeHint().width());
|
||||
}
|
||||
|
||||
void TransformInspectorTests::cancelAndApplyShowOnlyWhileAnEditWaits()
|
||||
{
|
||||
Bar bar;
|
||||
QVERIFY(bar.active);
|
||||
QWidget &buttons = bar.control<QWidget>("pendingTransform");
|
||||
auto *fade = qobject_cast<QGraphicsOpacityEffect *>(buttons.graphicsEffect());
|
||||
QVERIFY(fade);
|
||||
QPushButton &apply = bar.control<QPushButton>("applyTransform");
|
||||
QPushButton &cancel = bar.control<QPushButton>("cancelTransform");
|
||||
const auto seen = [&](bool shown) {
|
||||
return fade->opacity() == (shown ? 1.0 : 0.0) && buttons.testAttribute(Qt::WA_TransparentForMouseEvents) == !shown
|
||||
&& apply.focusPolicy() == (shown ? Qt::StrongFocus : Qt::NoFocus) && cancel.focusPolicy() == apply.focusPolicy();
|
||||
};
|
||||
QVERIFY(seen(false));
|
||||
// SwiftUI's easeOut over 0.12 seconds; 12 points apart.
|
||||
auto *fadeIn = bar.header->findChild<QPropertyAnimation *>();
|
||||
QVERIFY(fadeIn && fadeIn->duration() == 120 && fadeIn->easingCurve().type() == QEasingCurve::BezierSpline);
|
||||
// CSS's ease-out, 0, 0, 0.58, 1: about 0.685 halfway.
|
||||
QVERIFY(std::abs(fadeIn->easingCurve().valueForProgress(0.5) - 0.685) < 0.01);
|
||||
QCOMPARE(apply.geometry().left() - cancel.geometry().right() - 1, 12);
|
||||
// A handle drag applies itself: nothing waits.
|
||||
bar.session.beginTransform(false);
|
||||
QVERIFY(apply.isEnabled());
|
||||
QTest::qWait(200);
|
||||
QVERIFY(seen(false));
|
||||
// Unseen, the click falls through; Return still applies.
|
||||
const QPoint middle = apply.mapTo(bar.header.get(), apply.rect().center());
|
||||
QTest::mouseClick(bar.header->windowHandle(), Qt::LeftButton, Qt::NoModifier, middle);
|
||||
QVERIFY(bar.session.transformEdit().has_value());
|
||||
QTest::keyClick(bar.header.get(), Qt::Key_Return);
|
||||
QVERIFY(!bar.session.transformEdit().has_value());
|
||||
// Typed values wait: the buttons fade in, a click applies.
|
||||
bar.session.beginTransform();
|
||||
QVERIFY(fade->opacity() < 1);
|
||||
QTRY_VERIFY(seen(true));
|
||||
QVERIFY(apply.isVisible() && apply.isEnabled());
|
||||
QTest::mouseClick(bar.header->windowHandle(), Qt::LeftButton, Qt::NoModifier, middle);
|
||||
QVERIFY(!bar.session.transformEdit().has_value());
|
||||
QTRY_VERIFY(seen(false));
|
||||
// Waiting again, Cancel drops it.
|
||||
bar.session.beginTransform();
|
||||
QTRY_VERIFY(seen(true));
|
||||
QTest::mouseClick(bar.header->windowHandle(), Qt::LeftButton, Qt::NoModifier, cancel.mapTo(bar.header.get(), cancel.rect().center()));
|
||||
QVERIFY(!bar.session.transformEdit().has_value());
|
||||
QTRY_VERIFY(seen(false));
|
||||
}
|
||||
|
||||
QTEST_MAIN(TransformInspectorTests)
|
||||
#include "TransformInspectorTests.moc"
|
||||
Reference in new issue
Block a user