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