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

+453
View File
@@ -0,0 +1,453 @@
#include "ContentView.h"
#include "UI/ColorPaletteControls.h"
#include "Logging.h"
#include "Document/ProjectWorkspace.h"
#include "IO/ImageFileDrop.h"
#include "UI/LayersPanel.h"
#include "UI/BrushControls.h"
#include "UI/CropControls.h"
#include "UI/GradientControls.h"
#include "UI/ShapeControls.h"
#include "UI/TypeControls.h"
#include "UI/LassoControls.h"
#include "UI/NavigationToolHeader.h"
#include "UI/TransformInspector.h"
#include "UI/NewCanvasSheet.h"
#include "UI/ToolHeaderStyle.h"
#include "UI/ToolIcons.h"
#include <QCheckBox>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QFrame>
#include <QLocale>
#include <QPainter>
#include <QProgressBar>
#include <QMouseEvent>
#include <QScrollArea>
#include <QSettings>
#include <cmath>
#include <map>
namespace {
const NavigationTool railTools[] = {NavigationTool::move, NavigationTool::marquee, NavigationTool::lasso, NavigationTool::wand,
NavigationTool::crop, NavigationTool::brush, NavigationTool::spotHealing, NavigationTool::cloneStamp,
NavigationTool::blur, NavigationTool::gradient, NavigationTool::shape, NavigationTool::type,
NavigationTool::eyedropper, NavigationTool::hand, NavigationTool::zoom};
// Swift's Eyedropper bar: its title and the ring's checkbox.
ToolHeaderBar *eyedropperBar(EditorSession &session, QWidget *parent)
{
auto *bar = new ToolHeaderBar(QStringLiteral("Eyedropper"), parent);
auto *ring = new QCheckBox(QStringLiteral("Sample Ring"), bar);
ring->setObjectName(QStringLiteral("sampleRing"));
ring->setChecked(session.showsSampleRing());
QObject::connect(ring, &QCheckBox::toggled, &session, &EditorSession::setShowsSampleRing);
// It follows the session, which others may set.
QObject::connect(&session, &EditorSession::changed, ring, [ring, &session] {
const QSignalBlocker quiet(ring);
ring->setChecked(session.showsSampleRing());
});
bar->row->setSpacing(16);
bar->row->insertWidget(1, ring);
return bar;
}
}
// A rail button: its tool's icon; a plate when chosen.
class ToolButton : public QToolButton {
public:
ToolButton(NavigationTool tool, QWidget *parent) : QToolButton(parent), m_tool(tool)
{
setCheckable(true);
setFixedSize(36, 36);
setFocusPolicy(Qt::NoFocus);
setToolTip(label(tool));
setAccessibleName(label(tool));
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QColor ink = palette().color(QPalette::WindowText);
if (isChecked()) {
QColor plate = ink, edge = ink;
plate.setAlphaF(0.12);
edge.setAlphaF(0.14);
painter.setPen(edge);
painter.setBrush(plate);
painter.drawRoundedRect(QRectF(0.5, 0.5, 35, 35), 7, 7);
}
ToolIcons::paint(painter, m_tool, QPointF(9, 9), ToolIcons::points, ink, m_kind);
}
public:
// The Marquee's and the Lasso's icons follow their kinds.
void setKind(ToolIconKind kind)
{
if (kind == m_kind)
return;
m_kind = kind;
update();
}
private:
const NavigationTool m_tool;
ToolIconKind m_kind = ToolIconKind::plain;
};
namespace {
// Swift's PanelResizeEdge: a divider with an 8-point grip.
class PanelResizeEdge : public QWidget {
public:
PanelResizeEdge(LayersPanel &panel, QWidget *parent) : QWidget(parent), m_panel(panel)
{
setObjectName(QStringLiteral("layersPanelEdge"));
setFixedWidth(8);
setCursor(Qt::SplitHCursor);
setToolTip(QStringLiteral("Drag to resize the panel"));
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.fillRect(QRect(width() / 2, 0, 1, height()), palette().color(QPalette::Mid));
}
void mousePressEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton)
m_start = std::pair(event->globalPosition().x(), double(m_panel.width()));
}
void mouseMoveEvent(QMouseEvent *event) override
{
if (!m_start)
return;
// Dragged left, the panel widens; whole points, within range.
const double wanted = std::round(m_start->second - (event->globalPosition().x() - m_start->first));
const double width = std::min(LayersPanel::maximumWidth, std::max(LayersPanel::minimumWidth, wanted));
if (width == m_panel.width())
return;
m_panel.setFixedWidth(int(width));
ContentView::setLayersPanelWidth(width);
}
void mouseReleaseEvent(QMouseEvent *) override { m_start = std::nullopt; }
private:
LayersPanel &m_panel;
std::optional<std::pair<double, double>> m_start;
};
// Swift's overlay: a 3-point accent ring inside the canvas.
class DropRing : public QWidget {
public:
explicit DropRing(QWidget *parent) : QWidget(parent)
{
setObjectName(QStringLiteral("dropRing"));
setAttribute(Qt::WA_TransparentForMouseEvents);
hide();
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QPen(palette().color(QPalette::Highlight), 3));
painter.setBrush(Qt::NoBrush);
painter.drawRoundedRect(QRectF(rect()).adjusted(4.5, 4.5, -4.5, -4.5), 8, 8);
}
};
QFrame *divider(QFrame::Shape shape, QWidget *parent)
{
auto *line = new QFrame(parent);
line->setFrameShape(shape);
line->setFrameShadow(QFrame::Plain);
line->setForegroundRole(QPalette::Mid);
return line;
}
QLabel *statusText(QLabel *label)
{
QFont font = label->font();
font.setPixelSize(11);
label->setFont(font);
label->setForegroundRole(QPalette::PlaceholderText);
return label;
}
// A row's widest item shrinks first; this ends in dots.
class ElidedLabel : public QLabel {
public:
using QLabel::QLabel;
protected:
void paintEvent(QPaintEvent *) override
{
// A widget's painter starts with its foreground role's pen.
QPainter painter(this);
painter.drawText(rect(), Qt::AlignRight | Qt::AlignVCenter, fontMetrics().elidedText(text(), Qt::ElideRight, width()));
}
};
}
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_dropRing(new DropRing(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))
{
setMinimumSize(800, 520);
m_column->setContentsMargins(0, 0, 0, 0);
m_column->setSpacing(0);
auto *tools = new QWidget(this);
auto *toolColumn = new QVBoxLayout(tools);
// Room for the palette's overhang; the tools stay centred.
toolColumn->setContentsMargins(7, 16, 7, 12);
toolColumn->setSpacing(10);
for (const NavigationTool tool : railTools) {
auto *button = new ToolButton(tool, tools);
// A refused click stays silent: set the buttons again.
connect(button, &QToolButton::clicked, this, [this, tool] {
m_session.selectTool(tool);
synchronize();
});
toolColumn->addWidget(button, 0, Qt::AlignHCenter);
m_toolButtons.push_back({tool, button});
}
// Swift's 8 points above the swatches, less their overhang.
toolColumn->addSpacing(5);
toolColumn->addWidget(new ColorPaletteControls(session, tools), 0, Qt::AlignHCenter);
toolColumn->addStretch(1);
// Too short a window scrolls the rail, not the bars.
auto *rail = new QScrollArea(this);
rail->setObjectName(QStringLiteral("toolRail"));
rail->setWidget(tools);
rail->setWidgetResizable(true);
rail->setFixedWidth(56);
rail->setFrameShape(QFrame::NoFrame);
rail->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
rail->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
auto *canvas = new QWidget(this);
canvas->setLayout(m_canvasSlot);
// Swift's canvas fills its area: no layout gutter.
m_canvasSlot->setContentsMargins(0, 0, 0, 0);
// The welcome sits over the canvas, as Swift's ZStack.
m_canvasSlot->addWidget(m_canvas, 0, 0);
m_canvasSlot->addWidget(m_dropRing, 0, 0);
setAcceptDrops(true);
auto *middle = new QHBoxLayout;
middle->setSpacing(0);
middle->addWidget(rail);
middle->addWidget(divider(QFrame::VLine, this));
middle->addWidget(canvas, 1);
m_layersPanel->setFixedWidth(int(layersPanelWidth()));
middle->addWidget(new PanelResizeEdge(*m_layersPanel, this));
middle->addWidget(m_layersPanel);
m_zoom->setObjectName(QStringLiteral("zoomStatus"));
m_zoom->setFixedWidth(62);
m_dimensions->setObjectName(QStringLiteral("canvasDimensions"));
m_colour->setObjectName(QStringLiteral("colourStatus"));
m_activity->setObjectName(QStringLiteral("activityStatus"));
m_spinner->setRange(0, 0);
m_spinner->setTextVisible(false);
m_spinner->setFixedSize(36, 6);
auto *status = new QWidget(this);
status->setObjectName(QStringLiteral("statusBar"));
status->setFixedHeight(30);
auto *statusRow = new QHBoxLayout(status);
statusRow->setContentsMargins(18, 0, 18, 0);
statusRow->setSpacing(16);
statusRow->addWidget(m_zoom);
statusRow->addWidget(m_dimensions);
statusRow->addWidget(m_colour);
statusRow->addWidget(m_spinner, 0, Qt::AlignRight);
// The hint takes what is left, and shrinks first.
statusRow->addWidget(m_activity, 1);
m_column->addWidget(divider(QFrame::HLine, this));
m_column->addLayout(middle, 1);
m_column->addWidget(divider(QFrame::HLine, this));
m_column->addWidget(status);
// The filter panel follows the session by itself.
connect(&m_session, &EditorSession::changed, this, &ContentView::synchronize);
synchronize();
}
// ~QWidget closes the window after members go; stop listening first.
ContentView::~ContentView()
{
disconnect(&m_session, &EditorSession::changed, this, &ContentView::synchronize);
}
// Another tab's layer, files or a picture, while free.
bool ContentView::acceptsDrop(const QMimeData &data) const
{
if (!(data.hasFormat(ProjectWorkspace::layerType) || ImageFileDrop::holdsImages(data)))
return false;
// Swift's session guards; a workspace's are canSwitch's own.
if (!m_workspace)
return !m_session.levels() && !m_session.isProjectBusy() && !m_session.showsNewDocument() && !m_session.showsImporter()
&& !m_session.renamingLayerID();
return m_workspace->canSwitch() && m_workspace->canReceiveDrag(data, m_workspace->current().id);
}
void ContentView::dragEnterEvent(QDragEnterEvent *event)
{
if (!acceptsDrop(*event->mimeData()))
return;
event->setDropAction(Qt::CopyAction);
event->accept();
m_dropRing->raise();
m_dropRing->show();
}
void ContentView::dragMoveEvent(QDragMoveEvent *event)
{
if (!acceptsDrop(*event->mimeData())) {
event->ignore();
return;
}
event->setDropAction(Qt::CopyAction);
event->accept();
}
void ContentView::dragLeaveEvent(QDragLeaveEvent *)
{
m_dropRing->hide();
}
void ContentView::dropEvent(QDropEvent *event)
{
m_dropRing->hide();
if (!acceptsDrop(*event->mimeData()))
return;
event->setDropAction(Qt::CopyAction);
event->accept();
// Over the canvas the drop names a document point.
std::optional<QPointF> point;
const QPointF inCanvas = m_canvas->mapFrom(this, event->position().toPoint());
if (m_session.document() && m_canvas->rect().contains(inCanvas.toPoint()))
point = m_session.viewport.documentPoint(inCanvas, m_session.document()->size());
if (m_workspace)
m_workspace->receiveProviders(*event->mimeData(), m_workspace->current().id, point);
else
ImageFileDrop::importProviders(*event->mimeData(), m_session, point);
}
double ContentView::layersPanelWidth()
{
const double stored = QSettings().value(QStringLiteral("layersPanelWidth"), LayersPanel::defaultWidth).toDouble();
// A stored width outside the range is the default, logged.
if (stored >= LayersPanel::minimumWidth && stored <= LayersPanel::maximumWidth)
return stored;
qCWarning(lcApp) << "layersPanelWidth" << stored << "is out of range, using" << LayersPanel::defaultWidth;
return LayersPanel::defaultWidth;
}
void ContentView::setLayersPanelWidth(double width)
{
QSettings().setValue(QStringLiteral("layersPanelWidth"), width);
}
// Shows what the session holds: every line compares, then sets.
void ContentView::synchronize()
{
m_canvas->consumeFocusRequest(m_session.canvasFocusRequest());
m_canvas->synchronizeDisplay();
if (m_session.document() && !m_hadDocument)
QMetaObject::invokeMethod(&m_session, &EditorSession::requestCanvasFocus, Qt::QueuedConnection);
m_hadDocument = m_session.document().has_value();
showHeader(m_session.tool());
for (const auto &[tool, button] : m_toolButtons) {
button->setChecked(m_session.tool() == tool);
button->setKind(iconKind(tool));
}
const std::optional<CanvasDocument> &document = m_session.document();
showWelcome(!document);
m_zoom->setVisible(document.has_value());
m_dimensions->setVisible(document.has_value());
if (document) {
const QLocale english(QLocale::English, QLocale::UnitedStates);
m_zoom->setText(percent(m_session.viewport.zoom()));
m_dimensions->setText(QStringLiteral("%1 × %2 px").arg(english.toString(document->width), english.toString(document->height)));
}
m_colour->setText(document ? QStringLiteral("sRGB · Transparent") : QStringLiteral("Ready when you are"));
m_spinner->setVisible(m_session.showsBusy() || m_session.isImporting());
m_activity->setText(m_session.showsBusy() ? QStringLiteral("Working…") : m_session.isImporting() ? QStringLiteral("Importing images…")
: hint(m_session.tool(), iconKind(m_session.tool()), m_session.blurMode(), m_session.shapeKind()));
showImporter();
showAlert(m_importAlert, QStringLiteral("Import couldn’t finish"), m_session.importError(), [this] { m_session.setImportError(std::nullopt); });
showAlert(m_brushAlert, QStringLiteral("Couldn’t paint"), m_session.brushError(), [this] { m_session.setBrushError(std::nullopt); });
showAlert(m_cropAlert, QStringLiteral("Couldn’t crop"), m_session.cropError(), [this] { m_session.setCropError(std::nullopt); });
synchronizePanels();
}
// Swift's `if document == nil` makes a fresh sheet.
void ContentView::showWelcome(bool shown)
{
if (shown == (m_welcome != nullptr))
return;
delete m_welcome;
m_welcome = nullptr;
if (!shown)
return;
m_welcome = new NewCanvasSheet(
m_session, [this](int width, int height) { m_session.createNewProject(width, height); },
[this] {
if (m_projects)
m_projects->open();
},
this);
m_canvasSlot->addWidget(m_welcome, 0, 0, Qt::AlignCenter);
m_welcome->show();
}
void ContentView::showHeader(NavigationTool tool)
{
// Shared bars: Hand and Zoom, selection tools, brush tools.
const auto kind = [](NavigationTool each) {
return each == NavigationTool::hand || each == NavigationTool::zoom ? NavigationTool::hand
: isSelectionTool(each) ? NavigationTool::lasso
: isBrushTool(each) ? NavigationTool::brush
: each;
};
if (m_shownTool && kind(*m_shownTool) == kind(tool))
return;
m_shownTool = tool;
delete m_header;
// A tool without a bar yet keeps the bar's height.
if (tool == NavigationTool::idle)
m_header = new ToolHeaderBar(QStringLiteral("Select a tool"), this);
else if (tool == NavigationTool::eyedropper)
m_header = eyedropperBar(m_session, this);
else if (kind(tool) == NavigationTool::hand)
m_header = new NavigationToolHeader(m_session, this);
else if (tool == NavigationTool::move)
m_header = new TransformInspector(m_session, this);
else if (isSelectionTool(tool))
m_header = new LassoControls(m_session, this);
else if (isBrushTool(tool))
m_header = new BrushControls(m_session, this);
else if (tool == NavigationTool::gradient)
m_header = new GradientControls(m_session, this);
else if (tool == NavigationTool::shape)
m_header = new ShapeControls(m_session, this);
else if (tool == NavigationTool::type)
m_header = new TypeControls(m_session, this);
else if (tool == NavigationTool::crop)
m_header = new CropControls(m_session, this);
else
m_header = new ToolHeaderBar(QString(), this);
m_header->setObjectName(QStringLiteral("toolHeader"));
m_column->insertWidget(0, m_header);
// A layout shows a late child only later: show now.
m_header->show();
}