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,418 @@
|
||||
#include "Document/Selection.h"
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include <QPainterPathStroker>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
QPainterPath rectPath(const QRectF &rect)
|
||||
{
|
||||
QPainterPath path;
|
||||
path.addRect(rect);
|
||||
return path;
|
||||
}
|
||||
|
||||
QPainterPath canvasPath(const CanvasDocument &document)
|
||||
{
|
||||
return rectPath(QRectF(QPointF(0, 0), document.size()));
|
||||
}
|
||||
|
||||
void fill(QPainter &painter, const QPainterPath &path, bool antialiased)
|
||||
{
|
||||
painter.setRenderHint(QPainter::Antialiasing, antialiased);
|
||||
painter.fillPath(path, Qt::white);
|
||||
}
|
||||
}
|
||||
|
||||
QString rawValue(LassoKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case LassoKind::freehand:
|
||||
return QStringLiteral("Freehand");
|
||||
case LassoKind::polygonal:
|
||||
return QStringLiteral("Polygonal");
|
||||
case LassoKind::rectangle:
|
||||
return QStringLiteral("Rectangle");
|
||||
case LassoKind::ellipse:
|
||||
return QStringLiteral("Ellipse");
|
||||
}
|
||||
throw std::logic_error("unknown lasso kind");
|
||||
}
|
||||
|
||||
QString rawValue(SelectionMode mode)
|
||||
{
|
||||
switch (mode) {
|
||||
case SelectionMode::replace:
|
||||
return QStringLiteral("New");
|
||||
case SelectionMode::add:
|
||||
return QStringLiteral("Add");
|
||||
case SelectionMode::subtract:
|
||||
return QStringLiteral("Subtract");
|
||||
}
|
||||
throw std::logic_error("unknown selection mode");
|
||||
}
|
||||
|
||||
bool DocumentSelection::isEmpty() const
|
||||
{
|
||||
return path.isEmpty() || path.boundingRect().isEmpty();
|
||||
}
|
||||
|
||||
QImage DocumentSelection::coverage(int width, int height) const
|
||||
{
|
||||
QImage image = BrushRaster::context(width, height, true);
|
||||
QPainter painter(&image);
|
||||
fill(painter, path, antialiased);
|
||||
return image;
|
||||
}
|
||||
|
||||
SelectionClip DocumentSelection::clip(QSizeF canvas) const
|
||||
{
|
||||
const QRect region = path.boundingRect().adjusted(-1, -1, 1, 1).toAlignedRect()
|
||||
.intersected(QRect(0, 0, int(canvas.width()), int(canvas.height())));
|
||||
if (isEmpty() || region.isEmpty())
|
||||
return SelectionClip{QRectF(), std::nullopt};
|
||||
QImage image = BrushRaster::context(region.width(), region.height(), true);
|
||||
QPainter painter(&image);
|
||||
painter.translate(-region.x(), -region.y());
|
||||
fill(painter, path, antialiased);
|
||||
return SelectionClip{QRectF(region), image};
|
||||
}
|
||||
|
||||
QRectF DragBox::rect(QPointF anchor, QPointF point, bool square, bool fromCenter)
|
||||
{
|
||||
double dx = std::round(point.x()) - anchor.x(), dy = std::round(point.y()) - anchor.y();
|
||||
if (square) {
|
||||
const double side = std::max(std::abs(dx), std::abs(dy));
|
||||
dx = dx < 0 ? -side : side;
|
||||
dy = dy < 0 ? -side : side;
|
||||
}
|
||||
if (fromCenter)
|
||||
return QRectF(anchor.x() - std::abs(dx), anchor.y() - std::abs(dy), std::abs(dx) * 2, std::abs(dy) * 2);
|
||||
return QRectF(std::min(anchor.x(), anchor.x() + dx), std::min(anchor.y(), anchor.y() + dy), std::abs(dx), std::abs(dy));
|
||||
}
|
||||
|
||||
std::optional<DocumentSelection> EditorSession::selection() const
|
||||
{
|
||||
return m_document ? m_document->selection : std::nullopt;
|
||||
}
|
||||
|
||||
bool EditorSession::canEditSelection() const
|
||||
{
|
||||
return canEditLayers();
|
||||
}
|
||||
|
||||
SelectionMode EditorSession::selectionMode(bool shift, bool option) const
|
||||
{
|
||||
return option ? SelectionMode::subtract : shift ? SelectionMode::add : m_selectionModeChoice;
|
||||
}
|
||||
|
||||
SelectionMode EditorSession::lassoCursorMode(bool shift, bool option) const
|
||||
{
|
||||
return m_lassoDraft ? m_lassoDraft->mode : selectionMode(shift, option);
|
||||
}
|
||||
|
||||
SelectionMode EditorSession::displayedSelectionMode() const
|
||||
{
|
||||
return m_lassoDraft ? m_lassoDraft->mode : m_heldSelectionMode.value_or(m_selectionModeChoice);
|
||||
}
|
||||
|
||||
void EditorSession::updateHeldSelectionKeys(bool shift, bool option)
|
||||
{
|
||||
const std::optional<SelectionMode> held = option ? std::optional(SelectionMode::subtract) : shift ? std::optional(SelectionMode::add) : std::nullopt;
|
||||
if (m_heldSelectionMode == held)
|
||||
return;
|
||||
m_heldSelectionMode = held;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setLassoKind(LassoKind kind)
|
||||
{
|
||||
m_lassoKind = kind;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setMarqueeKind(LassoKind kind)
|
||||
{
|
||||
m_marqueeKind = kind;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setSelectionModeChoice(SelectionMode mode)
|
||||
{
|
||||
m_selectionModeChoice = mode;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setSelectionAntialiased(bool antialiased)
|
||||
{
|
||||
m_selectionAntialiased = antialiased;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setSelectionExpandAmount(int amount)
|
||||
{
|
||||
m_selectionExpandAmount = amount;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setSelectionContractAmount(int amount)
|
||||
{
|
||||
m_selectionContractAmount = amount;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::beginLasso(QPointF point, SelectionMode mode)
|
||||
{
|
||||
// The Magic Wand selects with a click, never an outline.
|
||||
if (!isSelectionTool(m_tool) || m_tool == NavigationTool::wand || !canEditSelection() || m_selectionMoveOrigin)
|
||||
return;
|
||||
if (m_tool == NavigationTool::marquee) {
|
||||
const QPointF anchor(std::round(point.x()), std::round(point.y()));
|
||||
m_lassoDraft = LassoDraft{{anchor}, std::nullopt, mode, m_marqueeKind, anchor};
|
||||
} else {
|
||||
m_lassoDraft = LassoDraft{{point}, std::nullopt, mode, m_lassoKind, std::nullopt};
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::dragMarquee(QPointF point, bool square, bool fromCenter)
|
||||
{
|
||||
if (!m_lassoDraft || (m_lassoDraft->kind != LassoKind::rectangle && m_lassoDraft->kind != LassoKind::ellipse) || !m_lassoDraft->anchor
|
||||
|| !std::isfinite(point.x()) || !std::isfinite(point.y()))
|
||||
return;
|
||||
const QRectF rect = DragBox::rect(*m_lassoDraft->anchor, point, square, fromCenter);
|
||||
m_lassoDraft->points = {rect.topLeft(), rect.topRight(), rect.bottomRight(), rect.bottomLeft()};
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::extendLasso(QPointF point)
|
||||
{
|
||||
if (!m_lassoDraft || !std::isfinite(point.x()) || !std::isfinite(point.y()))
|
||||
return;
|
||||
// Points closer than a quarter pixel are skipped.
|
||||
const QPointF last = m_lassoDraft->points.back();
|
||||
if (std::hypot(point.x() - last.x(), point.y() - last.y()) < 0.25)
|
||||
return;
|
||||
m_lassoDraft->points.push_back(point);
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::moveLassoCursor(std::optional<QPointF> point)
|
||||
{
|
||||
if (!m_lassoDraft)
|
||||
return;
|
||||
m_lassoDraft->cursor = point;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::removeLastLassoPoint()
|
||||
{
|
||||
if (!m_lassoDraft)
|
||||
return;
|
||||
m_lassoDraft->points.pop_back();
|
||||
if (m_lassoDraft->points.empty())
|
||||
m_lassoDraft = std::nullopt;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::cancelLasso()
|
||||
{
|
||||
if (!m_lassoDraft)
|
||||
return;
|
||||
m_lassoDraft = std::nullopt;
|
||||
notify();
|
||||
}
|
||||
|
||||
// The key chooses the tool; the bar switches its kind.
|
||||
void EditorSession::pressMarqueeKey()
|
||||
{
|
||||
selectTool(NavigationTool::marquee);
|
||||
}
|
||||
|
||||
void EditorSession::pressLassoKey()
|
||||
{
|
||||
selectTool(NavigationTool::lasso);
|
||||
}
|
||||
|
||||
void EditorSession::toggleMarqueeKind()
|
||||
{
|
||||
cancelLasso();
|
||||
m_marqueeKind = m_marqueeKind == LassoKind::rectangle ? LassoKind::ellipse : LassoKind::rectangle;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::toggleLassoKind()
|
||||
{
|
||||
cancelLasso();
|
||||
m_lassoKind = m_lassoKind == LassoKind::freehand ? LassoKind::polygonal : LassoKind::freehand;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::finishLasso()
|
||||
{
|
||||
if (!m_lassoDraft)
|
||||
return;
|
||||
const LassoDraft draft = *std::exchange(m_lassoDraft, std::nullopt);
|
||||
QPainterPath outline;
|
||||
outline.setFillRule(Qt::WindingFill);
|
||||
if (draft.kind == LassoKind::ellipse && draft.points.size() == 4) {
|
||||
// The drag's box, whole pixels; the oval fills it.
|
||||
double left = draft.points[0].x(), top = draft.points[0].y(), right = left, bottom = top;
|
||||
for (const QPointF &point : draft.points) {
|
||||
left = std::min(left, point.x());
|
||||
right = std::max(right, point.x());
|
||||
top = std::min(top, point.y());
|
||||
bottom = std::max(bottom, point.y());
|
||||
}
|
||||
outline.addEllipse(QRectF(left, top, right - left, bottom - top));
|
||||
} else {
|
||||
outline.moveTo(draft.points.front());
|
||||
for (std::size_t index = 1; index < draft.points.size(); ++index)
|
||||
outline.lineTo(draft.points[index]);
|
||||
outline.closeSubpath();
|
||||
}
|
||||
const QRectF bounds = outline.boundingRect();
|
||||
if ((draft.points.size() < 3 && draft.kind != LassoKind::ellipse) || bounds.width() <= 0 || bounds.height() <= 0) {
|
||||
// A click that encloses nothing deselects in New mode.
|
||||
if (draft.mode == SelectionMode::replace)
|
||||
deselect();
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
applySelection(outline, draft.mode,
|
||||
draft.kind == LassoKind::freehand ? QStringLiteral("Lasso") : draft.kind == LassoKind::polygonal ? QStringLiteral("Polygonal Lasso")
|
||||
: draft.kind == LassoKind::ellipse ? QStringLiteral("Elliptical Marquee") : QStringLiteral("Rectangular Marquee"));
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::applySelection(const QPainterPath &shape, SelectionMode mode, const QString &name)
|
||||
{
|
||||
if (!m_document || !canEditSelection())
|
||||
return;
|
||||
const QPainterPath clipped = shape.intersected(canvasPath(*m_document));
|
||||
const std::optional<DocumentSelection> current = selection();
|
||||
QPainterPath result;
|
||||
switch (mode) {
|
||||
case SelectionMode::replace:
|
||||
result = clipped;
|
||||
break;
|
||||
case SelectionMode::add:
|
||||
result = current ? current->path.united(clipped) : clipped;
|
||||
break;
|
||||
case SelectionMode::subtract:
|
||||
// Nothing to subtract from: nothing changes.
|
||||
if (!current)
|
||||
return;
|
||||
result = current->path.subtracted(clipped);
|
||||
break;
|
||||
}
|
||||
setSelection(DocumentSelection{result, m_selectionAntialiased}, name);
|
||||
}
|
||||
|
||||
void EditorSession::setSelection(std::optional<DocumentSelection> value, const QString &name)
|
||||
{
|
||||
if (!m_document || !canEditSelection() || value == selection())
|
||||
return;
|
||||
beginEdit(name);
|
||||
m_document->selection = std::move(value);
|
||||
endEdit();
|
||||
}
|
||||
|
||||
bool EditorSession::canMoveSelection(QPointF point) const
|
||||
{
|
||||
const std::optional<DocumentSelection> current = selection();
|
||||
if (!current || current->isEmpty() || !canEditSelection() || m_lassoDraft)
|
||||
return false;
|
||||
return current->path.contains(point);
|
||||
}
|
||||
|
||||
bool EditorSession::beginSelectionMove()
|
||||
{
|
||||
const std::optional<DocumentSelection> current = selection();
|
||||
if (m_selectionMoveOrigin || !current || current->isEmpty() || !canEditSelection())
|
||||
return false;
|
||||
beginEdit(QStringLiteral("Move Selection"));
|
||||
m_selectionMoveOrigin = current;
|
||||
return true;
|
||||
}
|
||||
|
||||
void EditorSession::moveSelection(QSizeF offset)
|
||||
{
|
||||
// The document may have gone mid-drag: nothing to move.
|
||||
if (!m_selectionMoveOrigin || !m_document)
|
||||
return;
|
||||
// Whole pixels, so edges stay crisp; not re-clipped.
|
||||
m_document->selection = DocumentSelection{m_selectionMoveOrigin->path.translated(std::round(offset.width()), std::round(offset.height())),
|
||||
m_selectionMoveOrigin->antialiased};
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::endSelectionMove()
|
||||
{
|
||||
if (!m_selectionMoveOrigin)
|
||||
return;
|
||||
m_selectionMoveOrigin = std::nullopt;
|
||||
endEdit();
|
||||
}
|
||||
|
||||
void EditorSession::nudgeSelection(double dx, double dy)
|
||||
{
|
||||
if (!beginSelectionMove())
|
||||
return;
|
||||
moveSelection(QSizeF(dx, dy));
|
||||
endSelectionMove();
|
||||
}
|
||||
|
||||
bool EditorSession::canModifySelection() const
|
||||
{
|
||||
const std::optional<DocumentSelection> current = selection();
|
||||
return current && !current->isEmpty() && canEditSelection() && !m_lassoDraft;
|
||||
}
|
||||
|
||||
void EditorSession::expandSelection(int amount)
|
||||
{
|
||||
resizeSelection(amount, QStringLiteral("Expand Selection"));
|
||||
}
|
||||
|
||||
void EditorSession::contractSelection(int amount)
|
||||
{
|
||||
resizeSelection(-amount, QStringLiteral("Contract Selection"));
|
||||
}
|
||||
|
||||
void EditorSession::resizeSelection(double delta, const QString &name)
|
||||
{
|
||||
const std::optional<DocumentSelection> current = selection();
|
||||
if (!m_document || !current || !canModifySelection() || delta == 0 || std::abs(delta) > 500)
|
||||
return;
|
||||
// A band `|delta|` wide on each side, added or removed.
|
||||
QPainterPathStroker stroker;
|
||||
stroker.setWidth(std::abs(delta) * 2);
|
||||
stroker.setCapStyle(Qt::RoundCap);
|
||||
stroker.setJoinStyle(Qt::RoundJoin);
|
||||
const QPainterPath band = stroker.createStroke(current->path);
|
||||
const QPainterPath result = delta > 0 ? current->path.united(band).intersected(canvasPath(*m_document)) : current->path.subtracted(band);
|
||||
setSelection(DocumentSelection{result, current->antialiased}, name);
|
||||
}
|
||||
|
||||
void EditorSession::selectAll()
|
||||
{
|
||||
if (!m_document)
|
||||
return;
|
||||
setSelection(DocumentSelection{canvasPath(*m_document)}, QStringLiteral("Select All"));
|
||||
}
|
||||
|
||||
void EditorSession::deselect()
|
||||
{
|
||||
if (!selection())
|
||||
return;
|
||||
setSelection(std::nullopt, QStringLiteral("Deselect"));
|
||||
}
|
||||
|
||||
void EditorSession::invertSelection()
|
||||
{
|
||||
const std::optional<DocumentSelection> current = selection();
|
||||
if (!m_document || !current)
|
||||
return;
|
||||
setSelection(DocumentSelection{canvasPath(*m_document).subtracted(current->path), current->antialiased}, QStringLiteral("Inverse"));
|
||||
}
|
||||
Reference in new issue
Block a user