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

+292
View File
@@ -0,0 +1,292 @@
#include "UI/CanvasSizeSheet.h"
#include "UI/ByteCounts.h"
#include "UI/ColorPaletteControls.h"
#include "UI/ColorPickerSheet.h"
#include <QButtonGroup>
#include <QCheckBox>
#include <QColorDialog>
#include <QComboBox>
#include <QFrame>
#include <QGridLayout>
#include <QLabel>
#include <QPainter>
#include <QPushButton>
#include <QToolButton>
#include <QVBoxLayout>
#include <cmath>
namespace {
const std::array<QString, 9> anchorNames = {QStringLiteral("Top left"), QStringLiteral("Top center"), QStringLiteral("Top right"),
QStringLiteral("Middle left"), QStringLiteral("Center"), QStringLiteral("Middle right"),
QStringLiteral("Bottom left"), QStringLiteral("Bottom center"), QStringLiteral("Bottom right")};
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent)
{
auto *label = new QLabel(words, parent);
label->setTextFormat(Qt::PlainText);
QFont font = label->font();
font.setPixelSize(pixels);
font.setWeight(weight);
label->setFont(font);
label->setForegroundRole(role);
return label;
}
// Swift's `.number`: three places at most, none trailing, ties even.
QString shown(double value, QLocale locale)
{
locale.setNumberOptions(QLocale::OmitGroupSeparator);
QString number = locale.toString(std::nearbyint(value * 1000) / 1000, 'f', 3);
while (number.endsWith(locale.zeroDigit()))
number.chop(locale.zeroDigit().size());
if (number.endsWith(locale.decimalPoint()))
number.chop(locale.decimalPoint().size());
return number;
}
QString grouped(qint64 value)
{
return QLocale(QLocale::English, QLocale::UnitedStates).toString(value);
}
// Swift's anchor: a circle, filled with the accent when chosen.
class AnchorButton : public QToolButton {
public:
using QToolButton::QToolButton;
bool chosen = false;
protected:
void paintEvent(QPaintEvent *event) override
{
QToolButton::paintEvent(event);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QRectF dot(0, 0, 11, 11);
dot.moveCenter(QRectF(rect()).center());
if (chosen) {
painter.setPen(Qt::NoPen);
painter.setBrush(palette().color(QPalette::Highlight));
} else {
painter.setPen(QPen(palette().color(QPalette::PlaceholderText), 1.2));
}
painter.drawEllipse(dot);
}
};
}
CanvasSizeSheet::CanvasSizeSheet(const CanvasDocument &document, PaletteColor foreground, PaletteColor background,
std::function<void(std::optional<CanvasSizeOptions>)> finish, QWidget *parent)
: QWidget(parent), m_foreground(foreground), m_background(background), m_finish(std::move(finish)),
m_draft(document.width, document.height, document.resolution), m_width(dimension(true)), m_height(dimension(false)),
m_note(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)),
m_anchors(new QButtonGroup(this)), m_anchorName(text(QString(), 12, QFont::Bold, QPalette::WindowText, this)), m_extension(new QComboBox(this)),
m_customRow(new QWidget(this)), m_customSwatch(new SwatchButton([this] { return m_custom; }, 3, 0, 0.5, m_customRow)),
m_ok(new QPushButton(QStringLiteral("OK"), this))
{
setFixedWidth(450);
auto *column = new QVBoxLayout(this);
column->setContentsMargins(24, 24, 24, 24);
column->setSpacing(16);
column->addWidget(text(QStringLiteral("Canvas Size"), 17, QFont::Bold, QPalette::WindowText, this));
column->addWidget(text(QStringLiteral("Current: %1 × %2 pixels").arg(grouped(document.width), grouped(document.height)), 13, QFont::Normal,
QPalette::WindowText, this));
column->addWidget(text(ByteCounts::memory(qint64(document.width) * document.height * 4) + QStringLiteral(" uncompressed RGBA canvas"), 12, QFont::Normal,
QPalette::PlaceholderText, this));
auto *divider = new QFrame(this);
divider->setFrameShape(QFrame::HLine);
divider->setForegroundRole(QPalette::Mid);
column->addWidget(divider);
auto *fields = new QGridLayout;
fields->setHorizontalSpacing(8);
fields->setVerticalSpacing(16);
fields->setColumnMinimumWidth(0, 60);
auto *units = new QComboBox(this);
auto *unitsLabel = new QLabel(QStringLiteral("Units"), this);
unitsLabel->setBuddy(units);
units->setObjectName(QStringLiteral("canvasUnits"));
for (const CanvasUnit unit : {CanvasUnit::pixels, CanvasUnit::percent, CanvasUnit::inches, CanvasUnit::centimeters})
units->addItem(rawValue(unit), int(unit));
connect(units, &QComboBox::activated, this, [this, units](int index) {
// A wheel leaves the focus: pending typing commits first.
units->setFocus();
m_draft.unit = CanvasUnit(units->itemData(index).toInt());
synchronize();
});
fields->addWidget(unitsLabel, 0, 0);
fields->addWidget(units, 0, 1);
fields->addWidget(new QLabel(QStringLiteral("Width"), this), 1, 0);
fields->addWidget(m_width, 1, 1);
fields->addWidget(new QLabel(QStringLiteral("Height"), this), 2, 0);
fields->addWidget(m_height, 2, 1);
column->addLayout(fields);
auto *relative = new QCheckBox(QStringLiteral("Relative to current dimensions"), this);
relative->setObjectName(QStringLiteral("canvasRelative"));
connect(relative, &QCheckBox::clicked, this, [this](bool on) {
m_draft.relative = on;
synchronize();
});
auto *locked = new QCheckBox(QStringLiteral("Lock original aspect ratio"), this);
locked->setObjectName(QStringLiteral("canvasLocked"));
// Swift's onChange: locking takes the width's proportions.
connect(locked, &QCheckBox::clicked, this, [this](bool on) {
m_draft.locked = on;
if (on)
m_draft.set(m_draft.displayed(true), true);
synchronize();
});
column->addWidget(relative);
column->addWidget(locked);
m_note->setObjectName(QStringLiteral("canvasNote"));
column->addWidget(m_note);
auto *grid = new QGridLayout;
grid->setSpacing(3);
for (int index = 0; index < 9; ++index) {
auto *button = new AnchorButton(this);
button->setObjectName(QStringLiteral("anchor%1").arg(index));
button->setFixedSize(25, 25);
button->setToolTip(anchorNames[size_t(index)]);
button->setAccessibleName(anchorNames[size_t(index)]);
m_anchors->addButton(button, index);
grid->addWidget(button, index / 3, index % 3);
}
connect(m_anchors, &QButtonGroup::idClicked, this, [this](int index) {
m_anchor = index;
synchronize();
});
auto *anchorColumn = new QVBoxLayout;
anchorColumn->setSpacing(8);
anchorColumn->addWidget(new QLabel(QStringLiteral("Anchor"), this));
anchorColumn->addLayout(grid);
m_anchorName->setObjectName(QStringLiteral("anchorName"));
auto *explanation = text(QStringLiteral("Keeps this point fixed. Artwork is not scaled; cropped content remains outside the canvas."), 12,
QFont::Normal, QPalette::PlaceholderText, this);
explanation->setWordWrap(true);
auto *named = new QVBoxLayout;
named->setContentsMargins(0, 28, 0, 0);
named->setSpacing(8);
named->addWidget(m_anchorName);
named->addWidget(explanation);
named->addStretch(1);
auto *anchorRow = new QHBoxLayout;
anchorRow->setSpacing(24);
anchorRow->addLayout(anchorColumn);
anchorRow->addLayout(named, 1);
column->addLayout(anchorRow);
auto *extension = new QHBoxLayout;
auto *extensionLabel = new QLabel(QStringLiteral("Canvas extension"), this);
extensionLabel->setBuddy(m_extension);
m_extension->setObjectName(QStringLiteral("canvasExtension"));
m_extension->addItems({QStringLiteral("Transparent"), QStringLiteral("Foreground"), QStringLiteral("Background"), QStringLiteral("Black"),
QStringLiteral("White"), QStringLiteral("Custom")});
connect(m_extension, &QComboBox::activated, this, &CanvasSizeSheet::synchronize);
extension->addWidget(extensionLabel);
extension->addWidget(m_extension, 1);
column->addLayout(extension);
// Swift's ColorPicker: its label and a well, no opacity.
m_customRow->setObjectName(QStringLiteral("extensionColorRow"));
m_customSwatch->setObjectName(QStringLiteral("extensionColor"));
m_customSwatch->setFixedSize(36, 18);
m_customSwatch->setAccessibleName(QStringLiteral("Extension color"));
connect(m_customSwatch, &QAbstractButton::clicked, this, &CanvasSizeSheet::pickCustomColor);
auto *customLayout = new QHBoxLayout(m_customRow);
customLayout->setContentsMargins(0, 0, 0, 0);
customLayout->addWidget(new QLabel(QStringLiteral("Extension color"), m_customRow));
customLayout->addWidget(m_customSwatch);
customLayout->addStretch(1);
column->addWidget(m_customRow);
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setObjectName(QStringLiteral("canvasCancel"));
cancel->setAutoDefault(false);
connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); });
m_ok->setObjectName(QStringLiteral("canvasOK"));
m_ok->setDefault(true);
// OK rests while the draft is invalid: no guard needed.
connect(m_ok, &QPushButton::clicked, this, [this] {
m_finish(CanvasSizeOptions{.width = qint64(std::round(m_draft.width)), .height = qint64(std::round(m_draft.height)), .anchor = m_anchor,
.fill = fill()});
});
auto *buttons = new QHBoxLayout;
buttons->addWidget(cancel);
buttons->addStretch(1);
buttons->addWidget(m_ok);
column->addLayout(buttons);
synchronize();
}
// Swift's value binding: a readable number sets the draft.
PickerField *CanvasSizeSheet::dimension(bool widthAxis)
{
auto *field = new PickerField([this, widthAxis] {
PickerField &edited = widthAxis ? *m_width : *m_height;
bool number = false;
const double typed = edited.locale().toDouble(edited.text(), &number);
if (edited.isModified() && number && std::isfinite(typed))
m_draft.set(typed, widthAxis);
edited.setModified(false);
synchronize();
}, nullptr, this);
field->setObjectName(widthAxis ? QStringLiteral("canvasWidth") : QStringLiteral("canvasHeight"));
field->setPlaceholderText(widthAxis ? QStringLiteral("Width") : QStringLiteral("Height"));
field->setAccessibleName(widthAxis ? QStringLiteral("Width") : QStringLiteral("Height"));
return field;
}
std::optional<CanvasExtensionColor> CanvasSizeSheet::fill() const
{
const auto colour = [](PaletteColor value) { return CanvasExtensionColor{value.red, value.green, value.blue}; };
switch (m_extension->currentIndex()) {
case 0: return std::nullopt;
case 1: return colour(m_foreground);
case 2: return colour(m_background);
case 3: return colour(PaletteColor::black());
case 4: return colour(PaletteColor::white());
default: return colour(m_custom);
}
}
// The desktop's colour dialog, SwiftUI's colour panel, without alpha.
void CanvasSizeSheet::pickCustomColor()
{
auto *dialog = new QColorDialog(m_custom.color(), this);
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->setWindowTitle(QStringLiteral("Extension color"));
connect(dialog, &QColorDialog::colorSelected, this, [this](const QColor &picked) {
const QColor srgb = picked.toRgb();
m_custom = PaletteColor{srgb.redF(), srgb.greenF(), srgb.blueF()};
m_customSwatch->update();
});
dialog->open();
}
// Units, Relative and Lock change only through their own controls.
void CanvasSizeSheet::synchronize()
{
for (const auto &[field, widthAxis] : {std::pair(m_width, true), std::pair(m_height, false)}) {
// A field being typed in keeps its typing.
const QString number = shown(m_draft.displayed(widthAxis), field->locale());
if (!field->isModified() && field->text() != number)
field->setText(number);
}
const bool valid = m_draft.valid();
if (valid) {
const qint64 width = qint64(std::round(m_draft.width)), height = qint64(std::round(m_draft.height));
m_note->setText(QStringLiteral("New: %1 × %2 pixels · %3 uncompressed").arg(grouped(width), grouped(height), ByteCounts::memory(width * height * 4)));
} else {
m_note->setText(QStringLiteral("Final dimensions must be 1–30,000 pixels per side."));
}
m_note->setForegroundRole(valid ? QPalette::PlaceholderText : QPalette::BrightText);
for (QAbstractButton *each : m_anchors->buttons()) {
auto *button = static_cast<AnchorButton *>(each);
button->chosen = m_anchors->id(each) == m_anchor;
button->setAccessibleDescription(button->chosen ? QStringLiteral("Selected") : QString());
button->update();
}
m_anchorName->setText(anchorNames[size_t(m_anchor)]);
m_customRow->setVisible(m_extension->currentIndex() == 5);
m_ok->setEnabled(valid);
}