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>
305 lines
13 KiB
C++
305 lines
13 KiB
C++
#include "UI/CanvasSizeSheet.h"
|
||
#include "UI/ColorPaletteControls.h"
|
||
#include "UI/ColorPickerSheet.h"
|
||
#include <QCheckBox>
|
||
#include <QColorDialog>
|
||
#include <QComboBox>
|
||
#include <QLabel>
|
||
#include <QPushButton>
|
||
#include <QToolButton>
|
||
#include <QApplication>
|
||
#include <QWheelEvent>
|
||
#include <QtTest>
|
||
|
||
// Swift's CanvasSizeSheet: fields, units, anchor, extension, answers.
|
||
class CanvasSizeSheetTests : public QObject {
|
||
Q_OBJECT
|
||
private slots:
|
||
void theSheetShowsTheCanvasAndItsMemory();
|
||
void fieldsSetTheDraftInItsUnits();
|
||
void anInvalidSizeRestsOK();
|
||
void theAnchorAndTheExtensionGoWithOK();
|
||
void aCustomColorComesFromTheColorDialog();
|
||
void theAnchorsDrawTheirCircles();
|
||
void aFieldKeepsItsTypingAndItsNumber();
|
||
void aWheelOverUnitsCommitsTheTypingFirst();
|
||
void halfwayNumbersRoundToEven();
|
||
};
|
||
|
||
namespace {
|
||
CanvasDocument document(qint64 width, qint64 height)
|
||
{
|
||
CanvasDocument canvas{int(width), int(height)};
|
||
canvas.resolution = 72;
|
||
return canvas;
|
||
}
|
||
|
||
struct Sheet {
|
||
std::optional<std::optional<CanvasSizeOptions>> answer;
|
||
CanvasSizeSheet sheet;
|
||
explicit Sheet(qint64 width = 1200, qint64 height = 800, PaletteColor foreground = PaletteColor::black(),
|
||
PaletteColor background = PaletteColor::white())
|
||
: sheet(document(width, height), foreground, background, [this](std::optional<CanvasSizeOptions> options) { answer = options; })
|
||
{
|
||
sheet.show();
|
||
}
|
||
template <typename Widget> Widget &find(const char *name)
|
||
{
|
||
Widget *found = sheet.findChild<Widget *>(QString::fromLatin1(name));
|
||
if (!found)
|
||
throw std::runtime_error(std::string("no widget named ") + name);
|
||
return *found;
|
||
}
|
||
// Typed, then Return: Swift's value binding commits.
|
||
void type(const char *name, const QString &text)
|
||
{
|
||
auto &field = find<PickerField>(name);
|
||
field.setFocus();
|
||
field.selectAll();
|
||
QTest::keyClicks(&field, text);
|
||
QTest::keyClick(&field, Qt::Key_Return);
|
||
}
|
||
QString note() { return find<QLabel>("canvasNote").text(); }
|
||
QString shown(const char *name) { return find<PickerField>(name).text(); }
|
||
};
|
||
|
||
QStringList labels(const QWidget &sheet)
|
||
{
|
||
QStringList texts;
|
||
for (const QLabel *label : sheet.findChildren<QLabel *>())
|
||
texts << label->text();
|
||
return texts;
|
||
}
|
||
}
|
||
|
||
void CanvasSizeSheetTests::theSheetShowsTheCanvasAndItsMemory()
|
||
{
|
||
Sheet shown;
|
||
const QStringList texts = labels(shown.sheet);
|
||
QVERIFY(texts.contains("Canvas Size"));
|
||
QVERIFY(texts.contains("Current: 1,200 × 800 pixels"));
|
||
QVERIFY(texts.contains("3.7 MB uncompressed RGBA canvas"));
|
||
QCOMPARE(shown.sheet.width(), 450);
|
||
QCOMPARE(shown.shown("canvasWidth"), QString("1200"));
|
||
QCOMPARE(shown.shown("canvasHeight"), QString("800"));
|
||
QCOMPARE(shown.note(), QString("New: 1,200 × 800 pixels · 3.7 MB uncompressed"));
|
||
// Foundation's memory counts: bytes, whole KB, one place, two.
|
||
QVERIFY(labels(Sheet(1, 1).sheet).contains("4 bytes uncompressed RGBA canvas"));
|
||
QVERIFY(labels(Sheet(16, 16).sheet).contains("1 KB uncompressed RGBA canvas"));
|
||
QVERIFY(labels(Sheet(250, 1).sheet).contains("1,000 bytes uncompressed RGBA canvas"));
|
||
QVERIFY(labels(Sheet(2560, 1).sheet).contains("10 KB uncompressed RGBA canvas"));
|
||
QVERIFY(labels(Sheet(512, 500).sheet).contains("1,000 KB uncompressed RGBA canvas"));
|
||
QVERIFY(labels(Sheet(1024, 512).sheet).contains("2 MB uncompressed RGBA canvas"));
|
||
QVERIFY(labels(Sheet(30'000, 30'000).sheet).contains("3.35 GB uncompressed RGBA canvas"));
|
||
QVERIFY(labels(Sheet(23'170, 23'170).sheet).contains("2 GB uncompressed RGBA canvas"));
|
||
}
|
||
|
||
void CanvasSizeSheetTests::fieldsSetTheDraftInItsUnits()
|
||
{
|
||
Sheet shown;
|
||
shown.type("canvasWidth", "1600");
|
||
QCOMPARE(shown.shown("canvasWidth"), QString("1600"));
|
||
QCOMPARE(shown.shown("canvasHeight"), QString("800"));
|
||
QCOMPARE(shown.note(), QString("New: 1,600 × 800 pixels · 4.9 MB uncompressed"));
|
||
// Locking takes the width's proportions at once.
|
||
shown.find<QCheckBox>("canvasLocked").click();
|
||
QCOMPARE(shown.shown("canvasHeight"), QString("1066.667"));
|
||
shown.type("canvasHeight", "400");
|
||
QCOMPARE(shown.shown("canvasWidth"), QString("600"));
|
||
// Relative fields show what is added; units convert.
|
||
shown.find<QCheckBox>("canvasRelative").click();
|
||
QCOMPARE(shown.shown("canvasWidth"), QString("-600"));
|
||
QCOMPARE(shown.shown("canvasHeight"), QString("-400"));
|
||
shown.type("canvasWidth", "0");
|
||
QCOMPARE(shown.shown("canvasHeight"), QString("0"));
|
||
auto &units = shown.find<QComboBox>("canvasUnits");
|
||
QCOMPARE(units.count(), 4);
|
||
units.setCurrentIndex(1);
|
||
emit units.activated(1);
|
||
shown.type("canvasWidth", "50");
|
||
QCOMPARE(shown.note(), QString("New: 1,800 × 1,200 pixels · 8.2 MB uncompressed"));
|
||
shown.find<QCheckBox>("canvasRelative").click();
|
||
units.setCurrentIndex(2);
|
||
emit units.activated(2);
|
||
QCOMPARE(shown.shown("canvasWidth"), QString("25"));
|
||
units.setCurrentIndex(3);
|
||
emit units.activated(3);
|
||
QCOMPARE(shown.shown("canvasWidth"), QString("63.5"));
|
||
// Text that is no number goes back to the draft's.
|
||
shown.type("canvasWidth", "wide");
|
||
QCOMPARE(shown.shown("canvasWidth"), QString("63.5"));
|
||
units.setCurrentIndex(0);
|
||
emit units.activated(0);
|
||
QCOMPARE(shown.shown("canvasWidth"), QString("1800"));
|
||
}
|
||
|
||
void CanvasSizeSheetTests::anInvalidSizeRestsOK()
|
||
{
|
||
Sheet shown;
|
||
auto &ok = shown.find<QPushButton>("canvasOK");
|
||
QVERIFY(ok.isEnabled() && ok.isDefault());
|
||
shown.type("canvasWidth", "30001");
|
||
QCOMPARE(shown.note(), QString("Final dimensions must be 1–30,000 pixels per side."));
|
||
QCOMPARE(shown.find<QLabel>("canvasNote").foregroundRole(), QPalette::BrightText);
|
||
QVERIFY(!ok.isEnabled());
|
||
shown.type("canvasWidth", "0.4");
|
||
QVERIFY(!ok.isEnabled());
|
||
shown.type("canvasWidth", "0.5");
|
||
QVERIFY(ok.isEnabled());
|
||
QCOMPARE(shown.find<QLabel>("canvasNote").foregroundRole(), QPalette::PlaceholderText);
|
||
// Cancel answers with nothing.
|
||
shown.find<QPushButton>("canvasCancel").click();
|
||
QVERIFY(!shown.answer.value().has_value());
|
||
}
|
||
|
||
void CanvasSizeSheetTests::theAnchorAndTheExtensionGoWithOK()
|
||
{
|
||
Sheet shown(1200, 800, PaletteColor{1, 0, 0}, PaletteColor{0, 0, 1});
|
||
QCOMPARE(shown.find<QLabel>("anchorName").text(), QString("Center"));
|
||
QCOMPARE(shown.find<QToolButton>("anchor4").accessibleDescription(), QString("Selected"));
|
||
shown.find<QToolButton>("anchor0").click();
|
||
QCOMPARE(shown.find<QLabel>("anchorName").text(), QString("Top left"));
|
||
QCOMPARE(shown.find<QToolButton>("anchor0").toolTip(), QString("Top left"));
|
||
QCOMPARE(shown.find<QToolButton>("anchor0").accessibleDescription(), QString("Selected"));
|
||
QVERIFY(shown.find<QToolButton>("anchor4").accessibleDescription().isEmpty());
|
||
auto &extension = shown.find<QComboBox>("canvasExtension");
|
||
QStringList choices;
|
||
for (int index = 0; index < extension.count(); ++index)
|
||
choices << extension.itemText(index);
|
||
QCOMPARE(choices, (QStringList{"Transparent", "Foreground", "Background", "Black", "White", "Custom"}));
|
||
QVERIFY(!shown.find<QWidget>("extensionColorRow").isVisible());
|
||
const std::pair<int, std::optional<std::array<double, 3>>> fills[] = {
|
||
{0, std::nullopt}, {1, std::array<double, 3>{1, 0, 0}}, {2, std::array<double, 3>{0, 0, 1}}, {3, std::array<double, 3>{0, 0, 0}},
|
||
{4, std::array<double, 3>{1, 1, 1}}};
|
||
shown.type("canvasWidth", "1600.6");
|
||
shown.type("canvasHeight", "799.5");
|
||
for (const auto &[index, fill] : fills) {
|
||
extension.setCurrentIndex(index);
|
||
emit extension.activated(index);
|
||
shown.find<QPushButton>("canvasOK").click();
|
||
const CanvasSizeOptions options = shown.answer.value().value();
|
||
QCOMPARE(options.width, 1601);
|
||
QCOMPARE(options.height, 800);
|
||
QCOMPARE(options.anchor, 0);
|
||
QCOMPARE(options.fill.has_value(), fill.has_value());
|
||
if (fill)
|
||
QVERIFY(options.fill.value().red == fill.value()[0] && options.fill.value().green == fill.value()[1] && options.fill.value().blue == fill.value()[2]);
|
||
}
|
||
}
|
||
|
||
void CanvasSizeSheetTests::aCustomColorComesFromTheColorDialog()
|
||
{
|
||
Sheet shown;
|
||
auto &extension = shown.find<QComboBox>("canvasExtension");
|
||
extension.setCurrentIndex(5);
|
||
emit extension.activated(5);
|
||
QVERIFY(shown.find<QWidget>("extensionColorRow").isVisible());
|
||
auto &swatch = shown.find<SwatchButton>("extensionColor");
|
||
QCOMPARE(swatch.accessibleName(), QString("Extension color"));
|
||
swatch.click();
|
||
auto *dialog = shown.sheet.findChild<QColorDialog *>();
|
||
QVERIFY(dialog && dialog->isVisible());
|
||
QVERIFY(!dialog->testOption(QColorDialog::ShowAlphaChannel));
|
||
QCOMPARE(dialog->currentColor(), QColor(Qt::white));
|
||
dialog->setCurrentColor(QColor(0, 128, 255));
|
||
// The swatch repaints with the colour chosen.
|
||
int painted = 0;
|
||
struct Spy : QObject {
|
||
int &count;
|
||
explicit Spy(int &count) : count(count) {}
|
||
bool eventFilter(QObject *, QEvent *event) override
|
||
{
|
||
count += event->type() == QEvent::Paint;
|
||
return false;
|
||
}
|
||
} spy(painted);
|
||
swatch.installEventFilter(&spy);
|
||
QApplication::processEvents();
|
||
const int before = painted;
|
||
dialog->accept();
|
||
QTRY_VERIFY(painted > before);
|
||
swatch.removeEventFilter(&spy);
|
||
shown.find<QPushButton>("canvasOK").click();
|
||
const CanvasExtensionColor fill = shown.answer.value().value().fill.value();
|
||
QCOMPARE(fill.red, 0.0);
|
||
QCOMPARE(fill.green, QColor(0, 128, 255).greenF());
|
||
QCOMPARE(fill.blue, 1.0);
|
||
// Chosen again, the dialog opens on the colour kept.
|
||
swatch.click();
|
||
QTRY_VERIFY(shown.sheet.findChild<QColorDialog *>() && shown.sheet.findChild<QColorDialog *>()->isVisible());
|
||
QCOMPARE(shown.sheet.findChild<QColorDialog *>()->currentColor(), QColor(0, 128, 255));
|
||
}
|
||
|
||
void CanvasSizeSheetTests::theAnchorsDrawTheirCircles()
|
||
{
|
||
Sheet shown;
|
||
const QImage chosen = shown.find<QToolButton>("anchor4").grab().toImage();
|
||
const QImage other = shown.find<QToolButton>("anchor0").grab().toImage();
|
||
const QColor accent = shown.sheet.palette().color(QPalette::Highlight);
|
||
// The chosen one filled in the accent; the others ringed.
|
||
QCOMPARE(chosen.pixelColor(12, 12), accent);
|
||
QCOMPARE(chosen.pixelColor(16, 12), accent);
|
||
QVERIFY(other.pixelColor(12, 12) != accent);
|
||
QVERIFY(other.pixelColor(12, 12) != other.pixelColor(17, 12));
|
||
QCOMPARE(other.pixelColor(12, 12), other.pixelColor(20, 12));
|
||
}
|
||
|
||
void CanvasSizeSheetTests::aFieldKeepsItsTypingAndItsNumber()
|
||
{
|
||
// Typing waits for Return while the sheet redraws.
|
||
Sheet shown;
|
||
auto &width = shown.find<PickerField>("canvasWidth");
|
||
width.setFocus();
|
||
width.selectAll();
|
||
QTest::keyClicks(&width, "1234");
|
||
emit shown.find<QComboBox>("canvasExtension").activated(1);
|
||
QCOMPARE(width.text(), QString("1234"));
|
||
QTest::keyClick(&width, Qt::Key_Return);
|
||
QCOMPARE(shown.note(), QString("New: 1,234 × 800 pixels · 3.8 MB uncompressed"));
|
||
// Return without typing keeps the number, not what it shows.
|
||
Sheet thin(30'000, 1);
|
||
thin.find<QCheckBox>("canvasLocked").click();
|
||
thin.type("canvasWidth", "29999");
|
||
QCOMPARE(thin.shown("canvasHeight"), QString("1"));
|
||
auto &height = thin.find<PickerField>("canvasHeight");
|
||
height.setFocus();
|
||
QTest::keyClick(&height, Qt::Key_Return);
|
||
QCOMPARE(thin.note(), QString("New: 29,999 × 1 pixels · 117 KB uncompressed"));
|
||
}
|
||
|
||
// Scrolled, Units take no focus; the typing meant pixels.
|
||
void CanvasSizeSheetTests::aWheelOverUnitsCommitsTheTypingFirst()
|
||
{
|
||
Sheet shown;
|
||
// Focus moves only in the active window.
|
||
shown.sheet.activateWindow();
|
||
QVERIFY(QTest::qWaitForWindowActive(&shown.sheet));
|
||
auto &width = shown.find<PickerField>("canvasWidth");
|
||
width.setFocus();
|
||
width.selectAll();
|
||
QTest::keyClicks(&width, "600");
|
||
auto &units = shown.find<QComboBox>("canvasUnits");
|
||
QWheelEvent wheel(QPointF(5, 5), units.mapToGlobal(QPointF(5, 5)), QPoint(), QPoint(0, -120), Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false);
|
||
QApplication::sendEvent(&units, &wheel);
|
||
QCOMPARE(units.currentText(), QString("Percent"));
|
||
QCOMPARE(width.text(), QString("50"));
|
||
QCOMPARE(shown.find<QLabel>("canvasNote").text(), QString("New: 600 × 800 pixels · 1.8 MB uncompressed"));
|
||
}
|
||
|
||
// Foundation rounds halves to even: 17 pixels, 16 per inch.
|
||
void CanvasSizeSheetTests::halfwayNumbersRoundToEven()
|
||
{
|
||
CanvasDocument canvas{17, 19};
|
||
canvas.resolution = 16;
|
||
CanvasSizeSheet sheet(canvas, PaletteColor::black(), PaletteColor::white(), [](std::optional<CanvasSizeOptions>) {});
|
||
auto &units = *sheet.findChild<QComboBox *>(QStringLiteral("canvasUnits"));
|
||
const int inches = units.findText(QStringLiteral("Inches"));
|
||
units.setCurrentIndex(inches);
|
||
emit units.activated(inches);
|
||
QCOMPARE(sheet.findChild<PickerField *>(QStringLiteral("canvasWidth"))->text(), QString("1.062"));
|
||
QCOMPARE(sheet.findChild<PickerField *>(QStringLiteral("canvasHeight"))->text(), QString("1.188"));
|
||
}
|
||
|
||
QTEST_MAIN(CanvasSizeSheetTests)
|
||
#include "CanvasSizeSheetTests.moc"
|