#include "Document/BrushStroke.h" #include "Document/DocumentLimits.h" #include "Document/EditorSession.h" #include "Document/PixelAdjust.h" #include "Document/PixelInvert.h" #include "IO/ImageExporter.h" #include "Logging.h" #include #include namespace { // A uniform 1×1 mask given the layer's pixel grid. QImage expandedUniformMask(const QImage &image, int width, int height) { if (width <= 0 || height <= 0 || qint64(width) * height > DocumentLimits::maxSurfacePixels) throw ProjectError(ProjectError::Kind::tooLarge); QImage expanded = BrushRaster::context(width, height, true); QPainter painter(&expanded); BrushRaster::draw(image, QRectF(0, 0, width, height), painter); return expanded; } } bool EditorSession::canEditPixels() const { return canPaint(); } // Fills the selection or the layer with one colour. void EditorSession::fillSelection(FillSource source, std::function done) { const std::optional layer = activeLayer(); if (!canEditPixels() || !layer) { if (done) QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); return; } // A mask takes the palette's red as gray. const PaletteColor value = paletteColor(source == FillSource::background); // Live text takes the colour itself and stays text. if (!m_isMaskSelected && !selection() && recolorText(layer->id, value)) { if (done) QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); return; } const QColor color = m_isMaskSelected ? QColor::fromRgbF(float(value.red), float(value.red), float(value.red)) : value.color(); applyPixelEdit(*layer, m_isMaskSelected ? QStringLiteral("Fill Mask") : QStringLiteral("Fill"), [color](BrushStroke &edit) { edit.fill(color); }, std::move(done)); } // Delete with a selection: pixels clear; a mask takes background. void EditorSession::clearSelectedPixels(std::function done) { const std::optional layer = activeLayer(); if (!selection() || !canEditPixels() || !layer) { if (done) QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); return; } if (m_isMaskSelected) { fillSelection(FillSource::background, std::move(done)); return; } if (!layer->asset) { if (done) QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); return; } applyPixelEdit(*layer, QStringLiteral("Clear"), [](BrushStroke &edit) { edit.clearPixels(); }, std::move(done)); } // The Delete key: clears the selection, else mask or layer. void EditorSession::deleteKeyPressed() { if (selectedEffect()) { removeSelectedEffect(); return; } if (selection()) clearSelectedPixels(); else deleteLayerOrMask(); } // A raster edit committed off the UI thread, one step. void EditorSession::applyPixelEdit(const ImageLayer &layer, const QString &name, const std::function &paint, std::function done) { finishOpacityEdit(); try { // A fill on a mask covers the canvas too. std::unique_ptr edit = makeRasterEdit(layer, BrushSettings(), true); paint(*edit); if (edit->patches().empty()) { if (done) QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); return; } commitRasterEdit(std::move(edit), name, {}, std::move(done)); } catch (const ProjectError &error) { setBrushError(QString::fromUtf8(error.what())); if (done) QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); } catch (const ExportError &error) { setBrushError(QString::fromUtf8(error.what())); if (done) QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); } } void EditorSession::deleteLayerOrMask() { if (selectedEffect()) { removeSelectedEffect(); return; } const std::optional active = activeLayer(); if (m_isMaskSelected && active && active->mask && m_selectedLayerIDs.size() <= 1) deleteLayerMask(); else deleteSelectedLayers(); } bool EditorSession::canInvert() const { const std::optional layer = activeLayer(); if (!m_document || m_textDraft || !layer || m_isProjectBusy || m_isImporting || m_brushStroke || m_pixelMove || m_renamingLayerID || m_showsNewDocument || m_showsImporter || m_selectedLayerIDs.size() != 1 || (layer->isGroup && !m_isMaskSelected) || !m_document->effectiveVisibleIDs().contains(layer->id)) return false; const std::optional current = selection(); if (current && current->isEmpty()) return false; return m_isMaskSelected ? layer->mask && layer->mask->isEnabled : layer->asset.has_value(); } void EditorSession::invertPixels(std::function done) { // The caller resumes from the event loop, as after await. const auto finish = [this, done] { if (done) QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); }; if (!canInvert()) { finish(); return; } commitTransform(); // A pending gradient lands first, as Swift awaits it. if (m_gradientEdit) { commitGradient([this, done] { invertPixels(done); }); return; } if (!canInvert()) { finish(); return; } const ImageLayer layer = activeLayer().value(); const bool mask = m_isMaskSelected; finishOpacityEdit(); setIsProjectBusy(true); PixelInvert::Job job{QImage(), mask, QTransform(), std::nullopt}; try { // Flattening a painted asset may fail: caught like the rest. job.image = mask ? layer.mask.value().asset.image() : layer.asset.value().image(); const std::optional current = selection(); if (current) job.selection = current->clip(m_document->size()); // A uniform 1×1 mask cannot hold a partial selection. if (mask && job.selection && job.image.size() == QSize(1, 1)) { job.image = expandedUniformMask(job.image, layer.asset ? layer.asset->size().width() : int(std::round(layer.size().width())), layer.asset ? layer.asset->size().height() : int(std::round(layer.size().height()))); } job.pixelToDocument = BrushRaster::pixelToDocument(mask ? layer.maskTransform() : layer.transform, job.image.width(), job.image.height()); } catch (const std::runtime_error &error) { qCWarning(lcApp).noquote() << "cannot invert:" << error.what(); setBrushError(QString::fromUtf8(error.what())); setIsProjectBusy(false); finish(); return; } m_inverting = Inverting{layer.id, mask, layer.asset ? std::optional(layer.asset->identity()) : std::nullopt, layer.mask ? std::optional(layer.mask->asset.identity()) : std::nullopt, std::move(done)}; m_inverter.setFuture(QtConcurrent::run([job]() -> Inverted { try { return Inverted{PixelInvert::run(job), std::nullopt}; } catch (const ExportError &error) { return Inverted{std::nullopt, QString::fromUtf8(error.what())}; } })); } void EditorSession::finishInvert() { const Inverted result = m_inverter.result(); const Inverting pending = std::exchange(m_inverting, std::nullopt).value(); // The document may have gone meanwhile: nothing to write. const int index = m_document ? indexOf(m_document->layers, pending.layerID) : -1; if (result.failure) { qCWarning(lcApp).noquote() << "cannot invert:" << *result.failure; setBrushError(result.failure); } else if (index >= 0) { // Only the layer the invert was computed from is written. const ImageLayer ¤t = m_document->layers[size_t(index)]; const std::optional image = current.asset ? std::optional(current.asset->identity()) : std::nullopt; const std::optional maskImage = current.mask ? std::optional(current.mask->asset.identity()) : std::nullopt; if (image == pending.image && maskImage == pending.maskImage) { try { if (pending.mask) { const ImportedImage asset = LayerMask::assetFrom(*result.image); beginEdit(QStringLiteral("Invert Mask")); m_document->layers[size_t(index)].mask = current.mask.value().replacing(asset); } else { const ImportedImage asset(*result.image, PixelAdjust::thumbnail(*result.image), current.name); beginEdit(QStringLiteral("Invert")); m_document->layers[size_t(index)].asset = asset; // Swift rebuilds the layer without shape and text. m_document->layers[size_t(index)].shape = std::nullopt; m_document->layers[size_t(index)].text = std::nullopt; } endEdit(); ++m_brushRevision; } catch (const std::runtime_error &error) { qCWarning(lcApp).noquote() << "cannot invert:" << error.what(); setBrushError(QString::fromUtf8(error.what())); } } } setIsProjectBusy(false); if (pending.done) QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection); } PixelMove::PixelMove(std::shared_ptr raster, DocumentSelection origin, bool duplicate) : raster(std::move(raster)), origin(std::move(origin)), duplicate(duplicate) { } DocumentSelection PixelMove::movedSelection() const { return DocumentSelection{QTransform::fromTranslate(offset.width(), offset.height()).map(origin.path), origin.antialiased, origin.feather}; } // Ctrl-drag: starts moving the selected pixels; false with none. bool EditorSession::beginPixelMove(bool duplicate) { const std::optional current = selection(); const std::optional layer = activeLayer(); // `canPaint` refuses a pixel move and an empty selection. if (!current || !canPaint() || m_isMaskSelected || !layer || !layer->asset) return false; try { std::shared_ptr raster = makeRasterEdit(*layer); if (!raster->liftSelection()) return false; finishOpacityEdit(); m_pixelMove = std::make_unique(std::move(raster), *current, duplicate); notify(); return true; } catch (const ProjectError &error) { setBrushError(QString::fromUtf8(error.what())); } catch (const ExportError &error) { setBrushError(QString::fromUtf8(error.what())); } return false; } // The pixels previewed `offset` whole document pixels away. void EditorSession::movePixels(QSizeF offset) { if (!m_pixelMove) return; const QSizeF rounded(std::round(offset.width()), std::round(offset.height())); try { m_pixelMove->raster->moveLifted(rounded, m_pixelMove->duplicate); } catch (const ProjectError &error) { cancelPixelMove(); setBrushError(QString::fromUtf8(error.what())); return; } catch (const ExportError &error) { cancelPixelMove(); setBrushError(QString::fromUtf8(error.what())); return; } m_pixelMove->offset = rounded; ++m_brushRevision; notify(); } // The outline to draw, following a pixel move or transform. std::optional EditorSession::displayedSelection() const { if (m_pixelMove) return m_pixelMove->movedSelection(); // Transforming selected pixels: the outline follows the handles. const std::optional current = selection(); if (m_transformEdit && current) { if (const std::optional matrix = floatingSelectionTransform(*m_transformEdit)) return DocumentSelection{matrix->map(current->path), current->antialiased, current->feather}; } return current; } // Pixels and moved outline commit together, one step. void EditorSession::finishPixelMove(std::function done) { const auto finish = [this, done] { if (done) QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); }; if (!m_pixelMove || m_isProjectBusy) { finish(); return; } const auto end = [this, finish] { m_pixelMove.reset(); ++m_brushRevision; notify(); finish(); }; if (m_pixelMove->offset.isNull()) { end(); return; } // The outline stays at its new place while committing. const DocumentSelection moved = m_pixelMove->movedSelection(); commitRasterEdit(m_pixelMove->raster, m_pixelMove->duplicate ? QStringLiteral("Duplicate Pixels") : QStringLiteral("Move Pixels"), [this, moved] { m_document->selection = moved; }, end); } void EditorSession::cancelPixelMove() { if (!m_pixelMove) return; m_pixelMove.reset(); ++m_brushRevision; notify(); } // Ctrl-arrow: the selected pixels a step, as one undo step. void EditorSession::nudgePixels(double dx, double dy, std::function done) { if (!beginPixelMove()) { qCWarning(lcApp) << "no selected pixels to nudge"; if (done) QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); return; } movePixels(QSizeF(dx, dy)); finishPixelMove(std::move(done)); }