From 51e373af4cf1b4cdd3f8f9401d0cb4fbc88a89dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 14:35:13 -0400 Subject: [PATCH 1/2] Shift keeps moved pixels on a line; Ctrl+A selects the canvas from the Layers panel (13.10) --- src/Rendering/EditorCanvas+Selection.cpp | 10 ++++++- src/UI/CompositorMenus.cpp | 12 --------- src/UI/NativeLayerList.cpp | 10 ------- src/UI/NativeLayerList.h | 2 -- tests/SelectMenuTests.cpp | 19 +++++++++----- tests/SelectionCanvasTests.cpp | 33 ++++++++++++++++++++++++ 6 files changed, 54 insertions(+), 32 deletions(-) diff --git a/src/Rendering/EditorCanvas+Selection.cpp b/src/Rendering/EditorCanvas+Selection.cpp index 4bd78f2..c55c102 100644 --- a/src/Rendering/EditorCanvas+Selection.cpp +++ b/src/Rendering/EditorCanvas+Selection.cpp @@ -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; diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp index 26f002d..3fbae05 100644 --- a/src/UI/CompositorMenus.cpp +++ b/src/UI/CompositorMenus.cpp @@ -2,20 +2,10 @@ #include "IO/RecentProjects.h" #include "Logging.h" #include "UI/KeyboardShortcuts.h" -#include "UI/NativeLayerList.h" #include #include 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(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(); }); diff --git a/src/UI/NativeLayerList.cpp b/src/UI/NativeLayerList.cpp index 69a3743..10719e6 100644 --- a/src/UI/NativeLayerList.cpp +++ b/src/UI/NativeLayerList.cpp @@ -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 ids; - for (const ImageLayer &row : m_rows) - ids.insert(row.id); - m_session.selectLayers(ids, m_rows.front().id); -} diff --git a/src/UI/NativeLayerList.h b/src/UI/NativeLayerList.h index 33028f8..d941dc9 100644 --- a/src/UI/NativeLayerList.h +++ b/src/UI/NativeLayerList.h @@ -215,8 +215,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 cells() const { return m_cells; } // The row under a point of this list, or -1. diff --git a/tests/SelectMenuTests.cpp b/tests/SelectMenuTests.cpp index 44ae4b0..432dac6 100644 --- a/tests/SelectMenuTests.cpp +++ b/tests/SelectMenuTests.cpp @@ -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(); list->setFocus(); QTRY_VERIFY(list->hasFocus()); - const std::vector &layers = bar.session().document().value().layers; - bar.action("selectAll").trigger(); - QCOMPARE(bar.session().selectedLayerIDs(), (QSet{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 selected = bar.session().selectedLayerIDs(); + const std::optional 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()->setFocus(); QTRY_VERIFY(!list->hasFocus()); bar.action("selectAll").trigger(); diff --git a/tests/SelectionCanvasTests.cpp b/tests/SelectionCanvasTests.cpp index aae405e..4abf118 100644 --- a/tests/SelectionCanvasTests.cpp +++ b/tests/SelectionCanvasTests.cpp @@ -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" From cc9bf95722952c81288ff5e8eb2b493a4f31e119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 17:48:38 -0400 Subject: [PATCH 2/2] Say why a brush cannot paint; paint at the layer's own resolution; Cancel and Apply only when an edit waits, scaled layers' scale (13.11) --- src/Document/BlurTool+Session.h | 4 +- src/Document/BlurTool.cpp | 60 +++--- src/Document/BrushStroke+Commit.cpp | 8 + src/Document/BrushStroke+Stroke.cpp | 7 +- src/Document/BrushStroke.h | 7 +- src/Document/CloneStamp+Session.h | 4 +- src/Document/CloneStamp.cpp | 19 +- src/Document/EditorSession+Brush.cpp | 63 ++++-- src/Document/EditorSession+Brush.h | 2 + src/Document/Gradient.cpp | 7 +- src/Document/Selection.cpp | 4 +- src/Document/SmudgeLiquify.cpp | 5 +- src/UI/NativeLayerList+Cell.cpp | 18 +- src/UI/NativeLayerList.h | 3 + src/UI/TransformInspector.cpp | 34 ++- src/UI/TransformInspector.h | 7 + tests/BlurToolTests.cpp | 147 +++++++++---- tests/BrushFixtures.h | 6 + tests/CloneStampTests.cpp | 27 ++- tests/NativeResolutionPaintTests.cpp | 298 +++++++++++++++++++++++++++ tests/PaintRefusalTests.cpp | 148 +++++++++++++ tests/SelectionTests.cpp | 18 ++ tests/Tests.cmake | 2 + tests/TransformInspectorTests.cpp | 51 ++++- 24 files changed, 820 insertions(+), 129 deletions(-) create mode 100644 tests/NativeResolutionPaintTests.cpp create mode 100644 tests/PaintRefusalTests.cpp diff --git a/src/Document/BlurTool+Session.h b/src/Document/BlurTool+Session.h index a9f0ce5..4bbdfad 100644 --- a/src/Document/BlurTool+Session.h +++ b/src/Document/BlurTool+Session.h @@ -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 blurSample(const CanvasDocument &document, bool mask = false) const; + // The stroke's pixels or mask softened, in its own grid. + std::optional blurSample(const BrushStroke &stroke) const; diff --git a/src/Document/BlurTool.cpp b/src/Document/BlurTool.cpp index ea62db3..b7d5714 100644 --- a/src/Document/BlurTool.cpp +++ b/src/Document/BlurTool.cpp @@ -2,49 +2,39 @@ #include "Document/PixelAdjust.h" #include "IO/ImageExporter.h" #include "Logging.h" -#include "Rendering/LayerRenderer.h" #include -// Swift's BlurTool extension: the layer, or its mask, softened. -std::optional EditorSession::blurSample(const CanvasDocument &document, bool mask) const +// Swift's BlurTool extension: the layer's pixels, or mask, softened. +std::optional EditorSession::blurSample(const BrushStroke &stroke) const { - const std::optional layer = activeLayer(); - if (!layer) + const ImageLayer &layer = stroke.layer; + const std::optional &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(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; diff --git a/src/Document/BrushStroke+Commit.cpp b/src/Document/BrushStroke+Commit.cpp index 51ba0a6..fc43ad9 100644 --- a/src/Document/BrushStroke+Commit.cpp +++ b/src/Document/BrushStroke+Commit.cpp @@ -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() { diff --git a/src/Document/BrushStroke+Stroke.cpp b/src/Document/BrushStroke+Stroke.cpp index 5a2f4a6..5118af9 100644 --- a/src/Document/BrushStroke+Stroke.cpp +++ b/src/Document/BrushStroke+Stroke.cpp @@ -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); diff --git a/src/Document/BrushStroke.h b/src/Document/BrushStroke.h index 8c82c01..bf7dc9d 100644 --- a/src/Document/BrushStroke.h +++ b/src/Document/BrushStroke.h @@ -107,10 +107,11 @@ public: qint64 pixelLimit = DocumentLimits::documentPixelBudget(); // Limits every edit to the document selection; nil when none. std::optional 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; // Blur: the clone is the layer softened, in place. @@ -144,6 +145,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; diff --git a/src/Document/CloneStamp+Session.h b/src/Document/CloneStamp+Session.h index b7bbaec..f175936 100644 --- a/src/Document/CloneStamp+Session.h +++ b/src/Document/CloneStamp+Session.h @@ -6,5 +6,5 @@ public: std::optional cloneStrokeOffset(QPointF point) const; // The source for a brush at `point`: the canvas's crosshair. std::optional cloneSamplePoint(QPointF point) const; - // What a stroke copies from, document-sized, taken as it starts. - std::optional cloneSample(const CanvasDocument &document) const; + // What `stroke` copies, `offset` away: its pixels or the canvas. + std::optional cloneSample(const CanvasDocument &document, const BrushStroke &stroke, QSizeF offset) const; diff --git a/src/Document/CloneStamp.cpp b/src/Document/CloneStamp.cpp index 28c8daa..d27f341 100644 --- a/src/Document/CloneStamp.cpp +++ b/src/Document/CloneStamp.cpp @@ -1,7 +1,6 @@ #include "Document/EditorSession.h" #include "IO/ImageExporter.h" #include "Logging.h" -#include "Rendering/LayerRenderer.h" #include // Swift's CloneStamp extension: the source, its offset, the sample. @@ -40,20 +39,20 @@ std::optional EditorSession::cloneSamplePoint(QPointF point) const return point + QPointF(m_cloneOffset->width(), m_cloneOffset->height()); } -std::optional EditorSession::cloneSample(const CanvasDocument &document) const +std::optional 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 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; diff --git a/src/Document/EditorSession+Brush.cpp b/src/Document/EditorSession+Brush.cpp index c90295f..1ba7087 100644 --- a/src/Document/EditorSession+Brush.cpp +++ b/src/Document/EditorSession+Brush.cpp @@ -37,6 +37,27 @@ bool EditorSession::canPaint() const && (m_isMaskSelected || !layer->adjustment); } +std::optional EditorSession::paintRefusal() const +{ + const std::optional 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 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 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 clone; + std::optional sourceOffset; if (m_tool == NavigationTool::cloneStamp) { - const std::optional 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 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 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 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; diff --git a/src/Document/EditorSession+Brush.h b/src/Document/EditorSession+Brush.h index a94f37f..bf840c1 100644 --- a/src/Document/EditorSession+Brush.h +++ b/src/Document/EditorSession+Brush.h @@ -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 paintRefusal() const; std::unique_ptr makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings = BrushSettings(), bool growsMask = false) const; void beginBrush(QPointF point); void continueBrush(QPointF point); diff --git a/src/Document/Gradient.cpp b/src/Document/Gradient.cpp index 5ac496d..da55813 100644 --- a/src/Document/Gradient.cpp +++ b/src/Document/Gradient.cpp @@ -35,8 +35,13 @@ void EditorSession::setGradientSettings(const GradientSettings &settings) void EditorSession::beginGradient(QPointF point) { const std::optional 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; diff --git a/src/Document/Selection.cpp b/src/Document/Selection.cpp index ba37f4e..a89c8dd 100644 --- a/src/Document/Selection.cpp +++ b/src/Document/Selection.cpp @@ -420,5 +420,7 @@ void EditorSession::invertSelection() const std::optional 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")); } diff --git a/src/Document/SmudgeLiquify.cpp b/src/Document/SmudgeLiquify.cpp index 73f534e..8cd159b 100644 --- a/src/Document/SmudgeLiquify.cpp +++ b/src/Document/SmudgeLiquify.cpp @@ -196,8 +196,7 @@ void EditorSession::beginWarp(QPointF point) { const std::optional 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 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()) diff --git a/src/UI/NativeLayerList+Cell.cpp b/src/UI/NativeLayerList+Cell.cpp index 5f0b95e..54a7a59 100644 --- a/src/UI/NativeLayerList+Cell.cpp +++ b/src/UI/NativeLayerList+Cell.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -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); @@ -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("%"); +} diff --git a/src/UI/NativeLayerList.h b/src/UI/NativeLayerList.h index d941dc9..b672fc4 100644 --- a/src/UI/NativeLayerList.h +++ b/src/UI/NativeLayerList.h @@ -279,3 +279,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); diff --git a/src/UI/TransformInspector.cpp b/src/UI/TransformInspector.cpp index 72f6d03..add059d 100644 --- a/src/UI/TransformInspector.cpp +++ b/src/UI/TransformInspector.cpp @@ -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{m_autoSelect, m_showControls, m_fields, m_cancel, m_apply}) + for (QWidget *widget : std::initializer_list{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(); } diff --git a/src/UI/TransformInspector.h b/src/UI/TransformInspector.h index f88ad29..ae112f7 100644 --- a/src/UI/TransformInspector.h +++ b/src/UI/TransformInspector.h @@ -3,7 +3,9 @@ #include "UI/ToolHeaderStyle.h" #include #include +#include #include +#include #include #include #include @@ -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; }; diff --git a/tests/BlurToolTests.cpp b/tests/BlurToolTests.cpp index 7119ad0..10b2adc 100644 --- a/tests/BlurToolTests.cpp +++ b/tests/BlurToolTests.cpp @@ -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 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); diff --git a/tests/BrushFixtures.h b/tests/BrushFixtures.h index c7d4c24..8cc5aa0 100644 --- a/tests/BrushFixtures.h +++ b/tests/BrushFixtures.h @@ -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)); diff --git a/tests/CloneStampTests.cpp b/tests/CloneStampTests.cpp index 1f2f074..5f3c91d 100644 --- a/tests/CloneStampTests.cpp +++ b/tests/CloneStampTests.cpp @@ -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 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 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{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); diff --git a/tests/NativeResolutionPaintTests.cpp b/tests/NativeResolutionPaintTests.cpp new file mode 100644 index 0000000..5fbb3e4 --- /dev/null +++ b/tests/NativeResolutionPaintTests.cpp @@ -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 channel(const QImage &image, int which) +{ + const QImage bytes = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + std::vector 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 edge(const std::vector &values, int width, int first, int last, int start) +{ + std::vector 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 &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 session(double scale, int source = 1000) +{ + auto made = std::make_unique(); + 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 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(); + 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 &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 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 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 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 values = channel(image, 1); + QVERIFY(holdsBoth(std::vector(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 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 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(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("layerDimensions")->text(), QString("50 × 50 px · 5%")); +} + +QTEST_MAIN(NativeResolutionPaintTests) +#include "NativeResolutionPaintTests.moc" diff --git a/tests/PaintRefusalTests.cpp b/tests/PaintRefusalTests.cpp new file mode 100644 index 0000000..6679795 --- /dev/null +++ b/tests/PaintRefusalTests.cpp @@ -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 makeSession() +{ + auto session = std::make_unique(); + session->createDocument(100, 80, true); + session->selectTool(NavigationTool::brush); + return session; +} + +// The press's error, cleared for the next. +std::optional pressed(EditorSession &session) +{ + session.beginBrush(QPointF(10, 10)); + const std::optional 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("Several layers are selected. Select just one to paint on it.")); + session->selectLayer(folder); + QCOMPARE(pressed(*session), std::optional("“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("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("“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("“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("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("“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("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("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("Smudge and Liquify work on a layer's pixels, not its mask.")); + session->toggleLayerMask(); + QCOMPARE(pressed(*session), std::optional("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("Earlier")); + session->selectTool(NavigationTool::move); + session->beginBrush(QPointF(10, 10)); + QCOMPARE(session->brushError(), std::optional("Earlier")); +} + +QTEST_GUILESS_MAIN(PaintRefusalTests) +#include "PaintRefusalTests.moc" diff --git a/tests/SelectionTests.cpp b/tests/SelectionTests.cpp index 5fd2b51..8c2bb6d 100644 --- a/tests/SelectionTests.cpp +++ b/tests/SelectionTests.cpp @@ -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(); diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 80012e2..13a922d 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -143,6 +143,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) @@ -288,6 +289,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) diff --git a/tests/TransformInspectorTests.cpp b/tests/TransformInspectorTests.cpp index 0115a7f..49e57af 100644 --- a/tests/TransformInspectorTests.cpp +++ b/tests/TransformInspectorTests.cpp @@ -2,6 +2,7 @@ #include "UI/LayerIcons.h" #include "UI/TransformInspector.h" #include +#include // 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("applyTransform").geometry().right() > 1600 - 40); + QTRY_VERIFY(bar.control("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("pendingTransform"); + auto *fade = qobject_cast(buttons.graphicsEffect()); + QVERIFY(fade); + QPushButton &apply = bar.control("applyTransform"); + QPushButton &cancel = bar.control("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(); + 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"