Merge 13.11 (cc9bf95); Blur pieces and per-tile clone pieces on its grid sampling (13.16, part two)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 18:38:05 -04:00
commit c7e8bdfe8e
30 files changed
+910 -173

No files matched your search

+2 -2
View File
@@ -1,4 +1,4 @@
// Swift's BlurTool extension: what a Blur stroke paints. // Swift's BlurTool extension: what a Blur stroke paints.
public: public:
// The sharp layer or mask, document-sized; `render` softens parts. // The stroke's pixels or mask, sharp; `render` softens parts.
std::optional<BrushStroke::Clone> blurSample(const CanvasDocument &document, bool mask = false) const; std::optional<BrushStroke::Clone> blurSample(const BrushStroke &stroke) const;
+26 -35
View File
@@ -2,7 +2,6 @@
#include "Document/PixelAdjust.h" #include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h" #include "IO/ImageExporter.h"
#include "Logging.h" #include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <cmath> #include <cmath>
namespace { namespace {
@@ -39,46 +38,38 @@ std::function<QImage(const QRect &)> softened(const QImage &sharp, double sigma,
} }
} }
// Swift's BlurTool extension: the layer, or its mask, softened. // Swift's BlurTool extension: the layer's pixels, or mask, softened.
std::optional<BrushStroke::Clone> EditorSession::blurSample(const CanvasDocument &document, bool mask) const std::optional<BrushStroke::Clone> EditorSession::blurSample(const BrushStroke &stroke) const
{ {
const std::optional<ImageLayer> layer = activeLayer(); const ImageLayer &layer = stroke.layer;
if (!layer) const std::optional<ImportedImage> &asset = stroke.isMask ? (layer.mask ? std::optional(layer.mask->asset) : std::nullopt) : layer.asset;
if (!asset)
return std::nullopt; return std::nullopt;
// The bar's Radius, whatever the brush's size. // The bar's Radius, carried into the layer's pixels.
const double sigma = std::min(50.0, std::max(0.5, m_brushSettings.blurRadius)); 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(50.0, std::max(0.5, m_brushSettings.blurRadius)) / 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 { try {
if (mask) { QImage context = BrushRaster::context(width, height, stroke.isMask);
if (!layer->mask) // Past its pixels a mask keeps its edge tone.
return std::nullopt; if (stroke.isMask && layer.mask)
QImage context = BrushRaster::context(document.width, document.height, true); context.fill(int(std::round(LayerMask::background(layer.mask->asset.thumbnail) * 255)));
// 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 BrushStroke::Clone{context, QSizeF(0, 0), softened(context, sigma, true)};
}
if (!layer->asset)
return std::nullopt;
QImage context = BrushRaster::context(document.width, document.height, false);
QPainter painter(&context); QPainter painter(&context);
// A painted asset flattens here; the catch covers it too. // A painted asset flattens here; the catch covers it too.
const LayerTransform transform = displayedTransform(*layer); BrushRaster::draw(asset->image(), placed, painter);
LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {});
painter.end(); painter.end();
return BrushStroke::Clone{context, QSizeF(0, 0), softened(context, sigma, false)}; // Sharp; only the parts the brush reaches are softened.
return BrushStroke::Clone{context, region, true, softened(context, sigma * fit, stroke.isMask)};
} catch (const ExportError &error) { } catch (const ExportError &error) {
qCWarning(lcApp) << "the Blur tool cannot sample the layer:" << error.what(); qCWarning(lcApp) << "the Blur tool cannot sample the layer:" << error.what();
return std::nullopt; return std::nullopt;
+8
View File
@@ -58,6 +58,14 @@ LayerTransform BrushStroke::transform(const QRectF &bounds) const
return result; 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. // Swift's heal: the painted spot rebuilt from nearby texture.
void BrushStroke::heal() void BrushStroke::heal()
{ {
+5 -4
View File
@@ -281,10 +281,11 @@ QImage BrushStroke::clonePixels(qint64 key, const Tile &tile)
if (!m_clone) if (!m_clone)
return QImage(); return QImage();
const int width = int(tile.rect.width()), height = int(tile.rect.height()); const int width = int(tile.rect.width()), height = int(tile.rect.height());
// Tile pixels to clone pixels: grid, document, then offset. // Tile to sample pixels; each tile's piece is cut once.
const QTransform toWhole = QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument const QRectF &placed = m_clone->placed;
* QTransform::fromTranslate(m_clone->offset.width(), m_clone->offset.height()); const QTransform toWhole = QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * (m_clone->inGrid ? QTransform() : pixelToDocument)
// Swift's clonePiece: the part under the tile, cut once. * QTransform::fromTranslate(-placed.left(), -placed.top())
* QTransform::fromScale(m_clone->image.width() / placed.width(), m_clone->image.height() / placed.height());
if (!m_clonePieces.contains(key)) { if (!m_clonePieces.contains(key)) {
const QRect part = const QRect part =
toWhole.mapRect(QRectF(0, 0, width, height)).adjusted(-2, -2, 2, 2).toAlignedRect().intersected(m_clone->image.rect()); toWhole.mapRect(QRectF(0, 0, width, height)).adjusted(-2, -2, 2, 2).toAlignedRect().intersected(m_clone->image.rect());
+5 -2
View File
@@ -109,10 +109,11 @@ public:
qint64 pixelLimit = DocumentLimits::documentPixelBudget(); qint64 pixelLimit = DocumentLimits::documentPixelBudget();
// Limits every edit to the document selection; nil when none. // Limits every edit to the document selection; nil when none.
std::optional<SelectionClip> selectionClip; 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 { struct Clone {
QImage image; QImage image;
QSizeF offset; QRectF placed;
bool inGrid;
// Makes a part of `image`: Blur softens piece by piece. // Makes a part of `image`: Blur softens piece by piece.
std::function<QImage(const QRect &)> render; std::function<QImage(const QRect &)> render;
}; };
@@ -152,6 +153,8 @@ public:
QRectF committedBounds() const; QRectF committedBounds() const;
LayerTransform committedTransform() const; LayerTransform committedTransform() const;
LayerTransform transform(const QRectF &bounds) 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. // Spot Healing's end: the painted spot rebuilt from around it.
void heal(); void heal();
PaintSnapshot paintSnapshot() const; PaintSnapshot paintSnapshot() const;
+2 -2
View File
@@ -6,5 +6,5 @@ public:
std::optional<QSizeF> cloneStrokeOffset(QPointF point) const; std::optional<QSizeF> cloneStrokeOffset(QPointF point) const;
// The source for a brush at `point`: the canvas's crosshair. // The source for a brush at `point`: the canvas's crosshair.
std::optional<QPointF> cloneSamplePoint(QPointF point) const; std::optional<QPointF> cloneSamplePoint(QPointF point) const;
// What a stroke copies from, document-sized, taken as it starts. // What `stroke` copies, `offset` away: its pixels or the canvas.
std::optional<QImage> cloneSample(const CanvasDocument &document) const; std::optional<BrushStroke::Clone> cloneSample(const CanvasDocument &document, const BrushStroke &stroke, QSizeF offset) const;
+9 -10
View File
@@ -1,7 +1,6 @@
#include "Document/EditorSession.h" #include "Document/EditorSession.h"
#include "IO/ImageExporter.h" #include "IO/ImageExporter.h"
#include "Logging.h" #include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <cmath> #include <cmath>
// Swift's CloneStamp extension: the source, its offset, the sample. // 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()); 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 { 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); QImage context = BrushRaster::context(document.width, document.height, false);
QPainter painter(&context); QPainter painter(&context);
// A painted asset flattens here; the catch covers it too. drawLiveComposite(document, painter);
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, {});
}
painter.end(); painter.end();
return context; return BrushStroke::Clone{context, QRectF(-offset.width(), -offset.height(), context.width(), context.height()), false, {}};
} catch (const ExportError &error) { } catch (const ExportError &error) {
qCWarning(lcApp) << "Clone Stamp cannot sample the canvas:" << error.what(); qCWarning(lcApp) << "Clone Stamp cannot sample the canvas:" << error.what();
return std::nullopt; return std::nullopt;
+44 -18
View File
@@ -37,6 +37,27 @@ bool EditorSession::canPaint() const
&& (m_isMaskSelected || !layer->adjustment); && (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. // 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 std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings, bool growsMask) const
{ {
@@ -74,27 +95,20 @@ void EditorSession::beginBrush(QPointF point)
} }
// Spot Healing and Clone Stamp rework pixels, never a mask. // Spot Healing and Clone Stamp rework pixels, never a mask.
const bool paintsMasks = m_tool == NavigationTool::brush || m_tool == NavigationTool::blur; 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; return;
if (!canPaint()) {
setBrushError(paintRefusal());
return;
}
const ImageLayer layer = activeLayer().value(); const ImageLayer layer = activeLayer().value();
std::optional<BrushStroke::Clone> clone; std::optional<QSizeF> sourceOffset;
if (m_tool == NavigationTool::cloneStamp) { if (m_tool == NavigationTool::cloneStamp) {
const std::optional<QSizeF> offset = cloneStrokeOffset(point); sourceOffset = cloneStrokeOffset(point);
if (!offset) { if (!sourceOffset) {
setBrushError(QStringLiteral("Alt-click where Clone Stamp should copy from first.")); setBrushError(QStringLiteral("Alt-click where Clone Stamp should copy from first."));
return; 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) {
clone = blurSample(m_document.value(), m_isMaskSelected);
if (!clone)
return;
} }
finishOpacityEdit(); finishOpacityEdit();
try { try {
@@ -107,9 +121,21 @@ void EditorSession::beginBrush(QPointF point)
settings.green = settings.red; settings.green = settings.red;
settings.blue = settings.red; settings.blue = settings.red;
} }
m_brushStroke = makeRasterEdit(layer, settings, m_tool == NavigationTool::brush); std::unique_ptr<BrushStroke> stroke = makeRasterEdit(layer, settings, m_tool == NavigationTool::brush);
m_brushStroke->setClone(clone); if (sourceOffset) {
m_brushStroke->isBlur = m_tool == NavigationTool::blur; stroke->setClone(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->setClone(blurSample(*stroke));
if (!stroke->clone())
return;
}
stroke->isBlur = m_tool == NavigationTool::blur;
m_brushStroke = std::move(stroke);
m_brushStroke->append(point); m_brushStroke->append(point);
m_brushAnchor = point; m_brushAnchor = point;
m_brushPointer = point; m_brushPointer = point;
+2
View File
@@ -1,6 +1,8 @@
// Swift's EditorSession+Brush extension: a stroke from press to commit. // Swift's EditorSession+Brush extension: a stroke from press to commit.
public: public:
bool canPaint() const; 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; std::unique_ptr<BrushStroke> makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings = BrushSettings(), bool growsMask = false) const;
void beginBrush(QPointF point); void beginBrush(QPointF point);
void continueBrush(QPointF point); void continueBrush(QPointF point);
+6 -1
View File
@@ -35,8 +35,13 @@ void EditorSession::setGradientSettings(const GradientSettings &settings)
void EditorSession::beginGradient(QPointF point) void EditorSession::beginGradient(QPointF point)
{ {
const std::optional<ImageLayer> layer = activeLayer(); const std::optional<ImageLayer> layer = activeLayer();
if (m_tool != NavigationTool::gradient || !(canPaint() || m_gradientEdit) || !layer) if (m_tool != NavigationTool::gradient || !layer)
return; 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. // 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) { if (m_gradientEdit && m_gradientEdit->raster->layer.id == layer->id && m_gradientEdit->raster->isMask == m_isMaskSelected) {
m_gradientEdit->start = point; m_gradientEdit->start = point;
+3 -1
View File
@@ -420,5 +420,7 @@ void EditorSession::invertSelection()
const std::optional<DocumentSelection> current = selection(); const std::optional<DocumentSelection> current = selection();
if (!m_document || !current) if (!m_document || !current)
return; 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"));
} }
+2 -3
View File
@@ -229,8 +229,7 @@ void EditorSession::beginWarp(QPointF point)
{ {
const std::optional<ImageLayer> layer = activeLayer(); const std::optional<ImageLayer> layer = activeLayer();
if (!canPaint() || m_isMaskSelected || !layer || !layer->asset || !m_document) { if (!canPaint() || m_isMaskSelected || !layer || !layer->asset || !m_document) {
if (m_isMaskSelected) setBrushError(m_isMaskSelected ? std::optional(QStringLiteral("Smudge and Liquify work on a layer's pixels, not its mask.")) : paintRefusal());
setBrushError(QStringLiteral("Smudge and Liquify work on a layer's pixels, not its mask."));
return; return;
} }
finishOpacityEdit(); finishOpacityEdit();
@@ -268,7 +267,7 @@ void EditorSession::finishWarp()
settings.hardness = 1; settings.hardness = 1;
settings.opacity = 1; settings.opacity = 1;
const std::unique_ptr<BrushStroke> stroke = makeRasterEdit(current, settings); const std::unique_ptr<BrushStroke> stroke = makeRasterEdit(current, settings);
stroke->setClone(BrushStroke::Clone{warp->image(), QSizeF(0, 0), {}}); stroke->setClone(BrushStroke::Clone{warp->image(), QRectF(QPointF(0, 0), warp->image().size()), false, {}});
stroke->replacesWithClone = true; stroke->replacesWithClone = true;
stroke->editName = rawValue(warp->mode); stroke->editName = rawValue(warp->mode);
// A point every twentieth of the tip covers every dab. // A point every twentieth of the tip covers every dab.
+9 -1
View File
@@ -85,7 +85,15 @@ bool CanvasView::moveSelectionTool(QPointF point, Qt::KeyboardModifiers modifier
{ {
if (m_pixelDragStart && m_session.document()) { if (m_pixelDragStart && m_session.document()) {
const QPointF pixel = m_session.viewport.documentPoint(point, m_session.document()->size()); 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(); updateCursor();
synchronizeDisplay(); synchronizeDisplay();
return true; return true;
-12
View File
@@ -2,20 +2,10 @@
#include "IO/RecentProjects.h" #include "IO/RecentProjects.h"
#include "Logging.h" #include "Logging.h"
#include "UI/KeyboardShortcuts.h" #include "UI/KeyboardShortcuts.h"
#include "UI/NativeLayerList.h"
#include <QApplication> #include <QApplication>
#include <QMenu> #include <QMenu>
namespace { 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. // An entry's name: Hue/Saturation is newHueSaturationAdjustment.
QString adjustmentName(AdjustmentKind kind) QString adjustmentName(AdjustmentKind kind)
{ {
@@ -186,8 +176,6 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
m_field->selectAll(); m_field->selectAll();
else if (InlineTextEditor *text = typedText()) else if (InlineTextEditor *text = typedText())
text->selectAll(); text->selectAll();
else if (NativeLayerList *list = focusedList())
list->selectAllRows();
else else
session().selectAll(); session().selectAll();
}); });
+17 -1
View File
@@ -4,6 +4,7 @@
#include <QApplication> #include <QApplication>
#include <QContextMenuEvent> #include <QContextMenuEvent>
#include <QKeyEvent> #include <QKeyEvent>
#include <QLocale>
#include <QMenu> #include <QMenu>
#include <QMouseEvent> #include <QMouseEvent>
#include <QPainter> #include <QPainter>
@@ -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_dimensions->setText(m_editableText ? QStringLiteral("Text · Double-click to edit")
: m_isAdjustment ? QStringLiteral("Adjustment · Double-click to edit") : m_isAdjustment ? QStringLiteral("Adjustment · Double-click to edit")
: layer.isGroup ? QStringLiteral("Folder") : 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()); m_dimensions->setToolTip(QString());
if (layer.maskSourceID && session.document()) { if (layer.maskSourceID && session.document()) {
const int source = indexOf(session.document()->layers, layer.maskSourceID); const int source = indexOf(session.document()->layers, layer.maskSourceID);
@@ -335,3 +336,18 @@ bool LayerCell::eventFilter(QObject *watched, QEvent *event)
return false; return false;
return true; 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("%");
}
-10
View File
@@ -261,13 +261,3 @@ void NativeLayerList::changeEvent(QEvent *event)
if (event->type() == QEvent::PaletteChange) if (event->type() == QEvent::PaletteChange)
QMetaObject::invokeMethod(this, &NativeLayerList::update, Qt::QueuedConnection); 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);
}
+3 -2
View File
@@ -215,8 +215,6 @@ public:
QString dragToken() const { return m_dragToken; } QString dragToken() const { return m_dragToken; }
EditorSession &session() const { return m_session; } EditorSession &session() const { return m_session; }
// The table's Select All: every row, the top one primary.
void selectAllRows();
void update(); void update();
std::vector<LayerCell *> cells() const { return m_cells; } std::vector<LayerCell *> cells() const { return m_cells; }
// The row under a point of this list, or -1. // The row under a point of this list, or -1.
@@ -281,3 +279,6 @@ private:
const QMimeData *m_dragData = nullptr; const QMimeData *m_dragData = nullptr;
Qt::DropActions m_dragActions; Qt::DropActions m_dragActions;
}; };
// The size on the canvas and, once scaled, how much.
QString sizeLabel(const ImageLayer &layer);
+32 -2
View File
@@ -109,7 +109,8 @@ TransformInspector::TransformInspector(EditorSession &session, QWidget *parent)
m_rotation(new TransformValueField(QStringLiteral("°"), QString(), -360, 360, session, [this] { finish(); }, [this](double number) { m_rotation(new TransformValueField(QStringLiteral("°"), QString(), -360, 360, session, [this] { finish(); }, [this](double number) {
change([number](LayerTransform &value) { value.rotation = std::fmod(number, 360.0); }); 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->setObjectName(QStringLiteral("transformAutoSelect"));
m_autoSelect->setToolTip(QStringLiteral("Select layers by clicking the canvas. Hold Ctrl to turn it the other way while you click.")); m_autoSelect->setToolTip(QStringLiteral("Select layers by clicking the canvas. Hold Ctrl to turn it the other way while you click."));
@@ -176,8 +177,25 @@ TransformInspector::TransformInspector(EditorSession &session, QWidget *parent)
// Swift's configuredNativeShortcut: Return and Escape, as remapped. // Swift's configuredNativeShortcut: Return and Escape, as remapped.
NativeShortcut::bind(*this, m_apply, m_cancel); NativeShortcut::bind(*this, m_apply, m_cancel);
connect(m_apply, &QPushButton::clicked, this, [this] { m_session.commitTransform(); }); 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. // 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); row->insertWidget(row->count() - 1, widget, widget == m_fields ? 1 : 0);
// The scroll view takes the spare room, as SwiftUI's does. // The scroll view takes the spare room, as SwiftUI's does.
row->setStretch(row->count() - 1, 0); row->setStretch(row->count() - 1, 0);
@@ -301,4 +319,16 @@ void TransformInspector::synchronize()
m_fields->widget()->setEnabled((m_session.canTransform() || editing) && !distorted); m_fields->widget()->setEnabled((m_session.canTransform() || editing) && !distorted);
m_cancel->setEnabled(editing); m_cancel->setEnabled(editing);
m_apply->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();
} }
+7
View File
@@ -3,7 +3,9 @@
#include "UI/ToolHeaderStyle.h" #include "UI/ToolHeaderStyle.h"
#include <QCheckBox> #include <QCheckBox>
#include <QComboBox> #include <QComboBox>
#include <QGraphicsOpacityEffect>
#include <QLineEdit> #include <QLineEdit>
#include <QPropertyAnimation>
#include <QPushButton> #include <QPushButton>
#include <QScrollArea> #include <QScrollArea>
#include <QToolButton> #include <QToolButton>
@@ -80,4 +82,9 @@ private:
QComboBox *const m_sampling; QComboBox *const m_sampling;
QPushButton *const m_cancel; QPushButton *const m_cancel;
QPushButton *const m_apply; 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;
}; };
+131 -47
View File
@@ -55,7 +55,9 @@ private slots:
void clearEdgesFadeAndClampedEdgesHold(); void clearEdgesFadeAndClampedEdgesHold();
void grayBlursAndBadInputIsRefused(); void grayBlursAndBadInputIsRefused();
void theSampleFollowsTheRadius(); void theSampleFollowsTheRadius();
void aMaskSampleKeepsItsEdgeToneAndPlacement(); void theSampleIsInTheLayersOwnPixels();
void aHugeLayersSampleIsMadeCoarser();
void aMaskSampleKeepsItsEdgeTone();
void aBlurStrokeSoftensUnderTheBrushAsOneStep(); void aBlurStrokeSoftensUnderTheBrushAsOneStep();
void aBlurStrokeSoftensAMask(); void aBlurStrokeSoftensAMask();
void aMaskBetweenPixelsTakesTheFraction(); void aMaskBetweenPixelsTakesTheFraction();
@@ -127,29 +129,129 @@ void BlurToolTests::grayBlursAndBadInputIsRefused()
QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::gaussianBlur(step.convertToFormat(QImage::Format_RGB32), 2, true)); QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::gaussianBlur(step.convertToFormat(QImage::Format_RGB32), 2, true));
} }
// The expected sharp sample: `image` at `margin` in.
QImage framed(const QImage &image, QSizeF size, int margin, 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 context;
}
// The expected softened sample, blurred whole.
QImage sampled(const QImage &image, QSizeF size, int margin, double sigma, bool mask, int tone = 0, double fit = 1)
{
return PixelAdjust::gaussianBlur(framed(image, size, margin, mask, tone, fit), sigma * fit, mask);
}
// The sample softened whole, through its pieces' function.
QImage soft(const BrushStroke::Clone &sample)
{
return sample.render(sample.image.rect());
}
void BlurToolTests::theSampleFollowsTheRadius() void BlurToolTests::theSampleFollowsTheRadius()
{ {
EditorSession session; EditorSession session;
blurring(session, BrushSettings{.diameter = 60}); blurring(session, BrushSettings{.diameter = 60, .blurRadius = 6});
const CanvasDocument document = session.document().value();
const QImage sharp = edge(60, 40); const QImage sharp = edge(60, 40);
// The bar's Radius, within 0.5 and 50, whatever the size. // The Radius, from 0.5, at most half the layer's side.
for (const auto &[radius, sigma] : {std::pair(6.0, 6.0), std::pair(0.1, 0.5), std::pair(90.0, 50.0)}) { for (const auto &[radius, sigma] : {std::pair(6.0, 6.0), std::pair(0.1, 0.5), std::pair(90.0, 30.0)}) {
BrushSettings settings = session.brushSettings(); BrushSettings settings = session.brushSettings();
settings.blurRadius = radius; settings.blurRadius = radius;
settings.diameter = 3 * radius; settings.diameter = radius < 10 ? 90 : 5;
session.setBrushSettings(settings); session.setBrushSettings(settings);
const BrushStroke::Clone sample = session.blurSample(document).value(); const std::unique_ptr<BrushStroke> stroke = session.makeRasterEdit(session.activeLayer().value(), settings);
QCOMPARE(sample.image, sharp); const BrushStroke::Clone sample = session.blurSample(*stroke).value();
QCOMPARE(sample.render(sharp.rect()), PixelAdjust::gaussianBlur(sharp, sigma, false)); const int margin = int(std::ceil(3 * sigma));
QCOMPARE(sample.image, framed(sharp, QSizeF(60, 40), margin, false));
QCOMPARE(soft(sample), 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. // On a wide layer, the Radius stops at 50.
session.selectLayer(std::nullopt); EditorSession wide;
QVERIFY(!session.blurSample(document)); wide.createDocument(400, 300);
QVERIFY(!session.blurSample(document, true)); const QImage broad = edge(400, 300);
wide.insert(ImportedImage(broad, broad, QStringLiteral("Edge")));
wide.setBrushSettings(BrushSettings{.diameter = 60, .blurRadius = 80});
const std::unique_ptr<BrushStroke> broadStroke = wide.makeRasterEdit(wide.activeLayer().value(), wide.brushSettings());
QCOMPARE(wide.blurSample(*broadStroke).value().placed, QRectF(-150, -150, 700, 600));
// 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, .blurRadius = 2});
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(soft(sample), 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(soft(sample), 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, .blurRadius = 6});
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(soft(sample), 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, .blurRadius = 1.5});
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(soft(sample), 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; EditorSession session;
blurring(session, BrushSettings{.diameter = 20, .blurRadius = 2}); blurring(session, BrushSettings{.diameter = 20, .blurRadius = 2});
@@ -157,7 +259,7 @@ void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement()
// A white mask with a black bar, placed mid-layer. // A white mask with a black bar, placed mid-layer.
QImage mask(20, 20, QImage::Format_Grayscale8); QImage mask(20, 20, QImage::Format_Grayscale8);
mask.fill(255); mask.fill(255);
for (int y = 0; y < 20; ++y) for (int y = 2; y < 18; ++y)
for (int x = 8; x < 12; ++x) for (int x = 8; x < 12; ++x)
mask.scanLine(y)[x] = 0; mask.scanLine(y)[x] = 0;
rewrite(session, [&](ProjectSnapshot &snapshot) { rewrite(session, [&](ProjectSnapshot &snapshot) {
@@ -165,36 +267,18 @@ void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement()
record(snapshot, id).maskPlacement = LayerTransform{.origin = {20, 10}, .size = {20, 20}}; record(snapshot, id).maskPlacement = LayerTransform{.origin = {20, 10}, .size = {20, 20}};
record(snapshot, id).maskLinked = false; record(snapshot, id).maskLinked = false;
}); });
const BrushStroke::Clone clone = session.blurSample(session.document().value(), true).value(); BrushStroke stroke(session.activeLayer().value(), true, session.brushSettings(), QSizeF(60, 40));
const QImage sample = clone.render(clone.image.rect()); const BrushStroke::Clone sample = session.blurSample(stroke).value();
QCOMPARE(sample.format(), QImage::Format_Grayscale8); QCOMPARE(sample.image.format(), QImage::Format_Grayscale8);
QImage expected(60, 40, QImage::Format_Grayscale8);
// Past its pixels the mask keeps its edge's tone: white. // Past its pixels the mask keeps its edge's tone: white.
expected.fill(255); QCOMPARE(soft(sample), sampled(mask, QSizeF(20, 20), 6, 2, true, 255));
QPainter painter(&expected); QCOMPARE(sample.placed, QRectF(-6, -6, 32, 32));
painter.drawImage(QRectF(20, 10, 20, 20), mask); QVERIFY(soft(sample).constScanLine(16)[16] < 128 && soft(sample).constScanLine(1)[1] == 255);
painter.end(); // A black edge keeps black past it.
QCOMPARE(sample, PixelAdjust::gaussianBlur(expected, 2, true)); mask.fill(0);
QVERIFY(sample.constScanLine(20)[30] < 128 && sample.constScanLine(2)[2] == 255); rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(mask)); });
// Turned, the black box turns with the mask. BrushStroke black(session.activeLayer().value(), true, session.brushSettings(), QSizeF(60, 40));
EditorSession turned; QCOMPARE(soft(session.blurSample(black).value()), sampled(mask, QSizeF(20, 20), 6, 2, true, 0));
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, .blurRadius = 1.5});
const BrushStroke::Clone sharp = turned.blurSample(turned.document().value(), true).value();
const QImage soft = sharp.render(sharp.image.rect());
// 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])));
} }
void BlurToolTests::aBlurStrokeSoftensUnderTheBrushAsOneStep() void BlurToolTests::aBlurStrokeSoftensUnderTheBrushAsOneStep()
@@ -265,7 +349,7 @@ void BlurToolTests::aMaskBetweenPixelsTakesTheFraction()
for (int y = 0; y < 20; ++y) for (int y = 0; y < 20; ++y)
for (int x = 0; x < 80; ++x) for (int x = 0; x < 80; ++x)
steps.scanLine(y)[x] = x < 21 ? 0 : 200; steps.scanLine(y)[x] = x < 21 ? 0 : 200;
paint.setClone(BrushStroke::Clone{steps, QSizeF(0, 0), {}}); paint.setClone(documentClone(steps, QSizeF(0, 0)));
paint.isBlur = true; paint.isBlur = true;
paint.append(QPointF(21, 10)); paint.append(QPointF(21, 10));
const QImage tile = paint.patches().at(0).image; const QImage tile = paint.patches().at(0).image;
@@ -285,7 +369,7 @@ void BlurToolTests::aMaskPastTheCanvasEdgeKeepsItsTone()
white.fill(255); white.fill(255);
layer.mask = LayerMask(LayerMask::assetFrom(white)); layer.mask = LayerMask(LayerMask::assetFrom(white));
BrushStroke paint(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20)); BrushStroke paint(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20));
paint.setClone(BrushStroke::Clone{white, QSizeF(0, 0), {}}); paint.setClone(documentClone(white, QSizeF(0, 0)));
paint.isBlur = true; paint.isBlur = true;
paint.append(QPointF(0, 10)); paint.append(QPointF(0, 10));
const QImage tile = paint.patches().at(0).image; const QImage tile = paint.patches().at(0).image;
@@ -296,7 +380,7 @@ void BlurToolTests::aMaskPastTheCanvasEdgeKeepsItsTone()
black.fill(0); black.fill(0);
layer.mask = LayerMask(LayerMask::assetFrom(black)); layer.mask = LayerMask(LayerMask::assetFrom(black));
BrushStroke over(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20)); BrushStroke over(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20));
over.setClone(BrushStroke::Clone{white, QSizeF(0, 0), {}}); over.setClone(documentClone(white, QSizeF(0, 0)));
over.isBlur = true; over.isBlur = true;
over.append(QPointF(0, 10)); over.append(QPointF(0, 10));
QCOMPARE(int(over.patches().at(0).image.constScanLine(10)[0]), 128); QCOMPARE(int(over.patches().at(0).image.constScanLine(10)[0]), 128);
+6
View File
@@ -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}; 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) inline ImageLayer blankLayer(int width, int height)
{ {
return ImageLayer(QStringLiteral("Layer 1"), QSizeF(width, height)); return ImageLayer(QStringLiteral("Layer 1"), QSizeF(width, height));
+20 -7
View File
@@ -68,6 +68,7 @@ private slots:
void theActiveLayerAloneOrEveryVisibleLayerIsSampled(); void theActiveLayerAloneOrEveryVisibleLayerIsSampled();
void aMaskIsNeverCloned(); void aMaskIsNeverCloned();
void aScaledLayerSamplesThroughItsGrid(); void aScaledLayerSamplesThroughItsGrid();
void aGridCloneIsScaledOntoItsPlace();
void aLayerBetweenPixelsSamplesTheFraction(); void aLayerBetweenPixelsSamplesTheFraction();
void aQuarterPixelWeighsItsNeighbours(); void aQuarterPixelWeighsItsNeighbours();
void opacityAndTheSelectionLimitTheClone(); void opacityAndTheSelectionLimitTheClone();
@@ -249,7 +250,7 @@ void CloneStampTests::aScaledLayerSamplesThroughItsGrid()
ImageLayer layer(ImportedImage(clear, clear, QStringLiteral("Scaled")), QPointF(0, 0)); ImageLayer layer(ImportedImage(clear, clear, QStringLiteral("Scaled")), QPointF(0, 0));
layer.transform.size = QSizeF(40, 20); layer.transform.size = QSizeF(40, 20);
BrushStroke paint(layer, false, brush(30, 1, 0, 0, 0), QSizeF(80, 20)); BrushStroke paint(layer, false, brush(30, 1, 0, 0, 0), QSizeF(80, 20));
paint.setClone(BrushStroke::Clone{edged(80, 20, 31), QSizeF(10, 0), {}}); paint.setClone(documentClone(edged(80, 20, 31), QSizeF(10, 0)));
paint.append(QPointF(21, 10)); paint.append(QPointF(21, 10));
const QImage tile = paint.patches().at(0).image; const QImage tile = paint.patches().at(0).image;
// Grid pixel 10 sits at document 21, sampling 31. // Grid pixel 10 sits at document 21, sampling 31.
@@ -258,13 +259,25 @@ void CloneStampTests::aScaledLayerSamplesThroughItsGrid()
QCOMPARE(pixel(tile, 11, 5), bluePixel); 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.setClone(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() void CloneStampTests::aLayerBetweenPixelsSamplesTheFraction()
{ {
// Half a pixel right: each grid centre falls between two. // Half a pixel right: each grid centre falls between two.
ImageLayer layer(QStringLiteral("Blank"), QSizeF(40, 20)); ImageLayer layer(QStringLiteral("Blank"), QSizeF(40, 20));
layer.transform.origin = QPointF(0.5, 0); layer.transform.origin = QPointF(0.5, 0);
BrushStroke paint(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 20)); BrushStroke paint(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 20));
paint.setClone(BrushStroke::Clone{edged(80, 20, 31), QSizeF(3, 0), {}}); paint.setClone(documentClone(edged(80, 20, 31), QSizeF(3, 0)));
paint.append(QPointF(28, 10)); paint.append(QPointF(28, 10));
const QPointF centre = paint.pixelToDocument.map(QPointF(28.5, 10.5)); const QPointF centre = paint.pixelToDocument.map(QPointF(28.5, 10.5));
QCOMPARE(centre, QPointF(28, 10.5)); QCOMPARE(centre, QPointF(28, 10.5));
@@ -279,7 +292,7 @@ void CloneStampTests::aLayerBetweenPixelsSamplesTheFraction()
QImage rows = BrushRaster::context(40, 40, false); QImage rows = BrushRaster::context(40, 40, false);
rows.fill(QColor(255, 0, 0)); rows.fill(QColor(255, 0, 0));
QPainter(&rows).fillRect(QRect(0, 12, 40, 28), QColor(0, 0, 255)); QPainter(&rows).fillRect(QRect(0, 12, 40, 28), QColor(0, 0, 255));
down.setClone(BrushStroke::Clone{rows, QSizeF(0, 0), {}}); down.setClone(documentClone(rows, QSizeF(0, 0)));
down.append(QPointF(20, 12)); down.append(QPointF(20, 12));
const QImage lowTile = down.patches().at(0).image; const QImage lowTile = down.patches().at(0).image;
const int row = int(std::floor(down.pixelToDocument.inverted().map(QPointF(20, 12)).y())); 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; 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)); BrushStroke across(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 40));
across.setClone(BrushStroke::Clone{edged(80, 40, 31), QSizeF(0, 0), {}}); across.setClone(documentClone(edged(80, 40, 31), QSizeF(0, 0)));
across.append(QPointF(31, 12)); across.append(QPointF(31, 12));
QCOMPARE(across.pixelToDocument.map(QPointF(31.5, 12.5)), QPointF(30.75, 12.25)); 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); 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)); rows.fill(QColor(255, 0, 0));
QPainter(&rows).fillRect(QRect(0, 12, 80, 28), QColor(0, 0, 255)); 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)); BrushStroke down(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 40));
down.setClone(BrushStroke::Clone{rows, QSizeF(0, 0), {}}); down.setClone(documentClone(rows, QSizeF(0, 0)));
down.append(QPointF(20, 12)); down.append(QPointF(20, 12));
const std::vector<int> stacked = pixel(down.patches().at(0).image, 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]))); 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)); 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), BrushStroke half(ImageLayer(ImportedImage(under, under, QStringLiteral("Blue")), QPointF(0, 0)), false, brush(10, 1, 0, 0, 0, 0.5),
QSizeF(40, 20)); QSizeF(40, 20));
half.setClone(BrushStroke::Clone{over, QSizeF(0, 0), {}}); half.setClone(documentClone(over, QSizeF(0, 0)));
half.append(QPointF(10, 10)); half.append(QPointF(10, 10));
// Each byte is rounded once: 127.5 and 127.5 make 128. // 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})); 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.setCompositionMode(QPainter::CompositionMode_Clear);
painter.fillRect(QRect(0, 0, 40, 5), Qt::transparent); painter.fillRect(QRect(0, 0, 40, 5), Qt::transparent);
} }
bounded.setClone(BrushStroke::Clone{sample, QSizeF(0, 0), {}}); bounded.setClone(documentClone(sample, QSizeF(0, 0)));
bounded.append(QPointF(10, 8)); bounded.append(QPointF(10, 8));
const QImage tile = bounded.patches().at(0).image; const QImage tile = bounded.patches().at(0).image;
QCOMPARE(pixel(tile, 8, 8), redPixel); QCOMPARE(pixel(tile, 8, 8), redPixel);
+10 -5
View File
@@ -126,10 +126,15 @@ void LargeCanvasBrushTests::blurPiecesMatchTheWhole()
BrushSettings settings = session.brushSettings(); BrushSettings settings = session.brushSettings();
settings.blurRadius = 12; settings.blurRadius = 12;
session.setBrushSettings(settings); session.setBrushSettings(settings);
const BrushStroke::Clone blur = session.blurSample(session.document().value(), mask).value(); const ImageLayer layer = session.activeLayer().value();
const std::unique_ptr<BrushStroke> stroke =
mask ? std::make_unique<BrushStroke>(layer, true, settings, QSizeF(600, 600)) : session.makeRasterEdit(layer, settings);
const BrushStroke::Clone blur = session.blurSample(*stroke).value();
const QImage whole = blur.render(blur.image.rect()); const QImage whole = blur.render(blur.image.rect());
QCOMPARE(whole.size(), QSize(600, 600)); // The sample reaches three sigmas past the layer.
for (const QRect part : {QRect(0, 0, 256, 256), QRect(250, 300, 260, 180), QRect(500, 530, 100, 70)}) { QCOMPARE(whole.size(), QSize(672, 672));
const int width = blur.image.width(), height = blur.image.height();
for (const QRect part : {QRect(0, 0, 256, 256), QRect(250, 300, 260, 180), QRect(width - 100, height - 70, 100, 70)}) {
const QImage piece = blur.render(part); const QImage piece = blur.render(part);
QCOMPARE(piece.size(), part.size()); QCOMPARE(piece.size(), part.size());
QCOMPARE(piece, whole.copy(part)); QCOMPARE(piece, whole.copy(part));
@@ -144,7 +149,7 @@ void LargeCanvasBrushTests::piecesAreCutOnceWhereTheBrushReaches()
const ImageLayer layer(ImportedImage(image, image, QStringLiteral("Red")), QPointF(0, 0)); const ImageLayer layer(ImportedImage(image, image, QStringLiteral("Red")), QPointF(0, 0));
BrushStroke stroke(layer, false, brush(20, 1, 0, 0, 0), QSizeF(1000, 1000)); BrushStroke stroke(layer, false, brush(20, 1, 0, 0, 0), QSizeF(1000, 1000));
std::vector<QRect> parts; std::vector<QRect> parts;
stroke.setClone(BrushStroke::Clone{image, QSizeF(0, 0), [&](const QRect &part) { stroke.setClone(BrushStroke::Clone{image, QRectF(0, 0, 1000, 1000), false, [&](const QRect &part) {
parts.push_back(part); parts.push_back(part);
return image.copy(part); return image.copy(part);
}}); }});
@@ -174,7 +179,7 @@ void LargeCanvasBrushTests::piecesAreCutOnceWhereTheBrushReaches()
QImage blue = BrushRaster::context(200, 200, false); QImage blue = BrushRaster::context(200, 200, false);
blue.fill(Qt::blue); blue.fill(Qt::blue);
parts.clear(); parts.clear();
past.setClone(BrushStroke::Clone{blue, QSizeF(0, 0), [&](const QRect &part) { past.setClone(BrushStroke::Clone{blue, QRectF(0, 0, 200, 200), false, [&](const QRect &part) {
parts.push_back(part); parts.push_back(part);
return blue.copy(part); return blue.copy(part);
}}); }});
+298
View File
@@ -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"
+148
View File
@@ -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"
+12 -7
View File
@@ -121,17 +121,22 @@ void SelectMenuTests::selectAllInAFieldIsTheFields()
QVERIFY(!bar.session().selection().has_value()); QVERIFY(!bar.session().selection().has_value());
QTest::keyClick(editor, Qt::Key_Escape); QTest::keyClick(editor, Qt::Key_Escape);
QTRY_VERIFY(!bar.session().renamingLayerID().has_value()); 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(); bar.session().addBlankLayer();
NativeLayerList *list = bar.window.findChild<NativeLayerList *>(); NativeLayerList *list = bar.window.findChild<NativeLayerList *>();
list->setFocus(); list->setFocus();
QTRY_VERIFY(list->hasFocus()); QTRY_VERIFY(list->hasFocus());
const std::vector<ImageLayer> &layers = bar.session().document().value().layers; const QSet<QUuid> selected = bar.session().selectedLayerIDs();
bar.action("selectAll").trigger(); const std::optional<QUuid> active = bar.session().activeLayerID();
QCOMPARE(bar.session().selectedLayerIDs(), (QSet<QUuid>{layers[0].id, layers[1].id})); QCOMPARE(selected.size(), 1);
QCOMPARE(bar.session().activeLayerID(), std::optional(layers[1].id)); QTest::keyClick(list, Qt::Key_A, Qt::ControlModifier);
QVERIFY(!bar.session().selection().has_value()); QCOMPARE(bar.session().selection().value().path.boundingRect(), QRectF(0, 0, 50, 50));
// Elsewhere it selects the canvas. 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(); bar.window.findChild<CanvasView *>()->setFocus();
QTRY_VERIFY(!list->hasFocus()); QTRY_VERIFY(!list->hasFocus());
bar.action("selectAll").trigger(); bar.action("selectAll").trigger();
+33
View File
@@ -11,6 +11,7 @@ private slots:
void focusLossEndsAMarqueeDraftAndAnOutlineMove(); void focusLossEndsAMarqueeDraftAndAnOutlineMove();
void shiftDeleteBeginsAContentAwareFill(); void shiftDeleteBeginsAContentAwareFill();
void aCtrlDragMovesSelectedPixelsAndCtrlArrowsNudgeThem(); void aCtrlDragMovesSelectedPixelsAndCtrlArrowsNudgeThem();
void shiftKeepsMovedPixelsOnALine();
}; };
void SelectionCanvasTests::marqueeDragsSelectShiftAddsAltSubtractsAndAFreshShiftSquares() void SelectionCanvasTests::marqueeDragsSelectShiftAddsAltSubtractsAndAFreshShiftSquares()
@@ -322,5 +323,37 @@ void SelectionCanvasTests::aCtrlDragMovesSelectedPixelsAndCtrlArrowsNudgeThem()
QVERIFY(session.lassoDraft().has_value() && !session.pixelMove()); 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) QTEST_MAIN(SelectionCanvasTests)
#include "SelectionCanvasTests.moc" #include "SelectionCanvasTests.moc"
+18
View File
@@ -11,6 +11,7 @@ private slots:
void polygonalCornersCanBeRemovedAndClosed(); void polygonalCornersCanBeRemovedAndClosed();
void antialiasingControlsEdgeCoverage(); void antialiasingControlsEdgeCoverage();
void selectAllInverseAndToolSwitchCancelsDraft(); void selectAllInverseAndToolSwitchCancelsDraft();
void inverseOfEverythingDeselects();
void cursorBadgeFollowsModifiersButKeepsAnOutlinesStartingMode(); void cursorBadgeFollowsModifiersButKeepsAnOutlinesStartingMode();
void draggingMovesTheOutlineInWholePixelsAsOneUndo(); void draggingMovesTheOutlineInWholePixelsAsOneUndo();
void movingOffCanvasAndBackKeepsTheWholeShape(); void movingOffCanvasAndBackKeepsTheWholeShape();
@@ -180,6 +181,23 @@ void SelectionTests::selectAllInverseAndToolSwitchCancelsDraft()
QCOMPARE(session->history.undoCount(), count + 1); 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() void SelectionTests::cursorBadgeFollowsModifiersButKeepsAnOutlinesStartingMode()
{ {
const auto session = selectionSession(); const auto session = selectionSession();
+2
View File
@@ -146,6 +146,7 @@ compositor_test(ProjectManifestTests)
compositor_test(NativeLayerListTests) compositor_test(NativeLayerListTests)
compositor_test(LayerContextMenuTests) compositor_test(LayerContextMenuTests)
compositor_test(LayerContextMenuEdgeTests) compositor_test(LayerContextMenuEdgeTests)
compositor_test(NativeResolutionPaintTests)
compositor_test(NavigationToolHeaderTests) compositor_test(NavigationToolHeaderTests)
compositor_test(NumericScrubTests) compositor_test(NumericScrubTests)
compositor_test(ScrubbableLabelsTests) compositor_test(ScrubbableLabelsTests)
@@ -293,6 +294,7 @@ compositor_test(LayerFlipTests)
compositor_test(LayerMergeTests) compositor_test(LayerMergeTests)
compositor_test(PixelEditTests) compositor_test(PixelEditTests)
compositor_test(PixelInvertTests) compositor_test(PixelInvertTests)
compositor_test(PaintRefusalTests)
compositor_test(PixelMoveTests) compositor_test(PixelMoveTests)
compositor_test(SelectionTests) compositor_test(SelectionTests)
compositor_test(SelectionFeatherTests) compositor_test(SelectionFeatherTests)
+50 -1
View File
@@ -3,6 +3,7 @@
#include "UI/LayerIcons.h" #include "UI/LayerIcons.h"
#include "UI/TransformInspector.h" #include "UI/TransformInspector.h"
#include <QtTest> #include <QtTest>
#include <cmath>
// The Move tool's bar: fields, settings, Apply and Cancel. // The Move tool's bar: fields, settings, Apply and Cancel.
namespace { namespace {
@@ -59,6 +60,7 @@ private slots:
void theLockIconFollowsThePalette(); void theLockIconFollowsThePalette();
void theFieldsTakeTheSpareRoom(); void theFieldsTakeTheSpareRoom();
void heldKeysShowFlippedInTheBar(); void heldKeysShowFlippedInTheBar();
void cancelAndApplyShowOnlyWhileAnEditWaits();
}; };
void TransformInspectorTests::theFieldsShowTheLayerAndTypingPreviews() void TransformInspectorTests::theFieldsShowTheLayerAndTypingPreviews()
@@ -341,7 +343,7 @@ void TransformInspectorTests::theFieldsTakeTheSpareRoom()
const QRect natural = flip.geometry(); const QRect natural = flip.geometry();
// Swift's ScrollView takes the room; its content keeps its size. // Swift's ScrollView takes the room; its content keeps its size.
bar.header->resize(1600, bar.header->height()); 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.geometry(), natural);
QCOMPARE(flip.width(), flip.sizeHint().width()); QCOMPARE(flip.width(), flip.sizeHint().width());
} }
@@ -415,5 +417,52 @@ void TransformInspectorTests::heldKeysShowFlippedInTheBar()
QTest::keyRelease(&x, Qt::Key_Control, Qt::NoModifier); QTest::keyRelease(&x, Qt::Key_Control, Qt::NoModifier);
} }
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) QTEST_MAIN(TransformInspectorTests)
#include "TransformInspectorTests.moc" #include "TransformInspectorTests.moc"