OmaPhoto 1.0.0: the Linux port of Compositor
A layered image editor for Linux and Omarchy, ported from Compositor (github.com/robbietilton/Compositor), whose Swift source is its specification. It reads and writes Compositor's .comp projects. Packages for Ubuntu, Fedora and Arch come from the release workflow; scripts/install/ holds one install script per distribution family, and flake.nix serves NixOS. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
c864304695
564 files changed
+100804
No files matched your search
@@ -0,0 +1,407 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "IO/ProjectStore.h"
|
||||
#include "Logging.h"
|
||||
#include "Rendering/RasterSnapshot.h"
|
||||
#include <QtConcurrent>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
|
||||
// Swift's EditorSession+Brush: the stroke from press to commit.
|
||||
namespace {
|
||||
bool sameAsset(const std::optional<ImportedImage> &a, const std::optional<ImportedImage> &b)
|
||||
{
|
||||
return a.has_value() == b.has_value() && (!a || a->identity() == b->identity());
|
||||
}
|
||||
|
||||
bool sameMask(const std::optional<LayerMask> &a, const std::optional<LayerMask> &b)
|
||||
{
|
||||
return a.has_value() == b.has_value() && (!a || a->asset.identity() == b->asset.identity());
|
||||
}
|
||||
|
||||
double uptime()
|
||||
{
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
}
|
||||
|
||||
bool EditorSession::canPaint() const
|
||||
{
|
||||
const std::optional<ImageLayer> layer = activeLayer();
|
||||
const std::optional<DocumentSelection> current = selection();
|
||||
// A folder has no pixels: only its mask paints.
|
||||
return canEditLayers() && m_selectedLayerIDs.size() == 1 && layer && (!layer->isGroup || m_isMaskSelected) && !(current && current->isEmpty())
|
||||
&& m_document->effectiveVisibleIDs().contains(layer->id) && (!m_isMaskSelected || (layer->mask && layer->mask->isEnabled))
|
||||
&& (m_isMaskSelected || !layer->adjustment);
|
||||
}
|
||||
|
||||
// A tiled raster edit of pixels or mask, within budget.
|
||||
std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings) const
|
||||
{
|
||||
if (!m_document)
|
||||
throw ProjectError(ProjectError::Kind::tooLarge);
|
||||
auto stroke = std::make_unique<BrushStroke>(layer, m_isMaskSelected, settings, m_document->size());
|
||||
qint64 used = 0;
|
||||
for (const ImageLayer &other : m_document->layers) {
|
||||
if (other.id == layer.id)
|
||||
continue;
|
||||
const std::optional<ImportedImage> &image = m_isMaskSelected ? (other.mask ? std::optional(other.mask->asset) : std::nullopt) : other.asset;
|
||||
if (image)
|
||||
used += qint64(image->size().width()) * image->size().height();
|
||||
}
|
||||
stroke->pixelLimit = 100'000'000 - used;
|
||||
const std::optional<DocumentSelection> current = selection();
|
||||
if (current)
|
||||
stroke->selectionClip = current->clip(m_document->size());
|
||||
if (!m_isMaskSelected && layer.mask) {
|
||||
qint64 maskPixels = 0;
|
||||
for (const ImageLayer &other : m_document->layers) {
|
||||
if (other.id != layer.id && other.mask)
|
||||
maskPixels += qint64(other.mask->asset.size().width()) * other.mask->asset.size().height();
|
||||
}
|
||||
stroke->pixelLimit = std::min<qint64>(stroke->pixelLimit, 100'000'000 - maskPixels);
|
||||
}
|
||||
return stroke;
|
||||
}
|
||||
|
||||
void EditorSession::beginBrush(QPointF point)
|
||||
{
|
||||
if (m_tool == NavigationTool::blur && m_blurMode != BlurToolMode::blur) {
|
||||
beginWarp(point);
|
||||
return;
|
||||
}
|
||||
// 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())
|
||||
return;
|
||||
const ImageLayer layer = activeLayer().value();
|
||||
std::optional<BrushStroke::Clone> clone;
|
||||
if (m_tool == NavigationTool::cloneStamp) {
|
||||
const std::optional<QSizeF> offset = cloneStrokeOffset(point);
|
||||
if (!offset) {
|
||||
setBrushError(QStringLiteral("Alt-click where Clone Stamp should copy from first."));
|
||||
return;
|
||||
}
|
||||
const std::optional<QImage> image = cloneSample(m_document.value());
|
||||
if (!image)
|
||||
return;
|
||||
m_cloneOffset = offset;
|
||||
clone = BrushStroke::Clone{*image, *offset};
|
||||
}
|
||||
// Blur paints a softened copy of the layer, in place.
|
||||
if (m_tool == NavigationTool::blur) {
|
||||
const std::optional<QImage> image = blurSample(m_document.value(), m_isMaskSelected);
|
||||
if (!image)
|
||||
return;
|
||||
clone = BrushStroke::Clone{*image, QSizeF(0, 0)};
|
||||
}
|
||||
finishOpacityEdit();
|
||||
try {
|
||||
BrushSettings settings = m_brushSettings;
|
||||
settings.healing = m_tool == NavigationTool::spotHealing;
|
||||
settings.erasing = m_tool == NavigationTool::brush && m_brushMode == BrushToolMode::erase && !m_isMaskSelected;
|
||||
settings.healingMode = m_spotHealingMode;
|
||||
if (m_isMaskSelected) {
|
||||
settings.red = m_maskPaintWhite ? 1 : 0;
|
||||
settings.green = settings.red;
|
||||
settings.blue = settings.red;
|
||||
}
|
||||
m_brushStroke = makeRasterEdit(layer, settings);
|
||||
m_brushStroke->clone = clone;
|
||||
m_brushStroke->isBlur = m_tool == NavigationTool::blur;
|
||||
m_brushStroke->append(point);
|
||||
m_lastBrushPoint = LastBrushPoint{point, layer.id, m_isMaskSelected};
|
||||
++m_brushRevision;
|
||||
resumeFileRequests();
|
||||
notify();
|
||||
} catch (const ProjectError &error) {
|
||||
cancelBrush();
|
||||
setBrushError(QString::fromUtf8(error.what()));
|
||||
} catch (const ExportError &error) {
|
||||
cancelBrush();
|
||||
setBrushError(QString::fromUtf8(error.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void EditorSession::continueBrush(QPointF point)
|
||||
{
|
||||
if (m_warpStroke) {
|
||||
m_warpStroke->append(point);
|
||||
m_lastBrushPoint->point = point;
|
||||
++m_brushRevision;
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
if (!m_brushStroke)
|
||||
return;
|
||||
try {
|
||||
m_brushStroke->append(point);
|
||||
m_lastBrushPoint->point = point;
|
||||
++m_brushRevision;
|
||||
notify();
|
||||
} catch (const ProjectError &error) {
|
||||
cancelBrush();
|
||||
setBrushError(QString::fromUtf8(error.what()));
|
||||
} catch (const ExportError &error) {
|
||||
cancelBrush();
|
||||
setBrushError(QString::fromUtf8(error.what()));
|
||||
}
|
||||
}
|
||||
|
||||
// Where a Shift-click's line starts, on the same target.
|
||||
std::optional<QPointF> EditorSession::shiftLineStart() const
|
||||
{
|
||||
if (!m_lastBrushPoint || m_lastBrushPoint->layerID != m_activeLayerID || m_lastBrushPoint->mask != m_isMaskSelected)
|
||||
return std::nullopt;
|
||||
return m_lastBrushPoint->point;
|
||||
}
|
||||
|
||||
void EditorSession::cancelBrush()
|
||||
{
|
||||
m_warpStroke.reset();
|
||||
m_brushStroke.reset();
|
||||
++m_brushRevision;
|
||||
resumeFileRequests();
|
||||
notify();
|
||||
}
|
||||
|
||||
// Mouse-up's own call, before the next event arrives.
|
||||
bool EditorSession::finishBrushImmediately()
|
||||
{
|
||||
if (m_warpStroke) {
|
||||
if (m_isProjectBusy)
|
||||
return false;
|
||||
finishWarp();
|
||||
return true;
|
||||
}
|
||||
if (!m_brushStroke)
|
||||
return true;
|
||||
if (m_isProjectBusy)
|
||||
return false;
|
||||
try {
|
||||
m_brushStroke->flush();
|
||||
if (m_brushStroke->settings.healing)
|
||||
m_brushStroke->heal();
|
||||
if (!m_brushStroke->patches().empty())
|
||||
commitPaintSnapshot(*m_brushStroke);
|
||||
} catch (const ProjectError &error) {
|
||||
setBrushError(QString::fromUtf8(error.what()));
|
||||
} catch (const ExportError &error) {
|
||||
setBrushError(QString::fromUtf8(error.what()));
|
||||
}
|
||||
cancelBrush();
|
||||
return true;
|
||||
}
|
||||
|
||||
void EditorSession::finishBrush()
|
||||
{
|
||||
finishBrushImmediately();
|
||||
}
|
||||
|
||||
// Immutable tiles land at once, undo entry included.
|
||||
void EditorSession::commitPaintSnapshot(const BrushStroke &stroke)
|
||||
{
|
||||
const PaintSnapshot result = stroke.paintSnapshot();
|
||||
const int index = m_document ? indexOf(m_document->layers, stroke.layer.id) : -1;
|
||||
if (!result.transform.isValid() || index < 0)
|
||||
return;
|
||||
const ImageLayer current = m_document->layers[index];
|
||||
if (!sameAsset(current.asset, stroke.layer.asset) || current.transform != stroke.layer.transform)
|
||||
return;
|
||||
std::optional<LayerMask> mask = current.mask;
|
||||
if (!stroke.isMask && mask && !mask->placement && result.bounds != stroke.sourceRect) {
|
||||
// The mask grows with the layer, revealing past it.
|
||||
const std::shared_ptr<const RasterSnapshot> raster = RasterSnapshot::replacing(mask->asset, stroke.sourceRect, {}, result.bounds, true);
|
||||
mask = mask->replacing(ImportedImage(raster, raster->thumbnail(), mask->asset.name));
|
||||
}
|
||||
beginEdit(stroke.editName.value_or(stroke.isMask ? QStringLiteral("Paint Mask")
|
||||
: stroke.settings.erasing ? QStringLiteral("Erase")
|
||||
: stroke.isBlur ? QStringLiteral("Blur")
|
||||
: stroke.clone ? QStringLiteral("Clone Stamp")
|
||||
: stroke.settings.healing ? QStringLiteral("Spot Healing")
|
||||
: QStringLiteral("Brush Stroke")));
|
||||
ImageLayer &layer = m_document->layers[index];
|
||||
if (stroke.isMask) {
|
||||
layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset);
|
||||
} else {
|
||||
layer.asset = result.asset;
|
||||
layer.transform = result.transform;
|
||||
layer.isGroup = false;
|
||||
layer.mask = mask;
|
||||
// Swift rebuilds the layer; effects stay, shape and text go.
|
||||
layer.shape = std::nullopt;
|
||||
layer.text = std::nullopt;
|
||||
}
|
||||
endEdit();
|
||||
}
|
||||
|
||||
// A raster edit assembled off the UI thread; one step.
|
||||
void EditorSession::commitRasterEdit(std::shared_ptr<const BrushStroke> stroke, const QString &name, std::function<void()> alsoApply, std::function<void()> done)
|
||||
{
|
||||
// The caller resumes from the event loop, like await.
|
||||
const auto finish = [this, done] {
|
||||
if (done)
|
||||
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
|
||||
};
|
||||
if (!stroke->committedTransform().isValid()) {
|
||||
setBrushError(QString::fromUtf8(ProjectError(ProjectError::Kind::tooLarge).what()));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
setIsProjectBusy(true);
|
||||
const BrushCommit::Input input = stroke->commitInput();
|
||||
m_committingRaster = CommittingRaster{std::move(stroke), name, std::move(alsoApply), std::move(done)};
|
||||
m_rasterCommit.setFuture(QtConcurrent::run([input]() -> RasterMade {
|
||||
try {
|
||||
return RasterMade{BrushCommit::render(input), std::nullopt};
|
||||
} catch (const ExportError &error) {
|
||||
return RasterMade{std::nullopt, QString::fromUtf8(error.what())};
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void EditorSession::finishRasterCommit()
|
||||
{
|
||||
const RasterMade made = m_rasterCommit.result();
|
||||
const CommittingRaster pending = std::move(m_committingRaster.value());
|
||||
m_committingRaster = std::nullopt;
|
||||
const auto finish = [&] {
|
||||
setIsProjectBusy(false);
|
||||
++m_brushRevision;
|
||||
notify();
|
||||
if (pending.done)
|
||||
QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection);
|
||||
};
|
||||
if (made.failure) {
|
||||
setBrushError(made.failure);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
const BrushStroke &stroke = *pending.stroke;
|
||||
const BrushCommit::Output &result = *made.output;
|
||||
try {
|
||||
const LayerTransform transform = stroke.transform(result.pixelBounds.translated(stroke.committedBounds().topLeft()));
|
||||
if (!transform.isValid())
|
||||
throw ProjectError(ProjectError::Kind::tooLarge);
|
||||
std::optional<LayerMask> mask = stroke.layer.mask;
|
||||
if (!stroke.isMask && mask && !mask->placement)
|
||||
mask = mask->replacing(BrushCommit::expandMask(mask->asset, stroke.commitInput(), result.pixelBounds));
|
||||
// Never over content that changed underneath it.
|
||||
const int index = m_document ? indexOf(m_document->layers, stroke.layer.id) : -1;
|
||||
if (index < 0) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
const ImageLayer current = m_document->layers[index];
|
||||
if (!sameAsset(current.asset, stroke.layer.asset) || current.transform != stroke.layer.transform || !sameMask(current.mask, stroke.layer.mask)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
beginEdit(pending.name);
|
||||
ImageLayer &layer = m_document->layers[index];
|
||||
if (stroke.isMask) {
|
||||
layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset);
|
||||
} else {
|
||||
layer.asset = result.asset;
|
||||
layer.transform = transform;
|
||||
layer.isGroup = false;
|
||||
// Swift rebuilds the layer; effects stay, shape and text go.
|
||||
layer.shape = std::nullopt;
|
||||
layer.text = std::nullopt;
|
||||
if (mask && current.mask)
|
||||
mask->isEnabled = current.mask->isEnabled;
|
||||
layer.mask = mask;
|
||||
}
|
||||
if (pending.alsoApply)
|
||||
pending.alsoApply();
|
||||
endEdit();
|
||||
} catch (const ProjectError &error) {
|
||||
setBrushError(QString::fromUtf8(error.what()));
|
||||
} catch (const ExportError &error) {
|
||||
setBrushError(QString::fromUtf8(error.what()));
|
||||
}
|
||||
finish();
|
||||
}
|
||||
|
||||
bool EditorSession::usesOpacityKeys() const
|
||||
{
|
||||
return isBrushTool(m_tool) || m_tool == NavigationTool::gradient || m_tool == NavigationTool::move;
|
||||
}
|
||||
|
||||
void EditorSession::typeOpacityDigit(int digit, std::optional<double> time)
|
||||
{
|
||||
if (!usesOpacityKeys() || m_brushStroke || m_isProjectBusy || digit < 0 || digit > 9)
|
||||
return;
|
||||
const double now = time.value_or(uptime());
|
||||
int percent = digit == 0 ? 100 : digit * 10;
|
||||
if (m_pendingOpacityDigit && now - m_pendingOpacityDigit->time < 0.6) {
|
||||
percent = std::max(1, m_pendingOpacityDigit->digit * 10 + digit);
|
||||
m_pendingOpacityDigit = std::nullopt;
|
||||
} else {
|
||||
m_pendingOpacityDigit = PendingOpacityDigit{digit, now};
|
||||
}
|
||||
const double value = percent / 100.0;
|
||||
if (isBrushTool(m_tool)) {
|
||||
m_brushSettings.opacity = value;
|
||||
notify();
|
||||
} else if (m_tool == NavigationTool::gradient) {
|
||||
setGradientSettings(GradientSettings{m_gradientSettings.shape, m_gradientSettings.style, m_gradientSettings.reversed, value});
|
||||
} else {
|
||||
setSelectedLayersOpacity(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Shift-[ and Shift-]: hardness in Photoshop's quarters.
|
||||
void EditorSession::changeBrushHardness(bool increase)
|
||||
{
|
||||
if (m_brushStroke)
|
||||
return;
|
||||
const double quarter = m_brushSettings.hardness * 4;
|
||||
const double step = increase ? std::floor(quarter + 0.001) + 1 : std::ceil(quarter - 0.001) - 1;
|
||||
m_brushSettings.hardness = std::min(4.0, std::max(0.0, step)) / 4;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::changeBrushSize(bool increase)
|
||||
{
|
||||
if (m_brushStroke)
|
||||
return;
|
||||
// A fifth, at least a pixel: 2 would stay 2.
|
||||
const double current = m_brushSettings.diameter;
|
||||
const double stepped = increase ? std::max(current + 1, std::round(current * 1.2)) : std::min(current - 1, std::round(current / 1.2));
|
||||
m_brushSettings.diameter = std::min(2000.0, std::max(1.0, stepped));
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setBrushSettings(const BrushSettings &settings)
|
||||
{
|
||||
m_brushSettings = settings;
|
||||
refreshGradient();
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setBrushMode(BrushToolMode mode)
|
||||
{
|
||||
m_brushMode = mode;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setBlurMode(BlurToolMode mode)
|
||||
{
|
||||
m_blurMode = mode;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setSpotHealingMode(SpotHealingMode mode)
|
||||
{
|
||||
m_spotHealingMode = mode;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setMaskPaintWhite(bool white)
|
||||
{
|
||||
m_maskPaintWhite = white;
|
||||
refreshGradient();
|
||||
notify();
|
||||
}
|
||||
Reference in new issue
Block a user