OmaPhoto 1.0.0: the Linux port of Compositor
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s

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:
雷电芽衣andClaude committed 2026-09-26 00:44:52 -04:00
commit c864304695
564 files changed
+100804

No files matched your search

+407
View File
@@ -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();
}